diff --git a/package.json b/package.json index da2b720..afd9b51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@scalekit-inc/cli", - "version": "0.3.6", + "version": "0.3.8", "description": "Scalekit CLI", "type": "module", "bin": { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 208bd62..0220024 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -6,16 +6,19 @@ import { log, multiselect, outro, + select, } from "@clack/prompts"; import type { Command } from "commander"; import pc from "picocolors"; import { styledCommand } from "../core/help.js"; import { isJson, isNonInteractive, jsonErr, jsonOut } from "../core/output.js"; +import { installSkills, SKILLS_CMD } from "../core/skills.js"; import { findStack, type Stack, stacks } from "../stacks/registry.js"; interface SetupOpts { yes?: boolean; dryRun?: boolean; + skipSkills?: boolean; } interface StackResult { @@ -63,6 +66,22 @@ async function runStack( } } +async function runSkillsInstall(): Promise { + log.step("Installing skills..."); + try { + await installSkills(); + log.success("Skills installed."); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error(`Skills installation failed: ${message}`); + log.info( + `You can install later: ${pc.cyan("npx skills add scalekit-inc/skills")}`, + ); + return false; + } +} + async function interactiveSetup(opts: SetupOpts, cmd: Command) { const json = isJson(cmd); const nonInteractive = isNonInteractive(cmd); @@ -75,18 +94,34 @@ async function interactiveSetup(opts: SetupOpts, cmd: Command) { log.info(`Detected: ${detected.map((s) => s.name).join(", ")}`); } + const SKILLS_ID = "skills"; + let toInstall: Stack[]; + let installSkillsSelected = !opts.skipSkills; if (nonInteractive) { toInstall = detected.length > 0 ? detected : stacks; } else { + const skillsOption = opts.skipSkills + ? [] + : [ + { + value: SKILLS_ID, + label: "Other agents", + hint: "Cline, Windsurf, Aider & more", + }, + ]; + const selected = await multiselect({ - message: "Which editors do you want to set up?", - options: stacks.map((s) => ({ - value: s.id, - label: s.name, - hint: s.detect() ? "detected" : undefined, - })), + message: "What do you want to set up?", + options: [ + ...stacks.map((s) => ({ + value: s.id, + label: s.name, + hint: s.detect() ? "detected" : undefined, + })), + ...skillsOption, + ], required: true, }); @@ -95,6 +130,7 @@ async function interactiveSetup(opts: SetupOpts, cmd: Command) { process.exit(0); } + installSkillsSelected = selected.includes(SKILLS_ID); toInstall = stacks.filter((s) => selected.includes(s.id)); } @@ -109,6 +145,42 @@ async function interactiveSetup(opts: SetupOpts, cmd: Command) { } } + // Install skills if selected + let skillsInstalled = false; + + if (installSkillsSelected) { + if (opts.dryRun) { + if (!json) log.info(`Would run: ${SKILLS_CMD}`); + } else if (nonInteractive) { + skillsInstalled = await runSkillsInstall(); + } else { + const action = await select({ + message: "How do you want to install skills?", + options: [ + { + value: "auto", + label: "Install now", + hint: `runs ${SKILLS_CMD}`, + }, + { + value: "manual", + label: "I'll do it myself", + hint: "shows the command to run", + }, + ], + }); + + if (!isCancel(action) && action === "auto") { + skillsInstalled = await runSkillsInstall(); + } else { + log.info(""); + log.info("Run this to install skills with the interactive wizard:"); + log.info(` ${pc.cyan("npx skills add scalekit-inc/skills")}`); + log.info(""); + } + } + } + const succeeded = results.filter((r) => r.status !== "failed").length; const failed = results.filter((r) => r.status === "failed").length; @@ -135,11 +207,13 @@ async function interactiveSetup(opts: SetupOpts, cmd: Command) { } } + const total = succeeded + (skillsInstalled ? 1 : 0); + if (opts.dryRun) { outro("Dry run complete — no commands were executed."); } else if (failed === 0) { outro( - `Setup complete! ${succeeded} stack${succeeded !== 1 ? "s" : ""} installed.`, + `Setup complete! ${total} component${total !== 1 ? "s" : ""} installed.`, ); } else { outro(`Done. ${succeeded} succeeded, ${failed} failed.`); @@ -209,10 +283,11 @@ const setupExtensionShortcut = styledCommand("extension") }); export const setupCommand = styledCommand("setup") - .description("set up ScaleKit auth stacks for your editors") + .description("set up ScaleKit auth stacks for your coding agents") .argument("[stack]", "cursor, claude, codex, copilot (or any alias)") .option("-y, --yes", "skip confirmation prompts") .option("--dry-run", "show commands without executing") + .option("--skip-skills", "skip Scalekit skills installation") .addCommand(setupExtensionShortcut) .addHelpText( "after", @@ -222,7 +297,8 @@ Examples: $ scalekit setup cursor set up Cursor directly (alias for setup extension cursor) $ scalekit setup extension cc shortcut → extension install claude $ scalekit setup codex -y skip confirmation - $ scalekit setup --dry-run preview commands without running them`, + $ scalekit setup --dry-run preview commands without running them + $ scalekit setup --skip-skills set up agents only, skip skills`, ) .action( async (stackId: string | undefined, opts: SetupOpts, cmd: Command) => { diff --git a/src/core/skills.ts b/src/core/skills.ts new file mode 100644 index 0000000..bba84a4 --- /dev/null +++ b/src/core/skills.ts @@ -0,0 +1,19 @@ +import { spawn } from "node:child_process"; + +const SKILLS_REPO = "scalekit-inc/skills"; + +export const SKILLS_CMD = `npx skills add ${SKILLS_REPO} --all`; + +export async function installSkills(): Promise { + return new Promise((resolve, reject) => { + const child = spawn("npx", ["skills", "add", SKILLS_REPO, "--all"], { + shell: true, + stdio: "inherit", + }); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`"${SKILLS_CMD}" exited with code ${code}`)); + }); + child.on("error", reject); + }); +} diff --git a/src/stacks/cursor.ts b/src/stacks/cursor.ts index 87bfb90..7b591d9 100644 --- a/src/stacks/cursor.ts +++ b/src/stacks/cursor.ts @@ -11,7 +11,7 @@ const INSTALL_CMD = `curl -fsSL ${INSTALL_URL} | bash`; export const cursorStack: Stack = { id: "cursor", name: "Cursor", - description: "Scalekit auth plugins for Cursor editor", + description: "Scalekit auth plugins for Cursor", commands: [INSTALL_CMD], detect() { diff --git a/test/commands/setup.test.ts b/test/commands/setup.test.ts index 2f57bd2..1a206f2 100644 --- a/test/commands/setup.test.ts +++ b/test/commands/setup.test.ts @@ -5,19 +5,35 @@ vi.mock("@clack/prompts", () => ({ outro: vi.fn(), log: { info: vi.fn(), step: vi.fn(), success: vi.fn(), error: vi.fn() }, multiselect: vi.fn(), + select: vi.fn(), confirm: vi.fn(), cancel: vi.fn(), isCancel: vi.fn(() => false), })); -import { cancel, confirm, isCancel, log, multiselect } from "@clack/prompts"; +vi.mock("../../src/core/skills.js", () => ({ + installSkills: vi.fn(() => Promise.resolve()), + SKILLS_CMD: "npx skills add scalekit-inc/skills --all", +})); + +import { + cancel, + confirm, + isCancel, + log, + multiselect, + select, +} from "@clack/prompts"; import { setupCommand } from "../../src/commands/setup.js"; +import { installSkills } from "../../src/core/skills.js"; import { stacks } from "../../src/stacks/registry.js"; const mockLog = vi.mocked(log); const mockMultiselect = vi.mocked(multiselect); +const mockSelect = vi.mocked(select); const mockConfirm = vi.mocked(confirm); const mockIsCancel = vi.mocked(isCancel); +const mockInstallSkills = vi.mocked(installSkills); function stubStacks(opts: { detect?: boolean; installError?: Error } = {}) { for (const stack of stacks) { @@ -224,6 +240,7 @@ describe("next steps after setup", () => { it("shows next steps in interactive setup", async () => { stubStacks({ detect: true }); mockMultiselect.mockResolvedValue(["claude", "copilot"] as never); + mockConfirm.mockResolvedValue(false as never); await run([]); @@ -236,3 +253,131 @@ describe("next steps after setup", () => { ); }); }); + +describe("skills installation", () => { + it("--yes installs skills automatically", async () => { + stubStacks({ detect: true }); + await run(["--yes"]); + + expect(mockInstallSkills).toHaveBeenCalled(); + }); + + it("--yes --skip-skills skips skills", async () => { + stubStacks({ detect: true }); + await run(["--yes", "--skip-skills"]); + + expect(mockInstallSkills).not.toHaveBeenCalled(); + }); + + it("--dry-run previews the skills command without running it", async () => { + stubStacks({ detect: true }); + await run(["--dry-run", "--yes"]); + + expect(mockInstallSkills).not.toHaveBeenCalled(); + expect(mockLog.info).toHaveBeenCalledWith( + "Would run: npx skills add scalekit-inc/skills --all", + ); + }); + + it("interactive: 'Install now' runs installSkills", async () => { + stubStacks(); + mockMultiselect.mockResolvedValue(["cursor", "skills"] as never); + mockSelect.mockResolvedValue("auto" as never); + + await run([]); + + expect(mockInstallSkills).toHaveBeenCalled(); + expect(mockLog.success).toHaveBeenCalledWith("Skills installed."); + }); + + it("interactive: 'I'll do it myself' shows the command", async () => { + stubStacks(); + mockMultiselect.mockResolvedValue(["cursor", "skills"] as never); + mockSelect.mockResolvedValue("manual" as never); + + await run([]); + + expect(mockInstallSkills).not.toHaveBeenCalled(); + const calls = mockLog.info.mock.calls.map((c) => c[0] as string); + expect( + calls.some((c) => c.includes("npx skills add scalekit-inc/skills")), + ).toBe(true); + }); + + it("interactive: not selecting skills skips installation", async () => { + stubStacks(); + mockMultiselect.mockResolvedValue(["cursor"] as never); + + await run([]); + + expect(mockInstallSkills).not.toHaveBeenCalled(); + }); + + it("multiselect includes 'Other agents' option", async () => { + stubStacks(); + mockMultiselect.mockResolvedValue(["cursor"] as never); + + await run([]); + + const call = mockMultiselect.mock.calls[0][0] as { + options: { value: string; label: string }[]; + }; + const skillsOpt = call.options.find((o) => o.value === "skills"); + expect(skillsOpt).toBeDefined(); + expect(skillsOpt?.label).toBe("Other agents"); + }); + + it("--skip-skills hides skills from multiselect", async () => { + stubStacks(); + mockMultiselect.mockResolvedValue(["cursor"] as never); + + await run(["--skip-skills"]); + + const call = mockMultiselect.mock.calls[0][0] as { + options: { value: string; label: string }[]; + }; + const skillsOpt = call.options.find((o) => o.value === "skills"); + expect(skillsOpt).toBeUndefined(); + }); + + it("handles skills installation failure gracefully", async () => { + stubStacks({ detect: true }); + mockInstallSkills.mockRejectedValueOnce(new Error("network error")); + + await run(["--yes"]); + + expect(mockLog.error).toHaveBeenCalledWith( + "Skills installation failed: network error", + ); + const infoCalls = mockLog.info.mock.calls.map((c) => c[0] as string); + expect( + infoCalls.some((c) => c.includes("npx skills add scalekit-inc/skills")), + ).toBe(true); + }); + + it("outro counts skills in total when installed", async () => { + stubStacks(); + mockMultiselect.mockResolvedValue(["cursor", "skills"] as never); + mockSelect.mockResolvedValue("auto" as never); + + await run([]); + + const { outro } = await import("@clack/prompts"); + expect(outro).toHaveBeenCalledWith( + "Setup complete! 2 components installed.", + ); + }); + + it("outro says '1 component' when only skills installed", async () => { + stubStacks(); + mockMultiselect.mockResolvedValue(["skills"] as never); + mockSelect.mockResolvedValue("auto" as never); + + await run([]); + + const { outro } = await import("@clack/prompts"); + expect(outro).toHaveBeenCalledWith( + "Setup complete! 1 component installed.", + ); + }); +}); diff --git a/test/e2e/__snapshots__/cli.test.ts.snap b/test/e2e/__snapshots__/cli.test.ts.snap index 5c61665..46ed342 100644 --- a/test/e2e/__snapshots__/cli.test.ts.snap +++ b/test/e2e/__snapshots__/cli.test.ts.snap @@ -8,7 +8,7 @@ USAGE COMMANDS extension|ext manage Scalekit extensions for coding tools - setup set up ScaleKit auth stacks for your editors + setup set up ScaleKit auth stacks for your coding agents OPTIONS -V, --version output the version number @@ -19,7 +19,7 @@ OPTIONS `; exports[`CLI E2E > shows setup help 1`] = ` -"scalekit setup — set up ScaleKit auth stacks for your editors +"scalekit setup — set up ScaleKit auth stacks for your coding agents USAGE $ scalekit setup [options] [command] [stack] @@ -28,14 +28,16 @@ COMMANDS extension|ext shortcut → extension install OPTIONS - -y, --yes skip confirmation prompts - --dry-run show commands without executing - -h, --help display help for command + -y, --yes skip confirmation prompts + --dry-run show commands without executing + --skip-skills skip Scalekit skills installation + -h, --help display help for command Examples: $ scalekit setup interactive setup wizard $ scalekit setup cursor set up Cursor directly (alias for setup extension cursor) $ scalekit setup extension cc shortcut → extension install claude $ scalekit setup codex -y skip confirmation - $ scalekit setup --dry-run preview commands without running them" + $ scalekit setup --dry-run preview commands without running them + $ scalekit setup --skip-skills set up agents only, skip skills" `;