@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 +96 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +13 -1
- package/dist/commands/export.d.ts +11 -0
- package/dist/commands/export.js +215 -0
- package/dist/commands/import.js +51 -3
- package/dist/commands/setup.js +6 -1
- package/dist/commands/versions.js +36 -5
- package/dist/commands/view.js +75 -27
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +35 -0
- package/dist/lib/agents.js +63 -1
- package/dist/lib/config-transfer.d.ts +16 -0
- package/dist/lib/config-transfer.js +56 -0
- package/dist/lib/export.d.ts +72 -0
- package/dist/lib/export.js +269 -0
- package/dist/lib/import.d.ts +23 -0
- package/dist/lib/import.js +74 -2
- package/dist/lib/isolation-boundary-report.d.ts +10 -0
- package/dist/lib/isolation-boundary-report.js +35 -0
- package/dist/lib/project-resources.js +20 -5
- package/dist/lib/shims.d.ts +26 -0
- package/dist/lib/shims.js +68 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.js +14 -4
- package/dist/lib/types.d.ts +11 -0
- package/dist/lib/uninstall.js +1 -45
- package/dist/lib/versions.d.ts +13 -0
- package/dist/lib/versions.js +61 -2
- package/package.json +1 -1
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
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/commands/import.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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) {
|