@phnx-labs/agents-cli 1.20.33 → 1.20.35

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 (159) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +32 -2
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +17 -12
  8. package/dist/commands/doctor.d.ts +11 -0
  9. package/dist/commands/doctor.js +108 -14
  10. package/dist/commands/events.d.ts +16 -0
  11. package/dist/commands/events.js +173 -0
  12. package/dist/commands/exec.js +6 -1
  13. package/dist/commands/hosts.js +28 -24
  14. package/dist/commands/import.js +1 -1
  15. package/dist/commands/inspect.d.ts +2 -0
  16. package/dist/commands/inspect.js +54 -7
  17. package/dist/commands/logs.d.ts +17 -0
  18. package/dist/commands/logs.js +139 -0
  19. package/dist/commands/models.d.ts +3 -0
  20. package/dist/commands/models.js +27 -8
  21. package/dist/commands/permissions.js +9 -2
  22. package/dist/commands/repo.d.ts +34 -0
  23. package/dist/commands/repo.js +243 -65
  24. package/dist/commands/resource-view.d.ts +20 -0
  25. package/dist/commands/resource-view.js +90 -28
  26. package/dist/commands/rules.js +1 -1
  27. package/dist/commands/secrets-migrate.js +23 -11
  28. package/dist/commands/secrets.d.ts +20 -0
  29. package/dist/commands/secrets.js +93 -17
  30. package/dist/commands/sessions-resume.d.ts +2 -0
  31. package/dist/commands/sessions-resume.js +209 -0
  32. package/dist/commands/sessions-tail.d.ts +10 -0
  33. package/dist/commands/sessions-tail.js +11 -0
  34. package/dist/commands/sessions.d.ts +73 -1
  35. package/dist/commands/sessions.js +273 -53
  36. package/dist/commands/status.d.ts +12 -0
  37. package/dist/commands/status.js +81 -0
  38. package/dist/commands/teams.js +70 -6
  39. package/dist/commands/versions.js +2 -1
  40. package/dist/commands/view.d.ts +42 -0
  41. package/dist/commands/view.js +213 -83
  42. package/dist/commands/wallet.d.ts +6 -0
  43. package/dist/commands/wallet.js +22 -5
  44. package/dist/index.js +61 -32
  45. package/dist/lib/acp/harnesses.d.ts +1 -1
  46. package/dist/lib/acp/harnesses.js +2 -2
  47. package/dist/lib/agents.d.ts +12 -0
  48. package/dist/lib/agents.js +145 -41
  49. package/dist/lib/browser/cdp.js +5 -1
  50. package/dist/lib/browser/chrome.js +20 -0
  51. package/dist/lib/browser/drivers/ssh.d.ts +27 -0
  52. package/dist/lib/browser/drivers/ssh.js +94 -19
  53. package/dist/lib/browser/ipc.d.ts +3 -0
  54. package/dist/lib/browser/ipc.js +13 -9
  55. package/dist/lib/browser/service.js +71 -15
  56. package/dist/lib/daemon.d.ts +35 -0
  57. package/dist/lib/daemon.js +33 -5
  58. package/dist/lib/devices/connect.d.ts +3 -2
  59. package/dist/lib/devices/connect.js +5 -3
  60. package/dist/lib/doctor-diff.js +29 -2
  61. package/dist/lib/drift-sync.d.ts +43 -0
  62. package/dist/lib/drift-sync.js +179 -0
  63. package/dist/lib/events.d.ts +9 -2
  64. package/dist/lib/events.js +70 -11
  65. package/dist/lib/exec.d.ts +15 -0
  66. package/dist/lib/exec.js +43 -11
  67. package/dist/lib/hooks.js +8 -2
  68. package/dist/lib/hosts/dispatch.js +6 -7
  69. package/dist/lib/hosts/logs.d.ts +16 -0
  70. package/dist/lib/hosts/logs.js +45 -0
  71. package/dist/lib/hosts/progress.d.ts +66 -0
  72. package/dist/lib/hosts/progress.js +125 -17
  73. package/dist/lib/hosts/ready.d.ts +23 -2
  74. package/dist/lib/hosts/ready.js +35 -13
  75. package/dist/lib/hosts/reconcile.d.ts +53 -0
  76. package/dist/lib/hosts/reconcile.js +81 -0
  77. package/dist/lib/hosts/tasks.d.ts +8 -0
  78. package/dist/lib/hosts/tasks.js +14 -0
  79. package/dist/lib/permissions.d.ts +4 -0
  80. package/dist/lib/permissions.js +35 -0
  81. package/dist/lib/picker.d.ts +24 -0
  82. package/dist/lib/picker.js +124 -0
  83. package/dist/lib/platform/winpath.d.ts +31 -2
  84. package/dist/lib/platform/winpath.js +133 -24
  85. package/dist/lib/plugin-marketplace.d.ts +30 -0
  86. package/dist/lib/plugin-marketplace.js +215 -2
  87. package/dist/lib/plugins.d.ts +5 -0
  88. package/dist/lib/plugins.js +45 -4
  89. package/dist/lib/pwsh.d.ts +11 -0
  90. package/dist/lib/pwsh.js +13 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/agent.d.ts +42 -1
  94. package/dist/lib/secrets/agent.js +89 -11
  95. package/dist/lib/secrets/bundles.js +40 -9
  96. package/dist/lib/secrets/filestore.js +31 -1
  97. package/dist/lib/secrets/index.d.ts +33 -1
  98. package/dist/lib/secrets/index.js +90 -9
  99. package/dist/lib/secrets/windows.d.ts +74 -0
  100. package/dist/lib/secrets/windows.js +440 -0
  101. package/dist/lib/session/active.d.ts +7 -0
  102. package/dist/lib/session/active.js +11 -3
  103. package/dist/lib/session/discover.js +94 -0
  104. package/dist/lib/session/parse.d.ts +6 -0
  105. package/dist/lib/session/parse.js +283 -2
  106. package/dist/lib/session/pid-registry.d.ts +21 -0
  107. package/dist/lib/session/pid-registry.js +80 -0
  108. package/dist/lib/session/remote-active.d.ts +26 -0
  109. package/dist/lib/session/remote-active.js +141 -0
  110. package/dist/lib/session/remote.js +2 -6
  111. package/dist/lib/session/render.js +1 -1
  112. package/dist/lib/session/sync/config.d.ts +8 -0
  113. package/dist/lib/session/sync/config.js +11 -2
  114. package/dist/lib/session/types.d.ts +1 -1
  115. package/dist/lib/session/types.js +1 -1
  116. package/dist/lib/shims.d.ts +30 -2
  117. package/dist/lib/shims.js +107 -50
  118. package/dist/lib/ssh-exec.d.ts +26 -3
  119. package/dist/lib/ssh-exec.js +45 -3
  120. package/dist/lib/ssh-tunnel.d.ts +24 -5
  121. package/dist/lib/ssh-tunnel.js +60 -62
  122. package/dist/lib/staleness/writers/hooks.js +1 -1
  123. package/dist/lib/startup/command-registry.d.ts +3 -0
  124. package/dist/lib/startup/command-registry.js +6 -0
  125. package/dist/lib/sync-status.d.ts +102 -0
  126. package/dist/lib/sync-status.js +135 -0
  127. package/dist/lib/teams/agents.d.ts +24 -0
  128. package/dist/lib/teams/agents.js +30 -1
  129. package/dist/lib/teams/registry.js +25 -9
  130. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  131. package/dist/lib/terminal/backends/ghostty.js +69 -0
  132. package/dist/lib/terminal/backends/index.d.ts +18 -0
  133. package/dist/lib/terminal/backends/index.js +29 -0
  134. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  135. package/dist/lib/terminal/backends/iterm.js +62 -0
  136. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  137. package/dist/lib/terminal/backends/tmux.js +23 -0
  138. package/dist/lib/terminal/engine.d.ts +39 -0
  139. package/dist/lib/terminal/engine.js +54 -0
  140. package/dist/lib/terminal/index.d.ts +14 -0
  141. package/dist/lib/terminal/index.js +5 -0
  142. package/dist/lib/terminal/policy.d.ts +11 -0
  143. package/dist/lib/terminal/policy.js +11 -0
  144. package/dist/lib/terminal/quote.d.ts +11 -0
  145. package/dist/lib/terminal/quote.js +13 -0
  146. package/dist/lib/terminal/shell.d.ts +6 -0
  147. package/dist/lib/terminal/shell.js +23 -0
  148. package/dist/lib/terminal/transport.d.ts +15 -0
  149. package/dist/lib/terminal/transport.js +40 -0
  150. package/dist/lib/terminal/types.d.ts +59 -0
  151. package/dist/lib/terminal/types.js +13 -0
  152. package/dist/lib/types.d.ts +27 -1
  153. package/dist/lib/usage.d.ts +30 -0
  154. package/dist/lib/usage.js +159 -2
  155. package/dist/lib/versions.d.ts +7 -3
  156. package/dist/lib/versions.js +10 -5
  157. package/dist/lib/whats-new.d.ts +9 -0
  158. package/dist/lib/whats-new.js +35 -0
  159. package/package.json +1 -1
@@ -197,3 +197,127 @@ export function itemPicker(config) {
197
197
  });
198
198
  return prompt(config);
199
199
  }
200
+ /**
201
+ * Multi-select variant of {@link itemPicker}. Same searchable, paginated list
202
+ * and preview pane, but `space` toggles a checkbox on the active row instead of
203
+ * the preview (preview moves to `tab`), and `enter` confirms every checked row.
204
+ *
205
+ * Returns the selected items (in the config's `items` order) or `null` on
206
+ * cancel. Pressing `enter` with nothing checked confirms just the highlighted
207
+ * row, so a quick single-pick still works.
208
+ */
209
+ export function multiItemPicker(config) {
210
+ const prompt = createPrompt((cfg, done) => {
211
+ const theme = makeTheme({});
212
+ const [status, setStatus] = useState('idle');
213
+ const [searchTerm, setSearchTerm] = useState(cfg.initialSearch ?? '');
214
+ const [previewOpen, setPreviewOpen] = useState(false);
215
+ const [selectedKeys, setSelectedKeys] = useState(new Set());
216
+ const [active, setActive] = useState(0);
217
+ const prefix = usePrefix({ status, theme });
218
+ const results = useMemo(() => {
219
+ const filtered = cfg.filter(searchTerm).slice(0, 200);
220
+ return filtered.map((item) => ({
221
+ value: item,
222
+ label: cfg.labelFor(item, searchTerm),
223
+ }));
224
+ }, [searchTerm]);
225
+ useEffect(() => {
226
+ if (active >= results.length)
227
+ setActive(0);
228
+ }, [results]);
229
+ const selected = results[active];
230
+ // Selected items resolved in the original list order for deterministic fan-out.
231
+ const collectSelected = () => cfg.items.filter((it) => selectedKeys.has(cfg.keyFor(it)));
232
+ useKeypress((key, rl) => {
233
+ if (isEnterKey(key)) {
234
+ const chosen = selectedKeys.size > 0 ? collectSelected() : selected ? [selected.value] : [];
235
+ if (chosen.length === 0)
236
+ return;
237
+ setStatus('done');
238
+ done(chosen);
239
+ return;
240
+ }
241
+ // space toggles the active row's checkbox; strip the space from the buffer
242
+ // so it never leaks into the filter.
243
+ if (isSpaceKey(key)) {
244
+ rl.clearLine(0);
245
+ if (selected) {
246
+ const k = cfg.keyFor(selected.value);
247
+ const next = new Set(selectedKeys);
248
+ if (next.has(k))
249
+ next.delete(k);
250
+ else
251
+ next.add(k);
252
+ setSelectedKeys(next);
253
+ }
254
+ return;
255
+ }
256
+ if (key.name === 'tab' && cfg.buildPreview) {
257
+ rl.clearLine(0);
258
+ setPreviewOpen(!previewOpen);
259
+ return;
260
+ }
261
+ if (isUpKey(key)) {
262
+ rl.clearLine(0);
263
+ if (results.length > 0)
264
+ setActive((active - 1 + results.length) % results.length);
265
+ return;
266
+ }
267
+ if (isDownKey(key)) {
268
+ rl.clearLine(0);
269
+ if (results.length > 0)
270
+ setActive((active + 1) % results.length);
271
+ return;
272
+ }
273
+ setSearchTerm(rl.line);
274
+ });
275
+ const message = theme.style.message(cfg.message, status);
276
+ const count = selectedKeys.size;
277
+ if (status === 'done') {
278
+ return `${prefix} ${message} ${chalk.cyan(`${count || 1} session${(count || 1) === 1 ? '' : 's'}`)}`;
279
+ }
280
+ const placeholder = '(type to filter · space to toggle · enter to resume)';
281
+ const searchStr = searchTerm ? chalk.cyan(searchTerm) : chalk.gray(placeholder);
282
+ const header = [prefix, message, searchStr].filter(Boolean).join(' ');
283
+ const page = usePagination({
284
+ items: results,
285
+ active,
286
+ renderItem({ item, isActive }) {
287
+ if (Separator.isSeparator(item))
288
+ return ` ${item.separator}`;
289
+ const checked = selectedKeys.has(cfg.keyFor(item.value));
290
+ const box = checked ? chalk.green('[x]') : chalk.gray('[ ]');
291
+ const cursor = isActive ? chalk.cyan('>') : ' ';
292
+ const row = isActive ? chalk.bold(item.label) : item.label;
293
+ return `${cursor} ${box} ${row}`;
294
+ },
295
+ pageSize: cfg.pageSize ?? 10,
296
+ loop: false,
297
+ });
298
+ const enter = cfg.enterHint ?? 'resume';
299
+ const countStr = count > 0 ? chalk.green(`${count} selected`) : chalk.gray('0 selected');
300
+ const help = chalk.gray(`${countStr}${chalk.gray(' · ↑↓ navigate · space toggle')}${cfg.buildPreview ? chalk.gray(' · tab preview') : ''}${chalk.gray(` · ⏎ ${enter} · esc cancel`)}`);
301
+ const parts = [header, page];
302
+ if (results.length === 0) {
303
+ parts.push(chalk.gray(` ${cfg.emptyMessage ?? 'No matches.'}`));
304
+ }
305
+ if (previewOpen && selected && cfg.buildPreview) {
306
+ const width = terminalWidth();
307
+ const separator = chalk.gray('─'.repeat(Math.min(width, 80)));
308
+ const fixedRows = renderedRows(header, width) +
309
+ renderedRows(parts.slice(1).join('\n'), width) +
310
+ renderedRows(separator, width) +
311
+ renderedRows(help, width);
312
+ const availablePreviewRows = terminalRows() - fixedRows;
313
+ const preview = limitPreviewHeight(cfg.buildPreview(selected.value), availablePreviewRows, width);
314
+ if (preview) {
315
+ parts.push(separator);
316
+ parts.push(preview);
317
+ }
318
+ }
319
+ parts.push(help);
320
+ return [header, parts.slice(1).join('\n')];
321
+ });
322
+ return prompt(config);
323
+ }
@@ -4,12 +4,41 @@ export interface WinPathResult {
4
4
  alreadyPresent?: boolean;
5
5
  error?: string;
6
6
  }
7
+ /**
8
+ * Compute the new User PATH from the RAW (unexpanded) current value. Pure and
9
+ * OS-independent — the single source of truth for the prepend/dedup logic.
10
+ *
11
+ * Idempotent: returns `{ changed: false }` (value unchanged, verbatim) when
12
+ * `dir` is already the first `;`-split entry. Otherwise removes every existing
13
+ * occurrence of `dir` and prepends it, dropping empty segments — matching POSIX
14
+ * `export PATH="${dir}:$PATH"`. `%VAR%` segments are preserved verbatim (never
15
+ * expanded), which is the #308 regression this fix targets.
16
+ */
17
+ export declare function computeNewUserPath(currentRaw: string, dir: string): {
18
+ changed: boolean;
19
+ value: string;
20
+ };
21
+ /**
22
+ * Decide whether to write the User PATH back as REG_EXPAND_SZ (ExpandString) vs
23
+ * REG_SZ (String). Pure — testable on any host.
24
+ *
25
+ * True (expandable) when the original value was already ExpandString, when the
26
+ * raw value contains a `%VAR%` reference, or when `Path` was absent (default to
27
+ * ExpandString — Windows' native Path type). Only a plain String value with no
28
+ * `%` stays REG_SZ. `originalKind` is the .NET RegistryValueKind name
29
+ * (`ExpandString`/`String`/…) or `null`/`Absent` when `Path` had no value.
30
+ */
31
+ export declare function shouldWriteExpandable(originalKind: string | null, rawValue: string): boolean;
7
32
  /**
8
33
  * Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
9
34
  * already first; moves it to the front when it exists but is positioned later
10
35
  * (e.g. appended by an older install) so it overrides conflicting entries.
11
- * `dir` is passed via an env var so it is never interpolated into the script
12
- * text.
36
+ *
37
+ * Reads the RAW registry value (preserving `%VAR%` and the REG_EXPAND_SZ type),
38
+ * computes the new value in TS via `computeNewUserPath`, and only writes when
39
+ * the value actually changes — preserving the original value type and
40
+ * broadcasting WM_SETTINGCHANGE. `dir` and the computed value are passed via env
41
+ * vars so they are never interpolated into the script text.
13
42
  */
14
43
  export declare function prependToWindowsUserPath(dir: string): WinPathResult;
15
44
  /**
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * Windows User PATH + execution-policy primitives.
3
3
  *
4
- * The single place that mutates the Windows User PATH via the .NET environment
5
- * API (which writes the registry AND broadcasts WM_SETTINGCHANGE the correct
6
- * analog of editing a shell rc file: no `setx` truncation, no manual step).
4
+ * The single place that mutates the Windows User PATH. It reads and writes the
5
+ * RAW registry value via `Microsoft.Win32.Registry` (NOT the .NET
6
+ * `[Environment]::*Environment*Variable` API, which expands `%VAR%` references
7
+ * on read and downgrades REG_EXPAND_SZ to REG_SZ on write — issue #308,
8
+ * dotnet/runtime#89695 / #1442). The prepend/dedup itself is computed in TS
9
+ * (`computeNewUserPath`, the single source of truth) so it has unit coverage on
10
+ * every OS; PowerShell is used only for the registry primitives. Because a raw
11
+ * `SetValue` does NOT broadcast the change (the old `[Environment]` API did), the
12
+ * write script broadcasts WM_SETTINGCHANGE itself so a new terminal picks up the
13
+ * PATH without re-login.
7
14
  * Consumers: `shims.ts` (shims dir) and `scripts/postinstall.js` (npm global-bin
8
15
  * dir, so the `agents` command itself resolves).
9
16
  *
@@ -12,34 +19,136 @@
12
19
  */
13
20
  import { execFileSync } from 'child_process';
14
21
  import * as path from 'path';
22
+ /**
23
+ * Compute the new User PATH from the RAW (unexpanded) current value. Pure and
24
+ * OS-independent — the single source of truth for the prepend/dedup logic.
25
+ *
26
+ * Idempotent: returns `{ changed: false }` (value unchanged, verbatim) when
27
+ * `dir` is already the first `;`-split entry. Otherwise removes every existing
28
+ * occurrence of `dir` and prepends it, dropping empty segments — matching POSIX
29
+ * `export PATH="${dir}:$PATH"`. `%VAR%` segments are preserved verbatim (never
30
+ * expanded), which is the #308 regression this fix targets.
31
+ */
32
+ export function computeNewUserPath(currentRaw, dir) {
33
+ const parts = currentRaw.split(';').filter((p) => p !== '');
34
+ if (parts.length > 0 && parts[0] === dir) {
35
+ return { changed: false, value: currentRaw };
36
+ }
37
+ const others = parts.filter((p) => p !== dir);
38
+ return { changed: true, value: [dir, ...others].join(';') };
39
+ }
40
+ /**
41
+ * Decide whether to write the User PATH back as REG_EXPAND_SZ (ExpandString) vs
42
+ * REG_SZ (String). Pure — testable on any host.
43
+ *
44
+ * True (expandable) when the original value was already ExpandString, when the
45
+ * raw value contains a `%VAR%` reference, or when `Path` was absent (default to
46
+ * ExpandString — Windows' native Path type). Only a plain String value with no
47
+ * `%` stays REG_SZ. `originalKind` is the .NET RegistryValueKind name
48
+ * (`ExpandString`/`String`/…) or `null`/`Absent` when `Path` had no value.
49
+ */
50
+ export function shouldWriteExpandable(originalKind, rawValue) {
51
+ if (originalKind === null || originalKind === 'Absent')
52
+ return true;
53
+ if (originalKind === 'ExpandString')
54
+ return true;
55
+ return rawValue.includes('%');
56
+ }
57
+ // Sentinel separating the value kind from the (possibly '%'-laden) raw value in
58
+ // the read script's stdout — PATH entries never contain a newline, so an
59
+ // exclusive line marker parses unambiguously.
60
+ const READ_MARKER = '===AGENTS-PATH-VALUE===';
61
+ // Reads the RAW User PATH preserving REG_EXPAND_SZ: DoNotExpandEnvironmentNames
62
+ // keeps `%VAR%` literal, and GetValueKind reports the original type (throws when
63
+ // 'Path' is absent -> caught, reported as Absent).
64
+ const READ_SCRIPT = [
65
+ "$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)",
66
+ 'if ($null -eq $key) {',
67
+ " Write-Output 'KIND:Absent'",
68
+ ` Write-Output '${READ_MARKER}'`,
69
+ " Write-Output ''",
70
+ '} else {',
71
+ " try { $kind = $key.GetValueKind('Path').ToString() } catch { $kind = 'Absent' }",
72
+ " $val = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)",
73
+ " Write-Output ('KIND:' + $kind)",
74
+ ` Write-Output '${READ_MARKER}'`,
75
+ ' Write-Output $val',
76
+ '}',
77
+ ].join('\n');
78
+ // Writes the computed value back with the preserved kind and broadcasts
79
+ // WM_SETTINGCHANGE (raw SetValue does not, unlike the old [Environment] API).
80
+ // The value comes in via AGENTS_WINPATH_VALUE so it is never interpolated into
81
+ // the script text (preserves the no-injection property for '%'-laden paths).
82
+ const WRITE_SCRIPT = [
83
+ "$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)",
84
+ "if ($null -eq $key) { $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') }",
85
+ '$val = $env:AGENTS_WINPATH_VALUE',
86
+ "if ($env:AGENTS_WINPATH_EXPAND -eq '1') {",
87
+ ' $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString',
88
+ '} else {',
89
+ ' $kind = [Microsoft.Win32.RegistryValueKind]::String',
90
+ '}',
91
+ "$key.SetValue('Path', $val, $kind)",
92
+ 'Add-Type @"',
93
+ 'using System;',
94
+ 'using System.Runtime.InteropServices;',
95
+ 'public static class AgentsWinPath {',
96
+ ' [DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)]',
97
+ ' public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);',
98
+ '}',
99
+ '"@',
100
+ '$res = [UIntPtr]::Zero',
101
+ // HWND_BROADCAST=0xffff, WM_SETTINGCHANGE=0x1a, SMTO_ABORTIFHUNG=2, 5s timeout
102
+ "[AgentsWinPath]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$res) | Out-Null",
103
+ "Write-Output 'written'",
104
+ ].join('\n');
105
+ function runPowerShell(script, extraEnv) {
106
+ return execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
107
+ encoding: 'utf-8',
108
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ });
111
+ }
112
+ /** Parse the read script's stdout into the original value kind and RAW value. */
113
+ function parseReadOutput(out) {
114
+ const idx = out.indexOf(READ_MARKER);
115
+ if (idx === -1)
116
+ return { kind: null, raw: '' };
117
+ const head = out.slice(0, idx);
118
+ const kindMatch = head.match(/KIND:(\S+)/);
119
+ const kind = kindMatch ? kindMatch[1] : null;
120
+ // Everything after the marker line, minus the leading/trailing newline PS adds.
121
+ const raw = out
122
+ .slice(idx + READ_MARKER.length)
123
+ .replace(/^\r?\n/, '')
124
+ .replace(/\r?\n$/, '');
125
+ return { kind, raw };
126
+ }
15
127
  /**
16
128
  * Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
17
129
  * already first; moves it to the front when it exists but is positioned later
18
130
  * (e.g. appended by an older install) so it overrides conflicting entries.
19
- * `dir` is passed via an env var so it is never interpolated into the script
20
- * text.
131
+ *
132
+ * Reads the RAW registry value (preserving `%VAR%` and the REG_EXPAND_SZ type),
133
+ * computes the new value in TS via `computeNewUserPath`, and only writes when
134
+ * the value actually changes — preserving the original value type and
135
+ * broadcasting WM_SETTINGCHANGE. `dir` and the computed value are passed via env
136
+ * vars so they are never interpolated into the script text.
21
137
  */
22
138
  export function prependToWindowsUserPath(dir) {
23
- const script = [
24
- '$d = $env:AGENTS_WINPATH_DIR',
25
- "$u = [Environment]::GetEnvironmentVariable('Path','User')",
26
- "if ($null -eq $u) { $u = '' }",
27
- "$parts = @($u -split ';' | Where-Object { $_ -ne '' })",
28
- // Already first — nothing to do
29
- "if ($parts.Count -gt 0 -and $parts[0] -eq $d) { 'present' } else {",
30
- // Remove any existing occurrence then prepend, matching POSIX `export PATH="${dir}:$PATH"`
31
- " $newParts = @($d) + @($parts | Where-Object { $_ -ne $d })",
32
- " [Environment]::SetEnvironmentVariable('Path', ($newParts -join ';'), 'User')",
33
- " 'added'",
34
- '}',
35
- ].join('\n');
36
139
  try {
37
- const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
38
- encoding: 'utf-8',
39
- env: { ...process.env, AGENTS_WINPATH_DIR: dir },
40
- stdio: ['ignore', 'pipe', 'pipe'],
41
- }).trim();
42
- return { success: true, alreadyPresent: out.includes('present') };
140
+ const readOut = runPowerShell(READ_SCRIPT);
141
+ const { kind, raw } = parseReadOutput(readOut);
142
+ const { changed, value } = computeNewUserPath(raw, dir);
143
+ if (!changed) {
144
+ return { success: true, alreadyPresent: true };
145
+ }
146
+ const expandable = shouldWriteExpandable(kind, value);
147
+ runPowerShell(WRITE_SCRIPT, {
148
+ AGENTS_WINPATH_VALUE: value,
149
+ AGENTS_WINPATH_EXPAND: expandable ? '1' : '0',
150
+ });
151
+ return { success: true, alreadyPresent: false };
43
152
  }
44
153
  catch (err) {
45
154
  return { success: false, error: `Could not update the Windows user PATH: ${err.message}` };
@@ -144,6 +144,36 @@ export declare function syncMarketplaceManifest(spec: MarketplaceSpec, agent: Ag
144
144
  * marketplaces' entries are preserved untouched.
145
145
  */
146
146
  export declare function registerMarketplace(spec: MarketplaceSpec, agent: AgentId, versionHome: string): void;
147
+ /**
148
+ * Record a plugin in Droid's installed_plugins.json (user scope), pointing
149
+ * installPath at the marketplace install dir so no second copy is needed. This
150
+ * is what makes `droid plugin list` show the plugin (Active once enabledPlugins
151
+ * is set). Idempotent: re-running refreshes lastUpdated and preserves the
152
+ * original installedAt plus any non-user scope entries.
153
+ */
154
+ export declare function registerDroidInstalledPlugin(pluginName: string, marketplaceName: string, installDir: string, version: string, agent: AgentId, versionHome: string): void;
155
+ /** True when Droid's installed_plugins.json carries a user-scope entry for the plugin. */
156
+ export declare function isDroidPluginInstalled(pluginName: string, marketplaceName: string, agent: AgentId, versionHome: string): boolean;
157
+ /**
158
+ * Remove a plugin's user-scope entry from Droid's installed_plugins.json.
159
+ * Inverse of registerDroidInstalledPlugin. Drops the key when no scopes remain
160
+ * and deletes the file when the registry is empty.
161
+ */
162
+ export declare function unregisterDroidInstalledPlugin(pluginName: string, marketplaceName: string, agent: AgentId, versionHome: string): void;
163
+ /**
164
+ * Record a plugin in Copilot's config.json#installedPlugins (the registry that
165
+ * makes `copilot plugin list` see it). `cache_path` points at the marketplace
166
+ * install dir — no separate copy. Idempotent: re-running refreshes the entry and
167
+ * preserves the original installed_at plus every other config key.
168
+ */
169
+ export declare function registerCopilotInstalledPlugin(pluginName: string, marketplaceName: string, installDir: string, version: string, enabled: boolean, agent: AgentId, versionHome: string): void;
170
+ /**
171
+ * Remove a plugin's entry from Copilot's config.json#installedPlugins. Inverse
172
+ * of registerCopilotInstalledPlugin. Leaves the file (with an empty
173
+ * installedPlugins) rather than deleting it, since config.json may hold other
174
+ * Copilot-managed keys.
175
+ */
176
+ export declare function unregisterCopilotInstalledPlugin(pluginName: string, marketplaceName: string, agent: AgentId, versionHome: string): void;
147
177
  /**
148
178
  * Drop a marketplace entry from known_marketplaces.json. Called when the last
149
179
  * plugin under it is removed. Removes only its own entry; deletes the file only
@@ -127,7 +127,14 @@ export function marketplaceRoot(specOrName, agent, versionHome) {
127
127
  return path.join(pluginsRootForVersion(agent, versionHome), 'marketplaces', nameOf(specOrName));
128
128
  }
129
129
  export function marketplaceManifestPath(specOrName, agent, versionHome) {
130
- return path.join(marketplaceRoot(specOrName, agent, versionHome), '.claude-plugin', 'marketplace.json');
130
+ const root = marketplaceRoot(specOrName, agent, versionHome);
131
+ // Copilot resolves a marketplace's catalog from `marketplace.json` at the
132
+ // marketplace ROOT (also `.plugin/marketplace.json`, `.github/plugin/
133
+ // marketplace.json`) — NOT `.claude-plugin/marketplace.json`. Verified against
134
+ // GitHub Copilot CLI 1.0.56 (`plugin marketplace add`).
135
+ if (agent === 'copilot')
136
+ return path.join(root, 'marketplace.json');
137
+ return path.join(root, '.claude-plugin', 'marketplace.json');
131
138
  }
132
139
  export function pluginInstallDir(plugin, specOrName, agent, versionHome) {
133
140
  return path.join(marketplaceRoot(specOrName, agent, versionHome), 'plugins', plugin.name);
@@ -375,6 +382,14 @@ export function syncMarketplaceManifest(spec, agent, versionHome) {
375
382
  export function registerMarketplace(spec, agent, versionHome) {
376
383
  const name = marketplaceNameFor(spec);
377
384
  const root = marketplaceRoot(spec, agent, versionHome);
385
+ // Copilot diverges from Claude's known_marketplaces.json: it reads registered
386
+ // marketplaces from settings.json#extraKnownMarketplaces. Verified against
387
+ // GitHub Copilot CLI 1.0.56 (`plugin marketplace add <dir>` writes exactly
388
+ // this shape). A "directory"-source entry points at the on-disk catalog root.
389
+ if (agent === 'copilot') {
390
+ registerCopilotMarketplace(name, root, agent, versionHome);
391
+ return;
392
+ }
378
393
  const knownPath = knownMarketplacesPath(agent, versionHome);
379
394
  let known = {};
380
395
  if (fs.existsSync(knownPath)) {
@@ -385,14 +400,206 @@ export function registerMarketplace(spec, agent, versionHome) {
385
400
  known = {};
386
401
  }
387
402
  }
403
+ // Droid names the on-disk source type "local" (Claude/OpenClaw use
404
+ // "directory") and stamps autoUpdate — verified against `droid plugin
405
+ // marketplace add`. A "directory" entry is silently ignored by the Factory
406
+ // CLI, so the plugin never resolves.
407
+ const isDroid = agent === 'droid';
388
408
  known[name] = {
389
- source: { source: 'directory', path: root },
409
+ source: { source: isDroid ? 'local' : 'directory', path: root },
390
410
  installLocation: root,
391
411
  lastUpdated: new Date().toISOString(),
412
+ ...(isDroid ? { autoUpdate: true } : {}),
392
413
  };
393
414
  fs.mkdirSync(path.dirname(knownPath), { recursive: true });
394
415
  fs.writeFileSync(knownPath, JSON.stringify(known, null, 2) + '\n', 'utf-8');
395
416
  }
417
+ // ─── Droid installed-plugins registry ─────────────────────────────────────────
418
+ function installedPluginsPath(agent, versionHome) {
419
+ return path.join(pluginsRootForVersion(agent, versionHome), 'installed_plugins.json');
420
+ }
421
+ function readInstalledPlugins(agent, versionHome) {
422
+ const p = installedPluginsPath(agent, versionHome);
423
+ if (fs.existsSync(p)) {
424
+ try {
425
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
426
+ if (parsed && typeof parsed === 'object' && parsed.plugins && typeof parsed.plugins === 'object') {
427
+ return { schemaVersion: parsed.schemaVersion ?? 1, plugins: parsed.plugins };
428
+ }
429
+ }
430
+ catch { /* fall through to fresh registry */ }
431
+ }
432
+ return { schemaVersion: 1, plugins: {} };
433
+ }
434
+ /**
435
+ * Record a plugin in Droid's installed_plugins.json (user scope), pointing
436
+ * installPath at the marketplace install dir so no second copy is needed. This
437
+ * is what makes `droid plugin list` show the plugin (Active once enabledPlugins
438
+ * is set). Idempotent: re-running refreshes lastUpdated and preserves the
439
+ * original installedAt plus any non-user scope entries.
440
+ */
441
+ export function registerDroidInstalledPlugin(pluginName, marketplaceName, installDir, version, agent, versionHome) {
442
+ const registry = readInstalledPlugins(agent, versionHome);
443
+ const key = `${pluginName}@${marketplaceName}`;
444
+ const now = new Date().toISOString();
445
+ const existing = registry.plugins[key] ?? [];
446
+ const priorUser = existing.find(e => e.scope === 'user');
447
+ const others = existing.filter(e => e.scope !== 'user');
448
+ registry.plugins[key] = [
449
+ ...others,
450
+ {
451
+ scope: 'user',
452
+ installPath: installDir,
453
+ version,
454
+ installedAt: priorUser?.installedAt ?? now,
455
+ lastUpdated: now,
456
+ source: marketplaceName,
457
+ },
458
+ ];
459
+ const p = installedPluginsPath(agent, versionHome);
460
+ fs.mkdirSync(path.dirname(p), { recursive: true });
461
+ fs.writeFileSync(p, JSON.stringify(registry, null, 2) + '\n', 'utf-8');
462
+ }
463
+ /** True when Droid's installed_plugins.json carries a user-scope entry for the plugin. */
464
+ export function isDroidPluginInstalled(pluginName, marketplaceName, agent, versionHome) {
465
+ const registry = readInstalledPlugins(agent, versionHome);
466
+ const entries = registry.plugins[`${pluginName}@${marketplaceName}`];
467
+ return Array.isArray(entries) && entries.some(e => e.scope === 'user');
468
+ }
469
+ /**
470
+ * Remove a plugin's user-scope entry from Droid's installed_plugins.json.
471
+ * Inverse of registerDroidInstalledPlugin. Drops the key when no scopes remain
472
+ * and deletes the file when the registry is empty.
473
+ */
474
+ export function unregisterDroidInstalledPlugin(pluginName, marketplaceName, agent, versionHome) {
475
+ const p = installedPluginsPath(agent, versionHome);
476
+ if (!fs.existsSync(p))
477
+ return;
478
+ const registry = readInstalledPlugins(agent, versionHome);
479
+ const key = `${pluginName}@${marketplaceName}`;
480
+ const entries = registry.plugins[key];
481
+ if (!Array.isArray(entries))
482
+ return;
483
+ const kept = entries.filter(e => e.scope !== 'user');
484
+ if (kept.length > 0) {
485
+ registry.plugins[key] = kept;
486
+ }
487
+ else {
488
+ delete registry.plugins[key];
489
+ }
490
+ if (Object.keys(registry.plugins).length === 0) {
491
+ try {
492
+ fs.unlinkSync(p);
493
+ }
494
+ catch { /* ignore */ }
495
+ return;
496
+ }
497
+ fs.writeFileSync(p, JSON.stringify(registry, null, 2) + '\n', 'utf-8');
498
+ }
499
+ // ─── Copilot marketplace + installed-plugin registry ──────────────────────────
500
+ function copilotConfigPath(agent, versionHome) {
501
+ return path.join(versionHome, agentConfigDirName(agent), 'config.json');
502
+ }
503
+ function readCopilotSettings(agent, versionHome) {
504
+ const p = settingsPath(agent, versionHome);
505
+ if (fs.existsSync(p)) {
506
+ try {
507
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
508
+ if (parsed && typeof parsed === 'object')
509
+ return parsed;
510
+ }
511
+ catch { /* fall through */ }
512
+ }
513
+ return {};
514
+ }
515
+ function writeCopilotSettings(agent, versionHome, settings) {
516
+ const p = settingsPath(agent, versionHome);
517
+ fs.mkdirSync(path.dirname(p), { recursive: true });
518
+ fs.writeFileSync(p, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
519
+ }
520
+ /** Register a marketplace in Copilot's settings.json#extraKnownMarketplaces. */
521
+ function registerCopilotMarketplace(name, root, agent, versionHome) {
522
+ const settings = readCopilotSettings(agent, versionHome);
523
+ const known = (settings.extraKnownMarketplaces && typeof settings.extraKnownMarketplaces === 'object'
524
+ ? settings.extraKnownMarketplaces
525
+ : {});
526
+ known[name] = { source: { source: 'directory', path: root } };
527
+ settings.extraKnownMarketplaces = known;
528
+ writeCopilotSettings(agent, versionHome, settings);
529
+ }
530
+ /** Inverse of registerCopilotMarketplace: drop the entry, prune the empty key. */
531
+ function unregisterCopilotMarketplace(name, agent, versionHome) {
532
+ const p = settingsPath(agent, versionHome);
533
+ if (!fs.existsSync(p))
534
+ return;
535
+ const settings = readCopilotSettings(agent, versionHome);
536
+ const known = settings.extraKnownMarketplaces;
537
+ if (!known || !(name in known))
538
+ return;
539
+ delete known[name];
540
+ if (Object.keys(known).length === 0)
541
+ delete settings.extraKnownMarketplaces;
542
+ writeCopilotSettings(agent, versionHome, settings);
543
+ }
544
+ function readCopilotConfig(agent, versionHome) {
545
+ const p = copilotConfigPath(agent, versionHome);
546
+ if (fs.existsSync(p)) {
547
+ try {
548
+ // config.json may carry a leading `//` comment banner written by Copilot;
549
+ // strip line comments before parsing so we never clobber a real config.
550
+ const raw = fs.readFileSync(p, 'utf-8').replace(/^\s*\/\/.*$/gm, '');
551
+ const parsed = JSON.parse(raw);
552
+ if (parsed && typeof parsed === 'object') {
553
+ return { ...parsed, installedPlugins: Array.isArray(parsed.installedPlugins) ? parsed.installedPlugins : [] };
554
+ }
555
+ }
556
+ catch { /* fall through to fresh config */ }
557
+ }
558
+ return { installedPlugins: [] };
559
+ }
560
+ /**
561
+ * Record a plugin in Copilot's config.json#installedPlugins (the registry that
562
+ * makes `copilot plugin list` see it). `cache_path` points at the marketplace
563
+ * install dir — no separate copy. Idempotent: re-running refreshes the entry and
564
+ * preserves the original installed_at plus every other config key.
565
+ */
566
+ export function registerCopilotInstalledPlugin(pluginName, marketplaceName, installDir, version, enabled, agent, versionHome) {
567
+ const config = readCopilotConfig(agent, versionHome);
568
+ const now = new Date().toISOString();
569
+ const prior = config.installedPlugins.find(e => e.name === pluginName && e.marketplace === marketplaceName);
570
+ const others = config.installedPlugins.filter(e => !(e.name === pluginName && e.marketplace === marketplaceName));
571
+ config.installedPlugins = [
572
+ ...others,
573
+ {
574
+ name: pluginName,
575
+ marketplace: marketplaceName,
576
+ version,
577
+ installed_at: prior?.installed_at ?? now,
578
+ enabled,
579
+ cache_path: installDir,
580
+ },
581
+ ];
582
+ const p = copilotConfigPath(agent, versionHome);
583
+ fs.mkdirSync(path.dirname(p), { recursive: true });
584
+ fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n', 'utf-8');
585
+ }
586
+ /**
587
+ * Remove a plugin's entry from Copilot's config.json#installedPlugins. Inverse
588
+ * of registerCopilotInstalledPlugin. Leaves the file (with an empty
589
+ * installedPlugins) rather than deleting it, since config.json may hold other
590
+ * Copilot-managed keys.
591
+ */
592
+ export function unregisterCopilotInstalledPlugin(pluginName, marketplaceName, agent, versionHome) {
593
+ const p = copilotConfigPath(agent, versionHome);
594
+ if (!fs.existsSync(p))
595
+ return;
596
+ const config = readCopilotConfig(agent, versionHome);
597
+ const kept = config.installedPlugins.filter(e => !(e.name === pluginName && e.marketplace === marketplaceName));
598
+ if (kept.length === config.installedPlugins.length)
599
+ return;
600
+ config.installedPlugins = kept;
601
+ fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n', 'utf-8');
602
+ }
396
603
  /**
397
604
  * Drop a marketplace entry from known_marketplaces.json. Called when the last
398
605
  * plugin under it is removed. Removes only its own entry; deletes the file only
@@ -400,6 +607,12 @@ export function registerMarketplace(spec, agent, versionHome) {
400
607
  */
401
608
  export function unregisterMarketplace(specOrName, agent, versionHome) {
402
609
  const name = nameOf(specOrName);
610
+ // Copilot stores registrations in settings.json#extraKnownMarketplaces, not
611
+ // known_marketplaces.json. Mirror the branch in registerMarketplace.
612
+ if (agent === 'copilot') {
613
+ unregisterCopilotMarketplace(name, agent, versionHome);
614
+ return;
615
+ }
403
616
  const knownPath = knownMarketplacesPath(agent, versionHome);
404
617
  if (!fs.existsSync(knownPath))
405
618
  return;
@@ -148,6 +148,11 @@ export declare function marketplaceSpecForName(name: string | undefined, cwd?: s
148
148
  * MCP servers, bin/, settings.json, and permissions once the plugin lives at the
149
149
  * native install path and is marked enabled — see
150
150
  * https://code.claude.com/docs/en/plugins.
151
+ *
152
+ * Droid (Factory CLI) reuses this same marketplace layout but additionally
153
+ * requires an installed_plugins.json registry entry and a "local"-source
154
+ * known_marketplaces.json entry before `droid plugin list` will see the plugin
155
+ * (steps handled by registerMarketplace's droid branch + step 5c below).
151
156
  */
152
157
  export declare function syncPluginToVersion(plugin: DiscoveredPlugin, agent: AgentId, versionHome: string, options?: {
153
158
  allowExecSurfaces?: boolean;