Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ memberstack-cli/
│ │ ├── members.ts # Member CRUD, search, pagination
│ │ ├── plans.ts # Plan CRUD, ordering, redirects, permissions
│ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops
│ │ ├── skills.ts # Agent skill add/remove (wraps npx skills)
│ │ ├── tables.ts # Data table CRUD, describe
│ │ └── whoami.ts # Show current app and user
│ │
Expand All @@ -42,6 +43,7 @@ memberstack-cli/
│ │ ├── members.test.ts
│ │ ├── plans.test.ts
│ │ ├── records.test.ts
│ │ ├── skills.test.ts
│ │ ├── tables.test.ts
│ │ └── whoami.test.ts
│ │
Expand Down Expand Up @@ -73,14 +75,16 @@ A shared Commander instance with two global options:

### Commands (`src/commands/`)

Each file exports a Commander `Command` with subcommands. All commands follow the same pattern:
Each file exports a Commander `Command` with subcommands. Most commands follow the same pattern:

1. Start a `yocto-spinner`
2. Call `graphqlRequest()` with a query/mutation and variables
3. Stop the spinner
4. Output results via `printTable()`, `printRecord()`, or `printSuccess()`
5. Catch errors and set `process.exitCode = 1`

The `skills` command is an exception — it wraps `npx skills` (child process) to add/remove agent skills instead of calling the GraphQL API.

Repeatable options use a `collect` helper: `(value, previous) => [...previous, value]`.

Boolean toggles use Commander's `--flag` / `--no-flag` pairs.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ memberstack <command> <subcommand> [options]
## Install Agent Skill (Optional)

```bash
npx skills add 224-industries/webflow-skills --skill memberstack-cli
memberstack skills add memberstack-cli
```

### Global Options
Expand Down Expand Up @@ -136,6 +136,13 @@ Show the current authenticated app and user.
| `update <id>` | Update a custom field |
| `delete <id>` | Delete a custom field |

#### `skills` — Agent Skill Management

| Subcommand | Description |
|---|---|
| `add <skill>` | Add a Memberstack agent skill |
| `remove <skill>` | Remove a Memberstack agent skill |

## Examples

```bash
Expand Down
80 changes: 80 additions & 0 deletions src/commands/skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Command } from "commander";
import yoctoSpinner from "yocto-spinner";
import { printError, printSuccess } from "../lib/utils.js";

const execAsync = promisify(exec);

const SKILLS_REPO = "Flash-Brew-Digital/memberstack-skills";

const runSkillsCommand = async (args: string[]): Promise<void> => {
await execAsync(`npx skills ${args.join(" ")}`);
};

export const skillsCommand = new Command("skills").description(
"Manage Memberstack skills"
);

skillsCommand
.command("add")
.description("Add a Memberstack skill")
.argument("<skill>", "Skill name to add")
.action(async (skill: string) => {
const spinner = yoctoSpinner({
text: `Adding agent skill "${skill}" to your project...`,
}).start();
try {
await runSkillsCommand([
"add",
SKILLS_REPO,
"--skill",
skill,
"--agent",
"claude-code",
"codex",
"-y",
]);
spinner.stop();
printSuccess(`Skill "${skill}" added successfully.`);
} catch (error) {
spinner.stop();
printError(
error instanceof Error
? error.message
: "Failed to add the agent skill. Please ensure the skill name is correct and try again."
);
process.exitCode = 1;
}
});

skillsCommand
.command("remove")
.description("Remove a Memberstack skill")
.argument("<skill>", "Skill name to remove")
.action(async (skill: string) => {
const spinner = yoctoSpinner({
text: `Removing agent skill "${skill}" from your project...`,
}).start();
try {
await runSkillsCommand([
"remove",
"--skill",
skill,
"--agent",
"claude-code",
"codex",
"-y",
]);
spinner.stop();
printSuccess(`Skill "${skill}" removed successfully.`);
} catch (error) {
spinner.stop();
printError(
error instanceof Error
? error.message
: "Failed to remove the agent skill. Please ensure the skill name is correct and try again."
);
process.exitCode = 1;
}
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { customFieldsCommand } from "./commands/custom-fields.js";
import { membersCommand } from "./commands/members.js";
import { plansCommand } from "./commands/plans.js";
import { recordsCommand } from "./commands/records.js";
import { skillsCommand } from "./commands/skills.js";
import { tablesCommand } from "./commands/tables.js";
import { whoamiCommand } from "./commands/whoami.js";
import { program } from "./lib/program.js";
Expand Down Expand Up @@ -54,5 +55,6 @@ program.addCommand(plansCommand);
program.addCommand(tablesCommand);
program.addCommand(recordsCommand);
program.addCommand(customFieldsCommand);
program.addCommand(skillsCommand);

await program.parseAsync();
67 changes: 67 additions & 0 deletions tests/commands/skills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { createMockSpinner, runCommand } from "./helpers.js";

vi.mock("yocto-spinner", () => ({ default: () => createMockSpinner() }));
vi.mock("../../src/lib/program.js", () => ({
program: { opts: () => ({}) },
}));

const execAsync = vi.fn();
vi.mock("node:child_process", () => ({
exec: (...args: unknown[]) => {
const cb = args.at(-1) as (
err: Error | null,
result: { stdout: string; stderr: string }
) => void;
const promise = execAsync(args[0]);
promise
.then(() => cb(null, { stdout: "", stderr: "" }))
.catch((err: Error) => cb(err, { stdout: "", stderr: "" }));
},
}));

const { skillsCommand } = await import("../../src/commands/skills.js");

describe("skills", () => {
it("add runs npx skills add with correct arguments", async () => {
execAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });

await runCommand(skillsCommand, ["add", "memberstack-cli"]);

expect(execAsync).toHaveBeenCalledWith(
expect.stringContaining(
"npx skills add Flash-Brew-Digital/memberstack-skills --skill memberstack-cli --agent claude-code codex -y"
)
);
});

it("remove runs npx skills remove with correct arguments", async () => {
execAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });

await runCommand(skillsCommand, ["remove", "memberstack-cli"]);

expect(execAsync).toHaveBeenCalledWith(
expect.stringContaining(
"npx skills remove --skill memberstack-cli --agent claude-code codex -y"
)
);
});

it("add handles errors gracefully", async () => {
execAsync.mockRejectedValueOnce(new Error("Command failed"));

const original = process.exitCode;
await runCommand(skillsCommand, ["add", "bad-skill"]);
expect(process.exitCode).toBe(1);
process.exitCode = original;
});

it("remove handles errors gracefully", async () => {
execAsync.mockRejectedValueOnce(new Error("Command failed"));

const original = process.exitCode;
await runCommand(skillsCommand, ["remove", "bad-skill"]);
expect(process.exitCode).toBe(1);
process.exitCode = original;
});
});