@phnx-labs/agents-cli 1.17.0 → 1.17.1
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 +6 -0
- package/dist/commands/import.d.ts +24 -0
- package/dist/commands/import.js +203 -0
- package/dist/index.js +3 -0
- package/dist/lib/import.d.ts +91 -0
- package/dist/lib/import.js +179 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.17.1
|
|
4
|
+
|
|
5
|
+
**Agent management**
|
|
6
|
+
|
|
7
|
+
- `agents import <agent>` — adopt an existing global npm/homebrew install into agents-cli management without reinstalling. Supports `--version`, `--from-path`, `--yes`. The imported version is wired in as the global default with shim + versioned alias so it behaves the same as a freshly `agents add`'d install.
|
|
8
|
+
|
|
3
9
|
## 1.17.0
|
|
4
10
|
|
|
5
11
|
**Workflows: a new first-class resource**
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents import` — adopt an existing unmanaged agent install into agents-cli.
|
|
3
|
+
*
|
|
4
|
+
* Three forms:
|
|
5
|
+
*
|
|
6
|
+
* agents import openclaw
|
|
7
|
+
* Auto-detect via the binary on PATH. Resolves the npm package directory,
|
|
8
|
+
* reads its version, and registers it under
|
|
9
|
+
* ~/.agents/.history/versions/<agent>/<version>/.
|
|
10
|
+
*
|
|
11
|
+
* agents import openclaw --version 2026.3.8
|
|
12
|
+
* Same auto-detect, but pin the version label rather than reading it from
|
|
13
|
+
* the package. Useful when the package metadata is stale or you want a
|
|
14
|
+
* canonical name.
|
|
15
|
+
*
|
|
16
|
+
* agents import openclaw --from-path /opt/homebrew/lib/node_modules/openclaw
|
|
17
|
+
* Skip detection entirely. The given path must be a directory containing
|
|
18
|
+
* a valid package.json with a `bin` entry.
|
|
19
|
+
*
|
|
20
|
+
* In all forms, the agent's config dir (e.g. ~/.openclaw) is also moved under
|
|
21
|
+
* management — same behavior as the first-run `agents setup` import flow.
|
|
22
|
+
*/
|
|
23
|
+
import type { Command } from 'commander';
|
|
24
|
+
export declare function registerImportCommand(program: Command): void;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents import` — adopt an existing unmanaged agent install into agents-cli.
|
|
3
|
+
*
|
|
4
|
+
* Three forms:
|
|
5
|
+
*
|
|
6
|
+
* agents import openclaw
|
|
7
|
+
* Auto-detect via the binary on PATH. Resolves the npm package directory,
|
|
8
|
+
* reads its version, and registers it under
|
|
9
|
+
* ~/.agents/.history/versions/<agent>/<version>/.
|
|
10
|
+
*
|
|
11
|
+
* agents import openclaw --version 2026.3.8
|
|
12
|
+
* Same auto-detect, but pin the version label rather than reading it from
|
|
13
|
+
* the package. Useful when the package metadata is stale or you want a
|
|
14
|
+
* canonical name.
|
|
15
|
+
*
|
|
16
|
+
* agents import openclaw --from-path /opt/homebrew/lib/node_modules/openclaw
|
|
17
|
+
* Skip detection entirely. The given path must be a directory containing
|
|
18
|
+
* a valid package.json with a `bin` entry.
|
|
19
|
+
*
|
|
20
|
+
* In all forms, the agent's config dir (e.g. ~/.openclaw) is also moved under
|
|
21
|
+
* management — same behavior as the first-run `agents setup` import flow.
|
|
22
|
+
*/
|
|
23
|
+
import chalk from 'chalk';
|
|
24
|
+
import ora from 'ora';
|
|
25
|
+
import * as fs from 'fs';
|
|
26
|
+
import * as path from 'path';
|
|
27
|
+
import { confirm } from '@inquirer/prompts';
|
|
28
|
+
import { ALL_AGENT_IDS } from '../lib/agents.js';
|
|
29
|
+
import { AGENTS, getCliPath, getCliVersion, agentLabel } from '../lib/agents.js';
|
|
30
|
+
import { getVersionDir } from '../lib/versions.js';
|
|
31
|
+
import { finalizeImport, importAgentBinary, importAgentConfig, resolvePackageDirFromBinary, } from '../lib/import.js';
|
|
32
|
+
import { isPromptCancelled, isInteractiveTerminal } from './utils.js';
|
|
33
|
+
function isValidAgentId(value) {
|
|
34
|
+
return ALL_AGENT_IDS.includes(value);
|
|
35
|
+
}
|
|
36
|
+
async function runImport(agentArg, opts) {
|
|
37
|
+
if (!isValidAgentId(agentArg)) {
|
|
38
|
+
console.error(chalk.red(`Unknown agent: ${agentArg}`));
|
|
39
|
+
console.error(chalk.gray(`Known agents: ${ALL_AGENT_IDS.join(', ')}`));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const agentId = agentArg;
|
|
43
|
+
const agent = AGENTS[agentId];
|
|
44
|
+
// Reject agents that don't ship via npm before we spin up PATH lookups and
|
|
45
|
+
// prompts. cursor/kiro/goose/roo all have npmPackage='' and use custom
|
|
46
|
+
// install scripts — the symlink-farm import doesn't apply to them.
|
|
47
|
+
if (!agent.npmPackage) {
|
|
48
|
+
console.error(chalk.red(`${agentLabel(agentId)} doesn't install via npm — \`agents import\` only handles npm-style packages.`));
|
|
49
|
+
console.error(chalk.gray(`Use \`agents add ${agentId}\` to install via its native script.`));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
let globalPath = null;
|
|
53
|
+
if (opts.fromPath) {
|
|
54
|
+
globalPath = path.resolve(opts.fromPath);
|
|
55
|
+
if (!fs.existsSync(globalPath)) {
|
|
56
|
+
console.error(chalk.red(`Path does not exist: ${globalPath}`));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const binary = await getCliPath(agentId);
|
|
62
|
+
if (!binary) {
|
|
63
|
+
// Use || (not ??) so empty-string npmPackage falls back to cliCommand.
|
|
64
|
+
// Defensive: agents with empty npmPackage are rejected above, but keep
|
|
65
|
+
// the operator correct in case that early check is ever relaxed.
|
|
66
|
+
const installName = agent.npmPackage || agent.cliCommand;
|
|
67
|
+
console.error(chalk.red(`No "${agent.cliCommand}" found on PATH.`));
|
|
68
|
+
console.error(chalk.gray(`Install it first (e.g. \`npm i -g ${installName}\`) or pass --from-path.`));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
globalPath = resolvePackageDirFromBinary(binary);
|
|
72
|
+
if (!globalPath) {
|
|
73
|
+
console.error(chalk.red(`Could not resolve npm package for binary: ${binary}`));
|
|
74
|
+
console.error(chalk.gray('Pass --from-path <dir> with the package directory explicitly.'));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
let version = opts.version;
|
|
79
|
+
if (!version) {
|
|
80
|
+
try {
|
|
81
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(globalPath, 'package.json'), 'utf8'));
|
|
82
|
+
version = typeof pkg.version === 'string' ? pkg.version : undefined;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* fall through */
|
|
86
|
+
}
|
|
87
|
+
// Only fall back to running the PATH binary's --version when we're
|
|
88
|
+
// auto-detecting. With --from-path, the PATH binary may belong to a
|
|
89
|
+
// different install entirely; reporting its version here would silently
|
|
90
|
+
// mis-attribute the imported version.
|
|
91
|
+
if (!version && !opts.fromPath) {
|
|
92
|
+
const detected = await getCliVersion(agentId);
|
|
93
|
+
version = detected ?? undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!version) {
|
|
97
|
+
console.error(chalk.red(`Could not determine version for ${agentLabel(agentId)}.`));
|
|
98
|
+
console.error(chalk.gray('Pass --version <version> explicitly.'));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const versionDir = getVersionDir(agentId, version);
|
|
102
|
+
console.log(chalk.bold(`\nImport ${agentLabel(agentId)} v${version}`));
|
|
103
|
+
console.log(` from: ${chalk.gray(globalPath)}`);
|
|
104
|
+
console.log(` into: ${chalk.gray(versionDir)}`);
|
|
105
|
+
const configDirExists = fs.existsSync(agent.configDir);
|
|
106
|
+
let configAlreadyManaged = false;
|
|
107
|
+
if (configDirExists) {
|
|
108
|
+
const stat = fs.lstatSync(agent.configDir);
|
|
109
|
+
if (stat.isSymbolicLink()) {
|
|
110
|
+
configAlreadyManaged = true;
|
|
111
|
+
console.log(` config: ${chalk.gray(`${agent.configDir} (already managed — will skip)`)}`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
console.log(` config: ${chalk.gray(`${agent.configDir} (will be moved into version home)`)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.log(` config: ${chalk.gray(`${agent.configDir} (does not exist — will skip)`)}`);
|
|
119
|
+
}
|
|
120
|
+
if (!opts.yes && isInteractiveTerminal()) {
|
|
121
|
+
console.log();
|
|
122
|
+
const proceed = await confirm({
|
|
123
|
+
message: `Import ${agentLabel(agentId)} v${version} into agents-cli?`,
|
|
124
|
+
default: true,
|
|
125
|
+
}).catch((err) => {
|
|
126
|
+
if (isPromptCancelled(err))
|
|
127
|
+
return false;
|
|
128
|
+
throw err;
|
|
129
|
+
});
|
|
130
|
+
if (!proceed) {
|
|
131
|
+
console.log(chalk.gray('Aborted.'));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Order: config first, then binary, then finalize. Config does the
|
|
136
|
+
// user-visible side effect (renaming ~/.<agent>/), so if it fails we don't
|
|
137
|
+
// want a stranded symlink farm. Binary registration is cheap and reversible
|
|
138
|
+
// — if it fails after config, the next `agents import` call retries cleanly.
|
|
139
|
+
const willImportConfig = configDirExists && !configAlreadyManaged;
|
|
140
|
+
if (willImportConfig) {
|
|
141
|
+
const cfgSpinner = ora(`Importing config dir for ${agentLabel(agentId)} v${version}...`).start();
|
|
142
|
+
const cfgResult = await importAgentConfig(agentId, version);
|
|
143
|
+
if (cfgResult.success) {
|
|
144
|
+
cfgSpinner.succeed(`Config imported (${agent.configDir} -> ${versionDir}/home/.${agentId})`);
|
|
145
|
+
}
|
|
146
|
+
else if (cfgResult.skipped) {
|
|
147
|
+
cfgSpinner.warn(`Config: ${cfgResult.error}`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
cfgSpinner.fail(`Config: ${cfgResult.error}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const binSpinner = ora(`Registering ${agentLabel(agentId)} v${version} binary...`).start();
|
|
155
|
+
const binResult = importAgentBinary({ agentId, npmPackage: agent.npmPackage, cliCommand: agent.cliCommand }, version, globalPath, versionDir);
|
|
156
|
+
if (binResult.success) {
|
|
157
|
+
binSpinner.succeed(`Binary registered (${agent.cliCommand} -> ${globalPath})`);
|
|
158
|
+
}
|
|
159
|
+
else if (binResult.skipped) {
|
|
160
|
+
binSpinner.warn(`Binary: ${binResult.error}`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
binSpinner.fail(`Binary: ${binResult.error}`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
// Wire the imported version into the resolver: global default, main shim,
|
|
167
|
+
// versioned alias, home-file symlinks. Idempotent — safe to call even if
|
|
168
|
+
// importAgentConfig already set the global default.
|
|
169
|
+
const finalizeSpinner = ora(`Wiring ${agentLabel(agentId)} v${version} as the active version...`).start();
|
|
170
|
+
try {
|
|
171
|
+
finalizeImport(agentId, version);
|
|
172
|
+
finalizeSpinner.succeed(`${agentLabel(agentId)} v${version} set as default with shim + alias`);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
finalizeSpinner.fail(`Finalize: ${err.message}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
console.log();
|
|
179
|
+
console.log(chalk.green(`${agentLabel(agentId)} v${version} is now managed.`));
|
|
180
|
+
console.log(chalk.gray(`Verify: agents view ${agentId}`));
|
|
181
|
+
}
|
|
182
|
+
export function registerImportCommand(program) {
|
|
183
|
+
program
|
|
184
|
+
.command('import')
|
|
185
|
+
.argument('<agent>', 'Agent id (e.g. openclaw, claude, codex)')
|
|
186
|
+
.description('Import an existing unmanaged agent install into agents-cli')
|
|
187
|
+
.option('--version <version>', 'Pin a version label (otherwise read from package.json)')
|
|
188
|
+
.option('--from-path <path>', 'Path to the npm package dir (otherwise auto-detected from PATH)')
|
|
189
|
+
.option('-y, --yes', 'Skip the confirmation prompt')
|
|
190
|
+
.addHelpText('after', `
|
|
191
|
+
Examples:
|
|
192
|
+
$ agents import openclaw Auto-detect via PATH
|
|
193
|
+
$ agents import openclaw --version 2026.3.8 Pin a version label
|
|
194
|
+
$ agents import openclaw --from-path /opt/homebrew/lib/node_modules/openclaw
|
|
195
|
+
|
|
196
|
+
When to use:
|
|
197
|
+
When an agent CLI is already installed globally via npm or homebrew and you
|
|
198
|
+
want to bring it under agents-cli management without reinstalling. Creates a
|
|
199
|
+
symlink farm pointing at the existing install — nothing is copied or moved
|
|
200
|
+
(except the agent's config dir, which is moved into the version's home).
|
|
201
|
+
`)
|
|
202
|
+
.action(runImport);
|
|
203
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ import { registerRulesCommands } from './commands/rules.js';
|
|
|
36
36
|
import { registerPermissionsCommands } from './commands/permissions.js';
|
|
37
37
|
import { registerMcpCommands } from './commands/mcp.js';
|
|
38
38
|
import { registerVersionsCommands } from './commands/versions.js';
|
|
39
|
+
import { registerImportCommand } from './commands/import.js';
|
|
39
40
|
import { registerPackagesCommands } from './commands/packages.js';
|
|
40
41
|
import { registerDaemonCommands } from './commands/daemon.js';
|
|
41
42
|
import { registerRoutinesCommands } from './commands/routines.js';
|
|
@@ -87,6 +88,7 @@ Quick start:
|
|
|
87
88
|
|
|
88
89
|
Agent versions:
|
|
89
90
|
add <agent>[@version] Install an agent CLI (e.g. agents add codex)
|
|
91
|
+
import <agent> Adopt an existing global install (npm/homebrew) into agents-cli
|
|
90
92
|
remove <agent>[@version] Uninstall a version
|
|
91
93
|
use <agent>@<version> Set the default version
|
|
92
94
|
prune [target] Remove orphan resources and older duplicate version installs (targets: commands, sessions, runs, trash)
|
|
@@ -460,6 +462,7 @@ registerSubagentsCommands(program);
|
|
|
460
462
|
registerPluginsCommands(program);
|
|
461
463
|
registerWorkflowsCommands(program);
|
|
462
464
|
registerVersionsCommands(program);
|
|
465
|
+
registerImportCommand(program);
|
|
463
466
|
registerPackagesCommands(program);
|
|
464
467
|
registerDaemonCommands(program);
|
|
465
468
|
registerRoutinesCommands(program);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import existing unmanaged agent installations into agents-cli.
|
|
3
|
+
*
|
|
4
|
+
* Two flavors:
|
|
5
|
+
*
|
|
6
|
+
* 1. Config-only import — moves an agent's config dir (e.g. ~/.openclaw)
|
|
7
|
+
* into the version structure and symlinks it back. Used by `agents setup`
|
|
8
|
+
* on first-run when an agent was previously installed via npm/homebrew.
|
|
9
|
+
*
|
|
10
|
+
* 2. Full import — also registers an existing binary install (e.g. a global
|
|
11
|
+
* `npm i -g openclaw`) under the managed version path so the shim
|
|
12
|
+
* resolver can find it. This is what `agents import <agent>` does.
|
|
13
|
+
*
|
|
14
|
+
* The binary side never moves files. It creates a thin symlink farm under
|
|
15
|
+
* `~/.agents/.history/versions/<agent>/<version>/` pointing at the original
|
|
16
|
+
* global install, plus a package.json marker so `isVersionInstalled` returns
|
|
17
|
+
* true.
|
|
18
|
+
*/
|
|
19
|
+
import type { AgentId } from './types.js';
|
|
20
|
+
export interface ImportConfigResult {
|
|
21
|
+
success: boolean;
|
|
22
|
+
skipped?: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ImportBinaryResult {
|
|
26
|
+
success: boolean;
|
|
27
|
+
skipped?: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
resolvedFromPath?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Move an agent's config dir into the managed version structure and symlink it
|
|
33
|
+
* back to its original location. Sets the imported version as the global
|
|
34
|
+
* default and refreshes the shim so the user's PATH lookup hits the managed
|
|
35
|
+
* version.
|
|
36
|
+
*
|
|
37
|
+
* No-op (returns skipped=true) if the version's config dir is already created.
|
|
38
|
+
*/
|
|
39
|
+
export declare function importAgentConfig(agentId: AgentId, version: string): Promise<ImportConfigResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Wire an imported version into the rest of the system so it behaves the same
|
|
42
|
+
* as a freshly installed version:
|
|
43
|
+
*
|
|
44
|
+
* - registered as the global default in agents.yaml (so `agents view`
|
|
45
|
+
* reports it correctly and resolvers find it),
|
|
46
|
+
* - main shim refreshed (`~/.agents/.cache/shims/<cli>`),
|
|
47
|
+
* - versioned alias created (`~/.agents/.cache/shims/<cli>@<version>`),
|
|
48
|
+
* - home-file symlinks (CLAUDE.md / AGENTS.md / etc.) repointed at this
|
|
49
|
+
* version's home dir.
|
|
50
|
+
*
|
|
51
|
+
* Without this, the binary-only import path would leave the version stranded:
|
|
52
|
+
* isVersionInstalled returns true, but the resolver never picks it. Safe to
|
|
53
|
+
* call multiple times — each underlying function is idempotent.
|
|
54
|
+
*/
|
|
55
|
+
export declare function finalizeImport(agentId: AgentId, version: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* Agent metadata needed by importAgentBinary. Taking these as explicit
|
|
58
|
+
* inputs (rather than looking up AGENTS internally) decouples the symlink
|
|
59
|
+
* farm from the AGENTS registry, which keeps the function pure and avoids
|
|
60
|
+
* fragile coupling in test setups that stub `lib/agents.ts`.
|
|
61
|
+
*/
|
|
62
|
+
export interface AgentBinarySpec {
|
|
63
|
+
/** Agent id used in the marker package.json (`agents-{agentId}-{version}`). */
|
|
64
|
+
agentId: string;
|
|
65
|
+
/** npm package name (e.g. `openclaw`) — used as the `node_modules/<name>` dir. */
|
|
66
|
+
npmPackage: string;
|
|
67
|
+
/** Binary name on PATH (e.g. `openclaw`) — used as the `.bin/<name>` entry. */
|
|
68
|
+
cliCommand: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Register an existing global npm package install under the managed version
|
|
72
|
+
* path so the shim resolver finds it.
|
|
73
|
+
*
|
|
74
|
+
* Layout produced (everything is a symlink, nothing is copied):
|
|
75
|
+
*
|
|
76
|
+
* {versionDir}/
|
|
77
|
+
* package.json # marker so isVersionInstalled() is true
|
|
78
|
+
* home/ # empty isolated $HOME for this version
|
|
79
|
+
* node_modules/{npmPackage} -> {globalPath}
|
|
80
|
+
* node_modules/.bin/{cliCommand} -> {binaryEntry}
|
|
81
|
+
*/
|
|
82
|
+
export declare function importAgentBinary(spec: AgentBinarySpec, version: string, globalPath: string, versionDir: string): ImportBinaryResult;
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the on-disk npm package directory for an agent's CLI binary by
|
|
85
|
+
* walking up from the binary, following any symlinks. Returns null if the
|
|
86
|
+
* package can't be identified.
|
|
87
|
+
*
|
|
88
|
+
* Handles the homebrew/global-npm pattern where:
|
|
89
|
+
* /opt/homebrew/bin/{cli} -> ../lib/node_modules/{pkg}/dist/index.js
|
|
90
|
+
*/
|
|
91
|
+
export declare function resolvePackageDirFromBinary(binaryPath: string): string | null;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import existing unmanaged agent installations into agents-cli.
|
|
3
|
+
*
|
|
4
|
+
* Two flavors:
|
|
5
|
+
*
|
|
6
|
+
* 1. Config-only import — moves an agent's config dir (e.g. ~/.openclaw)
|
|
7
|
+
* into the version structure and symlinks it back. Used by `agents setup`
|
|
8
|
+
* on first-run when an agent was previously installed via npm/homebrew.
|
|
9
|
+
*
|
|
10
|
+
* 2. Full import — also registers an existing binary install (e.g. a global
|
|
11
|
+
* `npm i -g openclaw`) under the managed version path so the shim
|
|
12
|
+
* resolver can find it. This is what `agents import <agent>` does.
|
|
13
|
+
*
|
|
14
|
+
* The binary side never moves files. It creates a thin symlink farm under
|
|
15
|
+
* `~/.agents/.history/versions/<agent>/<version>/` pointing at the original
|
|
16
|
+
* global install, plus a package.json marker so `isVersionInstalled` returns
|
|
17
|
+
* true.
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import { AGENTS } from './agents.js';
|
|
22
|
+
import { getVersionsDir } from './state.js';
|
|
23
|
+
import { setGlobalDefault } from './versions.js';
|
|
24
|
+
import { createShim, createVersionedAlias, ensureShimCurrent, switchHomeFileSymlinks } from './shims.js';
|
|
25
|
+
/**
|
|
26
|
+
* Move an agent's config dir into the managed version structure and symlink it
|
|
27
|
+
* back to its original location. Sets the imported version as the global
|
|
28
|
+
* default and refreshes the shim so the user's PATH lookup hits the managed
|
|
29
|
+
* version.
|
|
30
|
+
*
|
|
31
|
+
* No-op (returns skipped=true) if the version's config dir is already created.
|
|
32
|
+
*/
|
|
33
|
+
export async function importAgentConfig(agentId, version) {
|
|
34
|
+
const agent = AGENTS[agentId];
|
|
35
|
+
const configDir = agent.configDir;
|
|
36
|
+
const versionsDir = getVersionsDir();
|
|
37
|
+
const versionHome = path.join(versionsDir, agentId, version, 'home');
|
|
38
|
+
const versionConfigDir = path.join(versionHome, `.${agentId}`);
|
|
39
|
+
if (fs.existsSync(versionConfigDir)) {
|
|
40
|
+
return { success: false, skipped: true, error: `${version} already installed` };
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(versionHome, { recursive: true });
|
|
44
|
+
fs.renameSync(configDir, versionConfigDir);
|
|
45
|
+
fs.symlinkSync(versionConfigDir, configDir);
|
|
46
|
+
setGlobalDefault(agentId, version);
|
|
47
|
+
switchHomeFileSymlinks(agentId, version);
|
|
48
|
+
ensureShimCurrent(agentId);
|
|
49
|
+
return { success: true };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return { success: false, error: err.message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Wire an imported version into the rest of the system so it behaves the same
|
|
57
|
+
* as a freshly installed version:
|
|
58
|
+
*
|
|
59
|
+
* - registered as the global default in agents.yaml (so `agents view`
|
|
60
|
+
* reports it correctly and resolvers find it),
|
|
61
|
+
* - main shim refreshed (`~/.agents/.cache/shims/<cli>`),
|
|
62
|
+
* - versioned alias created (`~/.agents/.cache/shims/<cli>@<version>`),
|
|
63
|
+
* - home-file symlinks (CLAUDE.md / AGENTS.md / etc.) repointed at this
|
|
64
|
+
* version's home dir.
|
|
65
|
+
*
|
|
66
|
+
* Without this, the binary-only import path would leave the version stranded:
|
|
67
|
+
* isVersionInstalled returns true, but the resolver never picks it. Safe to
|
|
68
|
+
* call multiple times — each underlying function is idempotent.
|
|
69
|
+
*/
|
|
70
|
+
export function finalizeImport(agentId, version) {
|
|
71
|
+
setGlobalDefault(agentId, version);
|
|
72
|
+
createShim(agentId);
|
|
73
|
+
createVersionedAlias(agentId, version);
|
|
74
|
+
switchHomeFileSymlinks(agentId, version);
|
|
75
|
+
ensureShimCurrent(agentId);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Register an existing global npm package install under the managed version
|
|
79
|
+
* path so the shim resolver finds it.
|
|
80
|
+
*
|
|
81
|
+
* Layout produced (everything is a symlink, nothing is copied):
|
|
82
|
+
*
|
|
83
|
+
* {versionDir}/
|
|
84
|
+
* package.json # marker so isVersionInstalled() is true
|
|
85
|
+
* home/ # empty isolated $HOME for this version
|
|
86
|
+
* node_modules/{npmPackage} -> {globalPath}
|
|
87
|
+
* node_modules/.bin/{cliCommand} -> {binaryEntry}
|
|
88
|
+
*/
|
|
89
|
+
export function importAgentBinary(spec, version, globalPath, versionDir) {
|
|
90
|
+
const binaryLink = path.join(versionDir, 'node_modules', '.bin', spec.cliCommand);
|
|
91
|
+
// lstat — we want to detect the symlink itself, not follow it. fs.existsSync
|
|
92
|
+
// can return false on dangling symlinks, which would incorrectly let us
|
|
93
|
+
// proceed to symlinkSync below and throw EEXIST.
|
|
94
|
+
let alreadyExists = false;
|
|
95
|
+
try {
|
|
96
|
+
fs.lstatSync(binaryLink);
|
|
97
|
+
alreadyExists = true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* not present */
|
|
101
|
+
}
|
|
102
|
+
if (alreadyExists) {
|
|
103
|
+
return { success: false, skipped: true, error: `${version} already installed`, resolvedFromPath: globalPath };
|
|
104
|
+
}
|
|
105
|
+
if (!fs.existsSync(globalPath)) {
|
|
106
|
+
return { success: false, error: `Path does not exist: ${globalPath}` };
|
|
107
|
+
}
|
|
108
|
+
const globalPkgJson = path.join(globalPath, 'package.json');
|
|
109
|
+
if (!fs.existsSync(globalPkgJson)) {
|
|
110
|
+
return { success: false, error: `Not an npm package (no package.json): ${globalPath}` };
|
|
111
|
+
}
|
|
112
|
+
let pkgBinEntry;
|
|
113
|
+
try {
|
|
114
|
+
const pkg = JSON.parse(fs.readFileSync(globalPkgJson, 'utf8'));
|
|
115
|
+
if (typeof pkg.bin === 'string') {
|
|
116
|
+
pkgBinEntry = pkg.bin;
|
|
117
|
+
}
|
|
118
|
+
else if (pkg.bin && typeof pkg.bin === 'object') {
|
|
119
|
+
// Strict: only accept the exact cliCommand key. Multi-bin packages
|
|
120
|
+
// (e.g. @anthropic-ai/claude-code ships several bins) would otherwise
|
|
121
|
+
// silently get a wrong binary chosen by Object.values() ordering.
|
|
122
|
+
pkgBinEntry = pkg.bin[spec.cliCommand];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
return { success: false, error: `Failed to read package.json: ${err.message}` };
|
|
127
|
+
}
|
|
128
|
+
if (!pkgBinEntry) {
|
|
129
|
+
return { success: false, error: `package.json has no bin entry for "${spec.cliCommand}" — pass --from-path to a package that ships it` };
|
|
130
|
+
}
|
|
131
|
+
const binaryTarget = path.resolve(globalPath, pkgBinEntry);
|
|
132
|
+
if (!fs.existsSync(binaryTarget)) {
|
|
133
|
+
return { success: false, error: `Binary entry missing: ${binaryTarget}` };
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
|
|
137
|
+
fs.mkdirSync(path.join(versionDir, 'node_modules', '.bin'), { recursive: true });
|
|
138
|
+
fs.writeFileSync(path.join(versionDir, 'package.json'), JSON.stringify({ name: `agents-${spec.agentId}-${version}`, version: '1.0.0', private: true, imported: true, from: globalPath }, null, 2));
|
|
139
|
+
const pkgLink = path.join(versionDir, 'node_modules', spec.npmPackage);
|
|
140
|
+
fs.mkdirSync(path.dirname(pkgLink), { recursive: true });
|
|
141
|
+
if (!fs.existsSync(pkgLink)) {
|
|
142
|
+
fs.symlinkSync(globalPath, pkgLink);
|
|
143
|
+
}
|
|
144
|
+
fs.symlinkSync(binaryTarget, binaryLink);
|
|
145
|
+
return { success: true, resolvedFromPath: globalPath };
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return { success: false, error: err.message };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the on-disk npm package directory for an agent's CLI binary by
|
|
153
|
+
* walking up from the binary, following any symlinks. Returns null if the
|
|
154
|
+
* package can't be identified.
|
|
155
|
+
*
|
|
156
|
+
* Handles the homebrew/global-npm pattern where:
|
|
157
|
+
* /opt/homebrew/bin/{cli} -> ../lib/node_modules/{pkg}/dist/index.js
|
|
158
|
+
*/
|
|
159
|
+
export function resolvePackageDirFromBinary(binaryPath) {
|
|
160
|
+
try {
|
|
161
|
+
let real = fs.realpathSync(binaryPath);
|
|
162
|
+
let dir = path.dirname(real);
|
|
163
|
+
// Walk up looking for the nearest package.json
|
|
164
|
+
for (let i = 0; i < 6; i++) {
|
|
165
|
+
const pkg = path.join(dir, 'package.json');
|
|
166
|
+
if (fs.existsSync(pkg)) {
|
|
167
|
+
return dir;
|
|
168
|
+
}
|
|
169
|
+
const parent = path.dirname(dir);
|
|
170
|
+
if (parent === dir)
|
|
171
|
+
break;
|
|
172
|
+
dir = parent;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
package/package.json
CHANGED