argsbarg 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.private/scratch.md +1 -1
  2. package/CHANGELOG.md +16 -1
  3. package/README.md +17 -13
  4. package/docs/ai-skills.md +24 -52
  5. package/docs/install.md +84 -0
  6. package/docs/mcp.md +5 -5
  7. package/index.d.ts +9 -18
  8. package/package.json +1 -1
  9. package/src/builtins/builtins.test.ts +101 -0
  10. package/src/builtins/completion-bash.ts +240 -0
  11. package/src/builtins/completion-fish.ts +73 -0
  12. package/src/builtins/completion-group.ts +50 -0
  13. package/src/builtins/completion-zsh.ts +244 -0
  14. package/src/builtins/dispatch.ts +123 -0
  15. package/src/builtins/export.ts +46 -0
  16. package/src/builtins/index.ts +10 -0
  17. package/src/builtins/install.ts +99 -0
  18. package/src/builtins/mcp.ts +13 -0
  19. package/src/builtins/presentation.ts +39 -0
  20. package/src/builtins/scopes.ts +45 -0
  21. package/src/builtins/shell-helpers.ts +24 -0
  22. package/src/completion.ts +10 -693
  23. package/src/index.test.ts +44 -55
  24. package/src/index.ts +1 -0
  25. package/src/install/binary.ts +82 -0
  26. package/src/install/compiled.ts +15 -0
  27. package/src/install/completions.ts +52 -0
  28. package/src/install/detect-installed.ts +67 -0
  29. package/src/install/index.ts +196 -0
  30. package/src/install/install.test.ts +124 -0
  31. package/src/install/mcp-config.ts +70 -0
  32. package/src/install/paths.ts +69 -0
  33. package/src/install/plan.ts +183 -0
  34. package/src/install/shell.ts +56 -0
  35. package/src/install/status.ts +63 -0
  36. package/src/install/uninstall.ts +111 -0
  37. package/src/mcp/tools.ts +1 -1
  38. package/src/runtime.ts +21 -83
  39. package/src/schema.ts +7 -49
  40. package/src/skill/generate.ts +4 -4
  41. package/src/skill/install.ts +20 -18
  42. package/src/types.ts +9 -9
  43. package/src/validate.ts +10 -22
  44. package/src/ai.ts +0 -7
package/src/index.test.ts CHANGED
@@ -729,7 +729,7 @@ async function mcpRequest(
729
729
  opts?: { script?: string; env?: Record<string, string> },
730
730
  ): Promise<Map<string | number, object>> {
731
731
  const script = opts?.script ?? "examples/nested.ts";
732
- const proc = Bun.spawn(["bun", "run", script, "ai", "mcp"], {
732
+ const proc = Bun.spawn(["bun", "run", script, "mcp"], {
733
733
  stdin: "pipe",
734
734
  stdout: "pipe",
735
735
  stderr: "pipe",
@@ -777,7 +777,8 @@ test("collectMcpTools lists user leaf commands only", () => {
777
777
  expect(names).toContain("stat_owner_lookup");
778
778
  expect(names).toContain("read");
779
779
  expect(names).not.toContain("hidden");
780
- expect(names).not.toContain("ai");
780
+ expect(names).not.toContain("install");
781
+ expect(names).not.toContain("mcp");
781
782
  expect(names).not.toContain("completion");
782
783
  const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
783
784
  expect(lookup.description).toBe("stat owner lookup — Resolve owner info.");
@@ -809,22 +810,22 @@ test("mcpToolCallToArgv expands varargs positionals", () => {
809
810
  expect(argv).toEqual(["read", "a", "b"]);
810
811
  });
811
812
 
812
- test("reserved command name ai is rejected", () => {
813
+ test("reserved command name install is rejected", () => {
813
814
  const root: CliCommand = {
814
815
  key: "app",
815
816
  description: "",
816
817
  commands: [
817
818
  {
818
- key: "ai",
819
+ key: "install",
819
820
  description: "bad",
820
821
  handler: () => {},
821
822
  },
822
823
  ],
823
824
  };
824
- expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: ai/);
825
+ expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: install/);
825
826
  });
826
827
 
827
- test("top-level command name mcp is allowed", () => {
828
+ test("top-level command name mcp is allowed without mcpServer", () => {
828
829
  const root: CliCommand = {
829
830
  key: "app",
830
831
  description: "",
@@ -839,6 +840,22 @@ test("top-level command name mcp is allowed", () => {
839
840
  expect(() => cliValidateRoot(root)).not.toThrow();
840
841
  });
841
842
 
843
+ test("top-level command name mcp is rejected when mcpServer is set", () => {
844
+ const root: CliCommand = {
845
+ key: "app",
846
+ description: "",
847
+ mcpServer: {},
848
+ commands: [
849
+ {
850
+ key: "mcp",
851
+ description: "user command",
852
+ handler: () => {},
853
+ },
854
+ ],
855
+ };
856
+ expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: mcp/);
857
+ });
858
+
842
859
  test("mcpServer on non-root node is rejected", () => {
843
860
  const root: CliCommand = {
844
861
  key: "app",
@@ -1014,8 +1031,8 @@ test("MCP ping returns empty result", async () => {
1014
1031
  expect(res.result).toEqual({});
1015
1032
  });
1016
1033
 
1017
- test("minimal.ts ai mcp without opt-in fails", async () => {
1018
- const { stderr, exitCode } = await $`bun run examples/minimal.ts ai mcp`.nothrow().quiet();
1034
+ test("minimal.ts mcp without opt-in fails", async () => {
1035
+ const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
1019
1036
  expect(exitCode).toBe(1);
1020
1037
  expect(stderr.toString()).toContain("MCP is not enabled");
1021
1038
  });
@@ -1653,9 +1670,9 @@ test("mcpToolCallToArgv empty string varargs appends nothing", () => {
1653
1670
  expect(argv).toEqual(["read"]);
1654
1671
  });
1655
1672
 
1656
- // ── AI builtins and skills ────────────────────────────────────────────────────
1673
+ // ── Skills ────────────────────────────────────────────────────────────────────
1657
1674
 
1658
- test("aiSkill on non-root node is rejected", () => {
1675
+ test("install config on non-root node is rejected", () => {
1659
1676
  const root: CliCommand = {
1660
1677
  key: "app",
1661
1678
  description: "",
@@ -1663,12 +1680,12 @@ test("aiSkill on non-root node is rejected", () => {
1663
1680
  {
1664
1681
  key: "x",
1665
1682
  description: "",
1666
- aiSkill: { enabled: false },
1683
+ install: { enabled: false },
1667
1684
  handler: () => {},
1668
1685
  },
1669
1686
  ],
1670
1687
  };
1671
- expect(() => cliValidateRoot(root)).toThrow(/aiSkill is only supported on the program root/);
1688
+ expect(() => cliValidateRoot(root)).toThrow(/install is only supported on the program root/);
1672
1689
  });
1673
1690
 
1674
1691
  test("generateSkillBundle includes frontmatter and command catalog", () => {
@@ -1676,7 +1693,7 @@ test("generateSkillBundle includes frontmatter and command catalog", () => {
1676
1693
  expect(bundle.dirName).toBe("nested_ts");
1677
1694
  expect(bundle.skillMd).toMatch(/^---\nname: nested_ts\n/);
1678
1695
  expect(bundle.skillMd).toContain("stat owner lookup");
1679
- expect(bundle.skillMd).toContain("ai mcp");
1696
+ expect(bundle.skillMd).toContain("nested.ts mcp");
1680
1697
  expect(bundle.referenceMd).toContain("```json");
1681
1698
  expect(() => JSON.parse(bundle.referenceMd.match(/```json\n([\s\S]*?)\n```/)![1]!)).not.toThrow();
1682
1699
  });
@@ -1686,8 +1703,8 @@ test("cliSkillInstall writes project Cursor skill files", () => {
1686
1703
  const prev = process.cwd();
1687
1704
  process.chdir(cwd);
1688
1705
  try {
1689
- const msg = cliSkillInstall(nestedMcpFixture, "cursor", { force: true });
1690
- expect(msg).toContain(".cursor/skills/nested_ts/");
1706
+ const files = cliSkillInstall(nestedMcpFixture, "cursor", { rimraf: true });
1707
+ expect(files.some((f) => f.includes(".cursor/skills/nested_ts/"))).toBe(true);
1691
1708
  const skillDir = join(cwd, ".cursor", "skills", "nested_ts");
1692
1709
  expect(existsSync(join(skillDir, "SKILL.md"))).toBe(true);
1693
1710
  expect(existsSync(join(skillDir, "reference.md"))).toBe(true);
@@ -1703,8 +1720,8 @@ test("cliSkillInstall global uses HOME skills directory", () => {
1703
1720
  const prevHome = process.env.HOME;
1704
1721
  process.env.HOME = home;
1705
1722
  try {
1706
- const msg = cliSkillInstall(nestedMcpFixture, "cursor", { global: true, force: true });
1707
- expect(msg).toContain(join(home, ".cursor", "skills", "nested_ts"));
1723
+ const files = cliSkillInstall(nestedMcpFixture, "cursor", { global: true, rimraf: true });
1724
+ expect(files.some((f) => f.includes(join(home, ".cursor", "skills", "nested_ts")))).toBe(true);
1708
1725
  expect(existsSync(join(home, ".cursor", "skills", "nested_ts", "SKILL.md"))).toBe(true);
1709
1726
  } finally {
1710
1727
  if (prevHome === undefined) {
@@ -1716,26 +1733,19 @@ test("cliSkillInstall global uses HOME skills directory", () => {
1716
1733
  }
1717
1734
  });
1718
1735
 
1719
- test("cliSkillInstall fails when directory exists without force", () => {
1736
+ test("cliSkillInstall rimraf overwrites existing directory", () => {
1720
1737
  const cwd = mkdtempSync(join(tmpdir(), "argsbarg-skill-dup-"));
1721
1738
  const prev = process.cwd();
1722
1739
  process.chdir(cwd);
1723
- const prevExit = process.exit;
1724
- let exitCode = 0;
1725
- process.exit = ((code?: number) => {
1726
- exitCode = code ?? 0;
1727
- throw new Error("exit");
1728
- }) as typeof process.exit;
1729
1740
  try {
1730
- cliSkillInstall(nestedMcpFixture, "cursor", { force: true });
1731
- try {
1732
- cliSkillInstall(nestedMcpFixture, "cursor", {});
1733
- } catch {
1734
- // expected exit throw
1735
- }
1736
- expect(exitCode).toBe(1);
1741
+ cliSkillInstall(nestedMcpFixture, "cursor", { rimraf: true });
1742
+ writeFileSync(join(cwd, ".cursor", "skills", "nested_ts", "SKILL.md"), "stale", "utf8");
1743
+ const files = cliSkillInstall(nestedMcpFixture, "cursor", { rimraf: true });
1744
+ expect(files.length).toBeGreaterThan(0);
1745
+ expect(readFileSync(join(cwd, ".cursor", "skills", "nested_ts", "SKILL.md"), "utf8")).toContain(
1746
+ "stat owner lookup",
1747
+ );
1737
1748
  } finally {
1738
- process.exit = prevExit;
1739
1749
  process.chdir(prev);
1740
1750
  rmSync(cwd, { recursive: true, force: true });
1741
1751
  }
@@ -1746,8 +1756,8 @@ test("cliSkillInstall claude target uses .claude/skills", () => {
1746
1756
  const prev = process.cwd();
1747
1757
  process.chdir(cwd);
1748
1758
  try {
1749
- const msg = cliSkillInstall(nestedMcpFixture, "claude", { force: true });
1750
- expect(msg).toContain(".claude/skills/nested_ts/");
1759
+ const files = cliSkillInstall(nestedMcpFixture, "claude", { rimraf: true });
1760
+ expect(files.some((f) => f.includes(".claude/skills/nested_ts/"))).toBe(true);
1751
1761
  expect(readFileSync(join(cwd, ".claude", "skills", "nested_ts", "SKILL.md"), "utf8")).toContain(
1752
1762
  "Claude Code",
1753
1763
  );
@@ -1755,25 +1765,4 @@ test("cliSkillInstall claude target uses .claude/skills", () => {
1755
1765
  process.chdir(prev);
1756
1766
  rmSync(cwd, { recursive: true, force: true });
1757
1767
  }
1758
- });
1759
-
1760
- test("ai skill cursor fails when aiSkill disabled", async () => {
1761
- const dir = mkdtempSync(join(tmpdir(), "argsbarg-skill-off-"));
1762
- const script = join(dir, "skill-off.ts");
1763
- writeFileSync(
1764
- script,
1765
- `import { cliRun, CliCommand } from ${JSON.stringify(join(import.meta.dir, "index.ts"))};
1766
- const cli: CliCommand = {
1767
- key: "offapp",
1768
- description: "demo",
1769
- aiSkill: { enabled: false },
1770
- commands: [{ key: "x", description: "x", handler: () => {} }],
1771
- };
1772
- await cliRun(cli);
1773
- `,
1774
- "utf8",
1775
- );
1776
- const { stderr, exitCode } = await $`bun run ${script} ai skill cursor`.nothrow().quiet();
1777
- expect(exitCode).toBe(1);
1778
- expect(stderr.toString()).toContain("AI skills are disabled");
1779
1768
  });
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export type {
19
19
  CliMcpResource,
20
20
  CliMcpServerConfig,
21
21
  CliMcpToolConfig,
22
+ CliInstallConfig,
22
23
  CliOption,
23
24
  CliPositional,
24
25
  } from "./types.ts";
@@ -0,0 +1,82 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CliCommand } from "../types.ts";
4
+ import { InstallPaths } from "./paths.ts";
5
+ import {
6
+ buildPathRcBlock,
7
+ buildZshFpathRcBlock,
8
+ detectShells,
9
+ hasRcBlock,
10
+ removeRcBlock,
11
+ } from "./shell.ts";
12
+
13
+ export interface BinaryInstallResult {
14
+ changedFiles: string[];
15
+ patchedBashRc: boolean;
16
+ patchedZshRc: boolean;
17
+ }
18
+
19
+ /** Copies the running binary to the install path and patches rc files when shells are detected. */
20
+ export function installBinary(root: CliCommand, paths: InstallPaths, dry: boolean): BinaryInstallResult {
21
+ const changed: string[] = [];
22
+ const shells = detectShells();
23
+ let patchedBashRc = false;
24
+ let patchedZshRc = false;
25
+
26
+ if (!dry) {
27
+ mkdirSync(paths.bindir, { recursive: true });
28
+ copyFileSync(process.execPath, paths.binaryPath);
29
+ }
30
+ changed.push(paths.binaryPath);
31
+
32
+ if (shells.bash && existsSync(dirname(paths.bashRc))) {
33
+ const block = buildPathRcBlock(root.key, paths.bindir);
34
+ let content = existsSync(paths.bashRc) ? readFileSync(paths.bashRc, "utf8") : "";
35
+ if (!hasRcBlock(content, root.key, "path")) {
36
+ if (!content.endsWith("\n") && content.length > 0) content += "\n";
37
+ content += block + "\n";
38
+ if (!dry) writeFileSync(paths.bashRc, content, "utf8");
39
+ changed.push(paths.bashRc);
40
+ patchedBashRc = true;
41
+ }
42
+ }
43
+
44
+ if (shells.zsh) {
45
+ const completionsDir = join(dirname(paths.zshCompletion));
46
+ const block = buildZshFpathRcBlock(root.key, completionsDir);
47
+ let content = existsSync(paths.zshRc) ? readFileSync(paths.zshRc, "utf8") : "";
48
+ if (!hasRcBlock(content, root.key, "fpath")) {
49
+ if (!content.endsWith("\n") && content.length > 0) content += "\n";
50
+ content += block + "\n";
51
+ if (!dry) writeFileSync(paths.zshRc, content, "utf8");
52
+ changed.push(paths.zshRc);
53
+ patchedZshRc = true;
54
+ }
55
+ }
56
+
57
+ return { changedFiles: changed, patchedBashRc, patchedZshRc };
58
+ }
59
+
60
+ /** Removes binary and rc marker blocks. */
61
+ export function uninstallBinary(root: CliCommand, paths: InstallPaths, dry: boolean): string[] {
62
+ const changed: string[] = [];
63
+ if (existsSync(paths.binaryPath)) {
64
+ if (!dry) unlinkSync(paths.binaryPath);
65
+ changed.push(paths.binaryPath);
66
+ }
67
+
68
+ for (const [rcPath, tag] of [
69
+ [paths.bashRc, "path"],
70
+ [paths.zshRc, "fpath"],
71
+ ] as const) {
72
+ if (!existsSync(rcPath)) continue;
73
+ const content = readFileSync(rcPath, "utf8");
74
+ if (hasRcBlock(content, root.key, tag)) {
75
+ const next = removeRcBlock(content, root.key, tag);
76
+ if (!dry) writeFileSync(rcPath, next, "utf8");
77
+ changed.push(rcPath);
78
+ }
79
+ }
80
+
81
+ return changed;
82
+ }
@@ -0,0 +1,15 @@
1
+ /** Test override: when set, `isCompiledExecutable()` returns this value instead of checking Bun.embeddedFiles. */
2
+ let compiledOverride: boolean | null = null;
3
+
4
+ /** @internal For tests only. */
5
+ export function setCompiledExecutableOverride(value: boolean | null): void {
6
+ compiledOverride = value;
7
+ }
8
+
9
+ /** True when running as a `bun build --compile` binary (embedded files present). */
10
+ export function isCompiledExecutable(): boolean {
11
+ if (compiledOverride !== null) {
12
+ return compiledOverride;
13
+ }
14
+ return (Bun.embeddedFiles?.length ?? 0) > 0;
15
+ }
@@ -0,0 +1,52 @@
1
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { CliCommand } from "../types.ts";
4
+ import { completionBashScript, completionFishScript, completionZshScript } from "../builtins/index.ts";
5
+ import { cliPresentationRoot } from "../builtins/presentation.ts";
6
+ import { InstallPaths } from "./paths.ts";
7
+ import { detectShells } from "./shell.ts";
8
+
9
+ /** Writes shell completion scripts for detected shells. */
10
+ export function installCompletions(root: CliCommand, paths: InstallPaths, dry: boolean): string[] {
11
+ const changed: string[] = [];
12
+ const shells = detectShells();
13
+ const schema = cliPresentationRoot(root);
14
+
15
+ if (shells.bash) {
16
+ if (!dry) {
17
+ mkdirSync(dirname(paths.bashCompletion), { recursive: true });
18
+ writeFileSync(paths.bashCompletion, completionBashScript(schema), "utf8");
19
+ }
20
+ changed.push(paths.bashCompletion);
21
+ }
22
+
23
+ if (shells.zsh) {
24
+ if (!dry) {
25
+ mkdirSync(dirname(paths.zshCompletion), { recursive: true });
26
+ writeFileSync(paths.zshCompletion, completionZshScript(schema), "utf8");
27
+ }
28
+ changed.push(paths.zshCompletion);
29
+ }
30
+
31
+ if (shells.fish) {
32
+ if (!dry) {
33
+ mkdirSync(dirname(paths.fishCompletion), { recursive: true });
34
+ writeFileSync(paths.fishCompletion, completionFishScript(schema), "utf8");
35
+ }
36
+ changed.push(paths.fishCompletion);
37
+ }
38
+
39
+ return changed;
40
+ }
41
+
42
+ /** Removes shell completion files. */
43
+ export function uninstallCompletions(paths: InstallPaths, dry: boolean): string[] {
44
+ const changed: string[] = [];
45
+ for (const p of [paths.bashCompletion, paths.zshCompletion, paths.fishCompletion]) {
46
+ if (existsSync(p)) {
47
+ if (!dry) unlinkSync(p);
48
+ changed.push(p);
49
+ }
50
+ }
51
+ return changed;
52
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { CliCommand } from "../types.ts";
3
+ import { InstallPaths } from "./paths.ts";
4
+
5
+ export interface InstalledArtifacts {
6
+ binary: boolean;
7
+ bashCompletion: boolean;
8
+ zshCompletion: boolean;
9
+ fishCompletion: boolean;
10
+ cursorSkill: boolean;
11
+ claudeSkill: boolean;
12
+ cursorMcp: boolean;
13
+ claudeMcp: boolean;
14
+ bashRcPath: boolean;
15
+ zshRcFpath: boolean;
16
+ }
17
+
18
+ function mcpConfigHasServer(path: string, name: string): boolean {
19
+ if (!existsSync(path)) return false;
20
+ try {
21
+ const data = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: Record<string, unknown> };
22
+ return data.mcpServers?.[name] !== undefined;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /** Detects which install artifacts are currently present. */
29
+ export function detectInstalledArtifacts(paths: InstallPaths): InstalledArtifacts {
30
+ return {
31
+ binary: existsSync(paths.binaryPath),
32
+ bashCompletion: existsSync(paths.bashCompletion),
33
+ zshCompletion: existsSync(paths.zshCompletion),
34
+ fishCompletion: existsSync(paths.fishCompletion),
35
+ cursorSkill: existsSync(paths.cursorSkillDir),
36
+ claudeSkill: existsSync(paths.claudeSkillDir),
37
+ cursorMcp: mcpConfigHasServer(paths.cursorMcpPath, paths.mcpName),
38
+ claudeMcp: mcpConfigHasServer(paths.claudeMcpPath, paths.mcpName),
39
+ bashRcPath: false,
40
+ zshRcFpath: false,
41
+ };
42
+ }
43
+
44
+ export interface InstallStatus {
45
+ binary?: string;
46
+ bashCompletion?: string;
47
+ zshCompletion?: string;
48
+ fishCompletion?: string;
49
+ cursorSkill?: string;
50
+ claudeSkill?: string;
51
+ cursorMcp?: string;
52
+ claudeMcp?: string;
53
+ }
54
+
55
+ /** Builds a status inventory from detected artifacts. */
56
+ export function buildInstallStatus(paths: InstallPaths, detected: InstalledArtifacts): InstallStatus {
57
+ const status: InstallStatus = {};
58
+ if (detected.binary) status.binary = paths.binaryPath;
59
+ if (detected.bashCompletion) status.bashCompletion = paths.bashCompletion;
60
+ if (detected.zshCompletion) status.zshCompletion = paths.zshCompletion;
61
+ if (detected.fishCompletion) status.fishCompletion = paths.fishCompletion;
62
+ if (detected.cursorSkill) status.cursorSkill = paths.cursorSkillDir + "/";
63
+ if (detected.claudeSkill) status.claudeSkill = paths.claudeSkillDir + "/";
64
+ if (detected.cursorMcp) status.cursorMcp = `${paths.cursorMcpPath} (server "${paths.mcpName}")`;
65
+ if (detected.claudeMcp) status.claudeMcp = `${paths.claudeMcpPath} (server "${paths.mcpName}")`;
66
+ return status;
67
+ }
@@ -0,0 +1,196 @@
1
+ import { readSync } from "node:fs";
2
+ import { CliCommand } from "../types.ts";
3
+ import { cliSkillInstall } from "../skill/install.ts";
4
+ import { checkMcpConflict, expectedMcpEntry } from "./mcp-config.ts";
5
+ import {
6
+ buildInstallPlan,
7
+ buildUpdatePlan,
8
+ type InstallAction,
9
+ type InstallOpts,
10
+ } from "./plan.ts";
11
+ import { resolveInstallPaths } from "./paths.ts";
12
+ import { installErr, installInfo, installOut, printInstallStatus } from "./status.ts";
13
+ import { buildUninstallPlan, uninstallSkillDir, type UninstallAction } from "./uninstall.ts";
14
+
15
+ function parseInstallOpts(raw: Record<string, string>): InstallOpts {
16
+ const flag = (name: string) => raw[name] === "1";
17
+ return {
18
+ all: flag("all"),
19
+ bin: flag("bin"),
20
+ completions: flag("completions"),
21
+ skill: flag("skill"),
22
+ mcp: flag("mcp"),
23
+ update: flag("update"),
24
+ status: flag("status"),
25
+ uninstall: flag("uninstall"),
26
+ yes: flag("yes"),
27
+ dry: flag("dry"),
28
+ json: flag("json"),
29
+ quiet: flag("quiet"),
30
+ prefix: raw.prefix,
31
+ };
32
+ }
33
+
34
+ function validateOpts(opts: InstallOpts): string | null {
35
+ if (opts.quiet && opts.dry) {
36
+ return "--quiet cannot be combined with --dry.";
37
+ }
38
+ if (opts.quiet && !opts.yes && !opts.json && !opts.update) {
39
+ return "--quiet requires --yes (or --json / --update).";
40
+ }
41
+ if (opts.json) {
42
+ opts.yes = true;
43
+ }
44
+ if (opts.update) {
45
+ opts.bin = true;
46
+ opts.yes = true;
47
+ }
48
+
49
+ const mutationFlags = opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.update || opts.uninstall;
50
+ if (opts.status && mutationFlags) {
51
+ return "--status is mutually exclusive with install/update/uninstall targets.";
52
+ }
53
+ if (opts.update && (opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.uninstall || opts.status)) {
54
+ return "--update cannot be combined with other target flags.";
55
+ }
56
+ if (opts.uninstall && (opts.all || opts.update || opts.status)) {
57
+ return "--uninstall cannot be combined with --all, --update, or --status.";
58
+ }
59
+ if (!opts.status && !opts.update && !opts.uninstall) {
60
+ const hasTarget = opts.all || opts.bin || opts.completions || opts.skill || opts.mcp;
61
+ if (!hasTarget) {
62
+ return "Specify at least one target: --all, --bin, --completions, --skill, or --mcp.";
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function promptConfirm(): boolean {
69
+ process.stderr.write("Continue? [y/N] ");
70
+ const buf = Buffer.alloc(256);
71
+ const n = readSync(0, buf, { length: 256 });
72
+ const ans = buf.toString("utf8", 0, n).trim();
73
+ return ans === "y" || ans === "Y";
74
+ }
75
+
76
+ function runSkillAction(
77
+ root: CliCommand,
78
+ kind: "cursor-skill" | "claude-skill",
79
+ opts: InstallOpts,
80
+ ): string[] {
81
+ const target = kind === "cursor-skill" ? "cursor" : "claude";
82
+ return cliSkillInstall(root, target, {
83
+ global: true,
84
+ rimraf: true,
85
+ dry: opts.dry,
86
+ });
87
+ }
88
+
89
+ function executePlan(
90
+ root: CliCommand,
91
+ actions: Array<InstallAction | UninstallAction>,
92
+ opts: InstallOpts,
93
+ ): string[] {
94
+ const changed: string[] = [];
95
+ for (const action of actions) {
96
+ installInfo(action.message, opts);
97
+ if ("kind" in action && (action.kind === "cursor-skill" || action.kind === "claude-skill")) {
98
+ changed.push(...runSkillAction(root, action.kind, opts));
99
+ continue;
100
+ }
101
+ if (!("kind" in action) && action.summary.startsWith("cursor skill")) {
102
+ const paths = resolveInstallPaths(root, opts);
103
+ changed.push(...uninstallSkillDir(paths.cursorSkillDir, !!opts.dry));
104
+ continue;
105
+ }
106
+ if (!("kind" in action) && action.summary.startsWith("claude skill")) {
107
+ const paths = resolveInstallPaths(root, opts);
108
+ changed.push(...uninstallSkillDir(paths.claudeSkillDir, !!opts.dry));
109
+ continue;
110
+ }
111
+ changed.push(...action.run());
112
+ }
113
+ return changed;
114
+ }
115
+
116
+ /** Main install command orchestrator. */
117
+ export async function cliInstall(root: CliCommand, rawOpts: Record<string, string>): Promise<never> {
118
+ const opts = parseInstallOpts(rawOpts);
119
+ const err = validateOpts(opts);
120
+ if (err) {
121
+ installErr(err);
122
+ process.exit(1);
123
+ }
124
+
125
+ const paths = resolveInstallPaths(root, opts);
126
+
127
+ if (opts.status) {
128
+ printInstallStatus(root, opts);
129
+ process.exit(0);
130
+ }
131
+
132
+ // MCP conflict checks before planning
133
+ if (!opts.uninstall && root.mcpServer && (opts.all || opts.mcp)) {
134
+ const entry = expectedMcpEntry(root);
135
+ const yes = !!opts.yes;
136
+ for (const p of [paths.cursorMcpPath, paths.claudeMcpPath]) {
137
+ const conflict = checkMcpConflict(p, paths.mcpName, entry, yes);
138
+ if (conflict) {
139
+ installErr(conflict);
140
+ process.exit(1);
141
+ }
142
+ }
143
+ }
144
+
145
+ let actions: Array<InstallAction | UninstallAction>;
146
+ if (opts.uninstall) {
147
+ actions = buildUninstallPlan(root, paths, opts);
148
+ } else if (opts.update) {
149
+ actions = buildUpdatePlan(root, paths, opts);
150
+ } else {
151
+ actions = buildInstallPlan(root, paths, opts);
152
+ }
153
+
154
+ if (actions.length === 0) {
155
+ installErr("Nothing to do.");
156
+ process.exit(1);
157
+ }
158
+
159
+ if (!opts.quiet && !opts.json) {
160
+ installOut("About to " + (opts.uninstall ? "remove" : opts.update ? "update" : "install") + ":", opts);
161
+ for (const a of actions) {
162
+ installOut(" - " + a.summary, opts);
163
+ }
164
+ }
165
+
166
+ const autoYes = opts.yes || opts.json || opts.update;
167
+ if (!autoYes) {
168
+ if (!process.stdin.isTTY) {
169
+ installErr("Refusing to proceed without --yes (stdin is not a TTY).");
170
+ process.exit(1);
171
+ }
172
+ if (!promptConfirm()) {
173
+ installErr("Aborted.");
174
+ process.exit(1);
175
+ }
176
+ }
177
+
178
+ const changed = executePlan(root, actions, opts);
179
+
180
+ if (opts.json) {
181
+ process.stdout.write(JSON.stringify(changed, null, 2) + "\n");
182
+ process.exit(0);
183
+ }
184
+
185
+ if (!opts.quiet && changed.length > 0) {
186
+ const verb = opts.uninstall ? "Removed" : opts.update ? "Updated" : "Installed";
187
+ installOut(`${verb} ${changed.length} file(s).`, opts);
188
+ if (!opts.uninstall && (opts.all || opts.bin) && changed.some((p) => p === paths.bashRc || p === paths.zshRc || p === paths.binaryPath)) {
189
+ installOut("Open a new shell, or run: hash -r (bash) / rehash (zsh)", opts);
190
+ }
191
+ }
192
+
193
+ process.exit(0);
194
+ }
195
+
196
+ export { parseInstallOpts };