Skip to content

Commit 45d52c6

Browse files
authored
feat: add skills command for adding/removing Memberstack agent skills (#2)
Adds `skills add` and `skills remove` subcommands that wrap `npx skills` scoped to claude-code and codex agents. Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
1 parent f42716f commit 45d52c6

5 files changed

Lines changed: 162 additions & 2 deletions

File tree

ARCHITECTURE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ memberstack-cli/
2121
│ │ ├── members.ts # Member CRUD, search, pagination
2222
│ │ ├── plans.ts # Plan CRUD, ordering, redirects, permissions
2323
│ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops
24+
│ │ ├── skills.ts # Agent skill add/remove (wraps npx skills)
2425
│ │ ├── tables.ts # Data table CRUD, describe
2526
│ │ └── whoami.ts # Show current app and user
2627
│ │
@@ -42,6 +43,7 @@ memberstack-cli/
4243
│ │ ├── members.test.ts
4344
│ │ ├── plans.test.ts
4445
│ │ ├── records.test.ts
46+
│ │ ├── skills.test.ts
4547
│ │ ├── tables.test.ts
4648
│ │ └── whoami.test.ts
4749
│ │
@@ -73,14 +75,16 @@ A shared Commander instance with two global options:
7375

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

76-
Each file exports a Commander `Command` with subcommands. All commands follow the same pattern:
78+
Each file exports a Commander `Command` with subcommands. Most commands follow the same pattern:
7779

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

86+
The `skills` command is an exception — it wraps `npx skills` (child process) to add/remove agent skills instead of calling the GraphQL API.
87+
8488
Repeatable options use a `collect` helper: `(value, previous) => [...previous, value]`.
8589

8690
Boolean toggles use Commander's `--flag` / `--no-flag` pairs.

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ memberstack <command> <subcommand> [options]
3737
## Install Agent Skill (Optional)
3838

3939
```bash
40-
npx skills add 224-industries/webflow-skills --skill memberstack-cli
40+
memberstack skills add memberstack-cli
4141
```
4242

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

139+
#### `skills` — Agent Skill Management
140+
141+
| Subcommand | Description |
142+
|---|---|
143+
| `add <skill>` | Add a Memberstack agent skill |
144+
| `remove <skill>` | Remove a Memberstack agent skill |
145+
139146
## Examples
140147

141148
```bash

src/commands/skills.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { exec } from "node:child_process";
2+
import { promisify } from "node:util";
3+
import { Command } from "commander";
4+
import yoctoSpinner from "yocto-spinner";
5+
import { printError, printSuccess } from "../lib/utils.js";
6+
7+
const execAsync = promisify(exec);
8+
9+
const SKILLS_REPO = "Flash-Brew-Digital/memberstack-skills";
10+
11+
const runSkillsCommand = async (args: string[]): Promise<void> => {
12+
await execAsync(`npx skills ${args.join(" ")}`);
13+
};
14+
15+
export const skillsCommand = new Command("skills").description(
16+
"Manage Memberstack skills"
17+
);
18+
19+
skillsCommand
20+
.command("add")
21+
.description("Add a Memberstack skill")
22+
.argument("<skill>", "Skill name to add")
23+
.action(async (skill: string) => {
24+
const spinner = yoctoSpinner({
25+
text: `Adding agent skill "${skill}" to your project...`,
26+
}).start();
27+
try {
28+
await runSkillsCommand([
29+
"add",
30+
SKILLS_REPO,
31+
"--skill",
32+
skill,
33+
"--agent",
34+
"claude-code",
35+
"codex",
36+
"-y",
37+
]);
38+
spinner.stop();
39+
printSuccess(`Skill "${skill}" added successfully.`);
40+
} catch (error) {
41+
spinner.stop();
42+
printError(
43+
error instanceof Error
44+
? error.message
45+
: "Failed to add the agent skill. Please ensure the skill name is correct and try again."
46+
);
47+
process.exitCode = 1;
48+
}
49+
});
50+
51+
skillsCommand
52+
.command("remove")
53+
.description("Remove a Memberstack skill")
54+
.argument("<skill>", "Skill name to remove")
55+
.action(async (skill: string) => {
56+
const spinner = yoctoSpinner({
57+
text: `Removing agent skill "${skill}" from your project...`,
58+
}).start();
59+
try {
60+
await runSkillsCommand([
61+
"remove",
62+
"--skill",
63+
skill,
64+
"--agent",
65+
"claude-code",
66+
"codex",
67+
"-y",
68+
]);
69+
spinner.stop();
70+
printSuccess(`Skill "${skill}" removed successfully.`);
71+
} catch (error) {
72+
spinner.stop();
73+
printError(
74+
error instanceof Error
75+
? error.message
76+
: "Failed to remove the agent skill. Please ensure the skill name is correct and try again."
77+
);
78+
process.exitCode = 1;
79+
}
80+
});

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { customFieldsCommand } from "./commands/custom-fields.js";
55
import { membersCommand } from "./commands/members.js";
66
import { plansCommand } from "./commands/plans.js";
77
import { recordsCommand } from "./commands/records.js";
8+
import { skillsCommand } from "./commands/skills.js";
89
import { tablesCommand } from "./commands/tables.js";
910
import { whoamiCommand } from "./commands/whoami.js";
1011
import { program } from "./lib/program.js";
@@ -54,5 +55,6 @@ program.addCommand(plansCommand);
5455
program.addCommand(tablesCommand);
5556
program.addCommand(recordsCommand);
5657
program.addCommand(customFieldsCommand);
58+
program.addCommand(skillsCommand);
5759

5860
await program.parseAsync();

tests/commands/skills.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createMockSpinner, runCommand } from "./helpers.js";
3+
4+
vi.mock("yocto-spinner", () => ({ default: () => createMockSpinner() }));
5+
vi.mock("../../src/lib/program.js", () => ({
6+
program: { opts: () => ({}) },
7+
}));
8+
9+
const execAsync = vi.fn();
10+
vi.mock("node:child_process", () => ({
11+
exec: (...args: unknown[]) => {
12+
const cb = args.at(-1) as (
13+
err: Error | null,
14+
result: { stdout: string; stderr: string }
15+
) => void;
16+
const promise = execAsync(args[0]);
17+
promise
18+
.then(() => cb(null, { stdout: "", stderr: "" }))
19+
.catch((err: Error) => cb(err, { stdout: "", stderr: "" }));
20+
},
21+
}));
22+
23+
const { skillsCommand } = await import("../../src/commands/skills.js");
24+
25+
describe("skills", () => {
26+
it("add runs npx skills add with correct arguments", async () => {
27+
execAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
28+
29+
await runCommand(skillsCommand, ["add", "memberstack-cli"]);
30+
31+
expect(execAsync).toHaveBeenCalledWith(
32+
expect.stringContaining(
33+
"npx skills add Flash-Brew-Digital/memberstack-skills --skill memberstack-cli --agent claude-code codex -y"
34+
)
35+
);
36+
});
37+
38+
it("remove runs npx skills remove with correct arguments", async () => {
39+
execAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
40+
41+
await runCommand(skillsCommand, ["remove", "memberstack-cli"]);
42+
43+
expect(execAsync).toHaveBeenCalledWith(
44+
expect.stringContaining(
45+
"npx skills remove --skill memberstack-cli --agent claude-code codex -y"
46+
)
47+
);
48+
});
49+
50+
it("add handles errors gracefully", async () => {
51+
execAsync.mockRejectedValueOnce(new Error("Command failed"));
52+
53+
const original = process.exitCode;
54+
await runCommand(skillsCommand, ["add", "bad-skill"]);
55+
expect(process.exitCode).toBe(1);
56+
process.exitCode = original;
57+
});
58+
59+
it("remove handles errors gracefully", async () => {
60+
execAsync.mockRejectedValueOnce(new Error("Command failed"));
61+
62+
const original = process.exitCode;
63+
await runCommand(skillsCommand, ["remove", "bad-skill"]);
64+
expect(process.exitCode).toBe(1);
65+
process.exitCode = original;
66+
});
67+
});

0 commit comments

Comments
 (0)