argsbarg 1.4.2 → 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 (43) hide show
  1. package/.private/scratch.md +2 -1
  2. package/CHANGELOG.md +29 -1
  3. package/README.md +22 -7
  4. package/docs/ai-skills.md +47 -0
  5. package/docs/install.md +84 -0
  6. package/docs/mcp.md +7 -5
  7. package/index.d.ts +11 -9
  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 -652
  23. package/src/index.test.ts +135 -4
  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 +23 -66
  39. package/src/schema.ts +7 -49
  40. package/src/skill/generate.ts +183 -0
  41. package/src/skill/install.ts +47 -0
  42. package/src/types.ts +12 -0
  43. package/src/validate.ts +14 -20
package/src/index.test.ts CHANGED
@@ -19,12 +19,14 @@ import {
19
19
  } from "./mcp/tools.ts";
20
20
  import { applyShellEnv, loadEnvFile } from "./mcp/env.ts";
21
21
  import { buildToolCallSuccess } from "./mcp/result.ts";
22
+ import { generateSkillBundle } from "./skill/generate.ts";
23
+ import { cliSkillInstall } from "./skill/install.ts";
22
24
  import { ParseKind, parse, postParseValidate } from "./parse.ts";
23
25
  import { cliSchemaJson } from "./schema.ts";
24
26
  import { cliValidateRoot } from "./validate.ts";
25
27
  import { expect, test } from "bun:test";
26
28
  import { $ } from "bun";
27
- import { mkdtempSync, writeFileSync } from "node:fs";
29
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
30
  import { tmpdir } from "node:os";
29
31
  import { join } from "node:path";
30
32
 
@@ -775,6 +777,7 @@ test("collectMcpTools lists user leaf commands only", () => {
775
777
  expect(names).toContain("stat_owner_lookup");
776
778
  expect(names).toContain("read");
777
779
  expect(names).not.toContain("hidden");
780
+ expect(names).not.toContain("install");
778
781
  expect(names).not.toContain("mcp");
779
782
  expect(names).not.toContain("completion");
780
783
  const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
@@ -807,18 +810,49 @@ test("mcpToolCallToArgv expands varargs positionals", () => {
807
810
  expect(argv).toEqual(["read", "a", "b"]);
808
811
  });
809
812
 
810
- test("reserved command name mcp is rejected", () => {
813
+ test("reserved command name install is rejected", () => {
811
814
  const root: CliCommand = {
812
815
  key: "app",
813
816
  description: "",
814
817
  commands: [
815
818
  {
816
- key: "mcp",
819
+ key: "install",
817
820
  description: "bad",
818
821
  handler: () => {},
819
822
  },
820
823
  ],
821
824
  };
825
+ expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: install/);
826
+ });
827
+
828
+ test("top-level command name mcp is allowed without mcpServer", () => {
829
+ const root: CliCommand = {
830
+ key: "app",
831
+ description: "",
832
+ commands: [
833
+ {
834
+ key: "mcp",
835
+ description: "user command",
836
+ handler: () => {},
837
+ },
838
+ ],
839
+ };
840
+ expect(() => cliValidateRoot(root)).not.toThrow();
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
+ };
822
856
  expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: mcp/);
823
857
  });
824
858
 
@@ -1000,7 +1034,7 @@ test("MCP ping returns empty result", async () => {
1000
1034
  test("minimal.ts mcp without opt-in fails", async () => {
1001
1035
  const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
1002
1036
  expect(exitCode).toBe(1);
1003
- expect(stderr.toString()).toContain("mcp");
1037
+ expect(stderr.toString()).toContain("MCP is not enabled");
1004
1038
  });
1005
1039
 
1006
1040
  test("ctx.invocation is cli via cliRun", async () => {
@@ -1634,4 +1668,101 @@ test("mcpToolCallToArgv empty string varargs appends nothing", () => {
1634
1668
  const read = tools.find((t) => t.name === "read")!;
1635
1669
  const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "" });
1636
1670
  expect(argv).toEqual(["read"]);
1671
+ });
1672
+
1673
+ // ── Skills ────────────────────────────────────────────────────────────────────
1674
+
1675
+ test("install config on non-root node is rejected", () => {
1676
+ const root: CliCommand = {
1677
+ key: "app",
1678
+ description: "",
1679
+ commands: [
1680
+ {
1681
+ key: "x",
1682
+ description: "",
1683
+ install: { enabled: false },
1684
+ handler: () => {},
1685
+ },
1686
+ ],
1687
+ };
1688
+ expect(() => cliValidateRoot(root)).toThrow(/install is only supported on the program root/);
1689
+ });
1690
+
1691
+ test("generateSkillBundle includes frontmatter and command catalog", () => {
1692
+ const bundle = generateSkillBundle(nestedMcpFixture, "cursor");
1693
+ expect(bundle.dirName).toBe("nested_ts");
1694
+ expect(bundle.skillMd).toMatch(/^---\nname: nested_ts\n/);
1695
+ expect(bundle.skillMd).toContain("stat owner lookup");
1696
+ expect(bundle.skillMd).toContain("nested.ts mcp");
1697
+ expect(bundle.referenceMd).toContain("```json");
1698
+ expect(() => JSON.parse(bundle.referenceMd.match(/```json\n([\s\S]*?)\n```/)![1]!)).not.toThrow();
1699
+ });
1700
+
1701
+ test("cliSkillInstall writes project Cursor skill files", () => {
1702
+ const cwd = mkdtempSync(join(tmpdir(), "argsbarg-skill-"));
1703
+ const prev = process.cwd();
1704
+ process.chdir(cwd);
1705
+ try {
1706
+ const files = cliSkillInstall(nestedMcpFixture, "cursor", { rimraf: true });
1707
+ expect(files.some((f) => f.includes(".cursor/skills/nested_ts/"))).toBe(true);
1708
+ const skillDir = join(cwd, ".cursor", "skills", "nested_ts");
1709
+ expect(existsSync(join(skillDir, "SKILL.md"))).toBe(true);
1710
+ expect(existsSync(join(skillDir, "reference.md"))).toBe(true);
1711
+ expect(readFileSync(join(skillDir, "SKILL.md"), "utf8")).toContain("stat owner lookup");
1712
+ } finally {
1713
+ process.chdir(prev);
1714
+ rmSync(cwd, { recursive: true, force: true });
1715
+ }
1716
+ });
1717
+
1718
+ test("cliSkillInstall global uses HOME skills directory", () => {
1719
+ const home = mkdtempSync(join(tmpdir(), "argsbarg-home-"));
1720
+ const prevHome = process.env.HOME;
1721
+ process.env.HOME = home;
1722
+ try {
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);
1725
+ expect(existsSync(join(home, ".cursor", "skills", "nested_ts", "SKILL.md"))).toBe(true);
1726
+ } finally {
1727
+ if (prevHome === undefined) {
1728
+ delete process.env.HOME;
1729
+ } else {
1730
+ process.env.HOME = prevHome;
1731
+ }
1732
+ rmSync(home, { recursive: true, force: true });
1733
+ }
1734
+ });
1735
+
1736
+ test("cliSkillInstall rimraf overwrites existing directory", () => {
1737
+ const cwd = mkdtempSync(join(tmpdir(), "argsbarg-skill-dup-"));
1738
+ const prev = process.cwd();
1739
+ process.chdir(cwd);
1740
+ try {
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
+ );
1748
+ } finally {
1749
+ process.chdir(prev);
1750
+ rmSync(cwd, { recursive: true, force: true });
1751
+ }
1752
+ });
1753
+
1754
+ test("cliSkillInstall claude target uses .claude/skills", () => {
1755
+ const cwd = mkdtempSync(join(tmpdir(), "argsbarg-skill-claude-"));
1756
+ const prev = process.cwd();
1757
+ process.chdir(cwd);
1758
+ try {
1759
+ const files = cliSkillInstall(nestedMcpFixture, "claude", { rimraf: true });
1760
+ expect(files.some((f) => f.includes(".claude/skills/nested_ts/"))).toBe(true);
1761
+ expect(readFileSync(join(cwd, ".claude", "skills", "nested_ts", "SKILL.md"), "utf8")).toContain(
1762
+ "Claude Code",
1763
+ );
1764
+ } finally {
1765
+ process.chdir(prev);
1766
+ rmSync(cwd, { recursive: true, force: true });
1767
+ }
1637
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 };