From 9e0c86e9144fc84a0c06025082150f2b0d669aa5 Mon Sep 17 00:00:00 2001 From: OdevMatheus Date: Fri, 6 Nov 2026 12:49:12 -0300 Subject: [PATCH] feat(cli): support installing skills across multiple selected ai ides simultaneously (#170) --- src/cli.ts | 39 +++++++++++++++------ src/ui/prompt.ts | 30 ++++++++++++---- tests/router_sync.test.ts | 72 ++++++++++++++++++++++++++++++++++++++ tests/tui_terminal.test.ts | 51 +++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 17 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index d37a08b..fe62b0a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,18 +45,31 @@ const PACKAGE_ROOT = path.resolve( "..", ); -function getFlagValue( +function getAllFlagValues( args: readonly string[], ...flagNames: string[] -): string | undefined { - for (const flag of flagNames) { - const idx = args.indexOf(flag); - const val = idx !== -1 ? args[idx + 1] : undefined; +): string[] { + const flagSet = new Set(flagNames); + const values: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg || !flagSet.has(arg)) { + continue; + } + const val = args[i + 1]; if (val && !val.startsWith("-")) { - return val; + values.push(val); + i += 1; } } - return undefined; + return values; +} + +function getFlagValue( + args: readonly string[], + ...flagNames: string[] +): string | undefined { + return getAllFlagValues(args, ...flagNames)[0]; } function getPositionalArgs(args: readonly string[]): string[] { @@ -91,7 +104,7 @@ function getPositionalArgs(args: readonly string[]): string[] { } function resolveTargetsArg(args: readonly string[]): string | undefined { - const explicit = getFlagValue( + const explicitValues = getAllFlagValues( args, "--targets", "--harness", @@ -99,8 +112,8 @@ function resolveTargetsArg(args: readonly string[]): string | undefined { "--target", "-t", ); - if (explicit) { - return explicit; + if (explicitValues.length > 0) { + return explicitValues.join(","); } if (args.includes("--auto")) { return "auto"; @@ -409,6 +422,7 @@ export function runCli(argv: readonly string[]): number { ? firstReport : { ...firstReport, + targets: [...new Set(reports.flatMap((r) => r.targets))], artifacts: reports.flatMap((r) => r.artifacts), skipped_existing: [ ...new Set(reports.flatMap((r) => r.skipped_existing)), @@ -491,6 +505,7 @@ export function runCli(argv: readonly string[]): number { ? firstReport : { ...firstReport, + targets: [...new Set(reports.flatMap((r) => r.targets))], artifacts: reports.flatMap((r) => r.artifacts), skipped_existing: [ ...new Set(reports.flatMap((r) => r.skipped_existing)), @@ -557,13 +572,15 @@ export async function runCliInteractive( step1Title, targetOptions, { - subtitle: "Select one or more AI coding assistants to configure", + subtitle: + "Select one or more AI coding assistants with Space · Enter to continue", tabLabels: { skill: "AI IDEs & Agents", agent: "", }, submitActionLabel: "continue", selectCursorOnEmptySubmit: true, + showSelectAll: true, }, ); diff --git a/src/ui/prompt.ts b/src/ui/prompt.ts index a87fc76..cea0752 100644 --- a/src/ui/prompt.ts +++ b/src/ui/prompt.ts @@ -118,13 +118,20 @@ export function createMultiSelectState( const agentItems = options.filter((opt) => optionMatchesTab(opt, "agent")); const withAll: MultiSelectOption[] = []; if (skillItems.length > 0) { + const isIdeSelector = + agentItems.length === 0 && + skillItems.every((opt) => opt.kind === "ide"); withAll.push({ - value: "__all_skills__", - label: "[ALL] Select all Skills", - kind: "skill", + value: isIdeSelector ? "__all_ides__" : "__all_skills__", + label: isIdeSelector + ? "[ALL] Select all AI IDEs" + : "[ALL] Select all Skills", + kind: isIdeSelector ? "ide" : "skill", tab: "skill", isSelectAll: true, - hint: `Select or deselect all ${skillItems.length} Skills in this tab`, + hint: isIdeSelector + ? `Select or deselect all ${skillItems.length} AI IDEs & CLIs` + : `Select or deselect all ${skillItems.length} Skills in this tab`, }); withAll.push(...skillItems); } @@ -204,7 +211,13 @@ export function applyMultiSelectKey( if (options.selectCursorOnEmptySubmit && state.selected.size === 0) { const visibleOnSubmit = getFilteredTabOptions(state, state.activeTab); const currentOpt = visibleOnSubmit[state.cursor]; - if (currentOpt) { + if (currentOpt?.isSelectAll) { + finalSelected = new Set( + visibleOnSubmit + .filter((opt) => !opt.isSelectAll) + .map((opt) => opt.value), + ); + } else if (currentOpt) { finalSelected = new Set([currentOpt.value]); } } @@ -632,9 +645,14 @@ export function renderMultiSelectFrame( ).length; const currentOptLabel = visible[state.cursor]?.label ?? ""; + const selectedList = [...state.selected]; + const selectedPreview = + selectedList.length <= 3 + ? selectedList.join(", ") + : `${selectedList.slice(0, 2).join(", ")}, +${selectedList.length - 2}`; const summaryText = options.tabLabels ? state.selected.size > 0 - ? `${state.selected.size} selected` + ? `${state.selected.size} selected (${selectedPreview})` : options.selectCursorOnEmptySubmit && currentOptLabel ? `Target: ${currentOptLabel}` : "0 selected" diff --git a/tests/router_sync.test.ts b/tests/router_sync.test.ts index 5321a98..455827e 100644 --- a/tests/router_sync.test.ts +++ b/tests/router_sync.test.ts @@ -425,4 +425,76 @@ describe("SkillOps Multi-Runtime Add & Remove Engine", () => { fs.rmSync(tmpRepo, { recursive: true, force: true }); } }); + + it("installs Skills and Subagents across all selected AI IDEs simultaneously via comma or repeated -t flags (Issue #170)", () => { + const tmpRepo = fs.mkdtempSync( + path.join(os.tmpdir(), "skillops-multi-ide-install-"), + ); + try { + const code = runCli([ + "node", + "cli.js", + "add", + "mcp-architect", + "context-scout", + "--repo", + tmpRepo, + "--catalog", + CATALOG_ROOT, + "-t", + "cursor", + "-t", + "claude", + "--target", + "gemini", + ]); + expect(code).toBe(ExitCode.SUCCESS); + + // Verify all 3 selected AI IDEs received both the Skill and the Subagent + expect( + fs.existsSync( + path.join(tmpRepo, ".cursor", "rules", "skillops-mcp-architect.mdc"), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join( + tmpRepo, + ".cursor", + "rules", + "skillops-agent-context-scout.mdc", + ), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(tmpRepo, ".claude", "skills", "mcp-architect", "SKILL.md"), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(tmpRepo, ".claude", "agents", "context-scout.md"), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(tmpRepo, ".gemini", "skills", "mcp-architect", "SKILL.md"), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(tmpRepo, ".gemini", "agents", "context-scout.md"), + ), + ).toBe(true); + + const lockRaw = JSON.parse( + fs.readFileSync(path.join(tmpRepo, ".skillops-lock.json"), "utf-8"), + ) as { targets: string[]; selected_skills: string[]; selected_subagents: string[] }; + expect(lockRaw.targets).toEqual(["cursor", "claude", "gemini"]); + expect(lockRaw.selected_skills).toEqual(["mcp-architect"]); + expect(lockRaw.selected_subagents).toEqual(["context-scout"]); + } finally { + fs.rmSync(tmpRepo, { recursive: true, force: true }); + } + }); }); diff --git a/tests/tui_terminal.test.ts b/tests/tui_terminal.test.ts index 1f1759d..6b762bd 100644 --- a/tests/tui_terminal.test.ts +++ b/tests/tui_terminal.test.ts @@ -258,6 +258,57 @@ describe("Zero-Dependency ANSI TUI Aesthetic Engine", () => { expect(ideState.status).toBe("submitted"); expect([...ideState.selected]).toEqual(["antigravity"]); + // Multi-IDE selection in Step 1 (RN2 #170): selecting multiple IDEs with Space or [ALL] + let multiIdeState = createMultiSelectState( + [ + { + value: "cursor", + label: "cursor", + kind: "ide", + tab: "skill", + hint: "Cursor AI IDE (.cursor/rules)", + }, + { + value: "claude", + label: "claude", + kind: "ide", + tab: "skill", + hint: "Anthropic Claude Code (.claude/skills)", + }, + { + value: "gemini", + label: "gemini", + kind: "ide", + tab: "skill", + hint: "Google Gemini CLI (.gemini/skills)", + }, + ], + { showSelectAll: true }, + ); + expect(multiIdeState.options[0]?.label).toBe("[ALL] Select all AI IDEs"); + // Move down to cursor, toggle Space, move down to claude, toggle Space + multiIdeState = applyMultiSelectKey(multiIdeState, "\x1b[B"); + multiIdeState = applyMultiSelectKey(multiIdeState, " "); + multiIdeState = applyMultiSelectKey(multiIdeState, "\x1b[B"); + multiIdeState = applyMultiSelectKey(multiIdeState, " "); + const multiIdeFrame = renderMultiSelectFrame( + "SkillOps — Step 1/2: Select Your AI IDE / Agent", + multiIdeState, + { + tabLabels: { skill: "AI IDEs & Agents", agent: "" }, + submitActionLabel: "continue", + selectCursorOnEmptySubmit: true, + showSelectAll: true, + colorEnabled: false, + }, + ); + expect(multiIdeFrame).toContain("2 selected (cursor, claude) — Enter to continue"); + multiIdeState = applyMultiSelectKey(multiIdeState, "\r", { + selectCursorOnEmptySubmit: true, + }); + expect(multiIdeState.status).toBe("submitted"); + expect([...multiIdeState.selected]).toEqual(["cursor", "claude"]); + // Select All option at the top of Skills & Agents tabs (showSelectAll: true) let selectAllState = createMultiSelectState( [