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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@scalekit-inc/cli",
"version": "0.3.6",
"version": "0.3.8",
"description": "Scalekit CLI",
"type": "module",
"bin": {
Expand Down
94 changes: 85 additions & 9 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -63,6 +66,22 @@ async function runStack(
}
}

async function runSkillsInstall(): Promise<boolean> {
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);
Expand All @@ -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,
});

Expand All @@ -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));
}

Expand All @@ -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;

Expand All @@ -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.`);
Expand Down Expand Up @@ -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",
Expand All @@ -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) => {
Expand Down
19 changes: 19 additions & 0 deletions src/core/skills.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
}
2 changes: 1 addition & 1 deletion src/stacks/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
147 changes: 146 additions & 1 deletion test/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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([]);

Expand All @@ -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.",
);
});
});
Loading
Loading