@phnx-labs/agents-cli 1.20.73 → 1.20.74

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.
@@ -4,13 +4,13 @@ import { visibleWidth, termLink } from '../lib/format.js';
4
4
  import ora from 'ora';
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
- import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
7
+ import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
8
8
  import { loginHint } from '../lib/signin-badge.js';
9
9
  import { machineId } from '../lib/machine-id.js';
10
10
  import { authCacheKey, formatCheckedAge, readAuthHealthCache } from '../lib/auth-health.js';
11
11
  import { agentReportsUsage, deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
12
12
  import { readManifest } from '../lib/manifest.js';
13
- import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, } from '../lib/versions.js';
13
+ import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, isVersionIsolated, getIsolatedDefault, } from '../lib/versions.js';
14
14
  import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
15
15
  import { getAgentResources } from '../lib/resources.js';
16
16
  import { resolveVersionFilter, AgentSpecError } from '../lib/agent-spec/index.js';
@@ -243,9 +243,27 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
243
243
  ? `Checking ${agentLabel(filterAgentId)} agents...`
244
244
  : 'Checking installed agents...';
245
245
  const spinner = ora({ text: spinnerText, isSilent: !process.stdout.isTTY }).start();
246
- const cliStates = await getAllCliStates();
247
- spinner.stop();
248
246
  const agentsToShow = filterAgentId ? [filterAgentId] : ALL_AGENT_IDS;
247
+ // A globally-installed CLI is superseded only by a NORMAL managed version — that
248
+ // is when agents-cli owns the launcher and a "global" row would just be our own
249
+ // shim reported back. `--isolated` promises the opposite: no default, no bare
250
+ // shim, no adopted launcher, the user's own `~/.<agent>` untouched. So an
251
+ // isolated-only install must not make that still-live global CLI disappear from
252
+ // `agents view` — the two are genuinely separate installs and both get listed.
253
+ const hasNonIsolatedVersion = (agentId) => listInstalledVersions(agentId).some((v) => !isVersionIsolated(agentId, v));
254
+ // Every `cliStates` read in this function feeds the "Not Managed by Agents CLI"
255
+ // block, so resolve it the way that block means it: the user's own CLI on PATH.
256
+ // `getCliState` would answer with a version-dir install — including an isolated
257
+ // copy that is deliberately absent from PATH — and print it as "(global)".
258
+ //
259
+ // Resolved only for agents that can actually reach that block. `getCliState`
260
+ // deliberately avoids subprocesses for a version-managed agent, and PATH
261
+ // resolution costs a `<cli> --version` spawn on a cold cache — so probing an
262
+ // agent whose global row is suppressed anyway would be pure added latency.
263
+ const cliStates = Object.fromEntries(await Promise.all(agentsToShow
264
+ .filter((agentId) => !hasNonIsolatedVersion(agentId))
265
+ .map(async (agentId) => [agentId, await getUnmanagedCliState(agentId)])));
266
+ spinner.stop();
249
267
  const showPaths = !!filterAgentId;
250
268
  const profilesByAgent = getProfilesByAgent(filterAgentId);
251
269
  const profileSummaries = [...profilesByAgent.values()].flat();
@@ -276,19 +294,18 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
276
294
  const infoFetches = [];
277
295
  const globalInfoFetches = [];
278
296
  for (const agentId of agentsToShow) {
279
- const versions = listInstalledVersions(agentId);
280
- if (versions.length > 0) {
281
- for (const ver of versions) {
282
- const home = getVersionHomePath(agentId, ver);
283
- infoFetches.push(getAccountInfo(agentId, home).then((info) => ({
284
- agentId,
285
- version: ver,
286
- home,
287
- info,
288
- })));
289
- }
297
+ for (const ver of listInstalledVersions(agentId)) {
298
+ const home = getVersionHomePath(agentId, ver);
299
+ infoFetches.push(getAccountInfo(agentId, home).then((info) => ({
300
+ agentId,
301
+ version: ver,
302
+ home,
303
+ info,
304
+ })));
290
305
  }
291
- else {
306
+ // Mirrors the classification below: fetch the global account whenever the
307
+ // global install will still be rendered (no versions at all, or isolated-only).
308
+ if (!hasNonIsolatedVersion(agentId)) {
292
309
  globalInfoFetches.push(getAccountInfo(agentId).then((info) => ({
293
310
  agentId,
294
311
  cliVersion: cliStates[agentId]?.version || null,
@@ -356,10 +373,12 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
356
373
  if (versions.length > 0) {
357
374
  versionManaged.push(agentId);
358
375
  }
359
- else if (cliState?.installed) {
360
- globallyInstalled.push(agentId);
376
+ if (cliState?.installed) {
377
+ // Isolated-only installs sit alongside the global CLI rather than replacing it.
378
+ if (!hasNonIsolatedVersion(agentId))
379
+ globallyInstalled.push(agentId);
361
380
  }
362
- else if (hasProfiles) {
381
+ else if (versions.length === 0 && hasProfiles) {
363
382
  profileOnly.push(agentId);
364
383
  }
365
384
  }
@@ -376,6 +395,22 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
376
395
  liveVersionByAgent.set(agentId, live);
377
396
  }));
378
397
  const displayVersion = (agentId, dirVersion) => liveVersionByAgent.get(agentId) ?? dirVersion;
398
+ // Uncolored row label, shared by the width pass and the render so padding lines
399
+ // up. An isolated copy is never the global default (installing one deliberately
400
+ // records no default), so the two tags can't collide.
401
+ const versionRowLabel = (agentId, version, globalDefault) => {
402
+ const shown = displayVersion(agentId, version);
403
+ if (version === globalDefault)
404
+ return `${shown} (default)`;
405
+ if (isVersionIsolated(agentId, version)) {
406
+ // The isolated default is what a bare `agents run <agent>` reaches, so it is
407
+ // worth distinguishing from the other isolated copies sitting beside it.
408
+ return getIsolatedDefault(agentId) === version
409
+ ? `${shown} (isolated default)`
410
+ : `${shown} (isolated)`;
411
+ }
412
+ return shown;
413
+ };
379
414
  // Show version-managed agents
380
415
  if (versionManaged.length > 0) {
381
416
  // Calculate column widths across all agents for alignment
@@ -388,9 +423,7 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
388
423
  const versions = listInstalledVersions(agentId);
389
424
  const globalDefault = getGlobalDefault(agentId);
390
425
  for (const v of versions) {
391
- const shown = displayVersion(agentId, v);
392
- const label = v === globalDefault ? `${shown} (default)` : shown;
393
- maxVerLabel = Math.max(maxVerLabel, label.length);
426
+ maxVerLabel = Math.max(maxVerLabel, versionRowLabel(agentId, v, globalDefault).length);
394
427
  const rawInfo = infoMap.get(`${agentId}:${v}`);
395
428
  const info = rawInfo ? mergeCanonical(rawInfo) : undefined;
396
429
  const accountLabel = accountColumnLabel(info);
@@ -431,7 +464,14 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
431
464
  const globalDefault = getGlobalDefault(agentId);
432
465
  const runStrategy = getConfiguredRunStrategy(agentId);
433
466
  const strategyLabel = chalk.gray(` (${runStrategy})`);
434
- const noDefaultLabel = !globalDefault ? chalk.yellow(' (no default)') : '';
467
+ // `(no default)` is a nudge to go set one. It would read as a contradiction
468
+ // directly above a row tagged `(isolated default)`, and it would be bad
469
+ // advice besides: for an isolated-only agent the pointer below IS how a
470
+ // bare `agents run <agent>` resolves, and setting a global default is
471
+ // precisely what `--isolated` exists to avoid.
472
+ const noDefaultLabel = !globalDefault && !getIsolatedDefault(agentId)
473
+ ? chalk.yellow(' (no default)')
474
+ : '';
435
475
  console.log(` ${chalk.bold(agentLabel(agentId))}${strategyLabel}${noDefaultLabel}`);
436
476
  // Sort versions with default first, then by semver descending
437
477
  const sortedVersions = [...versions].sort((a, b) => {
@@ -443,10 +483,16 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
443
483
  });
444
484
  for (const version of sortedVersions) {
445
485
  const isDefault = version === globalDefault;
486
+ const isolated = !isDefault && isVersionIsolated(agentId, version);
487
+ const isolatedTag = getIsolatedDefault(agentId) === version ? ' (isolated default)' : ' (isolated)';
446
488
  const shown = displayVersion(agentId, version);
447
- const base = isDefault ? `${shown} (default)` : shown;
448
- const padded = base.padEnd(maxVerLabel);
449
- const label = isDefault ? `${shown}${chalk.green(' (default)')}${' '.repeat(maxVerLabel - base.length)}` : padded;
489
+ const base = versionRowLabel(agentId, version, globalDefault);
490
+ const tagPad = ' '.repeat(maxVerLabel - base.length);
491
+ const label = isDefault
492
+ ? `${shown}${chalk.green(' (default)')}${tagPad}`
493
+ : isolated
494
+ ? `${shown}${chalk.gray(isolatedTag)}${tagPad}`
495
+ : base.padEnd(maxVerLabel);
450
496
  const rawInfo = infoMap.get(`${agentId}:${version}`);
451
497
  const vInfo = rawInfo ? mergeCanonical(rawInfo) : undefined;
452
498
  const usageKey = getUsageLookupKey(vInfo);
@@ -580,7 +626,9 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
580
626
  // Profile rows under a globally-installed harness. Use a simpler
581
627
  // alignment here since this section doesn't share column state with
582
628
  // the version-managed block.
583
- const profilesHere = profilesByAgent.get(agentId) ?? [];
629
+ // An isolated-only agent now appears in BOTH blocks; its profiles already
630
+ // rendered under the version-managed one, so don't print them twice.
631
+ const profilesHere = versionManaged.includes(agentId) ? [] : (profilesByAgent.get(agentId) ?? []);
584
632
  if (profilesHere.length > 0) {
585
633
  const nameWidth = Math.max(globalMaxVerLabel, ...profilesHere.map((p) => p.name.length));
586
634
  const authWidth = Math.max(...profilesHere.map((p) => p.auth.length));
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ if (IS_DEV_BUILD) {
55
55
  // module on each invocation (which loaded the whole ~50-module tree before the
56
56
  // first byte of output), the registry maps a command name to a thunk that
57
57
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
58
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadResources, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
58
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadResources, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
59
59
  import { applyGlobalHelpConventions } from './lib/help.js';
60
60
  import { renderWhatsNew } from './lib/whats-new.js';
61
61
  import { emit, redactArgs } from './lib/events.js';
@@ -730,6 +730,7 @@ async function registerAllEagerCommands() {
730
730
  await reg(loadWorktree);
731
731
  await reg(loadVersions);
732
732
  await reg(loadImport);
733
+ await reg(loadExport);
733
734
  await reg(loadPackages);
734
735
  await reg(loadRoutines);
735
736
  await reg(loadMonitors);
@@ -71,6 +71,17 @@ export declare function getCliPath(agentId: AgentId): Promise<string | null>;
71
71
  * back to a plain PATH lookup.
72
72
  */
73
73
  export declare function getCliState(agentId: AgentId): Promise<CliState>;
74
+ /**
75
+ * Resolve the agent's OWN install — the one agents-cli does not manage — by plain
76
+ * PATH lookup, ignoring the version dirs entirely.
77
+ *
78
+ * Callers that specifically mean "the user's own globally-installed CLI" must use
79
+ * this rather than `getCliState`, whose managed fast path reports an installed
80
+ * version (any version dir, in readdir order) and would therefore hand back an
81
+ * isolated copy — a copy that is deliberately unreachable from PATH — labelled as
82
+ * the global install.
83
+ */
84
+ export declare function getUnmanagedCliState(agentId: AgentId): Promise<CliState>;
74
85
  /** Resolve CLI state for all registered agents in parallel. */
75
86
  export declare function getAllCliStates(): Promise<Partial<Record<AgentId, CliState>>>;
76
87
  /** Info about an existing unmanaged agent installation. */
@@ -230,6 +241,30 @@ export declare function antigravityOsKeyringProbe(platform?: NodeJS.Platform): {
230
241
  } | null;
231
242
  /** @internal test hook — clear the per-process keyring probe cache. */
232
243
  export declare function __resetAntigravityKeychainCacheForTest(): void;
244
+ /**
245
+ * Whether a Claude version home's credential file is present but carries no
246
+ * token — the "must have a real credential" floor (see
247
+ * `isValidOpenCodeCredential`) applied to claude.
248
+ *
249
+ * A FAILED OAuth refresh leaves exactly this state behind: Claude Code rewrites
250
+ * `.claude/.credentials.json` with `accessToken: ""`, `refreshToken: ""` and
251
+ * `expiresAt: 0`, keeping only the descriptive fields (`subscriptionType`,
252
+ * `rateLimitTier`, `refreshTokenExpiresAt`). Everything we derive from
253
+ * `.claude.json` — email, plan — still looks healthy, so the install reported
254
+ * `signedIn: true`, `agents view` drew usage bars for it, and balanced rotation
255
+ * (whose `authValid` is just "email present") kept picking it — every pick dying
256
+ * at spawn on "OAuth session expired and could not be refreshed".
257
+ *
258
+ * Only decidable off macOS: there the login Keychain is the canonical store and
259
+ * this file is not authoritative, and probing the Keychain would raise an
260
+ * authorization sheet per installed version on every `agents run` — the reason
261
+ * rotation stopped calling `isClaudeAuthValid` at all. Off macOS the file IS the
262
+ * only store, so a token-less file is proof of signed-out. `platform` is a
263
+ * parameter so both branches are testable on any host.
264
+ *
265
+ * Sync, no Keychain, no network — safe on the `agents run` hot path.
266
+ */
267
+ export declare function isClaudeCredentialFileBlank(base: string, platform?: NodeJS.Platform): boolean;
233
268
  export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
234
269
  /**
235
270
  * Determine when the agent was last used by checking session file mtimes,
@@ -860,7 +860,20 @@ export async function getCliState(agentId) {
860
860
  }
861
861
  }
862
862
  }
863
- // Non-version-managed: single PATH lookup + cached version read
863
+ return getUnmanagedCliState(agentId);
864
+ }
865
+ /**
866
+ * Resolve the agent's OWN install — the one agents-cli does not manage — by plain
867
+ * PATH lookup, ignoring the version dirs entirely.
868
+ *
869
+ * Callers that specifically mean "the user's own globally-installed CLI" must use
870
+ * this rather than `getCliState`, whose managed fast path reports an installed
871
+ * version (any version dir, in readdir order) and would therefore hand back an
872
+ * isolated copy — a copy that is deliberately unreachable from PATH — labelled as
873
+ * the global install.
874
+ */
875
+ export async function getUnmanagedCliState(agentId) {
876
+ const agent = AGENTS[agentId];
864
877
  // Special case for grok: it manages its own binaries in ~/.grok/downloads/
865
878
  if (agentId === 'grok') {
866
879
  const grokBin = resolveGrokBinary();
@@ -1322,6 +1335,48 @@ function isValidOpenCodeCredential(value) {
1322
1335
  default: return false;
1323
1336
  }
1324
1337
  }
1338
+ /**
1339
+ * Whether a Claude version home's credential file is present but carries no
1340
+ * token — the "must have a real credential" floor (see
1341
+ * `isValidOpenCodeCredential`) applied to claude.
1342
+ *
1343
+ * A FAILED OAuth refresh leaves exactly this state behind: Claude Code rewrites
1344
+ * `.claude/.credentials.json` with `accessToken: ""`, `refreshToken: ""` and
1345
+ * `expiresAt: 0`, keeping only the descriptive fields (`subscriptionType`,
1346
+ * `rateLimitTier`, `refreshTokenExpiresAt`). Everything we derive from
1347
+ * `.claude.json` — email, plan — still looks healthy, so the install reported
1348
+ * `signedIn: true`, `agents view` drew usage bars for it, and balanced rotation
1349
+ * (whose `authValid` is just "email present") kept picking it — every pick dying
1350
+ * at spawn on "OAuth session expired and could not be refreshed".
1351
+ *
1352
+ * Only decidable off macOS: there the login Keychain is the canonical store and
1353
+ * this file is not authoritative, and probing the Keychain would raise an
1354
+ * authorization sheet per installed version on every `agents run` — the reason
1355
+ * rotation stopped calling `isClaudeAuthValid` at all. Off macOS the file IS the
1356
+ * only store, so a token-less file is proof of signed-out. `platform` is a
1357
+ * parameter so both branches are testable on any host.
1358
+ *
1359
+ * Sync, no Keychain, no network — safe on the `agents run` hot path.
1360
+ */
1361
+ export function isClaudeCredentialFileBlank(base, platform = process.platform) {
1362
+ if (platform === 'darwin')
1363
+ return false;
1364
+ try {
1365
+ const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
1366
+ const oauth = JSON.parse(raw).claudeAiOauth;
1367
+ if (!oauth)
1368
+ return false;
1369
+ const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
1370
+ return !nonEmpty(oauth.accessToken) && !nonEmpty(oauth.refreshToken);
1371
+ }
1372
+ catch {
1373
+ // No file (a Keychain-backed home, or never logged in here) or an
1374
+ // unreadable/corrupt one: not positive evidence of a blank credential, so
1375
+ // leave the existing signal alone rather than declaring a working install
1376
+ // signed out.
1377
+ return false;
1378
+ }
1379
+ }
1325
1380
  export async function getAccountInfo(agentId, home) {
1326
1381
  const base = home || os.homedir();
1327
1382
  const empty = {
@@ -1358,6 +1413,13 @@ export async function getAccountInfo(agentId, home) {
1358
1413
  const accountId = normalizeIdentityPart(oa?.accountUuid);
1359
1414
  const organizationId = normalizeIdentityPart(oa?.organizationUuid);
1360
1415
  const email = oa?.emailAddress || null;
1416
+ // Credential floor: a blanked credential file means this home cannot
1417
+ // authenticate, whatever `.claude.json` still says. Report it signed out
1418
+ // so `agents view` prompts a re-login and rotation routes around it,
1419
+ // instead of handing runs to an install that dies at spawn.
1420
+ if (email && isClaudeCredentialFileBlank(base)) {
1421
+ return { ...empty, lastActive };
1422
+ }
1361
1423
  const accountKey = buildIdentityKey(agentId, [
1362
1424
  ['account', accountId],
1363
1425
  ['org', organizationId],
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Move `source` onto `dest` across possibly-different volumes. `renameSync` is
3
+ * atomic but throws EXDEV when `~/.agents` lives on a different filesystem than
4
+ * `$HOME`; fall back to copy-then-remove so the restore still completes. The
5
+ * source is removed only after the copy succeeds, so a mid-copy failure never
6
+ * destroys the sole surviving copy.
7
+ */
8
+ export declare function moveDirCrossDevice(source: string, dest: string): void;
9
+ /**
10
+ * Copy `source` to `dest`, dropping any symlink whose target resolves back into
11
+ * `~/.agents`. Managed resources (skills/commands) are synced into a version home
12
+ * as symlinks into `~/.agents`; copying them verbatim would leave the result full
13
+ * of links that dangle the moment `~/.agents` is disposed. Stripping them yields a
14
+ * clean, self-contained copy — which is the entire point of an export.
15
+ */
16
+ export declare function copyDirStrippingAgentsSymlinks(source: string, dest: string, agentsDir: string): void;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Primitives for moving an agent config directory across the agents-cli boundary.
3
+ *
4
+ * Both helpers were part of {@link ./uninstall.ts} and are unchanged — teardown was
5
+ * simply the first caller. `agents export` needs the same two operations (relocate a
6
+ * directory that may sit on another volume; copy one without dragging `~/.agents`
7
+ * symlinks along), so they live here rather than being duplicated or imported out of
8
+ * a module named for teardown.
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ /**
13
+ * Move `source` onto `dest` across possibly-different volumes. `renameSync` is
14
+ * atomic but throws EXDEV when `~/.agents` lives on a different filesystem than
15
+ * `$HOME`; fall back to copy-then-remove so the restore still completes. The
16
+ * source is removed only after the copy succeeds, so a mid-copy failure never
17
+ * destroys the sole surviving copy.
18
+ */
19
+ export function moveDirCrossDevice(source, dest) {
20
+ try {
21
+ fs.renameSync(source, dest);
22
+ }
23
+ catch (err) {
24
+ if (err.code !== 'EXDEV')
25
+ throw err;
26
+ fs.cpSync(source, dest, { recursive: true });
27
+ fs.rmSync(source, { recursive: true, force: true });
28
+ }
29
+ }
30
+ /**
31
+ * Copy `source` to `dest`, dropping any symlink whose target resolves back into
32
+ * `~/.agents`. Managed resources (skills/commands) are synced into a version home
33
+ * as symlinks into `~/.agents`; copying them verbatim would leave the result full
34
+ * of links that dangle the moment `~/.agents` is disposed. Stripping them yields a
35
+ * clean, self-contained copy — which is the entire point of an export.
36
+ */
37
+ export function copyDirStrippingAgentsSymlinks(source, dest, agentsDir) {
38
+ const inside = agentsDir + path.sep;
39
+ fs.cpSync(source, dest, {
40
+ recursive: true,
41
+ filter: (src) => {
42
+ try {
43
+ const st = fs.lstatSync(src);
44
+ if (st.isSymbolicLink()) {
45
+ const tgt = path.resolve(path.dirname(src), fs.readlinkSync(src));
46
+ if (tgt === agentsDir || tgt.startsWith(inside))
47
+ return false;
48
+ }
49
+ }
50
+ catch {
51
+ /* unreadable entry — let cpSync surface it on the real copy */
52
+ }
53
+ return true;
54
+ },
55
+ });
56
+ }
@@ -0,0 +1,72 @@
1
+ import type { AgentId } from './types.js';
2
+ export type ExportMode = 'merge' | 'replace' | 'staged';
3
+ /** Filename of the provenance receipt written into the agent's config dir. */
4
+ export declare const RECEIPT_NAME = ".agents-cli-export.json";
5
+ /** Suffix for the incoming copy of a file the user already has. */
6
+ export declare const CONFLICT_SUFFIX = ".from-agents-cli";
7
+ /** Why an export cannot proceed. Each maps to a distinct user-facing remedy. */
8
+ export type ExportBlocker = {
9
+ kind: 'not-installed';
10
+ } | {
11
+ kind: 'not-isolated';
12
+ } | {
13
+ kind: 'no-config';
14
+ source: string;
15
+ } | {
16
+ kind: 'dest-adopted';
17
+ realPath: string;
18
+ adoptedVersion: string;
19
+ };
20
+ /** What the destination `~/.<agent>` currently is. */
21
+ export type DestKind = 'absent' | 'real-dir' | 'foreign-symlink';
22
+ /** One file the export would place, relative to the config dir root. */
23
+ export interface ExportEntry {
24
+ /** Path relative to the config dir, e.g. `prompts/review.md`. */
25
+ rel: string;
26
+ /** Absolute source file inside the isolated home. */
27
+ source: string;
28
+ /** Absolute path this file will be written to. */
29
+ target: string;
30
+ /**
31
+ * Set when the user already has `rel`. `target` then points at the
32
+ * `.from-agents-cli` sibling, and `existing` is the untouched original.
33
+ */
34
+ existing?: string;
35
+ }
36
+ export interface ExportPlan {
37
+ agent: AgentId;
38
+ version: string;
39
+ mode: ExportMode;
40
+ /** The isolated config dir, e.g. `<versionDir>/home/.codex`. */
41
+ source: string;
42
+ /** The user's real config dir, e.g. `~/.codex`. */
43
+ dest: string;
44
+ destKind: DestKind;
45
+ /** Files with no counterpart in `dest` — written in place. */
46
+ writes: ExportEntry[];
47
+ /** Files the user already has — written alongside, never over. */
48
+ conflicts: ExportEntry[];
49
+ /** replace mode only: where `dest` gets moved first. */
50
+ backupPath: string | null;
51
+ /** staged mode only: the subdirectory receiving the whole tree. */
52
+ stagedPath: string | null;
53
+ receiptPath: string;
54
+ blocker: ExportBlocker | null;
55
+ }
56
+ export interface ExportResult {
57
+ exported: boolean;
58
+ dest: string;
59
+ written: string[];
60
+ conflicts: Array<{
61
+ path: string;
62
+ theirs: string;
63
+ }>;
64
+ backupPath: string | null;
65
+ stagedPath: string | null;
66
+ receiptPath: string | null;
67
+ errors: string[];
68
+ }
69
+ /** Build a read-only plan. Performs no mutations. */
70
+ export declare function planExport(agent: AgentId, version: string, timestamp: number, mode?: ExportMode): ExportPlan;
71
+ /** Execute a plan from {@link planExport}. */
72
+ export declare function executeExport(plan: ExportPlan, timestamp: number): ExportResult;