@phnx-labs/agents-cli 1.20.12 → 1.20.14

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 (67) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +3 -0
  3. package/dist/commands/computer-actions.d.ts +3 -0
  4. package/dist/commands/computer-actions.js +16 -0
  5. package/dist/commands/doctor.js +51 -7
  6. package/dist/commands/exec.js +25 -4
  7. package/dist/commands/import.js +17 -6
  8. package/dist/commands/inspect.d.ts +28 -1
  9. package/dist/commands/inspect.js +330 -47
  10. package/dist/commands/mcp.js +3 -3
  11. package/dist/commands/plugins.d.ts +2 -0
  12. package/dist/commands/plugins.js +69 -26
  13. package/dist/commands/prune.js +8 -5
  14. package/dist/commands/sync.js +1 -1
  15. package/dist/commands/teams.js +1 -0
  16. package/dist/commands/trash.d.ts +11 -0
  17. package/dist/commands/trash.js +57 -41
  18. package/dist/commands/versions.js +68 -20
  19. package/dist/commands/view.d.ts +1 -0
  20. package/dist/commands/view.js +56 -12
  21. package/dist/commands/wallet.d.ts +14 -0
  22. package/dist/commands/wallet.js +199 -0
  23. package/dist/index.js +4 -1
  24. package/dist/lib/agents.js +70 -22
  25. package/dist/lib/browser/ipc.d.ts +7 -0
  26. package/dist/lib/browser/ipc.js +43 -27
  27. package/dist/lib/capabilities.js +7 -1
  28. package/dist/lib/command-skills.d.ts +1 -0
  29. package/dist/lib/command-skills.js +23 -7
  30. package/dist/lib/exec.d.ts +32 -1
  31. package/dist/lib/exec.js +79 -7
  32. package/dist/lib/hooks.d.ts +21 -1
  33. package/dist/lib/hooks.js +69 -7
  34. package/dist/lib/mcp.js +33 -0
  35. package/dist/lib/models.js +5 -0
  36. package/dist/lib/picker.d.ts +2 -0
  37. package/dist/lib/picker.js +96 -6
  38. package/dist/lib/platform/index.d.ts +1 -0
  39. package/dist/lib/platform/index.js +1 -0
  40. package/dist/lib/platform/winpath.d.ts +35 -0
  41. package/dist/lib/platform/winpath.js +86 -0
  42. package/dist/lib/plugins.d.ts +24 -0
  43. package/dist/lib/plugins.js +37 -2
  44. package/dist/lib/project-launch.js +110 -5
  45. package/dist/lib/registry.js +15 -2
  46. package/dist/lib/rotate.d.ts +7 -0
  47. package/dist/lib/rotate.js +17 -7
  48. package/dist/lib/runner.js +14 -0
  49. package/dist/lib/sandbox.js +5 -2
  50. package/dist/lib/settings-manifest.d.ts +39 -0
  51. package/dist/lib/settings-manifest.js +163 -0
  52. package/dist/lib/shims.d.ts +1 -1
  53. package/dist/lib/shims.js +16 -31
  54. package/dist/lib/staleness/detectors/subagents.js +16 -0
  55. package/dist/lib/staleness/writers/subagents.js +11 -3
  56. package/dist/lib/subagents.d.ts +9 -0
  57. package/dist/lib/subagents.js +33 -0
  58. package/dist/lib/teams/agents.js +1 -1
  59. package/dist/lib/teams/parsers.d.ts +1 -1
  60. package/dist/lib/teams/parsers.js +6 -0
  61. package/dist/lib/types.d.ts +1 -1
  62. package/dist/lib/versions.d.ts +15 -3
  63. package/dist/lib/versions.js +88 -19
  64. package/dist/lib/wallet/index.d.ts +78 -0
  65. package/dist/lib/wallet/index.js +253 -0
  66. package/package.json +3 -3
  67. package/scripts/postinstall.js +35 -7
@@ -105,6 +105,29 @@ export function buildDiscoveredPlugin(pluginRoot, manifest, spec = { kind: 'user
105
105
  hasSettings: pluginHasNonPermissionSettings(pluginRoot),
106
106
  };
107
107
  }
108
+ /**
109
+ * Ordered, non-empty resource groups a plugin packages. Single source of truth
110
+ * for the breakdown shown by the plugin picker, `agents inspect --plugins`, and
111
+ * its detail view. Empty categories are omitted; `settings` appears only when
112
+ * the plugin merges non-permission settings.
113
+ */
114
+ export function pluginResourceGroups(plugin) {
115
+ const groups = [
116
+ { label: 'skills', items: plugin.skills.map((s) => `/${plugin.name}:${s}`) },
117
+ { label: 'commands', items: plugin.commands.map((c) => `/${plugin.name}:${c}`) },
118
+ { label: 'subagents', items: plugin.agentDefs },
119
+ { label: 'hooks', items: plugin.hooks },
120
+ { label: 'mcp', items: plugin.mcpServers },
121
+ { label: 'lsp', items: plugin.lspServers },
122
+ { label: 'monitors', items: plugin.monitors },
123
+ { label: 'bin', items: plugin.bin },
124
+ { label: 'scripts', items: plugin.scripts },
125
+ ];
126
+ const out = groups.filter((g) => g.items.length > 0);
127
+ if (plugin.hasSettings)
128
+ out.push({ label: 'settings', items: ['settings.json'] });
129
+ return out;
130
+ }
108
131
  export function inspectPluginCapabilities(pluginRoot) {
109
132
  const manifest = loadPluginManifest(pluginRoot);
110
133
  const plugin = manifest ? buildDiscoveredPlugin(pluginRoot, manifest) : null;
@@ -190,13 +213,25 @@ function discoverPluginSkills(pluginRoot) {
190
213
  .filter(d => d.isDirectory() && !d.name.startsWith('.'))
191
214
  .map(d => d.name);
192
215
  }
193
- function discoverPluginHooks(pluginRoot) {
216
+ /**
217
+ * The lifecycle events a plugin hooks into, read from hooks/hooks.json.
218
+ *
219
+ * The official plugin format wraps the event map under a `hooks` key
220
+ * (`{ description, hooks: { SessionStart: [...], PreToolUse: [...] } }`), so the
221
+ * meaningful keys are the events — NOT the top-level keys (`description`,
222
+ * `hooks`). Older/flat files put the event names at the top level directly; we
223
+ * read whichever object actually holds the event map.
224
+ */
225
+ export function discoverPluginHooks(pluginRoot) {
194
226
  const hooksFile = path.join(pluginRoot, 'hooks', 'hooks.json');
195
227
  if (!fs.existsSync(hooksFile))
196
228
  return [];
197
229
  try {
198
230
  const content = JSON.parse(fs.readFileSync(hooksFile, 'utf-8'));
199
- return Object.keys(content);
231
+ const eventMap = content.hooks && typeof content.hooks === 'object' && !Array.isArray(content.hooks)
232
+ ? content.hooks
233
+ : content;
234
+ return Object.keys(eventMap);
200
235
  }
201
236
  catch {
202
237
  return [];
@@ -47,6 +47,7 @@ import * as path from 'path';
47
47
  import { supports } from './capabilities.js';
48
48
  import { getEnabledExtraRepos, getExtraPluginsDir, getPluginsDir, getProjectAgentsDir, getProjectPluginsDir, getSystemPluginsDir, } from './state.js';
49
49
  import { getVersionHomePath } from './versions.js';
50
+ import { transformSubagentForClaude } from './subagents.js';
50
51
  import { compileRulesForProject } from './rules/compile.js';
51
52
  import { discoverPluginsInDir, hasPluginExecSurfaces, inspectPluginCapabilities } from './plugins.js';
52
53
  import { MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, addPluginToSettings, copyPluginToMarketplace, marketplaceNameFor, marketplaceRoot, pluginInstallDir, registerMarketplace, removePluginFromSettings, syncMarketplaceManifest, } from './plugin-marketplace.js';
@@ -114,10 +115,22 @@ function touchLaunchSentinel(agent, version, cwd) {
114
115
  }
115
116
  }
116
117
  const CLAUDE_MIRROR_PLANS = [
117
- { srcSubdir: 'subagents', destSubdir: 'agents', entriesAreDirs: false },
118
- { srcSubdir: 'commands', destSubdir: 'commands', entriesAreDirs: false },
119
- { srcSubdir: 'skills', destSubdir: 'skills', entriesAreDirs: true },
118
+ { srcSubdir: 'subagents', destSubdir: 'agents', mode: 'subagent-write' },
119
+ { srcSubdir: 'commands', destSubdir: 'commands', mode: 'file-symlink' },
120
+ { srcSubdir: 'skills', destSubdir: 'skills', mode: 'dir-symlink' },
120
121
  ];
122
+ /**
123
+ * Marker prepended-as-trailing-comment to every subagent file WE generate.
124
+ * It's an HTML comment — invisible to the markdown the agent reads — placed on
125
+ * the last line so it never disturbs the leading `---` frontmatter block.
126
+ *
127
+ * Ownership rule (the one don't-clobber decision for written, non-symlink
128
+ * files): we only overwrite a `.claude/agents/<name>.md` whose content carries
129
+ * this marker. A user-authored file (no marker) or a symlink at the dest is
130
+ * left untouched. A marker beats an mtime/sidecar sentinel because it travels
131
+ * with the file across copies and git, and needs no out-of-band state.
132
+ */
133
+ const GENERATED_SUBAGENT_MARKER = '<!-- agents-cli:generated-subagent';
121
134
  function mirrorWorkspaceResources(cwd, agent) {
122
135
  // v1: claude-only. Other agents have workspace conventions we haven't
123
136
  // mapped (amp: ~/.config/amp; antigravity: ~/.gemini/antigravity-cli;
@@ -147,13 +160,20 @@ function mirrorWorkspaceResources(cwd, agent) {
147
160
  continue;
148
161
  const destDir = path.join(agentWorkspaceDir, plan.destSubdir);
149
162
  fs.mkdirSync(destDir, { recursive: true });
163
+ // Subagents flatten N source files into one written .md — not a symlink.
164
+ if (plan.mode === 'subagent-write') {
165
+ const r = writeProjectSubagents(srcDir, destDir, cwd);
166
+ links += r.links;
167
+ skipped.push(...r.skipped);
168
+ continue;
169
+ }
150
170
  const entries = fs.readdirSync(srcDir, { withFileTypes: true });
151
171
  for (const entry of entries) {
152
172
  if (entry.name.startsWith('.'))
153
173
  continue;
154
- if (plan.entriesAreDirs && !entry.isDirectory())
174
+ if (plan.mode === 'dir-symlink' && !entry.isDirectory())
155
175
  continue;
156
- if (!plan.entriesAreDirs && !entry.isFile() && !entry.isSymbolicLink())
176
+ if (plan.mode === 'file-symlink' && !entry.isFile() && !entry.isSymbolicLink())
157
177
  continue;
158
178
  const srcPath = path.join(srcDir, entry.name);
159
179
  const destPath = path.join(destDir, entry.name);
@@ -167,6 +187,91 @@ function mirrorWorkspaceResources(cwd, agent) {
167
187
  }
168
188
  return { links, skipped };
169
189
  }
190
+ /**
191
+ * Mirror project subagents into `<cwd>/.claude/agents/`. The canonical source
192
+ * shape is a DIRECTORY containing AGENT.md (e.g. `.agents/subagents/probe/AGENT.md`)
193
+ * — confirmed by the detector (versions.ts) and lister (subagents.ts). Each
194
+ * such directory is flattened via transformSubagentForClaude (the exact writer
195
+ * the version-home sync uses) into a single `<name>.md`, then written under an
196
+ * ownership marker so a re-launch refreshes our file but never clobbers a
197
+ * user-authored one.
198
+ *
199
+ * Returns the same {links, skipped} shape the symlink path reports, so the
200
+ * caller's accounting is uniform across resource kinds.
201
+ */
202
+ function writeProjectSubagents(srcDir, destDir, cwd) {
203
+ let links = 0;
204
+ const skipped = [];
205
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
206
+ if (entry.name.startsWith('.'))
207
+ continue;
208
+ if (!entry.isDirectory())
209
+ continue;
210
+ const subagentDir = path.join(srcDir, entry.name);
211
+ if (!fs.existsSync(path.join(subagentDir, 'AGENT.md')))
212
+ continue;
213
+ const destPath = path.join(destDir, `${entry.name}.md`);
214
+ if (writeSubagentIfOwned(subagentDir, destPath)) {
215
+ links += 1;
216
+ }
217
+ else {
218
+ skipped.push(path.relative(cwd, destPath));
219
+ }
220
+ }
221
+ return { links, skipped };
222
+ }
223
+ /**
224
+ * Write a flattened subagent file at `destPath`, refusing to clobber user state.
225
+ *
226
+ * - dest missing → write fresh.
227
+ * - dest is our generation → overwrite (refresh; carries GENERATED_SUBAGENT_MARKER).
228
+ * - dest is a symlink / any
229
+ * non-regular file → SKIP (user state we don't own).
230
+ * - dest is a regular file
231
+ * without our marker → SKIP (hand-authored .claude/agents/<name>.md).
232
+ *
233
+ * Returns true when our file is present (written now or already current),
234
+ * false when we left a user-owned dest alone.
235
+ */
236
+ function writeSubagentIfOwned(subagentDir, destPath) {
237
+ let existing = null;
238
+ let destLstat = null;
239
+ try {
240
+ destLstat = fs.lstatSync(destPath);
241
+ }
242
+ catch { /* missing — write fresh */ }
243
+ if (destLstat) {
244
+ if (!destLstat.isFile())
245
+ return false; // symlink/dir/etc. — user state
246
+ try {
247
+ existing = fs.readFileSync(destPath, 'utf-8');
248
+ }
249
+ catch {
250
+ return false;
251
+ }
252
+ if (!existing.includes(GENERATED_SUBAGENT_MARKER))
253
+ return false; // hand-authored
254
+ }
255
+ let body;
256
+ try {
257
+ body = transformSubagentForClaude(subagentDir);
258
+ }
259
+ catch {
260
+ return false; // malformed AGENT.md — don't write a broken file
261
+ }
262
+ const content = `${body}\n\n${GENERATED_SUBAGENT_MARKER} — edit .agents/subagents/${path.basename(subagentDir)}/ instead -->\n`;
263
+ // Skip-fast: identical content already on disk → no write (keeps mtime stable).
264
+ if (existing === content)
265
+ return true;
266
+ try {
267
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
268
+ fs.writeFileSync(destPath, content);
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ return true;
274
+ }
170
275
  /**
171
276
  * Create or refresh a symlink at `destPath` pointing at `srcPath`. Returns
172
277
  * true if we wrote (or already had) the link, false if we skipped because
@@ -61,6 +61,13 @@ export function removeRegistry(type, name) {
61
61
  }
62
62
  return false;
63
63
  }
64
+ /**
65
+ * Cap every registry network call. Without this a slow or unreachable registry
66
+ * hangs the calling command indefinitely (`agents add`, `agents mcp`, package
67
+ * resolution) — and makes CI flake when the registry is unreachable. On timeout
68
+ * the fetch aborts, callers fall back to their git/no-match path.
69
+ */
70
+ const REGISTRY_FETCH_TIMEOUT_MS = 8000;
64
71
  async function fetchMcpRegistry(url, query, limit = 20, apiKey) {
65
72
  const params = new URLSearchParams();
66
73
  if (query)
@@ -73,7 +80,10 @@ async function fetchMcpRegistry(url, query, limit = 20, apiKey) {
73
80
  if (apiKey) {
74
81
  headers['Authorization'] = `Bearer ${apiKey}`;
75
82
  }
76
- const response = await fetch(fullUrl, { headers });
83
+ const response = await fetch(fullUrl, {
84
+ headers,
85
+ signal: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
86
+ });
77
87
  if (!response.ok) {
78
88
  throw new Error(`Registry request failed: ${response.status} ${response.statusText}`);
79
89
  }
@@ -191,7 +201,10 @@ async function fetchSkillIndex(url, apiKey) {
191
201
  const headers = { Accept: 'application/json' };
192
202
  if (apiKey)
193
203
  headers['Authorization'] = `Bearer ${apiKey}`;
194
- const response = await fetch(url, { headers });
204
+ const response = await fetch(url, {
205
+ headers,
206
+ signal: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
207
+ });
195
208
  if (!response.ok) {
196
209
  throw new Error(`Registry request failed: ${response.status} ${response.statusText}`);
197
210
  }
@@ -11,6 +11,13 @@ export interface RotateCandidate {
11
11
  agent: AgentId;
12
12
  version: string;
13
13
  email: string | null;
14
+ /**
15
+ * Per-org usage/quota key (e.g. `claude:org=<orgUuid>`) — the unit rate
16
+ * limits are actually measured in. Distinct orgs signed in under the same
17
+ * email have distinct keys, so this is the correct dedup boundary; null when
18
+ * no usage identity is available (then we fall back to email).
19
+ */
20
+ usageKey: string | null;
14
21
  usageStatus: AccountInfo['usageStatus'];
15
22
  usageSnapshot: UsageSnapshot | null;
16
23
  authValid: boolean;
@@ -105,19 +105,29 @@ function compareCandidates(a, b) {
105
105
  return ta - tb;
106
106
  return Math.random() - 0.5;
107
107
  }
108
+ /**
109
+ * Identity a candidate dedups on. Quota is tracked per-org, so two versions
110
+ * that share an org are the same rate-limit bucket and must collapse — but two
111
+ * orgs under the same email (e.g. Enterprise + Personal on one Google identity)
112
+ * are genuinely separate buckets and must stay distinct. Prefer the org usage
113
+ * key; fall back to email only when no usage identity is available.
114
+ */
115
+ function candidateIdentity(c) {
116
+ return c.usageKey ?? c.email;
117
+ }
108
118
  function dedupeAndSortCandidates(candidates) {
109
- const byEmail = new Map();
119
+ const byIdentity = new Map();
110
120
  for (const c of candidates) {
111
- const email = c.email;
112
- const existing = byEmail.get(email);
121
+ const id = candidateIdentity(c);
122
+ const existing = byIdentity.get(id);
113
123
  if (!existing) {
114
- byEmail.set(email, c);
124
+ byIdentity.set(id, c);
115
125
  continue;
116
126
  }
117
127
  if (compareCandidates(c, existing) < 0)
118
- byEmail.set(email, c);
128
+ byIdentity.set(id, c);
119
129
  }
120
- return [...byEmail.values()].sort(compareCandidates);
130
+ return [...byIdentity.values()].sort(compareCandidates);
121
131
  }
122
132
  /**
123
133
  * Pick a healthy candidate using weighted random by remaining capacity.
@@ -252,7 +262,7 @@ async function collectRunCandidates(agent) {
252
262
  const usageSnapshot = usageKey
253
263
  ? usageByKey.get(usageKey)?.snapshot ?? null
254
264
  : null;
255
- return { ...candidate, usageSnapshot };
265
+ return { ...candidate, usageKey, usageSnapshot };
256
266
  });
257
267
  }
258
268
  /**
@@ -22,6 +22,7 @@ const AGENT_COMMANDS = {
22
22
  codex: ['codex', 'exec', '--sandbox', 'workspace-write', '{prompt}', '--json'],
23
23
  gemini: ['gemini', '{prompt}', '--output-format', 'stream-json'],
24
24
  kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
25
+ droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
25
26
  };
26
27
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
27
28
  export function buildJobCommand(config, resolvedPrompt) {
@@ -105,6 +106,19 @@ export function buildJobCommand(config, resolvedPrompt) {
105
106
  }
106
107
  appendModelAndReasoning(cmd, config);
107
108
  }
109
+ if (config.agent === 'droid') {
110
+ // droid exec defaults to read-only (plan). Escalate autonomy per mode.
111
+ if (mode === 'edit') {
112
+ cmd.push('--auto', 'low');
113
+ }
114
+ else if (mode === 'auto') {
115
+ cmd.push('--auto', 'high');
116
+ }
117
+ else if (mode === 'skip') {
118
+ cmd.push('--skip-permissions-unsafe');
119
+ }
120
+ appendModelAndReasoning(cmd, config);
121
+ }
108
122
  return cmd;
109
123
  }
110
124
  /**
@@ -10,7 +10,7 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import * as os from 'os';
12
12
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
13
- import { getRoutinesDir } from './state.js';
13
+ import { getRoutinesDir, getUserAgentsDir } from './state.js';
14
14
  function resolveRealHome() {
15
15
  const home = os.homedir();
16
16
  try {
@@ -58,7 +58,10 @@ function tomlString(value) {
58
58
  }
59
59
  /** Build a restricted environment for a sandboxed process, setting HOME to the overlay. */
60
60
  export function buildSpawnEnv(overlayHome, extraEnv) {
61
- const env = { HOME: overlayHome };
61
+ const env = {
62
+ HOME: overlayHome,
63
+ AGENTS_USER_DIR: getUserAgentsDir(),
64
+ };
62
65
  for (const key of ENV_ALLOWLIST) {
63
66
  if (process.env[key]) {
64
67
  env[key] = process.env[key];
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Settings carry-forward between version homes.
3
+ *
4
+ * Every installed version gets an isolated `home/`, so user-authored
5
+ * preferences (settings.json, config.toml, keybindings, auth) written while
6
+ * running one version do not exist in a freshly installed one. Resources
7
+ * managed in ~/.agents/ (commands, skills, hooks, rules, MCP YAML, plugins,
8
+ * subagents) are synced into every version home by syncResourcesToVersion and
9
+ * are deliberately NOT listed here — copying them would fight that sync.
10
+ *
11
+ * The manifest below classifies the remaining per-agent files, and
12
+ * carryForwardSettings() fills gaps in a target version home from a source
13
+ * version home. It never overwrites a value the target already has: scalars
14
+ * keep the target's value, objects merge recursively, arrays union. That makes
15
+ * the operation idempotent and safe to run on every `agents add` / `agents use`.
16
+ */
17
+ import type { AgentId } from './types.js';
18
+ export interface CarryForwardResult {
19
+ /** Manifest rel paths that were created or updated in the target home. */
20
+ applied: string[];
21
+ /** Backup directory holding pre-merge copies of modified target files, if any. */
22
+ backupDir?: string;
23
+ }
24
+ /**
25
+ * Fill gaps in `target` from `source` without overwriting target values:
26
+ * missing keys are copied, plain objects recurse, and everything else —
27
+ * scalars AND arrays — keeps the target's value. Arrays deliberately do not
28
+ * union: other writers (factory sync, hooks registration) mutate array entries
29
+ * in place, so a union would keep re-appending stale pre-mutation copies from
30
+ * the source on every carry (e.g. a user hook duplicated after the system
31
+ * hooks were merged into it). Returns a new object.
32
+ */
33
+ export declare function fillGaps(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
34
+ /**
35
+ * Carry user settings forward from one version home into another. Both paths
36
+ * are version-home roots (the directory containing `.claude/` / `.codex/`).
37
+ * Only fills gaps — never overwrites target values — so it is idempotent.
38
+ */
39
+ export declare function carryForwardSettings(agent: AgentId, fromHome: string, toHome: string): CarryForwardResult;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Settings carry-forward between version homes.
3
+ *
4
+ * Every installed version gets an isolated `home/`, so user-authored
5
+ * preferences (settings.json, config.toml, keybindings, auth) written while
6
+ * running one version do not exist in a freshly installed one. Resources
7
+ * managed in ~/.agents/ (commands, skills, hooks, rules, MCP YAML, plugins,
8
+ * subagents) are synced into every version home by syncResourcesToVersion and
9
+ * are deliberately NOT listed here — copying them would fight that sync.
10
+ *
11
+ * The manifest below classifies the remaining per-agent files, and
12
+ * carryForwardSettings() fills gaps in a target version home from a source
13
+ * version home. It never overwrites a value the target already has: scalars
14
+ * keep the target's value, objects merge recursively, arrays union. That makes
15
+ * the operation idempotent and safe to run on every `agents add` / `agents use`.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import * as TOML from 'smol-toml';
20
+ import { getBackupsDir } from './state.js';
21
+ const SETTINGS_MANIFEST = {
22
+ claude: [
23
+ { rel: '.claude/settings.json', strategy: 'json-merge' },
24
+ { rel: '.claude/settings.local.json', strategy: 'copy-if-absent' },
25
+ { rel: '.claude/keybindings.json', strategy: 'copy-if-absent' },
26
+ ],
27
+ codex: [
28
+ {
29
+ rel: '.codex/config.toml',
30
+ strategy: 'toml-merge',
31
+ stateKeys: ['notice', 'windows_wsl_setup_acknowledged'],
32
+ },
33
+ { rel: '.codex/auth.json', strategy: 'copy-if-absent', restrictMode: true },
34
+ { rel: '.codex/instructions.md', strategy: 'copy-if-absent' },
35
+ { rel: '.codex/hooks.json', strategy: 'copy-if-absent' },
36
+ { rel: '.codex/prompts', strategy: 'dir-entries' },
37
+ { rel: '.codex/rules', strategy: 'dir-entries' },
38
+ ],
39
+ };
40
+ function isPlainObject(value) {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
42
+ }
43
+ /**
44
+ * Fill gaps in `target` from `source` without overwriting target values:
45
+ * missing keys are copied, plain objects recurse, and everything else —
46
+ * scalars AND arrays — keeps the target's value. Arrays deliberately do not
47
+ * union: other writers (factory sync, hooks registration) mutate array entries
48
+ * in place, so a union would keep re-appending stale pre-mutation copies from
49
+ * the source on every carry (e.g. a user hook duplicated after the system
50
+ * hooks were merged into it). Returns a new object.
51
+ */
52
+ export function fillGaps(target, source) {
53
+ const out = { ...target };
54
+ for (const [key, sourceValue] of Object.entries(source)) {
55
+ if (!(key in out)) {
56
+ out[key] = sourceValue;
57
+ continue;
58
+ }
59
+ const targetValue = out[key];
60
+ if (isPlainObject(targetValue) && isPlainObject(sourceValue)) {
61
+ out[key] = fillGaps(targetValue, sourceValue);
62
+ }
63
+ // scalar, array, or type mismatch: target wins
64
+ }
65
+ return out;
66
+ }
67
+ function stripStateKeys(obj, stateKeys) {
68
+ if (!stateKeys?.length)
69
+ return obj;
70
+ const out = { ...obj };
71
+ for (const key of stateKeys)
72
+ delete out[key];
73
+ return out;
74
+ }
75
+ function backupFile(backupRoot, home, rel) {
76
+ const src = path.join(home, rel);
77
+ const dest = path.join(backupRoot, rel);
78
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
79
+ fs.copyFileSync(src, dest);
80
+ }
81
+ /**
82
+ * Carry user settings forward from one version home into another. Both paths
83
+ * are version-home roots (the directory containing `.claude/` / `.codex/`).
84
+ * Only fills gaps — never overwrites target values — so it is idempotent.
85
+ */
86
+ export function carryForwardSettings(agent, fromHome, toHome) {
87
+ const manifest = SETTINGS_MANIFEST[agent];
88
+ const result = { applied: [] };
89
+ if (!manifest || !fs.existsSync(fromHome) || fromHome === toHome)
90
+ return result;
91
+ const backupRoot = path.join(getBackupsDir(), 'settings-carry', agent, new Date().toISOString().replace(/[:.]/g, '-'));
92
+ for (const entry of manifest) {
93
+ const sourcePath = path.join(fromHome, entry.rel);
94
+ const targetPath = path.join(toHome, entry.rel);
95
+ if (!fs.existsSync(sourcePath))
96
+ continue;
97
+ try {
98
+ switch (entry.strategy) {
99
+ case 'copy-if-absent': {
100
+ if (fs.existsSync(targetPath))
101
+ break;
102
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
103
+ fs.copyFileSync(sourcePath, targetPath);
104
+ if (entry.restrictMode)
105
+ fs.chmodSync(targetPath, 0o600);
106
+ result.applied.push(entry.rel);
107
+ break;
108
+ }
109
+ case 'dir-entries': {
110
+ if (!fs.statSync(sourcePath).isDirectory())
111
+ break;
112
+ let copied = false;
113
+ fs.mkdirSync(targetPath, { recursive: true });
114
+ for (const name of fs.readdirSync(sourcePath)) {
115
+ const childTarget = path.join(targetPath, name);
116
+ if (fs.existsSync(childTarget))
117
+ continue;
118
+ fs.cpSync(path.join(sourcePath, name), childTarget, { recursive: true });
119
+ copied = true;
120
+ }
121
+ if (copied)
122
+ result.applied.push(entry.rel);
123
+ break;
124
+ }
125
+ case 'json-merge':
126
+ case 'toml-merge': {
127
+ const parse = entry.strategy === 'json-merge'
128
+ ? (text) => JSON.parse(text)
129
+ : (text) => TOML.parse(text);
130
+ const stringify = entry.strategy === 'json-merge'
131
+ ? (obj) => JSON.stringify(obj, null, 2) + '\n'
132
+ : (obj) => TOML.stringify(obj) + '\n';
133
+ const source = stripStateKeys(parse(fs.readFileSync(sourcePath, 'utf-8')), entry.stateKeys);
134
+ if (!fs.existsSync(targetPath)) {
135
+ if (Object.keys(source).length === 0)
136
+ break;
137
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
138
+ fs.writeFileSync(targetPath, stringify(source), 'utf-8');
139
+ result.applied.push(entry.rel);
140
+ break;
141
+ }
142
+ const targetText = fs.readFileSync(targetPath, 'utf-8');
143
+ const targetObj = parse(targetText);
144
+ const merged = fillGaps(targetObj, source);
145
+ // Compare parsed content, not text: other writers format differently,
146
+ // and a semantic no-op must not trigger a rewrite/backup every switch.
147
+ if (JSON.stringify(merged) === JSON.stringify(targetObj))
148
+ break;
149
+ backupFile(backupRoot, toHome, entry.rel);
150
+ result.backupDir = backupRoot;
151
+ fs.writeFileSync(targetPath, stringify(merged), 'utf-8');
152
+ result.applied.push(entry.rel);
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ catch {
158
+ // A malformed source or target file must not break install/use.
159
+ // Leave the target untouched for this entry and move on.
160
+ }
161
+ }
162
+ return result;
163
+ }
@@ -77,7 +77,7 @@ export interface ConflictInfo {
77
77
  * top-level entry add/remove — deep edits to plugin contents won't
78
78
  * trigger auto-resync, run `agents sync` for that.
79
79
  */
80
- export declare const SHIM_SCHEMA_VERSION = 17;
80
+ export declare const SHIM_SCHEMA_VERSION = 18;
81
81
  /**
82
82
  * Generate the full bash shim script for the given agent. The returned string
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
package/dist/lib/shims.js CHANGED
@@ -11,10 +11,9 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import * as os from 'os';
14
- import { execFileSync } from 'child_process';
15
14
  import { fileURLToPath } from 'url';
16
15
  import { confirm, select } from '@inquirer/prompts';
17
- import { IS_WINDOWS } from './platform/index.js';
16
+ import { IS_WINDOWS, prependToWindowsUserPath } from './platform/index.js';
18
17
  import { getShimsDir, getVersionsDir, getBackupsDir, ensureAgentsDir } from './state.js';
19
18
  export { getShimsDir };
20
19
  import { AGENTS } from './agents.js';
@@ -203,7 +202,7 @@ async function promptConflictStrategy(conflictInfos) {
203
202
  * top-level entry add/remove — deep edits to plugin contents won't
204
203
  * trigger auto-resync, run `agents sync` for that.
205
204
  */
206
- export const SHIM_SCHEMA_VERSION = 17;
205
+ export const SHIM_SCHEMA_VERSION = 18;
207
206
  /** Internal marker string used to embed the schema version in shim scripts. */
208
207
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
209
208
  function shellQuote(value) {
@@ -273,7 +272,7 @@ export KIMI_CODE_HOME="$VERSION_DIR/home/${configDirName}"
273
272
  # Shim for ${agentConfig.name}
274
273
  # ${SHIM_VERSION_MARKER} ${SHIM_SCHEMA_VERSION}
275
274
 
276
- AGENTS_USER_DIR="$HOME/.agents"
275
+ AGENTS_USER_DIR="\${AGENTS_USER_DIR:-$HOME/.agents}"
277
276
  AGENTS_BIN=${agentsBin}
278
277
  AGENT="${agent}"
279
278
  CLI_COMMAND="${cliCommand}"
@@ -1579,36 +1578,22 @@ export function addShimsToPath(overrides) {
1579
1578
  * Register the shims dir on the Windows User PATH via the .NET environment API,
1580
1579
  * which writes the registry AND broadcasts WM_SETTINGCHANGE — the correct analog
1581
1580
  * of editing a shell rc file (no `setx` truncation, no manual step). Idempotent:
1582
- * a no-op when the dir is already present. The shims dir is passed via an env var
1583
- * so it is never interpolated into the PowerShell script text.
1581
+ * a no-op when the shims dir is already first in the User PATH. Moves it to the
1582
+ * front when it exists but is in the wrong position (e.g. appended by an old
1583
+ * install) so it overrides any npm/global installs that appear later. The shims
1584
+ * dir is passed via an env var so it is never interpolated into the script text.
1584
1585
  */
1585
1586
  function addShimsToWindowsUserPath(shimsDir) {
1586
- const script = [
1587
- '$d = $env:AGENTS_SHIMS_DIR',
1588
- "$u = [Environment]::GetEnvironmentVariable('Path','User')",
1589
- "if ($null -eq $u) { $u = '' }",
1590
- "$parts = @($u -split ';' | Where-Object { $_ -ne '' })",
1591
- "if ($parts -contains $d) { 'present' } else {",
1592
- " [Environment]::SetEnvironmentVariable('Path', (($parts + $d) -join ';'), 'User')",
1593
- " 'added'",
1594
- '}',
1595
- ].join('\n');
1596
- try {
1597
- const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
1598
- encoding: 'utf-8',
1599
- env: { ...process.env, AGENTS_SHIMS_DIR: shimsDir },
1600
- stdio: ['ignore', 'pipe', 'pipe'],
1601
- }).trim();
1602
- return {
1603
- success: true,
1604
- alreadyPresent: out.includes('present'),
1605
- location: 'your user PATH',
1606
- reloadHint: 'Open a new terminal for the change to take effect.',
1607
- };
1608
- }
1609
- catch (err) {
1610
- return { success: false, error: `Could not update the Windows user PATH: ${err.message}` };
1587
+ const r = prependToWindowsUserPath(shimsDir);
1588
+ if (!r.success) {
1589
+ return { success: false, error: r.error };
1611
1590
  }
1591
+ return {
1592
+ success: true,
1593
+ alreadyPresent: r.alreadyPresent,
1594
+ location: 'your user PATH',
1595
+ reloadHint: 'Open a new terminal for the change to take effect.',
1596
+ };
1612
1597
  }
1613
1598
  export function listAgentsWithInstalledVersions() {
1614
1599
  const versionsDir = getVersionsDir();