diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8593b36..7e45dcc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 │ │ @@ -42,6 +43,7 @@ memberstack-cli/ │ │ ├── members.test.ts │ │ ├── plans.test.ts │ │ ├── records.test.ts +│ │ ├── skills.test.ts │ │ ├── tables.test.ts │ │ └── whoami.test.ts │ │ @@ -73,7 +75,7 @@ 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 @@ -81,6 +83,8 @@ Each file exports a Commander `Command` with subcommands. All commands follow th 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. diff --git a/README.md b/README.md index e848730..d67a137 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ memberstack [options] ## Install Agent Skill (Optional) ```bash -npx skills add 224-industries/webflow-skills --skill memberstack-cli +memberstack skills add memberstack-cli ``` ### Global Options @@ -136,6 +136,13 @@ Show the current authenticated app and user. | `update ` | Update a custom field | | `delete ` | Delete a custom field | +#### `skills` — Agent Skill Management + +| Subcommand | Description | +|---|---| +| `add ` | Add a Memberstack agent skill | +| `remove ` | Remove a Memberstack agent skill | + ## Examples ```bash diff --git a/src/commands/skills.ts b/src/commands/skills.ts new file mode 100644 index 0000000..f54fe7c --- /dev/null +++ b/src/commands/skills.ts @@ -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 => { + 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 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 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; + } + }); diff --git a/src/index.ts b/src/index.ts index 6824f04..9e13276 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -54,5 +55,6 @@ program.addCommand(plansCommand); program.addCommand(tablesCommand); program.addCommand(recordsCommand); program.addCommand(customFieldsCommand); +program.addCommand(skillsCommand); await program.parseAsync(); diff --git a/tests/commands/skills.test.ts b/tests/commands/skills.test.ts new file mode 100644 index 0000000..d0866f4 --- /dev/null +++ b/tests/commands/skills.test.ts @@ -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; + }); +});