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
39 changes: 28 additions & 11 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -91,16 +104,16 @@ function getPositionalArgs(args: readonly string[]): string[] {
}

function resolveTargetsArg(args: readonly string[]): string | undefined {
const explicit = getFlagValue(
const explicitValues = getAllFlagValues(
args,
"--targets",
"--harness",
"--harnesses",
"--target",
"-t",
);
if (explicit) {
return explicit;
if (explicitValues.length > 0) {
return explicitValues.join(",");
}
if (args.includes("--auto")) {
return "auto";
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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,
},
);

Expand Down
30 changes: 24 additions & 6 deletions src/ui/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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]);
}
}
Expand Down Expand Up @@ -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"
Expand Down
72 changes: 72 additions & 0 deletions tests/router_sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
51 changes: 51 additions & 0 deletions tests/tui_terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down
Loading