@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.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * `agents export` — copy an ISOLATED install's config back out to the user's real
3
+ * `~/.<agent>`, so they can keep their settings and delete agents-cli entirely.
4
+ *
5
+ * Isolation is currently a one-way door: `agents add <agent>@<v> --isolated` builds a
6
+ * self-contained home under the version dir, and nothing ever brings that work back.
7
+ * Reversibility is what makes a sandbox safe to try.
8
+ *
9
+ * Three modes, because "export" means different things depending on which config you
10
+ * consider authoritative:
11
+ *
12
+ * merge (default) — additive. Copy only paths the user doesn't already have. A
13
+ * collision is NOT silently skipped: the incoming file is written
14
+ * beside theirs as `<name>.from-agents-cli` so they can diff and
15
+ * take the parts they want. Nothing of theirs is ever modified.
16
+ * replace — the isolated config becomes `~/.<agent>`; theirs moves to
17
+ * `backups/<agent>/<ts>`. For when you've been living in the
18
+ * sandbox and want to promote it wholesale.
19
+ * staged — write the whole tree to `~/.<agent>/.agents-export-<ts>/` and
20
+ * activate nothing. For inspecting first.
21
+ *
22
+ * Every mode leaves a receipt at `~/.<agent>/.agents-cli-export.json` recording what
23
+ * came from the export. That is what makes provenance answerable ("which of these
24
+ * files are mine?") and the whole operation reversible.
25
+ *
26
+ * Deliberately NOT implemented: key-level merging of file *contents*. The TOML
27
+ * library here does not preserve comments across parse+stringify, so auto-merging a
28
+ * `config.toml` would silently delete the user's comments. Handing over both files
29
+ * and a diff is honest; rewriting their config behind a "merge" flag is not.
30
+ *
31
+ * Split into a read-only {@link planExport} and a mutating {@link executeExport} so
32
+ * `--dry-run` and the real run share one code path, mirroring `lib/uninstall.ts`.
33
+ */
34
+ import * as fs from 'fs';
35
+ import * as path from 'path';
36
+ import { AGENTS } from './agents.js';
37
+ import { getAgentConfigPath, getConfigSymlinkVersion } from './shims.js';
38
+ import { getVersionHomePath, isVersionIsolated, isVersionInstalled } from './versions.js';
39
+ import { getUserAgentsDir, getBackupsDir } from './state.js';
40
+ import { copyDirStrippingAgentsSymlinks, moveDirCrossDevice } from './config-transfer.js';
41
+ /** Filename of the provenance receipt written into the agent's config dir. */
42
+ export const RECEIPT_NAME = '.agents-cli-export.json';
43
+ /** Suffix for the incoming copy of a file the user already has. */
44
+ export const CONFLICT_SUFFIX = '.from-agents-cli';
45
+ /** The config dir's basename (`.codex`), used to locate it inside a version home. */
46
+ function configBasename(agent) {
47
+ return path.basename(AGENTS[agent].configDir);
48
+ }
49
+ /**
50
+ * Every regular file under `root`, as paths relative to it. Symlinks pointing back
51
+ * into `~/.agents` are omitted for the same reason `copyDirStrippingAgentsSymlinks`
52
+ * drops them: they dangle the moment `~/.agents` is disposed, and an export that
53
+ * leaves dangling links is not an export.
54
+ */
55
+ function walkFiles(root, agentsDir, rel = '') {
56
+ const inside = agentsDir + path.sep;
57
+ const out = [];
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
61
+ }
62
+ catch {
63
+ return out;
64
+ }
65
+ for (const entry of entries) {
66
+ const childRel = rel ? path.join(rel, entry.name) : entry.name;
67
+ const abs = path.join(root, childRel);
68
+ if (entry.isSymbolicLink()) {
69
+ try {
70
+ const tgt = path.resolve(path.dirname(abs), fs.readlinkSync(abs));
71
+ if (tgt === agentsDir || tgt.startsWith(inside))
72
+ continue;
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ out.push(childRel);
78
+ continue;
79
+ }
80
+ if (entry.isDirectory()) {
81
+ out.push(...walkFiles(root, agentsDir, childRel));
82
+ continue;
83
+ }
84
+ out.push(childRel);
85
+ }
86
+ return out;
87
+ }
88
+ /** Build a read-only plan. Performs no mutations. */
89
+ export function planExport(agent, version, timestamp, mode = 'merge') {
90
+ const source = path.join(getVersionHomePath(agent, version), configBasename(agent));
91
+ const dest = getAgentConfigPath(agent);
92
+ const base = {
93
+ agent,
94
+ version,
95
+ mode,
96
+ source,
97
+ dest,
98
+ destKind: 'absent',
99
+ writes: [],
100
+ conflicts: [],
101
+ backupPath: null,
102
+ stagedPath: null,
103
+ receiptPath: path.join(dest, RECEIPT_NAME),
104
+ };
105
+ if (!isVersionInstalled(agent, version))
106
+ return { ...base, blocker: { kind: 'not-installed' } };
107
+ if (!isVersionIsolated(agent, version))
108
+ return { ...base, blocker: { kind: 'not-isolated' } };
109
+ if (!fs.existsSync(source))
110
+ return { ...base, blocker: { kind: 'no-config', source } };
111
+ // Refuse when `~/.<agent>` is a symlink agents-cli owns: the agent has a normal
112
+ // install that adopted it, so writing there would land inside a version home
113
+ // rather than in the user's real config, and silently mutate that other install.
114
+ const adopted = getConfigSymlinkVersion(agent);
115
+ if (adopted !== null) {
116
+ return { ...base, blocker: { kind: 'dest-adopted', realPath: dest, adoptedVersion: adopted } };
117
+ }
118
+ let destKind = 'absent';
119
+ try {
120
+ destKind = fs.lstatSync(dest).isSymbolicLink() ? 'foreign-symlink' : 'real-dir';
121
+ }
122
+ catch {
123
+ destKind = 'absent';
124
+ }
125
+ const agentsDir = getUserAgentsDir();
126
+ const rels = walkFiles(source, agentsDir).sort();
127
+ if (mode === 'staged') {
128
+ const stagedPath = path.join(dest, `.agents-export-${timestamp}`);
129
+ return {
130
+ ...base,
131
+ destKind,
132
+ stagedPath,
133
+ writes: rels.map((rel) => ({
134
+ rel,
135
+ source: path.join(source, rel),
136
+ target: path.join(stagedPath, rel),
137
+ })),
138
+ blocker: null,
139
+ };
140
+ }
141
+ if (mode === 'replace') {
142
+ return {
143
+ ...base,
144
+ destKind,
145
+ backupPath: destKind === 'real-dir' ? path.join(getBackupsDir(), agent, String(timestamp)) : null,
146
+ writes: rels.map((rel) => ({
147
+ rel,
148
+ source: path.join(source, rel),
149
+ target: path.join(dest, rel),
150
+ })),
151
+ blocker: null,
152
+ };
153
+ }
154
+ // merge: additive. Anything the user already has becomes a conflict written
155
+ // alongside, so their file is never the thing that changes.
156
+ const writes = [];
157
+ const conflicts = [];
158
+ for (const rel of rels) {
159
+ const target = path.join(dest, rel);
160
+ let exists = false;
161
+ try {
162
+ fs.lstatSync(target);
163
+ exists = true;
164
+ }
165
+ catch {
166
+ exists = false;
167
+ }
168
+ if (exists) {
169
+ conflicts.push({
170
+ rel,
171
+ source: path.join(source, rel),
172
+ target: target + CONFLICT_SUFFIX,
173
+ existing: target,
174
+ });
175
+ }
176
+ else {
177
+ writes.push({ rel, source: path.join(source, rel), target });
178
+ }
179
+ }
180
+ return { ...base, destKind, writes, conflicts, blocker: null };
181
+ }
182
+ /** Copy one file, creating parent dirs. Symlinks are recreated, not followed. */
183
+ function placeFile(entry) {
184
+ fs.mkdirSync(path.dirname(entry.target), { recursive: true });
185
+ const st = fs.lstatSync(entry.source);
186
+ if (st.isSymbolicLink()) {
187
+ const link = fs.readlinkSync(entry.source);
188
+ try {
189
+ fs.unlinkSync(entry.target);
190
+ }
191
+ catch {
192
+ /* nothing there */
193
+ }
194
+ fs.symlinkSync(link, entry.target);
195
+ return;
196
+ }
197
+ fs.copyFileSync(entry.source, entry.target);
198
+ }
199
+ function writeReceipt(plan, result, timestamp) {
200
+ const receipt = {
201
+ exportedAt: new Date(timestamp).toISOString(),
202
+ from: `${plan.agent}@${plan.version} (isolated)`,
203
+ mode: plan.mode,
204
+ written: result.written,
205
+ conflicts: result.conflicts,
206
+ ...(result.backupPath ? { backupPath: result.backupPath } : {}),
207
+ ...(result.stagedPath ? { stagedAt: result.stagedPath } : {}),
208
+ note: plan.mode === 'merge'
209
+ ? `Files under "written" came from this export. Paths under "conflicts" already existed and were NOT modified — the incoming version sits beside yours with the ${CONFLICT_SUFFIX} suffix.`
210
+ : plan.mode === 'replace'
211
+ ? 'This config was replaced wholesale by the export; the previous one is at backupPath.'
212
+ : 'Nothing was activated — the exported tree sits under stagedAt.',
213
+ };
214
+ fs.mkdirSync(path.dirname(plan.receiptPath), { recursive: true });
215
+ fs.writeFileSync(plan.receiptPath, JSON.stringify(receipt, null, 2) + '\n');
216
+ }
217
+ /** Execute a plan from {@link planExport}. */
218
+ export function executeExport(plan, timestamp) {
219
+ const result = {
220
+ exported: false,
221
+ dest: plan.dest,
222
+ written: [],
223
+ conflicts: [],
224
+ backupPath: null,
225
+ stagedPath: null,
226
+ receiptPath: null,
227
+ errors: [],
228
+ };
229
+ if (plan.blocker) {
230
+ result.errors.push(`refusing to export: ${plan.blocker.kind}`);
231
+ return result;
232
+ }
233
+ try {
234
+ if (plan.mode === 'replace') {
235
+ if (plan.backupPath) {
236
+ fs.mkdirSync(path.dirname(plan.backupPath), { recursive: true });
237
+ moveDirCrossDevice(plan.dest, plan.backupPath);
238
+ result.backupPath = plan.backupPath;
239
+ }
240
+ else if (plan.destKind === 'foreign-symlink') {
241
+ // Not ours to back up, but it must go or cpSync would write through it.
242
+ fs.unlinkSync(plan.dest);
243
+ }
244
+ copyDirStrippingAgentsSymlinks(plan.source, plan.dest, getUserAgentsDir());
245
+ result.written = plan.writes.map((w) => w.rel);
246
+ }
247
+ else {
248
+ // merge and staged both place individual files; neither disturbs anything
249
+ // the user already has.
250
+ for (const entry of plan.writes) {
251
+ placeFile(entry);
252
+ result.written.push(entry.rel);
253
+ }
254
+ for (const entry of plan.conflicts) {
255
+ placeFile(entry);
256
+ result.conflicts.push({ path: entry.rel, theirs: entry.target });
257
+ }
258
+ if (plan.stagedPath)
259
+ result.stagedPath = plan.stagedPath;
260
+ }
261
+ writeReceipt(plan, result, timestamp);
262
+ result.receiptPath = plan.receiptPath;
263
+ result.exported = true;
264
+ }
265
+ catch (err) {
266
+ result.errors.push(err instanceof Error ? err.message : String(err));
267
+ }
268
+ return result;
269
+ }
@@ -111,3 +111,26 @@ export declare function importInstallScriptBinary(spec: AgentBinarySpec, version
111
111
  * /opt/homebrew/bin/{cli} -> ../lib/node_modules/{pkg}/dist/index.js
112
112
  */
113
113
  export declare function resolvePackageDirFromBinary(binaryPath: string): string | null;
114
+ /**
115
+ * Seed an ISOLATED version's home from the user's real `~/.<agent>` — the mirror of
116
+ * {@link importAgentConfig}, which adopts.
117
+ *
118
+ * The difference is the whole point: this COPIES and leaves the original in place,
119
+ * never symlinks it, never sets a default, never creates a shim. So an isolated copy
120
+ * can start from the setup the user already has instead of from nothing, which was
121
+ * the only way to get a working sandbox before.
122
+ *
123
+ * Credentials are skipped by default and reported, not silently included. An isolated
124
+ * copy is a separate principal — `agents add --isolated` already tells the user to
125
+ * sign in on first run — and copying tokens into it should be a choice, not a side
126
+ * effect of wanting your settings. `--with-auth` opts in.
127
+ */
128
+ export declare function seedIsolatedConfigFromLocal(agentId: AgentId, version: string, opts?: {
129
+ withAuth?: boolean;
130
+ }): {
131
+ seeded: boolean;
132
+ from: string;
133
+ to: string;
134
+ skippedAuth: string[];
135
+ error?: string;
136
+ };
@@ -20,9 +20,9 @@ import * as fs from 'fs';
20
20
  import * as os from 'os';
21
21
  import * as path from 'path';
22
22
  import { AGENTS } from './agents.js';
23
- import { getVersionsDir } from './state.js';
23
+ import { getUserAgentsDir, getVersionsDir } from './state.js';
24
24
  import { setGlobalDefault } from './versions.js';
25
- import { createShim, createVersionedAlias, ensureShimCurrent, switchHomeFileSymlinks } from './shims.js';
25
+ import { createShim, createVersionedAlias, ensureShimCurrent, switchHomeFileSymlinks, assertIsolationBoundary } from './shims.js';
26
26
  const IMPORT_VERSION_RE = /^(?:latest|[A-Za-z0-9._+-]{1,64})$/;
27
27
  export function isValidImportVersion(version) {
28
28
  return IMPORT_VERSION_RE.test(version);
@@ -39,6 +39,12 @@ export async function importAgentConfig(agentId, version) {
39
39
  if (!isValidImportVersion(version)) {
40
40
  return { success: false, error: `Invalid version: ${JSON.stringify(version)}` };
41
41
  }
42
+ // Adoption, done inline rather than via switchConfigSymlink — so it needs the gate
43
+ // directly. commands/import.ts checks at its entry point too (it must: it registers
44
+ // a normal version first, which would un-protect the agent before this runs), but
45
+ // an exported function that moves the user's real config must not depend on every
46
+ // future caller remembering.
47
+ assertIsolationBoundary(agentId, 'adopt your existing install');
42
48
  const agent = AGENTS[agentId];
43
49
  const configDir = agent.configDir;
44
50
  const versionsDir = getVersionsDir();
@@ -237,3 +243,69 @@ export function resolvePackageDirFromBinary(binaryPath) {
237
243
  return null;
238
244
  }
239
245
  }
246
+ /**
247
+ * Seed an ISOLATED version's home from the user's real `~/.<agent>` — the mirror of
248
+ * {@link importAgentConfig}, which adopts.
249
+ *
250
+ * The difference is the whole point: this COPIES and leaves the original in place,
251
+ * never symlinks it, never sets a default, never creates a shim. So an isolated copy
252
+ * can start from the setup the user already has instead of from nothing, which was
253
+ * the only way to get a working sandbox before.
254
+ *
255
+ * Credentials are skipped by default and reported, not silently included. An isolated
256
+ * copy is a separate principal — `agents add --isolated` already tells the user to
257
+ * sign in on first run — and copying tokens into it should be a choice, not a side
258
+ * effect of wanting your settings. `--with-auth` opts in.
259
+ */
260
+ export function seedIsolatedConfigFromLocal(agentId, version, opts = {}) {
261
+ const agent = AGENTS[agentId];
262
+ const configDir = agent.configDir;
263
+ const versionHome = path.join(getVersionsDir(), agentId, version, 'home');
264
+ // Mirror importAgentConfig's derivation so nested config dirs (e.g. Antigravity's
265
+ // ~/.gemini/antigravity-cli) land where the shim expects them.
266
+ const dest = path.join(versionHome, path.relative(os.homedir(), configDir));
267
+ const result = { seeded: false, from: configDir, to: dest, skippedAuth: [] };
268
+ if (!fs.existsSync(configDir))
269
+ return result;
270
+ // Known credential paths, relative to the config dir. `authFiles` covers the agents
271
+ // that declare them for cross-version carry; the rest are verified filenames for
272
+ // agents that do not declare any (codex `auth.json`, claude `.credentials.json`).
273
+ const authRel = new Set([
274
+ ...(agent.authFiles ?? []),
275
+ 'auth.json',
276
+ '.credentials.json',
277
+ 'credentials.json',
278
+ ]);
279
+ try {
280
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
281
+ const agentsDir = getUserAgentsDir();
282
+ const inside = agentsDir + path.sep;
283
+ fs.cpSync(configDir, dest, {
284
+ recursive: true,
285
+ filter: (src) => {
286
+ const rel = path.relative(configDir, src);
287
+ if (!opts.withAuth && rel && (authRel.has(rel) || rel.startsWith('credentials' + path.sep))) {
288
+ result.skippedAuth.push(rel);
289
+ return false;
290
+ }
291
+ // Same rule as the export path: a link into ~/.agents would dangle for a copy
292
+ // that is supposed to stand on its own.
293
+ try {
294
+ const st = fs.lstatSync(src);
295
+ if (st.isSymbolicLink()) {
296
+ const tgt = path.resolve(path.dirname(src), fs.readlinkSync(src));
297
+ if (tgt === agentsDir || tgt.startsWith(inside))
298
+ return false;
299
+ }
300
+ }
301
+ catch { /* let cpSync surface unreadable entries */ }
302
+ return true;
303
+ },
304
+ });
305
+ result.seeded = true;
306
+ }
307
+ catch (err) {
308
+ return { ...result, error: err.message };
309
+ }
310
+ return result;
311
+ }
@@ -0,0 +1,10 @@
1
+ import { IsolationBoundaryError } from './shims.js';
2
+ /**
3
+ * Turn an {@link IsolationBoundaryError} into guidance. The boundary is enforced by a
4
+ * throw so it cannot be forgotten; this is what keeps that from surfacing as a stack
5
+ * trace. The remedy is always the same shape, because the protection is derived from
6
+ * the isolated copies themselves: drop them and the agent is ordinary again.
7
+ */
8
+ export declare function explainIsolationBoundary(err: IsolationBoundaryError): void;
9
+ /** Run `fn`, converting a boundary refusal into guidance + a non-zero exit. */
10
+ export declare function withIsolationBoundary<T>(fn: () => Promise<T> | T): Promise<T | undefined>;
@@ -0,0 +1,35 @@
1
+ import chalk from 'chalk';
2
+ import { IsolationBoundaryError } from './shims.js';
3
+ import { listInstalledVersions } from './versions.js';
4
+ /**
5
+ * Turn an {@link IsolationBoundaryError} into guidance. The boundary is enforced by a
6
+ * throw so it cannot be forgotten; this is what keeps that from surfacing as a stack
7
+ * trace. The remedy is always the same shape, because the protection is derived from
8
+ * the isolated copies themselves: drop them and the agent is ordinary again.
9
+ */
10
+ export function explainIsolationBoundary(err) {
11
+ const versions = listInstalledVersions(err.agent);
12
+ console.error(chalk.red(`\n${err.agent} is installed only as isolated copies.`));
13
+ console.error(chalk.gray(` Refused: ${err.operation} — that is exactly what --isolated promises not to do.`));
14
+ console.error(chalk.gray('\n To keep the sandbox and act inside it:'));
15
+ console.error(chalk.gray(` agents add ${err.agent}@<version> --isolated # another isolated copy`));
16
+ console.error(chalk.gray(` agents use ${err.agent}@<version> # pick which one 'agents run ${err.agent}' uses`));
17
+ console.error(chalk.gray(` agents export ${err.agent} # copy its config out to your real config dir`));
18
+ console.error(chalk.gray('\n To manage this agent normally instead, remove the isolated copies first:'));
19
+ for (const v of versions) {
20
+ console.error(chalk.gray(` agents remove ${err.agent}@${v} --isolated`));
21
+ }
22
+ }
23
+ /** Run `fn`, converting a boundary refusal into guidance + a non-zero exit. */
24
+ export async function withIsolationBoundary(fn) {
25
+ try {
26
+ return await fn();
27
+ }
28
+ catch (err) {
29
+ if (err instanceof IsolationBoundaryError) {
30
+ explainIsolationBoundary(err);
31
+ process.exit(1);
32
+ }
33
+ throw err;
34
+ }
35
+ }
@@ -41,7 +41,7 @@ function loadProjectManifest(agentRoot) {
41
41
  return null;
42
42
  if (!raw.paths.every((p) => typeof p === 'string' && p.length > 0))
43
43
  return null;
44
- return raw;
44
+ return { ...raw, paths: raw.paths.map(toPosixRel) };
45
45
  }
46
46
  catch {
47
47
  return null;
@@ -106,10 +106,22 @@ function projectEntries(projectAgentsDir, kind) {
106
106
  return [];
107
107
  }
108
108
  }
109
+ /**
110
+ * Manifest paths are persisted to `.agents-managed.json`, which lives in the
111
+ * version-controlled project dir and therefore travels between machines. Store
112
+ * them POSIX-style: `path.join` yields `skills\myskill` on Windows, and a
113
+ * manifest carrying that would silently fail to match — and so fail to clean up
114
+ * its managed files — when the same project is synced on macOS or Linux.
115
+ * Normalizing on both write and read also repairs manifests written by earlier
116
+ * Windows builds.
117
+ */
118
+ function toPosixRel(rel) {
119
+ return rel.replace(/\\/g, '/');
120
+ }
109
121
  function record(kind, name, relPaths, result, manifestPaths) {
110
122
  result.synced.push(`${kind}/${name}`);
111
123
  for (const rel of relPaths)
112
- manifestPaths.add(rel);
124
+ manifestPaths.add(toPosixRel(rel));
113
125
  }
114
126
  function skip(dest, projectRoot, result) {
115
127
  const rel = path.relative(projectRoot, dest);
@@ -209,8 +221,8 @@ function syncProjectSubagents(agent, version, projectAgentsDir, projectRoot, age
209
221
  return;
210
222
  const all = readProjectSubagents(projectAgentsDir);
211
223
  const dir = target.dir(projectRoot);
212
- const targetDirRel = path.relative(agentRoot, dir);
213
- const hadManagedTarget = manifest?.paths.some((rel) => rel === targetDirRel || rel.startsWith(targetDirRel + path.sep)) ?? false;
224
+ const targetDirRel = toPosixRel(path.relative(agentRoot, dir));
225
+ const hadManagedTarget = manifest?.paths.some((rel) => rel === targetDirRel || rel.startsWith(targetDirRel + '/')) ?? false;
214
226
  for (const sub of all.values()) {
215
227
  const occupied = target.occupied(dir, sub.name);
216
228
  const existing = occupied.find((entry) => pathExists(entry.path));
@@ -234,7 +246,10 @@ function syncProjectSubagents(agent, version, projectAgentsDir, projectRoot, age
234
246
  if (target.finalize && (syncedNames.length > 0 || hadManagedTarget)) {
235
247
  target.finalize(dir, syncedNames);
236
248
  for (const entry of target.finalizeOccupied?.(dir) ?? []) {
237
- manifestPaths.add(path.relative(agentRoot, entry.path));
249
+ // Same normalization as record(): this is the one manifest write that
250
+ // doesn't funnel through it (the parent subagent index, e.g. Kimi's
251
+ // agents/_agents-cli.yaml).
252
+ manifestPaths.add(toPosixRel(path.relative(agentRoot, entry.path)));
238
253
  }
239
254
  }
240
255
  }
@@ -508,6 +508,32 @@ export declare function addShimsToPath(overrides?: {
508
508
  shimsDir?: string;
509
509
  }): ShimPathResult;
510
510
  export declare function listAgentsWithInstalledVersions(): AgentId[];
511
+ /**
512
+ * Thrown when an operation would carry an isolated-only agent across the isolation
513
+ * boundary. Callers that can explain the situation catch it and print guidance; the
514
+ * throw is what makes the boundary a property of the code rather than a convention
515
+ * every future call site has to remember.
516
+ */
517
+ export declare class IsolationBoundaryError extends Error {
518
+ readonly agent: AgentId;
519
+ readonly operation: string;
520
+ constructor(agent: AgentId, operation: string);
521
+ }
522
+ /**
523
+ * True when EVERY installed version of `agent` is isolated (and at least one is).
524
+ *
525
+ * This is the switch: installing with `--isolated` is itself the act of opting in,
526
+ * so there is no mode to set and none to forget. It is per-agent, so an isolated
527
+ * codex constrains nothing about claude. And the escape hatch is inherent — remove
528
+ * the isolated copies and the agent is ordinary again, which means the state that
529
+ * grants protection is the same state you delete to drop it.
530
+ *
531
+ * An agent with any NORMAL version is not protected: that install already owns the
532
+ * launcher and the real `~/.<agent>`, so there is no boundary left to defend.
533
+ */
534
+ export declare function isIsolationProtected(agent: AgentId): boolean;
535
+ /** Refuse `operation` when `agent` is isolated-only. */
536
+ export declare function assertIsolationBoundary(agent: AgentId, operation: string): void;
511
537
  export declare function listAgentsWithNonIsolatedInstalledVersions(): AgentId[];
512
538
  /**
513
539
  * Resource diff between two versions. Each field lists resources present in
package/dist/lib/shims.js CHANGED
@@ -661,6 +661,9 @@ export function shimTargetsFor(platform) {
661
661
  * Create a shim for an agent.
662
662
  */
663
663
  export function createShim(agent) {
664
+ // A bare shim puts agents-cli first on PATH for this agent — the opposite of what
665
+ // an isolated-only install promises.
666
+ assertIsolationBoundary(agent, 'create the bare shim');
664
667
  ensureAgentsDir();
665
668
  const shimsDir = getShimsDir();
666
669
  const agentConfig = AGENTS[agent];
@@ -1322,6 +1325,8 @@ export function carryForwardAuthFiles(agent, toConfigDir) {
1322
1325
  }
1323
1326
  }
1324
1327
  export async function switchConfigSymlink(agent, version) {
1328
+ // Moves the user's real ~/.<agent> aside and symlinks it into a version home.
1329
+ assertIsolationBoundary(agent, 'repoint your real config directory');
1325
1330
  const configPath = getAgentConfigPath(agent);
1326
1331
  const versionConfigPath = getVersionConfigPath(agent, version);
1327
1332
  // Ensure version config directory exists
@@ -1426,6 +1431,8 @@ export async function switchConfigSymlink(agent, version) {
1426
1431
  * ALL installed versions so they inherit the current account.
1427
1432
  */
1428
1433
  export function switchHomeFileSymlinks(agent, version) {
1434
+ // Same, for home-level files such as ~/.claude.json.
1435
+ assertIsolationBoundary(agent, 'repoint your home-level config files');
1429
1436
  const agentConfig = AGENTS[agent];
1430
1437
  const homeFiles = agentConfig.homeFiles;
1431
1438
  if (!homeFiles || homeFiles.length === 0)
@@ -2076,6 +2083,9 @@ function canonicalOrNull(p) {
2076
2083
  * - Never records our own shim as the "original" (would loop).
2077
2084
  */
2078
2085
  export function adoptShadowingLauncher(agent, overrides) {
2086
+ // Repoints the user's OWN launcher symlink at our shim — the most invasive thing
2087
+ // in the codebase, and the one with no isolated-scoped equivalent at all.
2088
+ assertIsolationBoundary(agent, 'adopt your launcher');
2079
2089
  const shimsDir = overrides?.shimsDir ?? getShimsDir();
2080
2090
  const shimPath = path.join(shimsDir, AGENTS[agent].cliCommand);
2081
2091
  const shimReal = canonical(shimPath);
@@ -2431,6 +2441,64 @@ export function listAgentsWithInstalledVersions() {
2431
2441
  function isInstalledVersionIsolated(agent, version) {
2432
2442
  return fs.existsSync(path.join(getVersionsDir(), agent, version, '.isolated'));
2433
2443
  }
2444
+ /**
2445
+ * Thrown when an operation would carry an isolated-only agent across the isolation
2446
+ * boundary. Callers that can explain the situation catch it and print guidance; the
2447
+ * throw is what makes the boundary a property of the code rather than a convention
2448
+ * every future call site has to remember.
2449
+ */
2450
+ export class IsolationBoundaryError extends Error {
2451
+ agent;
2452
+ operation;
2453
+ constructor(agent, operation) {
2454
+ super(`${agent} is installed only as isolated copies; "${operation}" would adopt it into your local setup.`);
2455
+ this.agent = agent;
2456
+ this.operation = operation;
2457
+ this.name = 'IsolationBoundaryError';
2458
+ }
2459
+ }
2460
+ /**
2461
+ * True when EVERY installed version of `agent` is isolated (and at least one is).
2462
+ *
2463
+ * This is the switch: installing with `--isolated` is itself the act of opting in,
2464
+ * so there is no mode to set and none to forget. It is per-agent, so an isolated
2465
+ * codex constrains nothing about claude. And the escape hatch is inherent — remove
2466
+ * the isolated copies and the agent is ordinary again, which means the state that
2467
+ * grants protection is the same state you delete to drop it.
2468
+ *
2469
+ * An agent with any NORMAL version is not protected: that install already owns the
2470
+ * launcher and the real `~/.<agent>`, so there is no boundary left to defend.
2471
+ */
2472
+ export function isIsolationProtected(agent) {
2473
+ const agentVersionsDir = path.join(getVersionsDir(), agent);
2474
+ let dirs;
2475
+ try {
2476
+ dirs = fs.readdirSync(agentVersionsDir, { withFileTypes: true })
2477
+ .filter((e) => e.isDirectory())
2478
+ .map((e) => e.name);
2479
+ }
2480
+ catch {
2481
+ return false;
2482
+ }
2483
+ // Count only directories that are actually an install. A bare version dir is
2484
+ // scaffolding, not a non-isolated version — and treating it as one would disable
2485
+ // protection at exactly the wrong moment: an adopting path that does
2486
+ // `mkdirSync(<version>/home)` before adopting would flip this to false with its own
2487
+ // first line and then walk straight through the gates. Ignoring scaffolding biases
2488
+ // the predicate toward protecting, which is the safe direction to fail in.
2489
+ const installed = dirs.filter((v) => {
2490
+ const dir = path.join(agentVersionsDir, v);
2491
+ return fs.existsSync(path.join(dir, 'node_modules')) || fs.existsSync(path.join(dir, 'package.json'));
2492
+ });
2493
+ if (installed.length === 0)
2494
+ return false;
2495
+ return installed.every((v) => isInstalledVersionIsolated(agent, v));
2496
+ }
2497
+ /** Refuse `operation` when `agent` is isolated-only. */
2498
+ export function assertIsolationBoundary(agent, operation) {
2499
+ if (isIsolationProtected(agent))
2500
+ throw new IsolationBoundaryError(agent, operation);
2501
+ }
2434
2502
  export function listAgentsWithNonIsolatedInstalledVersions() {
2435
2503
  const versionsDir = getVersionsDir();
2436
2504
  if (!fs.existsSync(versionsDir)) {
@@ -41,6 +41,7 @@ export declare const loadWorkflows: ModuleLoader;
41
41
  export declare const loadWorktree: ModuleLoader;
42
42
  export declare const loadVersions: ModuleLoader;
43
43
  export declare const loadImport: ModuleLoader;
44
+ export declare const loadExport: ModuleLoader;
44
45
  export declare const loadPackages: ModuleLoader;
45
46
  export declare const loadRoutines: ModuleLoader;
46
47
  export declare const loadMonitors: ModuleLoader;
@@ -19,6 +19,7 @@ export const loadWorkflows = async () => (await import('../../commands/workflows
19
19
  export const loadWorktree = async () => (await import('../../commands/worktree.js')).registerWorktreeCommands;
20
20
  export const loadVersions = async () => (await import('../../commands/versions.js')).registerVersionsCommands;
21
21
  export const loadImport = async () => (await import('../../commands/import.js')).registerImportCommand;
22
+ export const loadExport = async () => (await import('../../commands/export.js')).registerExportCommand;
22
23
  export const loadPackages = async () => (await import('../../commands/packages.js')).registerPackagesCommands;
23
24
  export const loadRoutines = async () => (await import('../../commands/routines.js')).registerRoutinesCommands;
24
25
  export const loadMonitors = async () => (await import('../../commands/monitors.js')).registerMonitorsCommands;
@@ -127,6 +128,7 @@ export const COMMAND_LOADERS = {
127
128
  purge: [loadVersions],
128
129
  prune: [loadVersions, loadPrune],
129
130
  import: [loadImport],
131
+ export: [loadExport],
130
132
  registry: [loadPackages],
131
133
  search: [loadPackages],
132
134
  install: [loadPackages],