@phnx-labs/agents-cli 1.19.2 → 1.20.3

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 (156) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -12
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/browser.js +88 -16
  5. package/dist/commands/cli.d.ts +14 -0
  6. package/dist/commands/cli.js +244 -0
  7. package/dist/commands/cloud.js +1 -1
  8. package/dist/commands/commands.js +27 -10
  9. package/dist/commands/computer.js +18 -1
  10. package/dist/commands/doctor.d.ts +1 -1
  11. package/dist/commands/doctor.js +2 -2
  12. package/dist/commands/exec.js +38 -18
  13. package/dist/commands/factory.d.ts +3 -14
  14. package/dist/commands/factory.js +3 -3
  15. package/dist/commands/feedback.d.ts +7 -0
  16. package/dist/commands/feedback.js +89 -0
  17. package/dist/commands/helper.d.ts +12 -0
  18. package/dist/commands/helper.js +87 -0
  19. package/dist/commands/hooks.js +89 -10
  20. package/dist/commands/mcp.js +166 -10
  21. package/dist/commands/packages.js +196 -27
  22. package/dist/commands/permissions.js +21 -6
  23. package/dist/commands/plugins.js +11 -4
  24. package/dist/commands/profiles.d.ts +8 -0
  25. package/dist/commands/profiles.js +118 -5
  26. package/dist/commands/prune.js +39 -160
  27. package/dist/commands/pull.js +58 -5
  28. package/dist/commands/routines.js +107 -14
  29. package/dist/commands/rules.js +8 -4
  30. package/dist/commands/secrets-migrate.d.ts +24 -0
  31. package/dist/commands/secrets-migrate.js +198 -0
  32. package/dist/commands/secrets-sync.d.ts +11 -0
  33. package/dist/commands/secrets-sync.js +155 -0
  34. package/dist/commands/secrets.js +79 -46
  35. package/dist/commands/sessions.d.ts +28 -0
  36. package/dist/commands/sessions.js +98 -33
  37. package/dist/commands/setup.d.ts +1 -0
  38. package/dist/commands/setup.js +37 -28
  39. package/dist/commands/skills.js +25 -8
  40. package/dist/commands/subagents.js +69 -49
  41. package/dist/commands/teams.js +61 -10
  42. package/dist/commands/utils.d.ts +33 -0
  43. package/dist/commands/utils.js +139 -0
  44. package/dist/commands/versions.d.ts +4 -3
  45. package/dist/commands/versions.js +134 -130
  46. package/dist/commands/view.d.ts +6 -0
  47. package/dist/commands/view.js +175 -19
  48. package/dist/commands/workflows.js +29 -6
  49. package/dist/computer.js +0 -0
  50. package/dist/index.js +38 -6
  51. package/dist/lib/acp/client.js +6 -1
  52. package/dist/lib/acp/harnesses.js +8 -0
  53. package/dist/lib/agents.d.ts +4 -0
  54. package/dist/lib/agents.js +125 -34
  55. package/dist/lib/auto-pull-worker.js +18 -1
  56. package/dist/lib/browser/cdp.d.ts +8 -1
  57. package/dist/lib/browser/cdp.js +40 -3
  58. package/dist/lib/browser/chrome.d.ts +13 -0
  59. package/dist/lib/browser/chrome.js +46 -3
  60. package/dist/lib/browser/domain-skills.d.ts +51 -0
  61. package/dist/lib/browser/domain-skills.js +157 -0
  62. package/dist/lib/browser/drivers/local.js +45 -4
  63. package/dist/lib/browser/drivers/ssh.js +2 -2
  64. package/dist/lib/browser/ipc.d.ts +8 -1
  65. package/dist/lib/browser/ipc.js +37 -28
  66. package/dist/lib/browser/profiles.d.ts +16 -3
  67. package/dist/lib/browser/profiles.js +44 -4
  68. package/dist/lib/browser/service.d.ts +3 -0
  69. package/dist/lib/browser/service.js +40 -5
  70. package/dist/lib/browser/types.d.ts +11 -4
  71. package/dist/lib/cli-resources.d.ts +137 -0
  72. package/dist/lib/cli-resources.js +477 -0
  73. package/dist/lib/cloud/factory.d.ts +1 -1
  74. package/dist/lib/cloud/factory.js +1 -1
  75. package/dist/lib/cloud/rush.js +5 -5
  76. package/dist/lib/command-skills.js +0 -2
  77. package/dist/lib/computer-rpc.d.ts +3 -0
  78. package/dist/lib/computer-rpc.js +53 -0
  79. package/dist/lib/daemon.js +20 -0
  80. package/dist/lib/events.d.ts +16 -2
  81. package/dist/lib/events.js +33 -2
  82. package/dist/lib/exec.d.ts +42 -13
  83. package/dist/lib/exec.js +127 -33
  84. package/dist/lib/help.js +11 -5
  85. package/dist/lib/hooks/cache.d.ts +38 -0
  86. package/dist/lib/hooks/cache.js +242 -0
  87. package/dist/lib/hooks/profile.d.ts +33 -0
  88. package/dist/lib/hooks/profile.js +129 -0
  89. package/dist/lib/hooks.d.ts +0 -10
  90. package/dist/lib/hooks.js +246 -11
  91. package/dist/lib/mcp.d.ts +15 -0
  92. package/dist/lib/mcp.js +46 -0
  93. package/dist/lib/migrate.js +1 -1
  94. package/dist/lib/overdue.d.ts +26 -0
  95. package/dist/lib/overdue.js +101 -0
  96. package/dist/lib/permissions.d.ts +13 -0
  97. package/dist/lib/permissions.js +55 -1
  98. package/dist/lib/plugin-marketplace.js +1 -1
  99. package/dist/lib/plugins.js +15 -1
  100. package/dist/lib/profiles-presets.d.ts +26 -0
  101. package/dist/lib/profiles-presets.js +216 -0
  102. package/dist/lib/profiles.d.ts +34 -0
  103. package/dist/lib/profiles.js +112 -1
  104. package/dist/lib/resources/mcp.js +37 -0
  105. package/dist/lib/resources.d.ts +1 -1
  106. package/dist/lib/rotate.js +10 -4
  107. package/dist/lib/routines-format.d.ts +47 -0
  108. package/dist/lib/routines-format.js +194 -0
  109. package/dist/lib/routines.d.ts +8 -2
  110. package/dist/lib/routines.js +34 -14
  111. package/dist/lib/runner.js +83 -15
  112. package/dist/lib/scheduler.js +8 -1
  113. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  114. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  115. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
  116. package/dist/lib/secrets/bundles.d.ts +34 -17
  117. package/dist/lib/secrets/bundles.js +210 -36
  118. package/dist/lib/secrets/index.d.ts +49 -30
  119. package/dist/lib/secrets/index.js +126 -115
  120. package/dist/lib/secrets/install-helper.d.ts +45 -0
  121. package/dist/lib/secrets/install-helper.js +165 -0
  122. package/dist/lib/secrets/linux.js +4 -4
  123. package/dist/lib/secrets/sync.d.ts +56 -0
  124. package/dist/lib/secrets/sync.js +180 -0
  125. package/dist/lib/session/active.d.ts +8 -0
  126. package/dist/lib/session/active.js +3 -2
  127. package/dist/lib/session/db.d.ts +0 -4
  128. package/dist/lib/session/db.js +0 -26
  129. package/dist/lib/session/parse.d.ts +1 -0
  130. package/dist/lib/session/parse.js +44 -0
  131. package/dist/lib/session/render.js +4 -4
  132. package/dist/lib/session/types.d.ts +2 -2
  133. package/dist/lib/session/types.js +1 -1
  134. package/dist/lib/shims.d.ts +5 -2
  135. package/dist/lib/shims.js +70 -38
  136. package/dist/lib/state.d.ts +14 -2
  137. package/dist/lib/state.js +51 -20
  138. package/dist/lib/teams/agents.d.ts +5 -4
  139. package/dist/lib/teams/agents.js +48 -22
  140. package/dist/lib/teams/api.d.ts +2 -1
  141. package/dist/lib/teams/api.js +4 -3
  142. package/dist/lib/teams/parsers.d.ts +1 -1
  143. package/dist/lib/teams/parsers.js +153 -3
  144. package/dist/lib/teams/summarizer.js +18 -2
  145. package/dist/lib/teams/worktree.js +14 -3
  146. package/dist/lib/types.d.ts +63 -4
  147. package/dist/lib/types.js +8 -3
  148. package/dist/lib/usage.d.ts +27 -2
  149. package/dist/lib/usage.js +100 -17
  150. package/dist/lib/versions.d.ts +45 -3
  151. package/dist/lib/versions.js +455 -60
  152. package/package.json +15 -14
  153. package/scripts/install-helper.js +97 -0
  154. package/scripts/postinstall.js +16 -0
  155. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  156. package/npm-shrinkwrap.json +0 -3162
@@ -0,0 +1,137 @@
1
+ /** A single install method. Exactly one of the keys (npm/brew/script/binary) is set. */
2
+ export type InstallMethod = {
3
+ npm: string;
4
+ } | {
5
+ brew: string;
6
+ } | {
7
+ script: string;
8
+ } | {
9
+ binary: BinarySpec;
10
+ };
11
+ /** Per-platform binary download spec. Keys are `<os>-<arch>` (e.g. darwin-arm64). */
12
+ export interface BinarySpec {
13
+ [platform: string]: {
14
+ url: string;
15
+ /** Path inside the archive (relative). Required when url is a .tar.gz/.zip. */
16
+ extract?: string;
17
+ };
18
+ }
19
+ /**
20
+ * How to verify a CLI is installed. Structured so we can dispatch to spawnSync
21
+ * with an argv array — never through a shell.
22
+ *
23
+ * `which` — just check PATH for `cmd`.
24
+ * `version` — spawn `cmd` with `args` and require exit 0.
25
+ */
26
+ export type CheckSpec = {
27
+ kind: 'which';
28
+ cmd: string;
29
+ } | {
30
+ kind: 'version';
31
+ cmd: string;
32
+ args: string[];
33
+ };
34
+ /** Parsed CLI manifest. */
35
+ export interface CliManifest {
36
+ /** Name as it appears on the command line (e.g. "higgsfield"). */
37
+ name: string;
38
+ /** One-line summary shown in `agents cli list`. */
39
+ description?: string;
40
+ /** Project homepage; used in detail view + post-install messaging. */
41
+ homepage?: string;
42
+ /** Structured check spec; never a raw shell command. */
43
+ check: CheckSpec;
44
+ /** Install methods tried in order; first one whose tool is available is used. */
45
+ install: InstallMethod[];
46
+ /** Message printed after successful install — typically auth instructions. */
47
+ postInstall?: string;
48
+ /** Origin layer this manifest was resolved from. */
49
+ source: string;
50
+ /** Absolute path to the yaml file. */
51
+ path: string;
52
+ }
53
+ /** A validation problem in a CLI manifest. */
54
+ export interface CliManifestError {
55
+ /** Filename that failed to parse. */
56
+ file: string;
57
+ /** Human-readable reason. */
58
+ reason: string;
59
+ }
60
+ /**
61
+ * Parse a `check:` field into a CheckSpec. Accepts either a structured object
62
+ * (`{ kind: 'which'|'version', cmd, args? }`) or a legacy whitespace-separated
63
+ * string. String form is split on whitespace and each token is validated against
64
+ * SAFE_CHECK_TOKEN — manifests cannot smuggle in shell metacharacters.
65
+ */
66
+ export declare function parseCheckSpec(raw: unknown, defaultName: string): CheckSpec;
67
+ /**
68
+ * Parse a single CLI manifest from its YAML contents.
69
+ * Returns a manifest on success; throws on schema violations so callers can
70
+ * decide whether to surface or swallow the error per file.
71
+ */
72
+ export declare function parseCliManifest(contents: string, opts: {
73
+ name: string;
74
+ source: string;
75
+ path: string;
76
+ }): CliManifest;
77
+ /**
78
+ * Discover all CLI manifests resolvable from the current cwd. Returns valid
79
+ * manifests and any parse errors separately so the CLI can show both.
80
+ */
81
+ export declare function listCliManifests(cwd?: string): {
82
+ manifests: CliManifest[];
83
+ errors: CliManifestError[];
84
+ };
85
+ /** Resolve a single CLI manifest by name. Returns null when not declared. */
86
+ export declare function resolveCliManifest(name: string, cwd?: string): CliManifest | null;
87
+ export declare function hasCommand(cmd: string): boolean;
88
+ /**
89
+ * Run the manifest's check. Dispatches on CheckSpec.kind — never invokes a
90
+ * shell, never interpolates strings into a command line.
91
+ */
92
+ export declare function isCliInstalled(manifest: CliManifest): boolean;
93
+ /**
94
+ * Pick the first install method whose required host tool is available.
95
+ * Returns null when none of the declared methods can run on this host.
96
+ */
97
+ export declare function selectInstallMethod(manifest: CliManifest): InstallMethod | null;
98
+ /** Render a CheckSpec back to a human-readable command string (display only). */
99
+ export declare function describeCheck(check: CheckSpec): string;
100
+ /** Short description of a method for display. */
101
+ export declare function describeMethod(method: InstallMethod): string;
102
+ export interface InstallResult {
103
+ manifest: CliManifest;
104
+ /** Method that was attempted (null if no compatible method existed). */
105
+ method: InstallMethod | null;
106
+ /** True when the post-install `check` passed. */
107
+ installed: boolean;
108
+ /** stdout/stderr captured from the install command, for surfacing on failure. */
109
+ output?: string;
110
+ /** Set when the install runner threw or exited non-zero. */
111
+ error?: string;
112
+ }
113
+ /**
114
+ * Display-only rendering of how a method would be run, for `--dry-run` and
115
+ * status output. Not used by installCli — execution goes through runInstallMethod
116
+ * which dispatches to spawnSync with argv arrays.
117
+ */
118
+ export declare function buildInstallCommand(method: InstallMethod): string;
119
+ /**
120
+ * Install a single CLI by running its first compatible method. Streams the
121
+ * underlying command's output to the parent terminal so users see brew/npm
122
+ * progress live. Verifies success by re-running `check`.
123
+ */
124
+ export declare function installCli(manifest: CliManifest, opts?: {
125
+ dryRun?: boolean;
126
+ }): InstallResult;
127
+ export interface CliStatus {
128
+ manifest: CliManifest;
129
+ installed: boolean;
130
+ }
131
+ /** Convenience: list all manifests + their installed-on-host status. */
132
+ export declare function listCliStatus(cwd?: string): {
133
+ statuses: CliStatus[];
134
+ errors: CliManifestError[];
135
+ };
136
+ /** Names of CLIs that are declared but not currently installed on the host. */
137
+ export declare function getMissingClis(cwd?: string): CliManifest[];
@@ -0,0 +1,477 @@
1
+ /**
2
+ * CLI tool resources — declarative manifests for command-line binaries the user
3
+ * wants installed on the host (e.g. higgsfield, gh, glab).
4
+ *
5
+ * A CLI resource is a YAML file under <repo>/cli/<name>.yaml. Resolution follows
6
+ * the same project > user > system > extra-repo precedence as other resources,
7
+ * but unlike skills/commands/hooks, CLI resources are NOT copied into per-agent
8
+ * version homes — they install binaries onto the host PATH. The relationship is
9
+ * "Brewfile-style": declare once in ~/.agents/cli/, install on any new machine.
10
+ *
11
+ * Security: every field that becomes a child-process argument is validated
12
+ * against a strict allowlist and dispatched via spawnSync with an argv array.
13
+ * Nothing here ever runs through a shell — manifests can come from project repos
14
+ * or pulled extras, so anything that would let a manifest author smuggle in
15
+ * `;`, `$(...)`, backticks, redirects, or pipe operators is a remote-code-
16
+ * execution sink.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { spawnSync } from 'child_process';
22
+ import * as yaml from 'yaml';
23
+ import { listResources, resolveResource } from './resources.js';
24
+ // ─── Validation primitives ───────────────────────────────────────────────────
25
+ /** Token allowed inside `check:` strings — letters, digits, underscore, dot, slash, dash. */
26
+ const SAFE_CHECK_TOKEN = /^[a-zA-Z0-9_./-]+$/;
27
+ /** npm package name with optional scope and optional version/tag. */
28
+ const NPM_PACKAGE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[a-zA-Z0-9._-]+)?$/;
29
+ /** Homebrew formula name (and optional tap prefix). */
30
+ const BREW_FORMULA = /^([a-z0-9][a-z0-9_.-]*\/[a-z0-9][a-z0-9_.-]*\/)?[a-z0-9][a-z0-9_.+-]*$/;
31
+ /** Path segment inside a tarball — no leading slash, no `..`, no shell metas. */
32
+ const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_./-]+$/;
33
+ function assertSafeCheckToken(tok) {
34
+ if (!SAFE_CHECK_TOKEN.test(tok)) {
35
+ throw new Error(`check contains unsafe token: ${JSON.stringify(tok)}`);
36
+ }
37
+ }
38
+ function assertNpmPackage(name) {
39
+ if (!NPM_PACKAGE.test(name)) {
40
+ throw new Error(`npm package name is not allowlisted: ${JSON.stringify(name)}`);
41
+ }
42
+ }
43
+ function assertBrewFormula(name) {
44
+ if (!BREW_FORMULA.test(name)) {
45
+ throw new Error(`brew formula name is not allowlisted: ${JSON.stringify(name)}`);
46
+ }
47
+ }
48
+ function assertHttpsUrl(url) {
49
+ let parsed;
50
+ try {
51
+ parsed = new URL(url);
52
+ }
53
+ catch {
54
+ throw new Error(`url is not parseable: ${JSON.stringify(url)}`);
55
+ }
56
+ if (parsed.protocol !== 'https:') {
57
+ throw new Error(`url must use https:// (got ${parsed.protocol}): ${JSON.stringify(url)}`);
58
+ }
59
+ }
60
+ function assertSafePathSegment(seg) {
61
+ if (!SAFE_PATH_SEGMENT.test(seg) || seg.startsWith('/') || seg.split('/').includes('..')) {
62
+ throw new Error(`extract path is not allowlisted: ${JSON.stringify(seg)}`);
63
+ }
64
+ }
65
+ // ─── Parsing ─────────────────────────────────────────────────────────────────
66
+ /**
67
+ * Parse a `check:` field into a CheckSpec. Accepts either a structured object
68
+ * (`{ kind: 'which'|'version', cmd, args? }`) or a legacy whitespace-separated
69
+ * string. String form is split on whitespace and each token is validated against
70
+ * SAFE_CHECK_TOKEN — manifests cannot smuggle in shell metacharacters.
71
+ */
72
+ export function parseCheckSpec(raw, defaultName) {
73
+ if (raw == null) {
74
+ assertSafeCheckToken(defaultName);
75
+ return { kind: 'version', cmd: defaultName, args: ['--version'] };
76
+ }
77
+ if (typeof raw === 'string') {
78
+ const tokens = raw.trim().split(/\s+/).filter((t) => t.length > 0);
79
+ if (tokens.length === 0) {
80
+ assertSafeCheckToken(defaultName);
81
+ return { kind: 'version', cmd: defaultName, args: ['--version'] };
82
+ }
83
+ for (const tok of tokens)
84
+ assertSafeCheckToken(tok);
85
+ const [cmd, ...args] = tokens;
86
+ return args.length === 0 ? { kind: 'which', cmd } : { kind: 'version', cmd, args };
87
+ }
88
+ if (typeof raw === 'object') {
89
+ const r = raw;
90
+ const kind = r.kind;
91
+ if (kind !== 'which' && kind !== 'version') {
92
+ throw new Error(`check.kind must be "which" or "version" (got ${JSON.stringify(kind)})`);
93
+ }
94
+ if (typeof r.cmd !== 'string' || !r.cmd.trim()) {
95
+ throw new Error('check.cmd must be a non-empty string');
96
+ }
97
+ const cmd = r.cmd.trim();
98
+ assertSafeCheckToken(cmd);
99
+ if (kind === 'which')
100
+ return { kind: 'which', cmd };
101
+ const args = Array.isArray(r.args) ? r.args : [];
102
+ const safeArgs = [];
103
+ for (const a of args) {
104
+ if (typeof a !== 'string')
105
+ throw new Error('check.args entries must be strings');
106
+ assertSafeCheckToken(a);
107
+ safeArgs.push(a);
108
+ }
109
+ return { kind: 'version', cmd, args: safeArgs };
110
+ }
111
+ throw new Error('check must be a string or an object with { kind, cmd, args? }');
112
+ }
113
+ /**
114
+ * Parse a single CLI manifest from its YAML contents.
115
+ * Returns a manifest on success; throws on schema violations so callers can
116
+ * decide whether to surface or swallow the error per file.
117
+ */
118
+ export function parseCliManifest(contents, opts) {
119
+ const raw = yaml.parse(contents);
120
+ if (!raw || typeof raw !== 'object') {
121
+ throw new Error('manifest must be a YAML object');
122
+ }
123
+ const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : opts.name;
124
+ assertSafeCheckToken(name);
125
+ const description = typeof raw.description === 'string' ? raw.description : undefined;
126
+ const homepage = typeof raw.homepage === 'string' ? raw.homepage : undefined;
127
+ const check = parseCheckSpec(raw.check, name);
128
+ const postInstall = typeof raw.post_install === 'string' ? raw.post_install : undefined;
129
+ if (!Array.isArray(raw.install) || raw.install.length === 0) {
130
+ throw new Error('install must be a non-empty list of methods');
131
+ }
132
+ const install = raw.install.map((entry, i) => {
133
+ if (!entry || typeof entry !== 'object') {
134
+ throw new Error(`install[${i}] must be an object with one of: npm, brew, script, binary`);
135
+ }
136
+ const e = entry;
137
+ const keys = Object.keys(e).filter((k) => e[k] !== undefined && e[k] !== null);
138
+ if (keys.length !== 1) {
139
+ throw new Error(`install[${i}] must declare exactly one method (got: ${keys.join(', ') || 'none'})`);
140
+ }
141
+ const key = keys[0];
142
+ const value = e[key];
143
+ if (key === 'npm') {
144
+ if (typeof value !== 'string' || !value.trim()) {
145
+ throw new Error(`install[${i}].npm must be a non-empty string`);
146
+ }
147
+ const v = value.trim();
148
+ assertNpmPackage(v);
149
+ return { npm: v };
150
+ }
151
+ if (key === 'brew') {
152
+ if (typeof value !== 'string' || !value.trim()) {
153
+ throw new Error(`install[${i}].brew must be a non-empty string`);
154
+ }
155
+ const v = value.trim();
156
+ assertBrewFormula(v);
157
+ return { brew: v };
158
+ }
159
+ if (key === 'script') {
160
+ if (typeof value !== 'string' || !value.trim()) {
161
+ throw new Error(`install[${i}].script must be a non-empty string`);
162
+ }
163
+ const v = value.trim();
164
+ assertHttpsUrl(v);
165
+ return { script: v };
166
+ }
167
+ if (key === 'binary') {
168
+ if (!value || typeof value !== 'object') {
169
+ throw new Error(`install[${i}].binary must be a platform map`);
170
+ }
171
+ const binary = {};
172
+ for (const [platform, spec] of Object.entries(value)) {
173
+ if (!spec || typeof spec !== 'object') {
174
+ throw new Error(`install[${i}].binary.${platform} must be an object with a url`);
175
+ }
176
+ const s = spec;
177
+ if (typeof s.url !== 'string' || !s.url.trim()) {
178
+ throw new Error(`install[${i}].binary.${platform}.url must be a non-empty string`);
179
+ }
180
+ const url = s.url.trim();
181
+ assertHttpsUrl(url);
182
+ let extract;
183
+ if (typeof s.extract === 'string' && s.extract.length > 0) {
184
+ assertSafePathSegment(s.extract);
185
+ extract = s.extract;
186
+ }
187
+ binary[platform] = { url, extract };
188
+ }
189
+ return { binary };
190
+ }
191
+ throw new Error(`install[${i}] has unknown method "${key}" (expected: npm, brew, script, binary)`);
192
+ });
193
+ return {
194
+ name,
195
+ description,
196
+ homepage,
197
+ check,
198
+ install,
199
+ postInstall,
200
+ source: opts.source,
201
+ path: opts.path,
202
+ };
203
+ }
204
+ /**
205
+ * Discover all CLI manifests resolvable from the current cwd. Returns valid
206
+ * manifests and any parse errors separately so the CLI can show both.
207
+ */
208
+ export function listCliManifests(cwd) {
209
+ const resolved = listResources('cli', cwd);
210
+ const manifests = [];
211
+ const errors = [];
212
+ for (const entry of resolved) {
213
+ if (!entry.path.endsWith('.yaml') && !entry.path.endsWith('.yml'))
214
+ continue;
215
+ try {
216
+ const contents = fs.readFileSync(entry.path, 'utf-8');
217
+ const manifest = parseCliManifest(contents, {
218
+ name: entry.name,
219
+ source: entry.source,
220
+ path: entry.path,
221
+ });
222
+ manifests.push(manifest);
223
+ }
224
+ catch (err) {
225
+ errors.push({ file: entry.path, reason: err.message });
226
+ }
227
+ }
228
+ return { manifests, errors };
229
+ }
230
+ /** Resolve a single CLI manifest by name. Returns null when not declared. */
231
+ export function resolveCliManifest(name, cwd) {
232
+ const resolved = resolveResource('cli', name, cwd);
233
+ if (!resolved)
234
+ return null;
235
+ if (!resolved.path.endsWith('.yaml') && !resolved.path.endsWith('.yml'))
236
+ return null;
237
+ const contents = fs.readFileSync(resolved.path, 'utf-8');
238
+ return parseCliManifest(contents, {
239
+ name: resolved.name,
240
+ source: resolved.source,
241
+ path: resolved.path,
242
+ });
243
+ }
244
+ // ─── Host detection ──────────────────────────────────────────────────────────
245
+ /**
246
+ * Return true if a command resolves on the current PATH. Uses POSIX `command -v`
247
+ * via spawn argv (no shell); results are cached for the lifetime of the process.
248
+ */
249
+ const cmdExistsCache = new Map();
250
+ export function hasCommand(cmd) {
251
+ if (cmdExistsCache.has(cmd))
252
+ return cmdExistsCache.get(cmd);
253
+ // `command` is a shell builtin on most POSIX shells; invoking `sh -c 'command -v X'`
254
+ // with X as an *argument* (not interpolated) is the safe path. `cmd` may be passed
255
+ // by callers that haven't validated it, so we route via argv to neutralize metas.
256
+ const result = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', '_', cmd], {
257
+ stdio: 'ignore',
258
+ });
259
+ const ok = result.status === 0;
260
+ cmdExistsCache.set(cmd, ok);
261
+ return ok;
262
+ }
263
+ /**
264
+ * Run the manifest's check. Dispatches on CheckSpec.kind — never invokes a
265
+ * shell, never interpolates strings into a command line.
266
+ */
267
+ export function isCliInstalled(manifest) {
268
+ const c = manifest.check;
269
+ if (c.kind === 'which') {
270
+ cmdExistsCache.delete(c.cmd);
271
+ return hasCommand(c.cmd);
272
+ }
273
+ const result = spawnSync(c.cmd, c.args, { stdio: 'ignore', timeout: 10_000 });
274
+ return result.status === 0;
275
+ }
276
+ // ─── Method selection ────────────────────────────────────────────────────────
277
+ /**
278
+ * Pick the first install method whose required host tool is available.
279
+ * Returns null when none of the declared methods can run on this host.
280
+ */
281
+ export function selectInstallMethod(manifest) {
282
+ for (const method of manifest.install) {
283
+ if ('npm' in method && hasCommand('npm'))
284
+ return method;
285
+ if ('brew' in method && hasCommand('brew'))
286
+ return method;
287
+ if ('script' in method && (hasCommand('curl') || hasCommand('wget')))
288
+ return method;
289
+ if ('binary' in method) {
290
+ const key = `${process.platform}-${process.arch}`;
291
+ if (method.binary[key])
292
+ return method;
293
+ }
294
+ }
295
+ return null;
296
+ }
297
+ /** Render a CheckSpec back to a human-readable command string (display only). */
298
+ export function describeCheck(check) {
299
+ return check.kind === 'which' ? check.cmd : `${check.cmd} ${check.args.join(' ')}`.trim();
300
+ }
301
+ /** Short description of a method for display. */
302
+ export function describeMethod(method) {
303
+ if ('npm' in method)
304
+ return `npm install -g ${method.npm}`;
305
+ if ('brew' in method)
306
+ return `brew install ${method.brew}`;
307
+ if ('script' in method)
308
+ return `curl ${method.script} | sh`;
309
+ const key = `${process.platform}-${process.arch}`;
310
+ const spec = method.binary[key];
311
+ return spec ? `download ${spec.url}` : 'binary download';
312
+ }
313
+ /**
314
+ * Display-only rendering of how a method would be run, for `--dry-run` and
315
+ * status output. Not used by installCli — execution goes through runInstallMethod
316
+ * which dispatches to spawnSync with argv arrays.
317
+ */
318
+ export function buildInstallCommand(method) {
319
+ if ('npm' in method)
320
+ return `npm install -g ${method.npm}`;
321
+ if ('brew' in method)
322
+ return `brew install ${method.brew}`;
323
+ if ('script' in method) {
324
+ return hasCommand('curl')
325
+ ? `curl -fsSL ${method.script} | sh`
326
+ : `wget -qO- ${method.script} | sh`;
327
+ }
328
+ const key = `${process.platform}-${process.arch}`;
329
+ const spec = method.binary[key];
330
+ if (!spec)
331
+ return 'binary download';
332
+ return spec.extract
333
+ ? `curl -fsSL ${spec.url} -o /tmp/agents-cli-bin.tgz && tar -xzf /tmp/agents-cli-bin.tgz -C /usr/local/bin ${spec.extract}`
334
+ : `curl -fsSL ${spec.url} -o /usr/local/bin/agents-cli-downloaded`;
335
+ }
336
+ /**
337
+ * Execute an install method via spawnSync with argv arrays. Each branch
338
+ * re-validates the relevant field — defense in depth, since callers may
339
+ * construct InstallMethod values without going through parseCliManifest
340
+ * (tests, future programmatic use).
341
+ *
342
+ * For `script`, the download is staged to a temp file and then exec'd as
343
+ * `sh <file>` so we never need a shell pipe (`curl | sh`).
344
+ */
345
+ function runInstallMethod(method) {
346
+ if ('npm' in method) {
347
+ assertNpmPackage(method.npm);
348
+ const r = spawnSync('npm', ['install', '-g', method.npm], { stdio: 'inherit' });
349
+ if (r.status !== 0) {
350
+ throw new Error(`npm install -g ${method.npm} exited with status ${r.status ?? 'unknown'}`);
351
+ }
352
+ return;
353
+ }
354
+ if ('brew' in method) {
355
+ assertBrewFormula(method.brew);
356
+ const r = spawnSync('brew', ['install', method.brew], { stdio: 'inherit' });
357
+ if (r.status !== 0) {
358
+ throw new Error(`brew install ${method.brew} exited with status ${r.status ?? 'unknown'}`);
359
+ }
360
+ return;
361
+ }
362
+ if ('script' in method) {
363
+ assertHttpsUrl(method.script);
364
+ const tmp = path.join(os.tmpdir(), `agents-cli-install-${process.pid}-${Date.now()}.sh`);
365
+ try {
366
+ let dl;
367
+ if (hasCommand('curl')) {
368
+ dl = spawnSync('curl', ['-fsSL', method.script, '-o', tmp], { stdio: 'inherit' });
369
+ }
370
+ else if (hasCommand('wget')) {
371
+ dl = spawnSync('wget', ['-q', '-O', tmp, method.script], { stdio: 'inherit' });
372
+ }
373
+ else {
374
+ throw new Error('neither curl nor wget is available on PATH');
375
+ }
376
+ if (dl.status !== 0) {
377
+ throw new Error(`download of install script failed (status ${dl.status ?? 'unknown'})`);
378
+ }
379
+ const r = spawnSync('sh', [tmp], { stdio: 'inherit' });
380
+ if (r.status !== 0) {
381
+ throw new Error(`install script exited with status ${r.status ?? 'unknown'}`);
382
+ }
383
+ }
384
+ finally {
385
+ try {
386
+ fs.unlinkSync(tmp);
387
+ }
388
+ catch { /* best effort */ }
389
+ }
390
+ return;
391
+ }
392
+ if ('binary' in method) {
393
+ const key = `${process.platform}-${process.arch}`;
394
+ const spec = method.binary[key];
395
+ if (!spec)
396
+ throw new Error(`no binary declared for ${key}`);
397
+ assertHttpsUrl(spec.url);
398
+ if (spec.extract) {
399
+ assertSafePathSegment(spec.extract);
400
+ const tmp = path.join(os.tmpdir(), `agents-cli-bin-${process.pid}-${Date.now()}.tgz`);
401
+ try {
402
+ const dl = spawnSync('curl', ['-fsSL', spec.url, '-o', tmp], { stdio: 'inherit' });
403
+ if (dl.status !== 0) {
404
+ throw new Error(`binary download failed (status ${dl.status ?? 'unknown'})`);
405
+ }
406
+ const x = spawnSync('tar', ['-xzf', tmp, '-C', '/usr/local/bin', spec.extract], {
407
+ stdio: 'inherit',
408
+ });
409
+ if (x.status !== 0) {
410
+ throw new Error(`tar extract failed (status ${x.status ?? 'unknown'})`);
411
+ }
412
+ }
413
+ finally {
414
+ try {
415
+ fs.unlinkSync(tmp);
416
+ }
417
+ catch { /* best effort */ }
418
+ }
419
+ }
420
+ else {
421
+ const r = spawnSync('curl', ['-fsSL', spec.url, '-o', '/usr/local/bin/agents-cli-downloaded'], { stdio: 'inherit' });
422
+ if (r.status !== 0) {
423
+ throw new Error(`binary download failed (status ${r.status ?? 'unknown'})`);
424
+ }
425
+ }
426
+ return;
427
+ }
428
+ }
429
+ /**
430
+ * Install a single CLI by running its first compatible method. Streams the
431
+ * underlying command's output to the parent terminal so users see brew/npm
432
+ * progress live. Verifies success by re-running `check`.
433
+ */
434
+ export function installCli(manifest, opts = {}) {
435
+ const method = selectInstallMethod(manifest);
436
+ if (!method) {
437
+ return {
438
+ manifest,
439
+ method: null,
440
+ installed: false,
441
+ error: `No compatible install method for this host (${process.platform}-${process.arch}). Declared methods: ${manifest.install.map(describeMethod).join('; ')}`,
442
+ };
443
+ }
444
+ if (opts.dryRun) {
445
+ return { manifest, method, installed: false, output: `[dry-run] would run: ${describeMethod(method)}` };
446
+ }
447
+ try {
448
+ runInstallMethod(method);
449
+ }
450
+ catch (err) {
451
+ return {
452
+ manifest,
453
+ method,
454
+ installed: false,
455
+ error: `install command failed: ${err.message}`,
456
+ };
457
+ }
458
+ // Re-check; many installers exit 0 but leave the binary off PATH for the
459
+ // current shell (e.g. brew on a fresh install). Trust `check`, not the
460
+ // installer's exit code.
461
+ cmdExistsCache.delete(manifest.name);
462
+ const installed = isCliInstalled(manifest);
463
+ return { manifest, method, installed };
464
+ }
465
+ /** Convenience: list all manifests + their installed-on-host status. */
466
+ export function listCliStatus(cwd) {
467
+ const { manifests, errors } = listCliManifests(cwd);
468
+ const statuses = manifests.map((manifest) => ({
469
+ manifest,
470
+ installed: isCliInstalled(manifest),
471
+ }));
472
+ return { statuses, errors };
473
+ }
474
+ /** Names of CLIs that are declared but not currently installed on the host. */
475
+ export function getMissingClis(cwd) {
476
+ return listCliStatus(cwd).statuses.filter((s) => !s.installed).map((s) => s.manifest);
477
+ }
@@ -8,7 +8,7 @@ import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOpt
8
8
  /**
9
9
  * Factory/Droid cloud provider — stub for Phase 2.
10
10
  *
11
- * Integration path: `droid daemon` running on a remote machine (mac-mini, cloud VM, k8s pod).
11
+ * Integration path: `droid daemon` running on a remote machine (workstation, cloud VM, k8s pod).
12
12
  * Dispatch via HTTP to the daemon, stream output, cancel via HTTP DELETE.
13
13
  *
14
14
  * Not yet implemented because:
@@ -7,7 +7,7 @@
7
7
  /**
8
8
  * Factory/Droid cloud provider — stub for Phase 2.
9
9
  *
10
- * Integration path: `droid daemon` running on a remote machine (mac-mini, cloud VM, k8s pod).
10
+ * Integration path: `droid daemon` running on a remote machine (workstation, cloud VM, k8s pod).
11
11
  * Dispatch via HTTP to the daemon, stream output, cancel via HTTP DELETE.
12
12
  *
13
13
  * Not yet implemented because:
@@ -16,7 +16,7 @@ import { listInstalledVersions, getVersionHomePath } from '../versions.js';
16
16
  import { getAccountInfo } from '../agents.js';
17
17
  import { loadClaudeOauth } from '../usage.js';
18
18
  import { selectBalancedVersion } from '../rotate.js';
19
- const PROXY_BASE = 'https://api.prix.dev';
19
+ const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
20
20
  const PROXY_HOST = new URL(PROXY_BASE).host;
21
21
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
22
22
  // Persistent consent record for uploading Claude OAuth blobs to Rush Cloud.
@@ -441,7 +441,7 @@ export class RushCloudProvider {
441
441
  }
442
442
  async status(taskId) {
443
443
  const token = readToken();
444
- const res = await api('GET', `/api/v1/cloud-runs/${taskId}`, token);
444
+ const res = await api('GET', `/api/v1/cloud-runs/${encodeURIComponent(taskId)}`, token);
445
445
  if (!res.ok) {
446
446
  throw new Error(`Failed to get task status (${res.status}).`);
447
447
  }
@@ -487,7 +487,7 @@ export class RushCloudProvider {
487
487
  }
488
488
  async *stream(taskId) {
489
489
  const token = readToken();
490
- const res = await fetch(`${PROXY_BASE}/api/v1/cloud-runs/${taskId}/stream`, {
490
+ const res = await fetch(`${PROXY_BASE}/api/v1/cloud-runs/${encodeURIComponent(taskId)}/stream`, {
491
491
  headers: { 'Authorization': `Bearer ${token}` },
492
492
  });
493
493
  if (!res.ok) {
@@ -497,14 +497,14 @@ export class RushCloudProvider {
497
497
  }
498
498
  async cancel(taskId) {
499
499
  const token = readToken();
500
- const res = await api('DELETE', `/api/v1/cloud-runs/${taskId}`, token);
500
+ const res = await api('DELETE', `/api/v1/cloud-runs/${encodeURIComponent(taskId)}`, token);
501
501
  if (!res.ok) {
502
502
  throw new Error(`Failed to cancel task (${res.status}).`);
503
503
  }
504
504
  }
505
505
  async message(taskId, content) {
506
506
  const token = readToken();
507
- const res = await api('POST', `/api/v1/cloud-runs/${taskId}/message`, token, { content });
507
+ const res = await api('POST', `/api/v1/cloud-runs/${encodeURIComponent(taskId)}/message`, token, { content });
508
508
  if (!res.ok) {
509
509
  throw new Error(`Failed to send message (${res.status}).`);
510
510
  }
@@ -50,8 +50,6 @@ function readSkillCommandMarker(skillMdPath) {
50
50
  }
51
51
  }
52
52
  export function shouldInstallCommandAsSkill(agent, version) {
53
- if (agent !== 'codex')
54
- return false;
55
53
  return !supports(agent, 'commands', version).ok && supports(agent, 'skills', version).ok;
56
54
  }
57
55
  export function commandSkillName(commandName) {