@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,101 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.74
4
+
5
+ - **Project resource manifests are now portable across Windows and POSIX.** The
6
+ managed-resource manifest `.agents-managed.json` recorded its paths with the
7
+ host's native separator, so a sync run on Windows wrote entries like
8
+ `skills\myskill`. That file lives in the version-controlled project `.agents`
9
+ dir and travels between machines, and the cleanup pass matches manifest entries
10
+ with `path.sep` — so a manifest written on Windows silently failed to match on
11
+ macOS or Linux and left previously managed files behind on the next sync (and
12
+ vice versa). Manifest paths are now normalized to POSIX separators on write and
13
+ on read, which also repairs manifests written by earlier Windows builds. Source:
14
+ `apps/cli/src/lib/project-resources.ts`.
15
+
16
+ - **`agents import <agent> --isolated` — bring your existing setup into a sandbox.**
17
+ Isolation was a cold start: a new isolated copy began empty, and the only way to get
18
+ settings into it was by hand. A plain `agents import` is the opposite of what is
19
+ wanted here — it *adopts*, moving `~/.<agent>` into a version home, symlinking the
20
+ original away, setting the global default and creating a shim (and is now refused
21
+ outright for an isolated-only agent). `--isolated` copies instead: your settings land
22
+ in the isolated home, your real config stays exactly where it is, and the version is
23
+ finalized the way `agents add --isolated` does — versioned alias and marker, no
24
+ default, no bare shim, no config symlink. Credentials are skipped by default and
25
+ named in the output rather than silently included, since an isolated copy signs in as
26
+ its own principal; `--with-auth` opts in. Symlinks into `~/.agents` are dropped so the
27
+ copy does not depend on the CLI's tree. Source: `apps/cli/src/lib/import.ts`,
28
+ `apps/cli/src/commands/import.ts`.
29
+
30
+ - **`agents use <agent>@<isolated>` now works, and a bare `agents run <agent>` reaches
31
+ your isolated copy.** Isolated installs were unreachable by name: `resolveVersion`
32
+ ended at the global default, and an isolated install deliberately never becomes one —
33
+ so `agents use` refused, and an isolated-only user had to type the full
34
+ `agents run codex@0.144.6` every time while a bare `agents run codex` fell through to
35
+ whatever `codex` meant on PATH. `use` now records an **isolated default** instead of
36
+ refusing, and resolution falls back to it (`project pin -> global default -> isolated
37
+ default`). Strictly a fallback, so nothing changes for anyone who has a global
38
+ default. The pointer lives in `isolatedAgents:` in `agents.yaml`, never in the global
39
+ `agents:` map — that separation is what keeps `getGlobalDefault` incapable of
40
+ returning an isolated version, and with it the launcher, bare shim, config symlink and
41
+ self-heal `shadowing` check all stay out of reach. It is verified on read and
42
+ re-pointed (or cleared) on removal, so it can never resolve to a version that is gone.
43
+ `agents view` labels it `(isolated default)`. Source: `apps/cli/src/lib/versions.ts`,
44
+ `apps/cli/src/commands/versions.ts`, `apps/cli/src/commands/view.ts`.
45
+
46
+ - **`agents export <agent>[@<version>]` — take an isolated install's config with you.**
47
+ `--isolated` was a one-way door: it builds a self-contained home under the version
48
+ dir and nothing ever brings that work back, so a user who configured a sandboxed copy
49
+ for a week had to copy files by hand to promote it — or to leave. Export is additive
50
+ by default: it copies only paths you don't already have, and a collision is **not**
51
+ silently skipped — the incoming file is written beside yours as
52
+ `<name>.from-agents-cli` so you can `--diff` it and take the parts you want. Your
53
+ files are never modified. `--replace` promotes a sandbox wholesale (yours is moved to
54
+ `backups/<agent>/<ts>`, and it is the only mode that asks for confirmation);
55
+ `--staged` dumps the tree into `~/.<agent>/.agents-export-<ts>/` and activates
56
+ nothing. Every mode strips symlinks pointing back into `~/.agents` so the result
57
+ keeps working after agents-cli is gone, keeps your own symlinks, and writes a receipt
58
+ to `~/.<agent>/.agents-cli-export.json` recording exactly what came from the export —
59
+ which makes "which of these files are mine?" answerable and the whole thing
60
+ reversible. A `~/.<agent>` that agents-cli already adopted is refused, since writing
61
+ there would mutate that version's home rather than your config. File *contents* are
62
+ never auto-merged: the TOML parser here drops comments across parse+stringify, so
63
+ unioning keys would silently delete them. Source: `apps/cli/src/lib/export.ts`,
64
+ `apps/cli/src/commands/export.ts`, `apps/cli/src/lib/config-transfer.ts`.
65
+
66
+ - **An isolated-only agent can no longer be adopted by anything.** `--isolated` used to
67
+ be defined by what it *doesn't* do — no global default, no bare shim, no config
68
+ symlink, no PATH edit — which meant every code path that could adopt an agent had to
69
+ remember to check first. It leaked three times that way. Protection is now derived
70
+ from the `.isolated` markers on disk (`isIsolationProtected`: at least one installed
71
+ version, and every one isolated) and enforced inside the five primitives that can
72
+ cross the boundary — `setGlobalDefault`, `createShim`, `switchConfigSymlink`,
73
+ `switchHomeFileSymlinks`, `adoptShadowingLauncher` — so refusal is a property of the
74
+ code rather than a convention. There is no mode to set and none to forget: installing
75
+ with `--isolated` *is* the opt-in, it is per-agent, and the escape hatch is inherent
76
+ (remove the isolated copies and the agent is ordinary again). `agents add`,
77
+ `agents import` and `doctor --adopt` refuse with guidance rather than a stack trace —
78
+ `import` is additionally checked at its entry point, because it registers the adopted
79
+ install as a normal version *before* adopting, which would otherwise un-protect the
80
+ agent underneath the primitive gate. Clearing a global default stays allowed, since
81
+ removal legitimately clears one as an agent becomes isolated-only. A completeness test
82
+ pins the primitive list and scans for any new ungated mutator. Source:
83
+ `apps/cli/src/lib/shims.ts`, `apps/cli/src/lib/versions.ts`,
84
+ `apps/cli/src/lib/isolation-boundary-report.ts`.
85
+
86
+ - **`agents view` no longer hides your own CLI behind an isolated install.** The listing
87
+ was either/or per agent: any managed version at all suppressed the "Not Managed by
88
+ Agents CLI" block, so a single `agents add <agent>@<v> --isolated` made the user's
89
+ globally-installed CLI disappear from the one command they'd run to confirm
90
+ `--isolated` had left it alone. Nothing on disk was ever touched — the isolation
91
+ boundary holds — but the report read exactly like the damage it was supposed to rule
92
+ out. Isolated copies now render alongside the global install and are tagged
93
+ `9.9.4 (isolated)`; a normal (non-isolated) version still takes the launcher over and
94
+ still suppresses the global row, since that row would just be our own shim. The global
95
+ row is also resolved from PATH now (`getUnmanagedCliState`) instead of from the version
96
+ dirs, which could otherwise report an isolated copy — deliberately unreachable from
97
+ PATH — as `(global)`. Source: `apps/cli/src/commands/view.ts`, `apps/cli/src/lib/agents.ts`.
98
+
3
99
  ## 1.20.73
4
100
 
5
101
  - **`agents cli install <binary-cli>` no longer hardcodes `/usr/local/bin` (#1103).**
package/dist/bin/agents CHANGED
Binary file
@@ -1,3 +1,5 @@
1
+ import { IsolationBoundaryError } from '../lib/shims.js';
2
+ import { explainIsolationBoundary } from '../lib/isolation-boundary-report.js';
1
3
  import { addHostOption } from '../lib/hosts/option.js';
2
4
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
3
5
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
@@ -690,7 +692,17 @@ export function registerDoctorCommand(program) {
690
692
  // adoptShadowingLauncher resolves the launcher itself (PATH shadow, then
691
693
  // the durable ~/.local/bin symlink), so it forces the take-over even when
692
694
  // this shell's PATH already has the shim first.
693
- const result = adoptShadowingLauncher(agent);
695
+ let result;
696
+ try {
697
+ result = adoptShadowingLauncher(agent);
698
+ }
699
+ catch (err) {
700
+ if (err instanceof IsolationBoundaryError) {
701
+ explainIsolationBoundary(err);
702
+ process.exit(1);
703
+ }
704
+ throw err;
705
+ }
694
706
  if (result.adopted) {
695
707
  console.log(chalk.green(`Adopted ${AGENTS[agent].cliCommand} launcher (${result.launcher} -> shim). Original recorded for --release; version management now wins regardless of PATH order.`));
696
708
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `agents export <agent>[@<version>]` — copy an isolated install's config out to the
3
+ * user's real `~/.<agent>`.
4
+ *
5
+ * The exit door for `--isolated`. Default is additive: your files are never modified,
6
+ * and anything that collides is written beside yours so you can diff and take what you
7
+ * want. A receipt records what came from the export, so "which of these are mine?" has
8
+ * an answer and the whole thing can be undone.
9
+ */
10
+ import type { Command } from 'commander';
11
+ export declare function registerExportCommand(program: Command): void;
@@ -0,0 +1,215 @@
1
+ import chalk from 'chalk';
2
+ import { execFileSync } from 'child_process';
3
+ import { confirm, select } from '@inquirer/prompts';
4
+ import { AGENTS, agentLabel, formatAgentError, resolveAgentName } from '../lib/agents.js';
5
+ import { listInstalledVersions, isVersionIsolated } from '../lib/versions.js';
6
+ import { planExport, executeExport, CONFLICT_SUFFIX } from '../lib/export.js';
7
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
8
+ function isolatedVersions(agent) {
9
+ return listInstalledVersions(agent).filter((v) => isVersionIsolated(agent, v));
10
+ }
11
+ /** Explain a blocker in terms of what the user should do instead. */
12
+ function reportBlocker(plan) {
13
+ const label = agentLabel(plan.agent);
14
+ const b = plan.blocker;
15
+ switch (b.kind) {
16
+ case 'not-installed':
17
+ console.log(chalk.red(`${label}@${plan.version} is not installed.`));
18
+ console.log(chalk.gray(` Installed: ${listInstalledVersions(plan.agent).join(', ') || 'none'}`));
19
+ break;
20
+ case 'not-isolated':
21
+ console.log(chalk.yellow(`${label}@${plan.version} is not an isolated install.`));
22
+ console.log(chalk.gray(` A normal install's config already IS ${plan.dest} (adoption symlinks it),`));
23
+ console.log(chalk.gray(' so there is nothing to export. To un-adopt it, use: agents uninstall'));
24
+ break;
25
+ case 'no-config':
26
+ console.log(chalk.yellow(`${label}@${plan.version} has no config to export yet (${b.source} is empty).`));
27
+ console.log(chalk.gray(` Run it once first: agents run ${plan.agent}@${plan.version}`));
28
+ break;
29
+ case 'dest-adopted':
30
+ console.log(chalk.red(`${b.realPath} is managed by agents-cli (adopted by ${label}@${b.adoptedVersion}).`));
31
+ console.log(chalk.gray(' Exporting would write into that version\'s home, not your real config.'));
32
+ console.log(chalk.gray(` Release it first: agents uninstall (or agents remove ${plan.agent}@${b.adoptedVersion})`));
33
+ break;
34
+ }
35
+ }
36
+ /** Unified diff between the user's file and the incoming one, via git. */
37
+ function printDiff(existing, incoming) {
38
+ try {
39
+ execFileSync('git', ['diff', '--no-index', '--color', '--', existing, incoming], {
40
+ stdio: ['ignore', 'inherit', 'ignore'],
41
+ });
42
+ }
43
+ catch {
44
+ // git diff --no-index exits 1 when files differ; output already streamed.
45
+ }
46
+ }
47
+ function printPlan(plan, opts) {
48
+ const label = agentLabel(plan.agent);
49
+ console.log(chalk.bold(`Export ${label}@${plan.version} -> ${plan.dest} ${chalk.gray(`[${plan.mode}]`)}\n`));
50
+ console.log(` ${chalk.gray('from')} ${plan.source}`);
51
+ console.log(` ${chalk.gray('to')} ${plan.stagedPath ?? plan.dest}`);
52
+ console.log();
53
+ if (plan.mode === 'replace') {
54
+ console.log(chalk.yellow(` Replacing ${plan.dest} wholesale — ${plan.writes.length} file(s).`));
55
+ if (plan.backupPath)
56
+ console.log(chalk.gray(` Your current config moves to: ${plan.backupPath}`));
57
+ else if (plan.destKind === 'foreign-symlink') {
58
+ console.log(chalk.yellow(` ${plan.dest} is a symlink agents-cli does not own; it will be removed.`));
59
+ console.log(chalk.gray(' Its target is left untouched.'));
60
+ }
61
+ }
62
+ else if (plan.mode === 'staged') {
63
+ console.log(chalk.gray(` Writing ${plan.writes.length} file(s) into ${plan.stagedPath}.`));
64
+ console.log(chalk.gray(' Nothing is activated — your config is untouched.'));
65
+ }
66
+ else {
67
+ if (plan.writes.length > 0) {
68
+ console.log(chalk.green(` ${plan.writes.length} new file(s) will be added:`));
69
+ for (const w of plan.writes.slice(0, 15))
70
+ console.log(chalk.gray(` + ${w.rel}`));
71
+ if (plan.writes.length > 15)
72
+ console.log(chalk.gray(` + ${plan.writes.length - 15} more`));
73
+ }
74
+ else {
75
+ console.log(chalk.gray(' No new files to add.'));
76
+ }
77
+ if (plan.conflicts.length > 0) {
78
+ console.log();
79
+ console.log(chalk.yellow(` ${plan.conflicts.length} file(s) you already have — YOURS ARE NOT MODIFIED.`));
80
+ console.log(chalk.gray(` The incoming version is written beside each as *${CONFLICT_SUFFIX}:`));
81
+ for (const c of plan.conflicts.slice(0, 15))
82
+ console.log(chalk.gray(` ~ ${c.rel}`));
83
+ if (plan.conflicts.length > 15)
84
+ console.log(chalk.gray(` ~ ${plan.conflicts.length - 15} more`));
85
+ }
86
+ }
87
+ if (opts.diff && plan.conflicts.length > 0) {
88
+ for (const c of plan.conflicts) {
89
+ console.log(chalk.bold(`\n─── ${c.rel} ───`));
90
+ printDiff(c.existing, c.source);
91
+ }
92
+ }
93
+ console.log(chalk.gray(`\n Receipt: ${plan.receiptPath}`));
94
+ }
95
+ export function registerExportCommand(program) {
96
+ program
97
+ .command('export <spec>')
98
+ .description('Copy an isolated install\'s config out to your real ~/.<agent>')
99
+ .option('--replace', 'Replace your config wholesale (yours is backed up) instead of merging')
100
+ .option('--staged', 'Write into ~/.<agent>/.agents-export-<ts>/ and activate nothing')
101
+ .option('--diff', 'Show a unified diff for each file you already have')
102
+ .option('--dry-run', 'Show what would change without writing anything')
103
+ .option('-y, --yes', 'Skip the confirmation prompt')
104
+ .addHelpText('after', `
105
+ Modes:
106
+ (default) merge — additive. Your files are NEVER modified. Anything that
107
+ collides is written beside yours as <name>${CONFLICT_SUFFIX}
108
+ so you can diff and take the parts you want.
109
+ --replace — the isolated config becomes ~/.<agent>; yours is moved to
110
+ backups/<agent>/<ts>. For promoting a sandbox wholesale.
111
+ --staged — dump the tree into ~/.<agent>/.agents-export-<ts>/ and
112
+ activate nothing. For inspecting first.
113
+
114
+ Every mode writes a receipt to ~/.<agent>/.agents-cli-export.json listing exactly
115
+ what came from the export — so you can tell your settings from the CLI's, and undo it.
116
+
117
+ Examples:
118
+ agents export codex --dry-run # see the plan
119
+ agents export codex --diff # ...and the deltas on colliding files
120
+ agents export codex # additive; nothing of yours changes
121
+ agents export codex@0.144.6 --replace # promote the sandbox wholesale
122
+
123
+ Note: file CONTENTS are never auto-merged. The TOML parser here does not preserve
124
+ comments, so merging keys would silently delete them. You get both files and a diff.
125
+ `)
126
+ .action(async (spec, options) => {
127
+ if (options.replace && options.staged) {
128
+ console.error(chalk.red('--replace and --staged are mutually exclusive; pass only one.'));
129
+ process.exit(1);
130
+ }
131
+ const mode = options.replace ? 'replace' : options.staged ? 'staged' : 'merge';
132
+ const [rawAgent, rawVersion] = spec.split('@');
133
+ const agent = resolveAgentName(rawAgent);
134
+ if (!agent) {
135
+ console.error(chalk.red(formatAgentError(rawAgent)));
136
+ process.exit(1);
137
+ }
138
+ let version = rawVersion;
139
+ if (!version) {
140
+ const candidates = isolatedVersions(agent);
141
+ if (candidates.length === 0) {
142
+ console.log(chalk.yellow(`No isolated ${agentLabel(agent)} installs to export.`));
143
+ console.log(chalk.gray(` Create one with: agents add ${agent}@<version> --isolated`));
144
+ return;
145
+ }
146
+ if (candidates.length === 1) {
147
+ version = candidates[0];
148
+ }
149
+ else if (isInteractiveTerminal()) {
150
+ version = await select({
151
+ message: `Which isolated ${agentLabel(agent)} version?`,
152
+ choices: candidates.map((v) => ({ name: v, value: v })),
153
+ }).catch((err) => {
154
+ if (isPromptCancelled(err))
155
+ process.exit(130);
156
+ throw err;
157
+ });
158
+ }
159
+ else {
160
+ console.error(chalk.red(`${agentLabel(agent)} has several isolated installs; name one.`));
161
+ console.error(chalk.gray(` ${candidates.map((v) => `agents export ${agent}@${v}`).join('\n ')}`));
162
+ process.exit(1);
163
+ }
164
+ }
165
+ const timestamp = Date.now();
166
+ const plan = planExport(agent, version, timestamp, mode);
167
+ if (plan.blocker) {
168
+ reportBlocker(plan);
169
+ process.exit(1);
170
+ }
171
+ printPlan(plan, { diff: options.diff });
172
+ if (options.dryRun) {
173
+ console.log(chalk.gray('\nDry run — nothing was written.'));
174
+ return;
175
+ }
176
+ // Only `--replace` can destroy anything, so it is the only mode that demands
177
+ // confirmation. merge and staged are additive by construction.
178
+ if (mode === 'replace' && !options.yes) {
179
+ if (!isInteractiveTerminal()) {
180
+ console.error(chalk.red('\nRefusing to replace your config without confirmation in a non-interactive shell.'));
181
+ console.error(chalk.gray(' Re-run with --yes once you have checked --dry-run.'));
182
+ process.exit(1);
183
+ }
184
+ const ok = await confirm({ message: `Replace ${plan.dest}?`, default: false }).catch((err) => {
185
+ if (isPromptCancelled(err))
186
+ process.exit(130);
187
+ throw err;
188
+ });
189
+ if (!ok) {
190
+ console.log(chalk.gray('Aborted.'));
191
+ return;
192
+ }
193
+ }
194
+ const result = executeExport(plan, timestamp);
195
+ if (!result.exported) {
196
+ console.error(chalk.red(`Export failed: ${result.errors.join('; ')}`));
197
+ process.exit(1);
198
+ }
199
+ console.log();
200
+ if (result.written.length > 0)
201
+ console.log(chalk.green(`Added ${result.written.length} file(s) to ${result.dest}`));
202
+ if (result.conflicts.length > 0) {
203
+ console.log(chalk.yellow(`${result.conflicts.length} of your file(s) were left untouched; incoming copies written as *${CONFLICT_SUFFIX}`));
204
+ console.log(chalk.gray(` Compare them with: agents export ${agent}@${version} --diff --dry-run`));
205
+ }
206
+ if (result.backupPath)
207
+ console.log(chalk.gray(`Previous config kept at: ${result.backupPath}`));
208
+ if (result.stagedPath)
209
+ console.log(chalk.gray(`Staged (nothing activated): ${result.stagedPath}`));
210
+ console.log(chalk.gray(`Receipt: ${result.receiptPath}`));
211
+ if (mode !== 'staged') {
212
+ console.log(chalk.gray(` ${AGENTS[plan.agent].cliCommand} reads this config directly.`));
213
+ }
214
+ });
215
+ }
@@ -20,6 +20,9 @@
20
20
  * In all forms, the agent's config dir (e.g. ~/.openclaw) is also moved under
21
21
  * management — same behavior as the first-run `agents setup` import flow.
22
22
  */
23
+ import { withIsolationBoundary } from '../lib/isolation-boundary-report.js';
24
+ import { assertIsolationBoundary, createVersionedAlias } from '../lib/shims.js';
25
+ import { markVersionIsolated } from '../lib/versions.js';
23
26
  import chalk from 'chalk';
24
27
  import ora from 'ora';
25
28
  import * as fs from 'fs';
@@ -29,7 +32,7 @@ import { confirm } from '@inquirer/prompts';
29
32
  import { ALL_AGENT_IDS } from '../lib/agents.js';
30
33
  import { AGENTS, getCliPath, getCliVersion, agentLabel, resolveAgentName } from '../lib/agents.js';
31
34
  import { getVersionDir } from '../lib/versions.js';
32
- import { finalizeImport, importAgentBinary, importAgentConfig, importInstallScriptBinary, isValidImportVersion, resolvePackageDirFromBinary, } from '../lib/import.js';
35
+ import { finalizeImport, importAgentBinary, importAgentConfig, importInstallScriptBinary, seedIsolatedConfigFromLocal, isValidImportVersion, resolvePackageDirFromBinary, } from '../lib/import.js';
33
36
  import { isPromptCancelled, isInteractiveTerminal } from './utils.js';
34
37
  async function runImport(agentArg, opts) {
35
38
  const agentId = resolveAgentName(agentArg);
@@ -39,6 +42,16 @@ async function runImport(agentArg, opts) {
39
42
  process.exit(1);
40
43
  }
41
44
  const agent = AGENTS[agentId];
45
+ // Import registers the adopted install as a NORMAL version and only then sets the
46
+ // default / creates the shim / repoints the config. By that point the agent has a
47
+ // non-isolated version, so it is no longer protected and the primitive gates —
48
+ // which read the state as it is at call time — would let the adoption through.
49
+ // The boundary has to be checked against the state BEFORE the import mutates it.
50
+ // --isolated adopts nothing, so the boundary does not apply to it — it is in fact
51
+ // the supported way to bring a local install in while protection is on.
52
+ if (!opts.isolated) {
53
+ assertIsolationBoundary(agentId, 'adopt your existing install');
54
+ }
42
55
  // installScript-based agents (Grok, Antigravity, Cursor, Kiro, Goose, Roo)
43
56
  // don't have an npm package; their binary lives wherever the curl/brew
44
57
  // installer dropped it. We adopt by symlinking that PATH binary directly
@@ -191,7 +204,27 @@ async function runImport(agentArg, opts) {
191
204
  // user-visible side effect (renaming ~/.<agent>/), so if it fails we don't
192
205
  // want a stranded symlink farm. Binary registration is cheap and reversible
193
206
  // — if it fails after config, the next `agents import` call retries cleanly.
194
- const willImportConfig = configDirExists && !configAlreadyManaged;
207
+ const willImportConfig = configDirExists && !configAlreadyManaged && !opts.isolated;
208
+ if (opts.isolated && configDirExists) {
209
+ // COPY the user's settings in; never move, never symlink. The original stays
210
+ // exactly where it is — that is what separates this from adoption.
211
+ const seedSpinner = ora(`Copying ${agent.configDir} into the isolated copy...`).start();
212
+ const seed = seedIsolatedConfigFromLocal(agentId, version, { withAuth: opts.withAuth });
213
+ if (seed.error) {
214
+ seedSpinner.fail(`Config: ${seed.error}`);
215
+ process.exit(1);
216
+ }
217
+ else if (seed.seeded) {
218
+ seedSpinner.succeed(`Settings copied (${seed.from} -> ${seed.to}); your original is untouched`);
219
+ if (seed.skippedAuth.length > 0) {
220
+ console.log(chalk.gray(` Credentials NOT copied: ${seed.skippedAuth.join(', ')}`));
221
+ console.log(chalk.gray(' The copy signs in separately. Use --with-auth to copy them too.'));
222
+ }
223
+ }
224
+ else {
225
+ seedSpinner.info('No existing config to copy.');
226
+ }
227
+ }
195
228
  if (willImportConfig) {
196
229
  const cfgSpinner = ora(`Importing config dir for ${agentLabel(agentId)} v${version}...`).start();
197
230
  const cfgResult = await importAgentConfig(agentId, version);
@@ -224,6 +257,19 @@ async function runImport(agentArg, opts) {
224
257
  // Wire the imported version into the resolver: global default, main shim,
225
258
  // versioned alias, home-file symlinks. Idempotent — safe to call even if
226
259
  // importAgentConfig already set the global default.
260
+ if (opts.isolated) {
261
+ // The isolated finalizer: launchable alias + marker, and nothing else. No global
262
+ // default, no bare shim, no config symlink — the same shape as
263
+ // `agents add --isolated`.
264
+ createVersionedAlias(agentId, version);
265
+ markVersionIsolated(agentId, version);
266
+ console.log();
267
+ console.log(chalk.green(`${agentLabel(agentId)} v${version} imported as an isolated copy.`));
268
+ console.log(chalk.gray(` Your ${agent.configDir} and ${agent.cliCommand} launcher are untouched.`));
269
+ console.log(chalk.gray(` Run it: agents run ${agentId}@${version}`));
270
+ console.log(chalk.gray(` Or make it the default isolated copy: agents use ${agentId}@${version}`));
271
+ return;
272
+ }
227
273
  const finalizeSpinner = ora(`Wiring ${agentLabel(agentId)} v${version} as the active version...`).start();
228
274
  try {
229
275
  finalizeImport(agentId, version);
@@ -244,6 +290,8 @@ export function registerImportCommand(program) {
244
290
  .description('Import an existing unmanaged agent install into agents-cli')
245
291
  .option('--version <version>', 'Pin a version label (otherwise read from package.json)')
246
292
  .option('--from-path <path>', 'Path to the npm package dir (otherwise auto-detected from PATH)')
293
+ .option('--isolated', 'Copy the install into a self-contained isolated version instead of adopting it')
294
+ .option('--with-auth', 'With --isolated, also copy credentials into the sandbox (skipped by default)')
247
295
  .option('-y, --yes', 'Skip the confirmation prompt')
248
296
  .addHelpText('after', `
249
297
  Examples:
@@ -265,5 +313,5 @@ When to use:
265
313
  npm-style packages (claude, codex, gemini, opencode, openclaw) and
266
314
  installScript-based agents (grok, antigravity, cursor, kiro, goose).
267
315
  `)
268
- .action(runImport);
316
+ .action((...args) => withIsolationBoundary(() => runImport(...args)));
269
317
  }
@@ -16,7 +16,7 @@ import { isGitRepo, cloneIntoExisting, pullRepo } from '../lib/git.js';
16
16
  import { isPromptCancelled, isInteractiveTerminal } from './utils.js';
17
17
  import { AGENTS, agentConfigDirName, getUnmanagedAgentInstalls, countSessionFiles, agentLabel } from '../lib/agents.js';
18
18
  import { setGlobalDefault } from '../lib/versions.js';
19
- import { ensureShimCurrent, switchHomeFileSymlinks, isShimsInPath, addShimsToPath, getPathSetupInstructions } from '../lib/shims.js';
19
+ import { ensureShimCurrent, switchHomeFileSymlinks, isShimsInPath, addShimsToPath, getPathSetupInstructions, assertIsolationBoundary } from '../lib/shims.js';
20
20
  import { setHelpSections } from '../lib/help.js';
21
21
  import { registerSetupBrowserCommand, runBrowserWizard } from './setup-browser.js';
22
22
  import { registerSetupComputerCommand, runComputerWizard } from './setup-computer.js';
@@ -29,6 +29,11 @@ const HOME = os.homedir();
29
29
  */
30
30
  async function importAgent(agentId, version) {
31
31
  const agent = AGENTS[agentId];
32
+ // setup has its own hand-rolled adoption (rename + symlink inline, rather than
33
+ // calling switchConfigSymlink), so the primitive gates never see it. Check the
34
+ // boundary here, before the first mkdirSync — which would otherwise create the
35
+ // scaffolding this very check reads.
36
+ assertIsolationBoundary(agentId, 'adopt your existing install');
32
37
  const configDir = agent.configDir;
33
38
  const versionsDir = getVersionsDir();
34
39
  const versionHome = path.join(versionsDir, agentId, version, 'home');
@@ -8,9 +8,9 @@ import { AGENTS, ALL_AGENT_IDS, accountOrgBadge, getAccountEmail, getAccountInfo
8
8
  import { formatUsageSummary, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
9
9
  import { viewAction } from './view.js';
10
10
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
11
- import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, isOldestInstalled, getGlobalDefault, setGlobalDefault, markVersionIsolated, isVersionIsolated, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/versions.js';
11
+ import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, isOldestInstalled, getGlobalDefault, setGlobalDefault, markVersionIsolated, setIsolatedDefault, isVersionIsolated, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/versions.js';
12
12
  import { carryForwardSettings } from '../lib/settings-manifest.js';
13
- import { createShim, createVersionedAlias, supportsIsolatedInstall, CONFIG_ENV_ISOLATED_AGENTS, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/shims.js';
13
+ import { createShim, createVersionedAlias, supportsIsolatedInstall, isIsolationProtected, CONFIG_ENV_ISOLATED_AGENTS, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/shims.js';
14
14
  import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
15
15
  import { tryAutoPull } from '../lib/git.js';
16
16
  import { getAgentsDir, getTrashVersionsDir } from '../lib/state.js';
@@ -342,6 +342,20 @@ export function registerVersionsCommands(program) {
342
342
  // from the user's real ~/.<agent>. Agents without one isolate only by
343
343
  // adopting ~/.<agent> (which --isolated skips), so an isolated copy
344
344
  // would silently read/write the real config. Refuse rather than lie.
345
+ // A normal install of a currently isolated-only agent would adopt it: set the
346
+ // global default, create the bare shim, and repoint the real ~/.<agent>. The
347
+ // primitives refuse that outright, so catch it here — before spending a
348
+ // network install on something that cannot finish — and say what to do.
349
+ if (!isIsolated && isIsolationProtected(agent)) {
350
+ console.log(chalk.red(`${agentLabel(agentConfig.id)} is installed only as isolated copies.`));
351
+ console.log(chalk.gray(` A normal install would adopt ${agentConfig.configDir} and your ${agentConfig.cliCommand} launcher.`));
352
+ console.log(chalk.gray(` Keep the sandbox: agents add ${agent}@${version ?? 'latest'} --isolated`));
353
+ console.log(chalk.gray(' Or manage it normally by removing the isolated copies first:'));
354
+ for (const v of listInstalledVersions(agent)) {
355
+ console.log(chalk.gray(` agents remove ${agent}@${v} --isolated`));
356
+ }
357
+ continue;
358
+ }
345
359
  if (isIsolated && !supportsIsolatedInstall(agent)) {
346
360
  console.log(chalk.red(`${agentLabel(agentConfig.id)} does not support --isolated installs.`));
347
361
  console.log(chalk.gray(` It has no config-directory env var, so it can only isolate by adopting ${agentConfig.configDir} — which --isolated deliberately avoids.`));
@@ -705,10 +719,27 @@ export function registerVersionsCommands(program) {
705
719
  // Refuse for both the explicit `use <agent>@<isolated>` path (the picker
706
720
  // above already filters isolated versions out of the interactive path).
707
721
  // Isolated copies are launched explicitly via `agents run <agent>@<v>`.
722
+ // ...so `use` is scoped to the sandbox rather than refused. Setting the
723
+ // ISOLATED default records which copy a bare `agents run <agent>` should
724
+ // reach, and touches none of the five adopting side effects above. The
725
+ // pointer lives in `meta.isolatedAgents`, never in `meta.agents`, so
726
+ // `getGlobalDefault` still cannot return an isolated version.
708
727
  if (isVersionIsolated(agentId, finalVersion)) {
709
- console.log(chalk.red(`${agentLabel(agentConfig.id)}@${finalVersion} is an isolated install and can't be set as your active version.`));
710
- console.log(chalk.gray(`Isolated copies stay walled off from your real ${agentConfig.configDir}. Run it directly: agents run ${agentId}@${finalVersion}`));
711
- console.log(chalk.gray(`To install this version normally: agents add ${agentId}@${finalVersion}`));
728
+ if (options.project) {
729
+ console.log(chalk.yellow(`${agentLabel(agentConfig.id)}@${finalVersion} is an isolated install; --project pins are for shared versions.`));
730
+ console.log(chalk.gray(`Run it directly instead: agents run ${agentId}@${finalVersion}`));
731
+ return;
732
+ }
733
+ setIsolatedDefault(agentId, finalVersion);
734
+ console.log(chalk.green(`Set ${agentLabel(agentConfig.id)}@${finalVersion} as your default ISOLATED copy.`));
735
+ console.log(chalk.gray(` agents run ${agentId} now reaches it (no @version needed).`));
736
+ const globalDefault = getGlobalDefault(agentId);
737
+ if (globalDefault) {
738
+ console.log(chalk.gray(` Your default ${agentConfig.cliCommand} is still ${globalDefault}; ${agentConfig.configDir} is untouched.`));
739
+ }
740
+ else {
741
+ console.log(chalk.gray(` ${agentConfig.configDir} and your ${agentConfig.cliCommand} launcher are untouched.`));
742
+ }
712
743
  return;
713
744
  }
714
745
  if (options.project) {