argsbarg 3.4.2 → 3.6.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 (73) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/README.md +24 -8
  3. package/biome.json +29 -6
  4. package/bun.lock +22 -0
  5. package/docs/cli-program.md +75 -1
  6. package/docs/install.md +1 -1
  7. package/docs/mcp.md +45 -1
  8. package/docs/templates/cursor/rules/cli-program.mdc +15 -21
  9. package/index.d.ts +95 -50
  10. package/justfile +24 -6
  11. package/package.json +4 -2
  12. package/scripts/release.ts +26 -9
  13. package/src/builtins/builtins.test.ts +9 -4
  14. package/src/builtins/completion-bash.ts +74 -50
  15. package/src/builtins/completion-fish.ts +3 -8
  16. package/src/builtins/completion-group.ts +1 -1
  17. package/src/builtins/completion-zsh.ts +80 -42
  18. package/src/builtins/dispatch.ts +20 -16
  19. package/src/builtins/export.ts +19 -10
  20. package/src/builtins/index.ts +9 -4
  21. package/src/builtins/install.ts +10 -10
  22. package/src/builtins/mcp.ts +1 -1
  23. package/src/builtins/presentation.ts +8 -8
  24. package/src/builtins/scopes.ts +1 -1
  25. package/src/builtins/version.ts +1 -1
  26. package/src/completion.ts +4 -4
  27. package/src/context.ts +91 -15
  28. package/src/docs/api-guide.test.ts +2 -2
  29. package/src/docs/api-guide.ts +19 -5
  30. package/src/docs/builtin.ts +27 -8
  31. package/src/docs/docs.test.ts +18 -11
  32. package/src/docs/mcp-guide.ts +108 -25
  33. package/src/docs/resolve.ts +10 -3
  34. package/src/docs/save.ts +11 -3
  35. package/src/formats.test.ts +35 -0
  36. package/src/formats.ts +135 -0
  37. package/src/headless.test.ts +8 -16
  38. package/src/help.ts +73 -43
  39. package/src/hidden-mcpb.test.ts +7 -6
  40. package/src/hidden.ts +2 -2
  41. package/src/index.test.ts +120 -96
  42. package/src/index.ts +36 -24
  43. package/src/install/binary.ts +12 -5
  44. package/src/install/completions.ts +7 -3
  45. package/src/install/detect-installed.ts +29 -4
  46. package/src/install/gh-release-update.ts +31 -23
  47. package/src/install/index.ts +69 -19
  48. package/src/install/install.test.ts +31 -8
  49. package/src/install/mcp-codex.test.ts +57 -0
  50. package/src/install/mcp-codex.ts +125 -0
  51. package/src/install/mcp-config.ts +12 -5
  52. package/src/install/mcp-opencode.test.ts +98 -0
  53. package/src/install/mcp-opencode.ts +149 -0
  54. package/src/install/paths.ts +29 -3
  55. package/src/install/plan.ts +73 -6
  56. package/src/install/shell.ts +1 -4
  57. package/src/install/status.ts +12 -6
  58. package/src/install/uninstall.ts +38 -4
  59. package/src/install/update.test.ts +2 -2
  60. package/src/install/update.ts +3 -1
  61. package/src/invoke.ts +12 -9
  62. package/src/mcp/bundle.ts +36 -8
  63. package/src/mcp/env.ts +7 -13
  64. package/src/mcp/server.ts +12 -6
  65. package/src/mcp/tools.ts +83 -18
  66. package/src/mcp.ts +3 -3
  67. package/src/parse.ts +129 -27
  68. package/src/runtime.ts +22 -12
  69. package/src/schema.ts +11 -5
  70. package/src/skill/generate.ts +4 -4
  71. package/src/skill/install.ts +6 -2
  72. package/src/types.ts +24 -0
  73. package/src/validate.ts +75 -16
package/src/index.ts CHANGED
@@ -7,28 +7,15 @@ It gives consumers one stable import path without forcing them to know the inter
7
7
  module layout.
8
8
  */
9
9
 
10
- export { cliInvoke } from "./invoke.ts";
11
- export type { CliInvokeKind, CliInvokeResult } from "./invoke.ts";
10
+ export type { CliLeafInputs } from "./context.ts";
12
11
  export { CliContext } from "./context.ts";
13
- export { cliErrWithHelp, cliRun } from "./runtime";
14
- export { CliFallbackMode, CliOptionKind, CliSchemaValidationError } from "./types.ts";
15
- export type {
16
- CliProgram,
17
- CliHandler,
18
- CliInvocation,
19
- CliMcpResource,
20
- CliMcpServerConfig,
21
- CliMcpBundleConfig,
22
- CliMcpToolConfig,
23
- CliInstallConfig,
24
- CliUpdateArtifact,
25
- CliUpdateGetLatest,
26
- CliDocsConfig,
27
- CliDocsTopic,
28
- CliOption,
29
- CliPositional,
30
- } from "./types.ts";
31
- export { isInteractiveTty } from "./utils.ts";
12
+ export {
13
+ parseCommaList,
14
+ parseDate,
15
+ parseDateTime,
16
+ parseDurationMs,
17
+ } from "./formats.ts";
18
+ export type { HeadlessContext } from "./headless.ts";
32
19
  export {
33
20
  formatDryRunMessage,
34
21
  requireYesInNonTty,
@@ -37,7 +24,7 @@ export {
37
24
  shouldRunHeadlessWithYes,
38
25
  wantsExplicitJson,
39
26
  } from "./headless.ts";
40
- export type { HeadlessContext } from "./headless.ts";
27
+ export type { GhReleaseUpdateConfig, GhVersionCheckConfig } from "./install/gh-release-update.ts";
41
28
  export {
42
29
  createGhFetchLatest,
43
30
  createGhVersionCheck,
@@ -45,6 +32,31 @@ export {
45
32
  isAlreadyCurrent,
46
33
  parseReleaseTag,
47
34
  } from "./install/gh-release-update.ts";
48
- export type { GhReleaseUpdateConfig, GhVersionCheckConfig } from "./install/gh-release-update.ts";
49
- export { generateMcpManifest, packMcpBundle, defaultMcpBundlePaths } from "./mcp/bundle.ts";
35
+ export type { CliInvokeKind, CliInvokeResult } from "./invoke.ts";
36
+ export { cliInvoke } from "./invoke.ts";
50
37
  export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
38
+ export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
39
+ export { cliErrWithHelp, cliRun } from "./runtime";
40
+ export type {
41
+ CliDocsConfig,
42
+ CliDocsTopic,
43
+ CliHandler,
44
+ CliInstallConfig,
45
+ CliInvocation,
46
+ CliMcpBundleConfig,
47
+ CliMcpResource,
48
+ CliMcpServerConfig,
49
+ CliMcpToolConfig,
50
+ CliOption,
51
+ CliPositional,
52
+ CliProgram,
53
+ CliUpdateArtifact,
54
+ CliUpdateGetLatest,
55
+ } from "./types.ts";
56
+ export {
57
+ CliFallbackMode,
58
+ CliOptionKind,
59
+ CliSchemaValidationError,
60
+ CliValueFormat,
61
+ } from "./types.ts";
62
+ export { isInteractiveTty } from "./utils.ts";
@@ -1,7 +1,14 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
2
9
  import { dirname, join } from "node:path";
3
- import { CliProgram } from "../types.ts";
4
- import { InstallPaths } from "./paths.ts";
10
+ import type { CliProgram } from "../types.ts";
11
+ import type { InstallPaths } from "./paths.ts";
5
12
  import {
6
13
  buildPathRcBlock,
7
14
  buildZshFpathRcBlock,
@@ -39,7 +46,7 @@ export function installBinary(
39
46
  let content = existsSync(paths.bashRc) ? readFileSync(paths.bashRc, "utf8") : "";
40
47
  if (!hasRcBlock(content, root.key, "path")) {
41
48
  if (!content.endsWith("\n") && content.length > 0) content += "\n";
42
- content += block + "\n";
49
+ content += `${block}\n`;
43
50
  if (!dry) writeFileSync(paths.bashRc, content, "utf8");
44
51
  changed.push(paths.bashRc);
45
52
  patchedBashRc = true;
@@ -52,7 +59,7 @@ export function installBinary(
52
59
  let content = existsSync(paths.zshRc) ? readFileSync(paths.zshRc, "utf8") : "";
53
60
  if (!hasRcBlock(content, root.key, "fpath")) {
54
61
  if (!content.endsWith("\n") && content.length > 0) content += "\n";
55
- content += block + "\n";
62
+ content += `${block}\n`;
56
63
  if (!dry) writeFileSync(paths.zshRc, content, "utf8");
57
64
  changed.push(paths.zshRc);
58
65
  patchedZshRc = true;
@@ -1,9 +1,13 @@
1
1
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
- import { CliProgram } from "../types.ts";
4
- import { completionBashScript, completionFishScript, completionZshScript } from "../builtins/index.ts";
3
+ import {
4
+ completionBashScript,
5
+ completionFishScript,
6
+ completionZshScript,
7
+ } from "../builtins/index.ts";
5
8
  import { cliPresentationRoot } from "../builtins/presentation.ts";
6
- import { InstallPaths } from "./paths.ts";
9
+ import type { CliProgram } from "../types.ts";
10
+ import type { InstallPaths } from "./paths.ts";
7
11
  import { detectShells } from "./shell.ts";
8
12
 
9
13
  /** Writes shell completion scripts for detected shells. */
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { InstallPaths } from "./paths.ts";
2
+ import { codexMcpHasServer } from "./mcp-codex.ts";
3
+ import { detectOpenCodeMcpConfigPath } from "./mcp-opencode.ts";
4
+ import { type InstallPaths, userHome } from "./paths.ts";
3
5
 
4
6
  export interface InstalledArtifacts {
5
7
  binary: boolean;
@@ -11,6 +13,9 @@ export interface InstalledArtifacts {
11
13
  cursorMcp: boolean;
12
14
  claudeMcp: boolean;
13
15
  claudeDesktopMcp: boolean;
16
+ opencodeMcp: boolean;
17
+ codexMcp: boolean;
18
+ chatGptMcp: boolean;
14
19
  bashRcPath: boolean;
15
20
  zshRcFpath: boolean;
16
21
  }
@@ -37,6 +42,9 @@ export function detectInstalledArtifacts(paths: InstallPaths): InstalledArtifact
37
42
  cursorMcp: mcpConfigHasServer(paths.cursorMcpPath, paths.mcpName),
38
43
  claudeMcp: mcpConfigHasServer(paths.claudeMcpPath, paths.mcpName),
39
44
  claudeDesktopMcp: mcpConfigHasServer(paths.claudeDesktopMcpPath, paths.mcpName),
45
+ opencodeMcp: detectOpenCodeMcpConfigPath(userHome(), paths.mcpName) !== undefined,
46
+ codexMcp: codexMcpHasServer(userHome(), paths.mcpName),
47
+ chatGptMcp: mcpConfigHasServer(paths.chatGptMcpPath, paths.mcpName),
40
48
  bashRcPath: false,
41
49
  zshRcFpath: false,
42
50
  };
@@ -52,21 +60,38 @@ export interface InstallStatus {
52
60
  cursorMcp?: string;
53
61
  claudeMcp?: string;
54
62
  claudeDesktopMcp?: string;
63
+ opencodeMcp?: string;
64
+ codexMcp?: string;
65
+ chatGptMcp?: string;
55
66
  }
56
67
 
57
68
  /** Builds a status inventory from detected artifacts. */
58
- export function buildInstallStatus(paths: InstallPaths, detected: InstalledArtifacts): InstallStatus {
69
+ export function buildInstallStatus(
70
+ paths: InstallPaths,
71
+ detected: InstalledArtifacts,
72
+ ): InstallStatus {
59
73
  const status: InstallStatus = {};
60
74
  if (detected.binary) status.binary = paths.binaryPath;
61
75
  if (detected.bashCompletion) status.bashCompletion = paths.bashCompletion;
62
76
  if (detected.zshCompletion) status.zshCompletion = paths.zshCompletion;
63
77
  if (detected.fishCompletion) status.fishCompletion = paths.fishCompletion;
64
- if (detected.cursorSkill) status.cursorSkill = paths.cursorSkillDir + "/";
65
- if (detected.claudeSkill) status.claudeSkill = paths.claudeSkillDir + "/";
78
+ if (detected.cursorSkill) status.cursorSkill = `${paths.cursorSkillDir}/`;
79
+ if (detected.claudeSkill) status.claudeSkill = `${paths.claudeSkillDir}/`;
66
80
  if (detected.cursorMcp) status.cursorMcp = `${paths.cursorMcpPath} (server "${paths.mcpName}")`;
67
81
  if (detected.claudeMcp) status.claudeMcp = `${paths.claudeMcpPath} (server "${paths.mcpName}")`;
68
82
  if (detected.claudeDesktopMcp) {
69
83
  status.claudeDesktopMcp = `${paths.claudeDesktopMcpPath} (server "${paths.mcpName}")`;
70
84
  }
85
+ if (detected.opencodeMcp) {
86
+ const openCodePath =
87
+ detectOpenCodeMcpConfigPath(userHome(), paths.mcpName) ?? paths.opencodeMcpPath;
88
+ status.opencodeMcp = `${openCodePath} (server "${paths.mcpName}")`;
89
+ }
90
+ if (detected.codexMcp) {
91
+ status.codexMcp = `${paths.codexConfigPath} (server "${paths.mcpName}")`;
92
+ }
93
+ if (detected.chatGptMcp) {
94
+ status.chatGptMcp = `${paths.chatGptMcpPath} (server "${paths.mcpName}")`;
95
+ }
71
96
  return status;
72
97
  }
@@ -1,4 +1,12 @@
1
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
2
10
  import { tmpdir } from "node:os";
3
11
  import { dirname, join } from "node:path";
4
12
  import type { CliUpdateGetLatest } from "../types.ts";
@@ -113,7 +121,8 @@ export function createGhVersionCheck(config: GhVersionCheckConfig): {
113
121
  return;
114
122
  }
115
123
 
116
- void config.fetchLatest()
124
+ void config
125
+ .fetchLatest()
117
126
  .then((latest) => {
118
127
  writeVersionCheckCache(config.cachePath, { fetchedAt: Date.now(), latest });
119
128
  })
@@ -125,26 +134,22 @@ export function createGhVersionCheck(config: GhVersionCheckConfig): {
125
134
  }
126
135
 
127
136
  /** Shared `gh release view` fetcher for hooks and version-check refresh. */
128
- export function createGhFetchLatest(config: Pick<GhReleaseUpdateConfig, "repo" | "repoEnvHint">): () => Promise<string> {
137
+ export function createGhFetchLatest(
138
+ config: Pick<GhReleaseUpdateConfig, "repo" | "repoEnvHint">,
139
+ ): () => Promise<string> {
129
140
  return async () => {
130
- const result = await runGh([
131
- "release",
132
- "view",
133
- "--repo",
134
- config.repo,
135
- "--json",
136
- "tagName",
137
- ]);
141
+ const result = await runGh(["release", "view", "--repo", config.repo, "--json", "tagName"]);
138
142
 
139
143
  if (result.exitCode !== 0) {
140
144
  const detail = result.stderr.trim() || result.stdout.trim();
141
- const hint = detail.includes("auth") || detail.includes("401")
142
- ? " Run `gh auth login` and try again."
143
- : detail.includes("release not found") || detail.includes("Not Found")
144
- ? config.repoEnvHint
145
- ? ` ${config.repoEnvHint}`
146
- : ` No releases found for ${config.repo}.`
147
- : "";
145
+ const hint =
146
+ detail.includes("auth") || detail.includes("401")
147
+ ? " Run `gh auth login` and try again."
148
+ : detail.includes("release not found") || detail.includes("Not Found")
149
+ ? config.repoEnvHint
150
+ ? ` ${config.repoEnvHint}`
151
+ : ` No releases found for ${config.repo}.`
152
+ : "";
148
153
  throw new Error(
149
154
  `Failed to fetch latest release from ${config.repo}: ${detail || "unknown error"}.${hint}`,
150
155
  );
@@ -173,7 +178,10 @@ function ensureGhAvailable(): void {
173
178
  }
174
179
  }
175
180
 
176
- async function downloadReleaseAsset(config: GhReleaseUpdateConfig, tempDir: string): Promise<string> {
181
+ async function downloadReleaseAsset(
182
+ config: GhReleaseUpdateConfig,
183
+ tempDir: string,
184
+ ): Promise<string> {
177
185
  const result = await runGh([
178
186
  "release",
179
187
  "download",
@@ -187,9 +195,7 @@ async function downloadReleaseAsset(config: GhReleaseUpdateConfig, tempDir: stri
187
195
 
188
196
  if (result.exitCode !== 0) {
189
197
  const detail = result.stderr.trim() || result.stdout.trim();
190
- throw new Error(
191
- `Failed to download release from ${config.repo}: ${detail || "unknown error"}`,
192
- );
198
+ throw new Error(`Failed to download release from ${config.repo}: ${detail || "unknown error"}`);
193
199
  }
194
200
 
195
201
  const downloadedPath = join(tempDir, config.asset);
@@ -200,7 +206,9 @@ async function downloadReleaseAsset(config: GhReleaseUpdateConfig, tempDir: stri
200
206
  return downloadedPath;
201
207
  }
202
208
 
203
- async function runGh(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
209
+ async function runGh(
210
+ args: string[],
211
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
204
212
  const proc = Bun.spawn(["gh", ...args], { stdout: "pipe", stderr: "pipe" });
205
213
  const [exitCode, stdout, stderr] = await Promise.all([
206
214
  proc.exited,
@@ -1,17 +1,14 @@
1
1
  import { readSync } from "node:fs";
2
2
  import { resolveCapabilities } from "../capabilities.ts";
3
- import { CliProgram } from "../types.ts";
4
3
  import { cliSkillInstall } from "../skill/install.ts";
4
+ import type { CliProgram } from "../types.ts";
5
+ import { checkCodexMcpConflict } from "./mcp-codex.ts";
5
6
  import { checkMcpConflict, expectedMcpEntry } from "./mcp-config.ts";
6
- import {
7
- buildInstallPlan,
8
- buildUpdatePlan,
9
- type InstallAction,
10
- type InstallOpts,
11
- } from "./plan.ts";
12
- import { resolveInstallPaths } from "./paths.ts";
7
+ import { checkOpenCodeMcpConflict, expectedOpenCodeMcpEntry } from "./mcp-opencode.ts";
8
+ import { resolveInstallPaths, userHome } from "./paths.ts";
9
+ import { buildInstallPlan, buildUpdatePlan, type InstallAction, type InstallOpts } from "./plan.ts";
13
10
  import { installErr, installInfo, installOut, printInstallStatus } from "./status.ts";
14
- import { buildUninstallPlan, uninstallSkillDir, type UninstallAction } from "./uninstall.ts";
11
+ import { buildUninstallPlan, type UninstallAction, uninstallSkillDir } from "./uninstall.ts";
15
12
  import { cliUpdate } from "./update.ts";
16
13
 
17
14
  export function parseInstallOpts(raw: Record<string, string>): InstallOpts {
@@ -54,19 +51,39 @@ export function validateInstallOpts(opts: InstallOpts): string | null {
54
51
  }
55
52
 
56
53
  const mutationFlags =
57
- opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.reinstall || opts.update || opts.uninstall;
54
+ opts.all ||
55
+ opts.bin ||
56
+ opts.completions ||
57
+ opts.skill ||
58
+ opts.mcp ||
59
+ opts.reinstall ||
60
+ opts.update ||
61
+ opts.uninstall;
58
62
  if (opts.status && mutationFlags) {
59
63
  return "--status is mutually exclusive with install/reinstall/uninstall targets.";
60
64
  }
61
65
  if (
62
66
  opts.reinstall &&
63
- (opts.all || opts.completions || opts.skill || opts.mcp || opts.uninstall || opts.status || opts.update)
67
+ (opts.all ||
68
+ opts.completions ||
69
+ opts.skill ||
70
+ opts.mcp ||
71
+ opts.uninstall ||
72
+ opts.status ||
73
+ opts.update)
64
74
  ) {
65
75
  return "--reinstall cannot be combined with other target flags.";
66
76
  }
67
77
  if (
68
78
  opts.update &&
69
- (opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.uninstall || opts.status || opts.reinstall)
79
+ (opts.all ||
80
+ opts.bin ||
81
+ opts.completions ||
82
+ opts.skill ||
83
+ opts.mcp ||
84
+ opts.uninstall ||
85
+ opts.status ||
86
+ opts.reinstall)
70
87
  ) {
71
88
  return "--update cannot be combined with other target flags.";
72
89
  }
@@ -134,7 +151,11 @@ function executePlan(
134
151
  export async function runInstallMutation(
135
152
  root: CliProgram,
136
153
  rawOpts: Record<string, string>,
137
- ): Promise<{ changed: string[]; opts: InstallOpts; paths: ReturnType<typeof resolveInstallPaths> }> {
154
+ ): Promise<{
155
+ changed: string[];
156
+ opts: InstallOpts;
157
+ paths: ReturnType<typeof resolveInstallPaths>;
158
+ }> {
138
159
  const opts = parseInstallOpts(rawOpts);
139
160
  const err = validateInstallOpts(opts);
140
161
  if (err) {
@@ -150,13 +171,32 @@ export async function runInstallMutation(
150
171
 
151
172
  if (!opts.uninstall && resolveCapabilities(root).mcp && (opts.all || opts.mcp)) {
152
173
  const entry = expectedMcpEntry(root);
174
+ const openCodeEntry = expectedOpenCodeMcpEntry(root);
153
175
  const yes = !!opts.yes;
154
- for (const p of [paths.cursorMcpPath, paths.claudeMcpPath, paths.claudeDesktopMcpPath]) {
176
+ for (const p of [
177
+ paths.cursorMcpPath,
178
+ paths.claudeMcpPath,
179
+ paths.claudeDesktopMcpPath,
180
+ paths.chatGptMcpPath,
181
+ ]) {
155
182
  const conflict = checkMcpConflict(p, paths.mcpName, entry, yes);
156
183
  if (conflict) {
157
184
  throw new Error(conflict);
158
185
  }
159
186
  }
187
+ const openCodeConflict = checkOpenCodeMcpConflict(
188
+ paths.opencodeMcpPath,
189
+ paths.mcpName,
190
+ openCodeEntry,
191
+ yes,
192
+ );
193
+ if (openCodeConflict) {
194
+ throw new Error(openCodeConflict);
195
+ }
196
+ const codexConflict = checkCodexMcpConflict(userHome(), paths.mcpName, entry, yes);
197
+ if (codexConflict) {
198
+ throw new Error(codexConflict);
199
+ }
160
200
  }
161
201
 
162
202
  let actions: Array<InstallAction | UninstallAction>;
@@ -173,9 +213,12 @@ export async function runInstallMutation(
173
213
  }
174
214
 
175
215
  if (!opts.quiet && !opts.json) {
176
- installOut("About to " + (opts.uninstall ? "remove" : opts.reinstall ? "reinstall" : "install") + ":", opts);
216
+ installOut(
217
+ `About to ${opts.uninstall ? "remove" : opts.reinstall ? "reinstall" : "install"}:`,
218
+ opts,
219
+ );
177
220
  for (const a of actions) {
178
- installOut(" - " + a.summary, opts);
221
+ installOut(` - ${a.summary}`, opts);
179
222
  }
180
223
  }
181
224
 
@@ -194,7 +237,10 @@ export async function runInstallMutation(
194
237
  }
195
238
 
196
239
  /** Main install command orchestrator. */
197
- export async function cliInstall(root: CliProgram, rawOpts: Record<string, string>): Promise<never> {
240
+ export async function cliInstall(
241
+ root: CliProgram,
242
+ rawOpts: Record<string, string>,
243
+ ): Promise<never> {
198
244
  const opts = parseInstallOpts(rawOpts);
199
245
  const err = validateInstallOpts(opts);
200
246
  if (err) {
@@ -225,12 +271,16 @@ export async function cliInstall(root: CliProgram, rawOpts: Record<string, strin
225
271
  }
226
272
 
227
273
  if (mutationOpts.json) {
228
- process.stdout.write(JSON.stringify(changed, null, 2) + "\n");
274
+ process.stdout.write(`${JSON.stringify(changed, null, 2)}\n`);
229
275
  process.exit(0);
230
276
  }
231
277
 
232
278
  if (!mutationOpts.quiet && changed.length > 0) {
233
- const verb = mutationOpts.uninstall ? "Removed" : mutationOpts.reinstall ? "Reinstalled" : "Installed";
279
+ const verb = mutationOpts.uninstall
280
+ ? "Removed"
281
+ : mutationOpts.reinstall
282
+ ? "Reinstalled"
283
+ : "Installed";
234
284
  installOut(`${verb} ${changed.length} file(s).`, mutationOpts);
235
285
  if (
236
286
  !mutationOpts.uninstall &&
@@ -1,14 +1,15 @@
1
- import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
3
  import { tmpdir } from "node:os";
5
- import { CliProgram } from "../types.ts";
4
+ import { join } from "node:path";
5
+ import type { CliProgram } from "../types.ts";
6
6
  import { detectInstalledArtifacts } from "./detect-installed.ts";
7
- import { resolveInstallPaths, resolveClaudeDesktopMcpPath } from "./paths.ts";
7
+ import { parseInstallOpts, runInstallMutation, validateInstallOpts } from "./index.ts";
8
+ import { opencodeConfigDir } from "./mcp-opencode.ts";
9
+ import { resolveClaudeDesktopMcpPath, resolveInstallPaths } from "./paths.ts";
8
10
  import { buildInstallPlan } from "./plan.ts";
9
- import { buildUninstallPlan } from "./uninstall.ts";
10
11
  import { printInstallStatus } from "./status.ts";
11
- import { parseInstallOpts, runInstallMutation, validateInstallOpts } from "./index.ts";
12
+ import { buildUninstallPlan } from "./uninstall.ts";
12
13
 
13
14
  const fixture: CliProgram = {
14
15
  key: "testapp",
@@ -21,15 +22,21 @@ const fixture: CliProgram = {
21
22
  let home: string;
22
23
  let prevHome: string | undefined;
23
24
 
25
+ let prevXdg: string | undefined;
26
+
24
27
  beforeEach(() => {
25
28
  home = mkdtempSync(join(tmpdir(), "argsbarg-install-"));
26
29
  prevHome = process.env.HOME;
30
+ prevXdg = process.env.XDG_CONFIG_HOME;
27
31
  process.env.HOME = home;
32
+ delete process.env.XDG_CONFIG_HOME;
28
33
  });
29
34
 
30
35
  afterEach(() => {
31
36
  if (prevHome === undefined) delete process.env.HOME;
32
37
  else process.env.HOME = prevHome;
38
+ if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
39
+ else process.env.XDG_CONFIG_HOME = prevXdg;
33
40
  rmSync(home, { recursive: true, force: true });
34
41
  });
35
42
 
@@ -94,6 +101,22 @@ describe("install plan", () => {
94
101
  const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
95
102
  expect(plan.some((a) => a.kind === "claude-desktop-mcp")).toBe(false);
96
103
  });
104
+
105
+ test("buildInstallPlan --mcp includes opencode when config dir exists", () => {
106
+ const paths = resolveInstallPaths(fixture, {});
107
+ mkdirSync(opencodeConfigDir(home), { recursive: true });
108
+ const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
109
+ expect(plan.some((a) => a.kind === "opencode-mcp")).toBe(true);
110
+ });
111
+
112
+ test("buildInstallPlan --mcp includes chatgpt when app data exists", () => {
113
+ const paths = resolveInstallPaths(fixture, {});
114
+ if (process.platform === "darwin") {
115
+ mkdirSync(join(home, "Library", "Application Support", "ChatGPT"), { recursive: true });
116
+ const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
117
+ expect(plan.some((a) => a.kind === "chatgpt-desktop-mcp")).toBe(true);
118
+ }
119
+ });
97
120
  });
98
121
 
99
122
  describe("install status", () => {
@@ -209,6 +232,6 @@ describe("uninstall plan", () => {
209
232
 
210
233
  const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", bin: "1" }));
211
234
  expect(plan).toHaveLength(1);
212
- expect(plan[0]!.summary.startsWith("binary:")).toBe(true);
235
+ expect(plan[0]?.summary.startsWith("binary:")).toBe(true);
213
236
  });
214
237
  });
@@ -0,0 +1,57 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { codexMcpHasServer, readCodexMcpEntry, resolveCodexConfigPath } from "./mcp-codex.ts";
6
+
7
+ let home: string;
8
+ let prevHome: string | undefined;
9
+
10
+ beforeEach(() => {
11
+ home = mkdtempSync(join(tmpdir(), "argsbarg-codex-"));
12
+ prevHome = process.env.HOME;
13
+ process.env.HOME = home;
14
+ });
15
+
16
+ afterEach(() => {
17
+ if (prevHome === undefined) delete process.env.HOME;
18
+ else process.env.HOME = prevHome;
19
+ rmSync(home, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("codex mcp config", () => {
23
+ test("readCodexMcpEntry parses simple command/args", () => {
24
+ const path = resolveCodexConfigPath(home);
25
+ mkdirSync(join(home, ".codex"), { recursive: true });
26
+ writeFileSync(
27
+ path,
28
+ `[mcp_servers.testapp]
29
+ command = "testapp"
30
+ args = ["mcp"]
31
+ `,
32
+ "utf8",
33
+ );
34
+ expect(readCodexMcpEntry(path, "testapp")).toEqual({
35
+ command: "testapp",
36
+ args: ["mcp"],
37
+ });
38
+ expect(codexMcpHasServer(home, "testapp")).toBe(true);
39
+ });
40
+
41
+ test("readCodexMcpEntry parses transport stdio block", () => {
42
+ const path = resolveCodexConfigPath(home);
43
+ mkdirSync(join(home, ".codex"), { recursive: true });
44
+ writeFileSync(
45
+ path,
46
+ `[mcp_servers.testapp]
47
+ enabled = true
48
+ transport = { type = "stdio", command = "testapp", args = ["mcp"] }
49
+ `,
50
+ "utf8",
51
+ );
52
+ expect(readCodexMcpEntry(path, "testapp")).toEqual({
53
+ command: "testapp",
54
+ args: ["mcp"],
55
+ });
56
+ });
57
+ });