@phnx-labs/agents-cli 1.16.0 → 1.17.1

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 (64) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/commands/browser.js +248 -9
  3. package/dist/commands/cloud.js +8 -0
  4. package/dist/commands/exec.js +70 -1
  5. package/dist/commands/import.d.ts +24 -0
  6. package/dist/commands/import.js +203 -0
  7. package/dist/commands/plugins.js +179 -5
  8. package/dist/commands/prune.js +6 -0
  9. package/dist/commands/secrets.js +117 -19
  10. package/dist/commands/view.js +21 -8
  11. package/dist/commands/workflows.d.ts +10 -0
  12. package/dist/commands/workflows.js +457 -0
  13. package/dist/index.js +34 -16
  14. package/dist/lib/browser/cdp.js +7 -4
  15. package/dist/lib/browser/chrome.d.ts +10 -0
  16. package/dist/lib/browser/chrome.js +37 -2
  17. package/dist/lib/browser/drivers/local.js +13 -2
  18. package/dist/lib/browser/input.d.ts +1 -0
  19. package/dist/lib/browser/input.js +3 -0
  20. package/dist/lib/browser/ipc.js +14 -0
  21. package/dist/lib/browser/profiles.d.ts +5 -0
  22. package/dist/lib/browser/profiles.js +45 -0
  23. package/dist/lib/browser/service.d.ts +10 -0
  24. package/dist/lib/browser/service.js +29 -1
  25. package/dist/lib/browser/types.d.ts +11 -1
  26. package/dist/lib/cloud/rush.d.ts +28 -1
  27. package/dist/lib/cloud/rush.js +68 -13
  28. package/dist/lib/commands.d.ts +0 -15
  29. package/dist/lib/commands.js +5 -5
  30. package/dist/lib/hooks.js +24 -11
  31. package/dist/lib/import.d.ts +91 -0
  32. package/dist/lib/import.js +179 -0
  33. package/dist/lib/migrate.js +59 -1
  34. package/dist/lib/permissions.d.ts +0 -58
  35. package/dist/lib/permissions.js +10 -10
  36. package/dist/lib/plugins.d.ts +75 -34
  37. package/dist/lib/plugins.js +640 -133
  38. package/dist/lib/resource-patterns.d.ts +41 -0
  39. package/dist/lib/resource-patterns.js +82 -0
  40. package/dist/lib/resources/index.d.ts +17 -0
  41. package/dist/lib/resources/index.js +7 -0
  42. package/dist/lib/resources/types.d.ts +1 -1
  43. package/dist/lib/resources/workflows.d.ts +24 -0
  44. package/dist/lib/resources/workflows.js +110 -0
  45. package/dist/lib/resources.d.ts +6 -1
  46. package/dist/lib/resources.js +12 -2
  47. package/dist/lib/session/db.d.ts +18 -0
  48. package/dist/lib/session/db.js +106 -7
  49. package/dist/lib/session/discover.d.ts +6 -0
  50. package/dist/lib/session/discover.js +28 -17
  51. package/dist/lib/shims.d.ts +3 -51
  52. package/dist/lib/shims.js +18 -10
  53. package/dist/lib/sqlite.js +10 -4
  54. package/dist/lib/state.d.ts +15 -2
  55. package/dist/lib/state.js +29 -8
  56. package/dist/lib/types.d.ts +43 -14
  57. package/dist/lib/versions.d.ts +3 -0
  58. package/dist/lib/versions.js +139 -27
  59. package/dist/lib/workflows.d.ts +79 -0
  60. package/dist/lib/workflows.js +233 -0
  61. package/package.json +1 -5
  62. package/scripts/postinstall.js +59 -58
  63. package/dist/commands/fork.d.ts +0 -10
  64. package/dist/commands/fork.js +0 -146
@@ -15,14 +15,15 @@ import { parseSSE } from './stream.js';
15
15
  import { listInstalledVersions, getVersionHomePath } from '../versions.js';
16
16
  import { getAccountInfo } from '../agents.js';
17
17
  import { loadClaudeOauth } from '../usage.js';
18
+ import { selectBalancedVersion } from '../rotate.js';
18
19
  const PROXY_BASE = 'https://api.prix.dev';
19
20
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
20
21
  // Persistent consent record for uploading Claude OAuth blobs to Rush Cloud.
21
22
  // Created on first explicit consent (env var or flag); subsequent dispatches
22
23
  // see it and proceed without re-prompting.
23
- const RUSH_CONSENT_PATH = path.join(getCloudDir(), 'rush-consent.json');
24
+ export const RUSH_CONSENT_PATH = path.join(getCloudDir(), 'rush-consent.json');
24
25
  const RUSH_CONSENT_ENV = 'AGENTS_RUSH_UPLOAD_TOKENS';
25
- function hasRushUploadConsent(opts) {
26
+ export function hasRushUploadConsent(opts) {
26
27
  if (process.env[RUSH_CONSENT_ENV] === '1')
27
28
  return true;
28
29
  const po = opts?.providerOptions;
@@ -184,20 +185,37 @@ async function readClaudeCredentialsBlob(home) {
184
185
  * Returns null when no Claude versions are signed in (the dispatch falls back
185
186
  * to the platform-wide key, current behavior).
186
187
  */
187
- export async function buildAccountManifest() {
188
- const versions = listInstalledVersions('claude');
189
- if (versions.length === 0)
190
- return null;
188
+ export async function buildAccountManifest(strategy) {
189
+ let candidateVersions;
190
+ if (strategy === 'balanced') {
191
+ // Use the same health-checked, deduped-by-email set that `agents run --balanced` uses.
192
+ // `result.healthy` contains one candidate per unique email, ordered by remaining capacity.
193
+ const result = await selectBalancedVersion('claude');
194
+ if (!result || result.healthy.length === 0)
195
+ return null;
196
+ candidateVersions = result.healthy
197
+ .filter((c) => !!c.email)
198
+ .map((c) => ({ version: c.version, email: c.email }));
199
+ }
200
+ else {
201
+ // Default: all installed versions that have a signed-in account.
202
+ const versions = listInstalledVersions('claude');
203
+ if (versions.length === 0)
204
+ return null;
205
+ const rows = await Promise.all(versions.map(async (version) => {
206
+ const home = getVersionHomePath('claude', version);
207
+ const info = await getAccountInfo('claude', home);
208
+ return info.email ? { version, email: info.email } : null;
209
+ }));
210
+ candidateVersions = rows.filter((r) => r !== null);
211
+ }
191
212
  const entries = [];
192
- for (const version of versions) {
213
+ for (const { version, email } of candidateVersions) {
193
214
  const home = getVersionHomePath('claude', version);
194
- const info = await getAccountInfo('claude', home);
195
- if (!info.email)
196
- continue;
197
215
  const blob = await readClaudeCredentialsBlob(home);
198
216
  if (!blob)
199
217
  continue;
200
- entries.push({ version, email: info.email, cred_fp: sha256(blob) });
218
+ entries.push({ version, email, cred_fp: sha256(blob) });
201
219
  }
202
220
  if (entries.length === 0)
203
221
  return null;
@@ -237,6 +255,7 @@ export function buildDispatchBody(input) {
237
255
  prompt: input.prompt,
238
256
  repos: input.resolvedRepos,
239
257
  mode: input.mode,
258
+ ...(input.strategy ? { strategy: input.strategy } : {}),
240
259
  };
241
260
  if (input.resolvedRepos.length === 1) {
242
261
  body.installation_id = primary.installation_id;
@@ -251,6 +270,37 @@ export function buildDispatchBody(input) {
251
270
  }
252
271
  return body;
253
272
  }
273
+ /** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
274
+ export async function listRemoteAccounts() {
275
+ const token = readToken();
276
+ const res = await api('GET', '/api/v1/cloud-accounts', token);
277
+ if (!res.ok) {
278
+ throw new Error(`Failed to list accounts (${res.status}): ${sanitizeErrorBody(await res.text())}`);
279
+ }
280
+ const data = await res.json();
281
+ return data.accounts ?? [];
282
+ }
283
+ /**
284
+ * Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
285
+ * The server validates the token against the Anthropic usage API and stores it
286
+ * encrypted in Vault. Returns the account metadata (no token).
287
+ */
288
+ export async function addRemoteAccount(provider, pastedToken) {
289
+ const token = readToken();
290
+ const res = await api('POST', '/api/v1/cloud-accounts', token, { provider, token: pastedToken });
291
+ if (!res.ok) {
292
+ throw new Error(`Failed to add account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
293
+ }
294
+ return await res.json();
295
+ }
296
+ /** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
297
+ export async function removeRemoteAccount(id) {
298
+ const token = readToken();
299
+ const res = await api('DELETE', `/api/v1/cloud-accounts/${encodeURIComponent(id)}`, token);
300
+ if (!res.ok) {
301
+ throw new Error(`Failed to remove account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
302
+ }
303
+ }
254
304
  export class RushCloudProvider {
255
305
  id = 'rush';
256
306
  name = 'Rush Cloud';
@@ -289,13 +339,18 @@ export class RushCloudProvider {
289
339
  repo_owner: r.owner,
290
340
  repo_name: r.name,
291
341
  })));
292
- const accountManifest = await buildAccountManifest();
342
+ const strategy = options.providerOptions?.strategy;
343
+ // When balanced, the server owns the pool and rotates internally — no
344
+ // client-side manifest needed. We just forward the strategy so the server
345
+ // knows to load from Vault instead of waiting for a manifest.
346
+ const accountManifest = strategy === 'balanced' ? null : await buildAccountManifest();
293
347
  const body = buildDispatchBody({
294
348
  agent: options.agent,
295
349
  prompt: options.prompt,
296
350
  mode: options.providerOptions?.mode,
297
351
  resolvedRepos,
298
352
  accountManifest,
353
+ strategy,
299
354
  });
300
355
  let res = await api('POST', '/api/v1/cloud-runs', token, body);
301
356
  // Server detects drift (new account or rotated token) by comparing the
@@ -317,7 +372,7 @@ export class RushCloudProvider {
317
372
  ``,
318
373
  `To consent, re-run with one of:`,
319
374
  ` AGENTS_RUSH_UPLOAD_TOKENS=1 agents cloud run ...`,
320
- ` agents cloud run --upload-account-tokens ... # if your CLI exposes this flag`,
375
+ ` agents cloud run --upload-account-tokens ...`,
321
376
  ``,
322
377
  `Consent will be recorded at ${RUSH_CONSENT_PATH} so you won't be asked again.`,
323
378
  `Remove that file to revoke.`,
@@ -35,10 +35,6 @@ export interface InstalledCommand {
35
35
  path: string;
36
36
  description?: string;
37
37
  }
38
- /** Parse command metadata (name, description) from YAML frontmatter or TOML headers. */
39
- export declare function parseCommandMetadata(filePath: string): CommandMetadata | null;
40
- /** Validate command metadata, returning errors and warnings. */
41
- export declare function validateCommandMetadata(metadata: CommandMetadata | null, commandName: string): ValidationResult;
42
38
  /** Discover all command markdown files in a repository's commands/ directory. */
43
39
  export declare function discoverCommands(repoPath: string): DiscoveredCommand[];
44
40
  /** Find the source path for a command in a repository. */
@@ -98,17 +94,6 @@ export declare function iterCommandsCapableVersions(filter?: {
98
94
  }>;
99
95
  /** Remove a command from an agent's config directory. */
100
96
  export declare function uninstallCommand(agentId: AgentId, commandName: string): boolean;
101
- /** List command names installed for an agent in the active version home. */
102
- export declare function listInstalledCommands(agentId: AgentId): string[];
103
- /**
104
- * Check if a command exists for an agent.
105
- */
106
- export declare function commandExists(agentId: AgentId, commandName: string): boolean;
107
- /**
108
- * Check if installed command content matches source content.
109
- * Handles format conversion (markdown to TOML for Gemini).
110
- */
111
- export declare function commandContentMatches(agentId: AgentId, commandName: string, sourcePath: string): boolean;
112
97
  /**
113
98
  * List installed commands with scope information.
114
99
  * Pass options.home to read from a version-managed agent's home directory.
@@ -15,7 +15,7 @@ import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAge
15
15
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
16
16
  import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
17
17
  /** Parse command metadata (name, description) from YAML frontmatter or TOML headers. */
18
- export function parseCommandMetadata(filePath) {
18
+ function parseCommandMetadata(filePath) {
19
19
  if (!fs.existsSync(filePath)) {
20
20
  return null;
21
21
  }
@@ -51,7 +51,7 @@ export function parseCommandMetadata(filePath) {
51
51
  }
52
52
  }
53
53
  /** Validate command metadata, returning errors and warnings. */
54
- export function validateCommandMetadata(metadata, commandName) {
54
+ function validateCommandMetadata(metadata, commandName) {
55
55
  const errors = [];
56
56
  const warnings = [];
57
57
  if (!metadata) {
@@ -336,7 +336,7 @@ export function uninstallCommand(agentId, commandName) {
336
336
  return false;
337
337
  }
338
338
  /** List command names installed for an agent in the active version home. */
339
- export function listInstalledCommands(agentId) {
339
+ function listInstalledCommands(agentId) {
340
340
  const agent = AGENTS[agentId];
341
341
  const home = getEffectiveHome(agentId);
342
342
  const commandsDir = path.join(home, `.${agentId}`, agent.commandsSubdir);
@@ -352,7 +352,7 @@ export function listInstalledCommands(agentId) {
352
352
  /**
353
353
  * Check if a command exists for an agent.
354
354
  */
355
- export function commandExists(agentId, commandName) {
355
+ function commandExists(agentId, commandName) {
356
356
  const agent = AGENTS[agentId];
357
357
  const home = getEffectiveHome(agentId);
358
358
  const commandsDir = path.join(home, `.${agentId}`, agent.commandsSubdir);
@@ -370,7 +370,7 @@ function normalizeContent(content) {
370
370
  * Check if installed command content matches source content.
371
371
  * Handles format conversion (markdown to TOML for Gemini).
372
372
  */
373
- export function commandContentMatches(agentId, commandName, sourcePath) {
373
+ function commandContentMatches(agentId, commandName, sourcePath) {
374
374
  const agent = AGENTS[agentId];
375
375
  const home = getEffectiveHome(agentId);
376
376
  const commandsDir = path.join(home, `.${agentId}`, agent.commandsSubdir);
package/dist/lib/hooks.js CHANGED
@@ -14,16 +14,15 @@ import * as TOML from 'smol-toml';
14
14
  import { AGENTS, HOOKS_CAPABLE_AGENTS } from './agents.js';
15
15
  import { supports, explainSkip } from './capabilities.js';
16
16
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
17
- import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir } from './state.js';
17
+ import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos } from './state.js';
18
18
  function getCentralHooksDir() { return getUserHooksDir(); }
19
19
  /**
20
- * Resolve a hook script's absolute path by checking the user dir first
21
- * (where `installHooksCentrally` lands new files) and falling back to the
22
- * system dir (where npm-shipped defaults live). Returns null if neither
23
- * exists. Mirrors the precedence used by `listCentralHooks`.
20
+ * Resolve a hook script's absolute path. Checks user dir first, then enabled
21
+ * extra repos in insertion order, then system dir. Returns null if not found.
24
22
  */
25
23
  function resolveHookScriptPath(script) {
26
- for (const root of [getUserAgentsDir(), getSystemAgentsDir()]) {
24
+ const extraDirs = getEnabledExtraRepos().map(e => e.dir);
25
+ for (const root of [getUserAgentsDir(), ...extraDirs, getSystemAgentsDir()]) {
27
26
  const candidate = path.join(root, 'hooks', script);
28
27
  if (fs.existsSync(candidate))
29
28
  return candidate;
@@ -32,14 +31,15 @@ function resolveHookScriptPath(script) {
32
31
  }
33
32
  /**
34
33
  * Prefixes used for stale-entry cleanup in agent settings files. A registered
35
- * hook command is considered "managed by us" if it lives under either
36
- * `~/.agents/hooks/` (user) or `~/.agents-system/hooks/` (system). Cleanup
37
- * filters use this list so leftover entries from either dir get garbage
38
- * collected on rewrite.
34
+ * hook command is considered "managed" if it lives under any known hooks dir
35
+ * (user, extra repos, or system). Entries from removed extra repos are also
36
+ * garbage-collected because they won't appear in this list any more.
39
37
  */
40
38
  function getManagedHookPrefixes() {
39
+ const extraDirs = getEnabledExtraRepos().map(e => e.dir);
41
40
  return [
42
41
  path.join(getUserAgentsDir(), 'hooks') + path.sep,
42
+ ...extraDirs.map(d => path.join(d, 'hooks') + path.sep),
43
43
  path.join(getSystemAgentsDir(), 'hooks') + path.sep,
44
44
  ];
45
45
  }
@@ -616,16 +616,29 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
616
616
  return { registered: [], errors: [] };
617
617
  }
618
618
  const overrideRoots = agentsDirOverride ? [agentsDirOverride] : null;
619
+ // Scripts are copied into the version home during sync — prefer that stable
620
+ // local path so registered commands don't break when source dirs change.
621
+ const localHooksDir = !overrideRoots
622
+ ? path.join(versionHome, `.${agentId}`, AGENTS[agentId].hooksDir)
623
+ : null;
619
624
  const resolveScript = (script) => {
620
625
  if (overrideRoots) {
621
626
  const candidate = path.join(overrideRoots[0], 'hooks', script);
622
627
  return fs.existsSync(candidate) ? candidate : null;
623
628
  }
629
+ if (localHooksDir) {
630
+ const local = path.join(localHooksDir, script);
631
+ if (fs.existsSync(local))
632
+ return local;
633
+ }
624
634
  return resolveHookScriptPath(script);
625
635
  };
626
636
  const managedPrefixes = overrideRoots
627
637
  ? [path.join(overrideRoots[0], 'hooks') + path.sep]
628
- : getManagedHookPrefixes();
638
+ : [
639
+ ...getManagedHookPrefixes(),
640
+ ...(localHooksDir ? [localHooksDir + path.sep] : []),
641
+ ];
629
642
  if (agentId === 'claude') {
630
643
  return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes);
631
644
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Import existing unmanaged agent installations into agents-cli.
3
+ *
4
+ * Two flavors:
5
+ *
6
+ * 1. Config-only import — moves an agent's config dir (e.g. ~/.openclaw)
7
+ * into the version structure and symlinks it back. Used by `agents setup`
8
+ * on first-run when an agent was previously installed via npm/homebrew.
9
+ *
10
+ * 2. Full import — also registers an existing binary install (e.g. a global
11
+ * `npm i -g openclaw`) under the managed version path so the shim
12
+ * resolver can find it. This is what `agents import <agent>` does.
13
+ *
14
+ * The binary side never moves files. It creates a thin symlink farm under
15
+ * `~/.agents/.history/versions/<agent>/<version>/` pointing at the original
16
+ * global install, plus a package.json marker so `isVersionInstalled` returns
17
+ * true.
18
+ */
19
+ import type { AgentId } from './types.js';
20
+ export interface ImportConfigResult {
21
+ success: boolean;
22
+ skipped?: boolean;
23
+ error?: string;
24
+ }
25
+ export interface ImportBinaryResult {
26
+ success: boolean;
27
+ skipped?: boolean;
28
+ error?: string;
29
+ resolvedFromPath?: string;
30
+ }
31
+ /**
32
+ * Move an agent's config dir into the managed version structure and symlink it
33
+ * back to its original location. Sets the imported version as the global
34
+ * default and refreshes the shim so the user's PATH lookup hits the managed
35
+ * version.
36
+ *
37
+ * No-op (returns skipped=true) if the version's config dir is already created.
38
+ */
39
+ export declare function importAgentConfig(agentId: AgentId, version: string): Promise<ImportConfigResult>;
40
+ /**
41
+ * Wire an imported version into the rest of the system so it behaves the same
42
+ * as a freshly installed version:
43
+ *
44
+ * - registered as the global default in agents.yaml (so `agents view`
45
+ * reports it correctly and resolvers find it),
46
+ * - main shim refreshed (`~/.agents/.cache/shims/<cli>`),
47
+ * - versioned alias created (`~/.agents/.cache/shims/<cli>@<version>`),
48
+ * - home-file symlinks (CLAUDE.md / AGENTS.md / etc.) repointed at this
49
+ * version's home dir.
50
+ *
51
+ * Without this, the binary-only import path would leave the version stranded:
52
+ * isVersionInstalled returns true, but the resolver never picks it. Safe to
53
+ * call multiple times — each underlying function is idempotent.
54
+ */
55
+ export declare function finalizeImport(agentId: AgentId, version: string): void;
56
+ /**
57
+ * Agent metadata needed by importAgentBinary. Taking these as explicit
58
+ * inputs (rather than looking up AGENTS internally) decouples the symlink
59
+ * farm from the AGENTS registry, which keeps the function pure and avoids
60
+ * fragile coupling in test setups that stub `lib/agents.ts`.
61
+ */
62
+ export interface AgentBinarySpec {
63
+ /** Agent id used in the marker package.json (`agents-{agentId}-{version}`). */
64
+ agentId: string;
65
+ /** npm package name (e.g. `openclaw`) — used as the `node_modules/<name>` dir. */
66
+ npmPackage: string;
67
+ /** Binary name on PATH (e.g. `openclaw`) — used as the `.bin/<name>` entry. */
68
+ cliCommand: string;
69
+ }
70
+ /**
71
+ * Register an existing global npm package install under the managed version
72
+ * path so the shim resolver finds it.
73
+ *
74
+ * Layout produced (everything is a symlink, nothing is copied):
75
+ *
76
+ * {versionDir}/
77
+ * package.json # marker so isVersionInstalled() is true
78
+ * home/ # empty isolated $HOME for this version
79
+ * node_modules/{npmPackage} -> {globalPath}
80
+ * node_modules/.bin/{cliCommand} -> {binaryEntry}
81
+ */
82
+ export declare function importAgentBinary(spec: AgentBinarySpec, version: string, globalPath: string, versionDir: string): ImportBinaryResult;
83
+ /**
84
+ * Resolve the on-disk npm package directory for an agent's CLI binary by
85
+ * walking up from the binary, following any symlinks. Returns null if the
86
+ * package can't be identified.
87
+ *
88
+ * Handles the homebrew/global-npm pattern where:
89
+ * /opt/homebrew/bin/{cli} -> ../lib/node_modules/{pkg}/dist/index.js
90
+ */
91
+ export declare function resolvePackageDirFromBinary(binaryPath: string): string | null;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Import existing unmanaged agent installations into agents-cli.
3
+ *
4
+ * Two flavors:
5
+ *
6
+ * 1. Config-only import — moves an agent's config dir (e.g. ~/.openclaw)
7
+ * into the version structure and symlinks it back. Used by `agents setup`
8
+ * on first-run when an agent was previously installed via npm/homebrew.
9
+ *
10
+ * 2. Full import — also registers an existing binary install (e.g. a global
11
+ * `npm i -g openclaw`) under the managed version path so the shim
12
+ * resolver can find it. This is what `agents import <agent>` does.
13
+ *
14
+ * The binary side never moves files. It creates a thin symlink farm under
15
+ * `~/.agents/.history/versions/<agent>/<version>/` pointing at the original
16
+ * global install, plus a package.json marker so `isVersionInstalled` returns
17
+ * true.
18
+ */
19
+ import * as fs from 'fs';
20
+ import * as path from 'path';
21
+ import { AGENTS } from './agents.js';
22
+ import { getVersionsDir } from './state.js';
23
+ import { setGlobalDefault } from './versions.js';
24
+ import { createShim, createVersionedAlias, ensureShimCurrent, switchHomeFileSymlinks } from './shims.js';
25
+ /**
26
+ * Move an agent's config dir into the managed version structure and symlink it
27
+ * back to its original location. Sets the imported version as the global
28
+ * default and refreshes the shim so the user's PATH lookup hits the managed
29
+ * version.
30
+ *
31
+ * No-op (returns skipped=true) if the version's config dir is already created.
32
+ */
33
+ export async function importAgentConfig(agentId, version) {
34
+ const agent = AGENTS[agentId];
35
+ const configDir = agent.configDir;
36
+ const versionsDir = getVersionsDir();
37
+ const versionHome = path.join(versionsDir, agentId, version, 'home');
38
+ const versionConfigDir = path.join(versionHome, `.${agentId}`);
39
+ if (fs.existsSync(versionConfigDir)) {
40
+ return { success: false, skipped: true, error: `${version} already installed` };
41
+ }
42
+ try {
43
+ fs.mkdirSync(versionHome, { recursive: true });
44
+ fs.renameSync(configDir, versionConfigDir);
45
+ fs.symlinkSync(versionConfigDir, configDir);
46
+ setGlobalDefault(agentId, version);
47
+ switchHomeFileSymlinks(agentId, version);
48
+ ensureShimCurrent(agentId);
49
+ return { success: true };
50
+ }
51
+ catch (err) {
52
+ return { success: false, error: err.message };
53
+ }
54
+ }
55
+ /**
56
+ * Wire an imported version into the rest of the system so it behaves the same
57
+ * as a freshly installed version:
58
+ *
59
+ * - registered as the global default in agents.yaml (so `agents view`
60
+ * reports it correctly and resolvers find it),
61
+ * - main shim refreshed (`~/.agents/.cache/shims/<cli>`),
62
+ * - versioned alias created (`~/.agents/.cache/shims/<cli>@<version>`),
63
+ * - home-file symlinks (CLAUDE.md / AGENTS.md / etc.) repointed at this
64
+ * version's home dir.
65
+ *
66
+ * Without this, the binary-only import path would leave the version stranded:
67
+ * isVersionInstalled returns true, but the resolver never picks it. Safe to
68
+ * call multiple times — each underlying function is idempotent.
69
+ */
70
+ export function finalizeImport(agentId, version) {
71
+ setGlobalDefault(agentId, version);
72
+ createShim(agentId);
73
+ createVersionedAlias(agentId, version);
74
+ switchHomeFileSymlinks(agentId, version);
75
+ ensureShimCurrent(agentId);
76
+ }
77
+ /**
78
+ * Register an existing global npm package install under the managed version
79
+ * path so the shim resolver finds it.
80
+ *
81
+ * Layout produced (everything is a symlink, nothing is copied):
82
+ *
83
+ * {versionDir}/
84
+ * package.json # marker so isVersionInstalled() is true
85
+ * home/ # empty isolated $HOME for this version
86
+ * node_modules/{npmPackage} -> {globalPath}
87
+ * node_modules/.bin/{cliCommand} -> {binaryEntry}
88
+ */
89
+ export function importAgentBinary(spec, version, globalPath, versionDir) {
90
+ const binaryLink = path.join(versionDir, 'node_modules', '.bin', spec.cliCommand);
91
+ // lstat — we want to detect the symlink itself, not follow it. fs.existsSync
92
+ // can return false on dangling symlinks, which would incorrectly let us
93
+ // proceed to symlinkSync below and throw EEXIST.
94
+ let alreadyExists = false;
95
+ try {
96
+ fs.lstatSync(binaryLink);
97
+ alreadyExists = true;
98
+ }
99
+ catch {
100
+ /* not present */
101
+ }
102
+ if (alreadyExists) {
103
+ return { success: false, skipped: true, error: `${version} already installed`, resolvedFromPath: globalPath };
104
+ }
105
+ if (!fs.existsSync(globalPath)) {
106
+ return { success: false, error: `Path does not exist: ${globalPath}` };
107
+ }
108
+ const globalPkgJson = path.join(globalPath, 'package.json');
109
+ if (!fs.existsSync(globalPkgJson)) {
110
+ return { success: false, error: `Not an npm package (no package.json): ${globalPath}` };
111
+ }
112
+ let pkgBinEntry;
113
+ try {
114
+ const pkg = JSON.parse(fs.readFileSync(globalPkgJson, 'utf8'));
115
+ if (typeof pkg.bin === 'string') {
116
+ pkgBinEntry = pkg.bin;
117
+ }
118
+ else if (pkg.bin && typeof pkg.bin === 'object') {
119
+ // Strict: only accept the exact cliCommand key. Multi-bin packages
120
+ // (e.g. @anthropic-ai/claude-code ships several bins) would otherwise
121
+ // silently get a wrong binary chosen by Object.values() ordering.
122
+ pkgBinEntry = pkg.bin[spec.cliCommand];
123
+ }
124
+ }
125
+ catch (err) {
126
+ return { success: false, error: `Failed to read package.json: ${err.message}` };
127
+ }
128
+ if (!pkgBinEntry) {
129
+ return { success: false, error: `package.json has no bin entry for "${spec.cliCommand}" — pass --from-path to a package that ships it` };
130
+ }
131
+ const binaryTarget = path.resolve(globalPath, pkgBinEntry);
132
+ if (!fs.existsSync(binaryTarget)) {
133
+ return { success: false, error: `Binary entry missing: ${binaryTarget}` };
134
+ }
135
+ try {
136
+ fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
137
+ fs.mkdirSync(path.join(versionDir, 'node_modules', '.bin'), { recursive: true });
138
+ fs.writeFileSync(path.join(versionDir, 'package.json'), JSON.stringify({ name: `agents-${spec.agentId}-${version}`, version: '1.0.0', private: true, imported: true, from: globalPath }, null, 2));
139
+ const pkgLink = path.join(versionDir, 'node_modules', spec.npmPackage);
140
+ fs.mkdirSync(path.dirname(pkgLink), { recursive: true });
141
+ if (!fs.existsSync(pkgLink)) {
142
+ fs.symlinkSync(globalPath, pkgLink);
143
+ }
144
+ fs.symlinkSync(binaryTarget, binaryLink);
145
+ return { success: true, resolvedFromPath: globalPath };
146
+ }
147
+ catch (err) {
148
+ return { success: false, error: err.message };
149
+ }
150
+ }
151
+ /**
152
+ * Resolve the on-disk npm package directory for an agent's CLI binary by
153
+ * walking up from the binary, following any symlinks. Returns null if the
154
+ * package can't be identified.
155
+ *
156
+ * Handles the homebrew/global-npm pattern where:
157
+ * /opt/homebrew/bin/{cli} -> ../lib/node_modules/{pkg}/dist/index.js
158
+ */
159
+ export function resolvePackageDirFromBinary(binaryPath) {
160
+ try {
161
+ let real = fs.realpathSync(binaryPath);
162
+ let dir = path.dirname(real);
163
+ // Walk up looking for the nearest package.json
164
+ for (let i = 0; i < 6; i++) {
165
+ const pkg = path.join(dir, 'package.json');
166
+ if (fs.existsSync(pkg)) {
167
+ return dir;
168
+ }
169
+ const parent = path.dirname(dir);
170
+ if (parent === dir)
171
+ break;
172
+ dir = parent;
173
+ }
174
+ return null;
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
@@ -8,7 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as os from 'os';
10
10
  import * as yaml from 'yaml';
11
- const HOME = os.homedir();
11
+ const HOME = process.env.HOME ?? os.homedir();
12
12
  const SYSTEM_DIR = path.join(HOME, '.agents-system');
13
13
  const USER_DIR = path.join(HOME, '.agents');
14
14
  const HISTORY_DIR = path.join(USER_DIR, '.history');
@@ -1325,6 +1325,63 @@ function warnSystemOrphans() {
1325
1325
  return;
1326
1326
  console.error(`~/.agents-system/ has unexpected entries (not part of the npm-shipped defaults): ${orphans.join(', ')}`);
1327
1327
  }
1328
+ const VERSION_RESOURCE_FLAT_KEYS = ['commands', 'skills', 'hooks', 'memory', 'subagents', 'plugins', 'workflows', 'permissions', 'mcp'];
1329
+ /**
1330
+ * Convert agents.yaml versions: entries from the old flat name-list format to
1331
+ * the new pattern format. Flat entries are detected by checking whether all
1332
+ * items in the array lack a ':' separator (plain names have no source prefix).
1333
+ *
1334
+ * The rulesPreset field is preserved. Flat resource lists are dropped — the
1335
+ * next `agents sync` will write default patterns (system:* user:* project:*).
1336
+ *
1337
+ * Idempotent: entries already in pattern format are left untouched.
1338
+ */
1339
+ function migrateVersionResourcesToPatterns() {
1340
+ const metaFile = path.join(USER_DIR, 'agents.yaml');
1341
+ if (!fs.existsSync(metaFile))
1342
+ return;
1343
+ let meta;
1344
+ try {
1345
+ const raw = fs.readFileSync(metaFile, 'utf-8');
1346
+ meta = yaml.parse(raw) || {};
1347
+ }
1348
+ catch {
1349
+ return;
1350
+ }
1351
+ const versions = meta.versions;
1352
+ if (!versions || typeof versions !== 'object')
1353
+ return;
1354
+ let changed = false;
1355
+ for (const agentVersions of Object.values(versions)) {
1356
+ if (!agentVersions || typeof agentVersions !== 'object')
1357
+ continue;
1358
+ for (const vr of Object.values(agentVersions)) {
1359
+ if (!vr || typeof vr !== 'object')
1360
+ continue;
1361
+ for (const key of VERSION_RESOURCE_FLAT_KEYS) {
1362
+ const val = vr[key];
1363
+ if (!Array.isArray(val) || val.length === 0)
1364
+ continue;
1365
+ // Detect legacy: all items are plain names (no ':' separator)
1366
+ if (val.every(item => typeof item === 'string' && !item.includes(':'))) {
1367
+ if (key === 'memory') {
1368
+ // memory was a single-element array holding the preset name — move to rulesPreset
1369
+ if (val.length === 1 && !vr['rulesPreset']) {
1370
+ vr['rulesPreset'] = val[0];
1371
+ }
1372
+ }
1373
+ delete vr[key];
1374
+ changed = true;
1375
+ }
1376
+ }
1377
+ }
1378
+ }
1379
+ if (changed) {
1380
+ const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n\n';
1381
+ fs.writeFileSync(metaFile, META_HEADER + yaml.stringify(meta), 'utf-8');
1382
+ console.error('Migrated agents.yaml versions: entries to pattern format');
1383
+ }
1384
+ }
1328
1385
  /** Run all idempotent migrations. Safe to call multiple times. */
1329
1386
  export async function runMigration() {
1330
1387
  migrateAgentsYaml();
@@ -1344,6 +1401,7 @@ export async function runMigration() {
1344
1401
  cleanupEmptyTopLevelRuns();
1345
1402
  foldUserHooksYamlIntoAgentsYaml();
1346
1403
  foldBrowserProfilesIntoAgentsYaml();
1404
+ migrateVersionResourcesToPatterns();
1347
1405
  // Bucket moves: collapse runtime state into ~/.agents/.history and ~/.agents/.cache.
1348
1406
  migrateRuntimeToHistory();
1349
1407
  migrateRuntimeToCache();