@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
|
@@ -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
|
+
}
|
package/dist/lib/import.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/lib/import.js
CHANGED
|
@@ -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 +
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/shims.d.ts
CHANGED
|
@@ -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],
|