@phnx-labs/agents-cli 1.16.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 +71 -0
- package/dist/commands/browser.js +248 -9
- package/dist/commands/cloud.js +8 -0
- package/dist/commands/exec.js +70 -1
- package/dist/commands/import.d.ts +24 -0
- package/dist/commands/import.js +203 -0
- package/dist/commands/plugins.js +179 -5
- package/dist/commands/prune.js +6 -0
- package/dist/commands/secrets.js +117 -19
- package/dist/commands/view.js +21 -8
- package/dist/commands/workflows.d.ts +10 -0
- package/dist/commands/workflows.js +457 -0
- package/dist/index.js +34 -16
- package/dist/lib/browser/cdp.js +7 -4
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +37 -2
- package/dist/lib/browser/drivers/local.js +13 -2
- package/dist/lib/browser/input.d.ts +1 -0
- package/dist/lib/browser/input.js +3 -0
- package/dist/lib/browser/ipc.js +14 -0
- package/dist/lib/browser/profiles.d.ts +5 -0
- package/dist/lib/browser/profiles.js +45 -0
- package/dist/lib/browser/service.d.ts +10 -0
- package/dist/lib/browser/service.js +29 -1
- package/dist/lib/browser/types.d.ts +11 -1
- package/dist/lib/cloud/rush.d.ts +28 -1
- package/dist/lib/cloud/rush.js +68 -13
- package/dist/lib/commands.d.ts +0 -15
- package/dist/lib/commands.js +5 -5
- package/dist/lib/hooks.js +24 -11
- package/dist/lib/import.d.ts +91 -0
- package/dist/lib/import.js +179 -0
- package/dist/lib/migrate.js +59 -1
- package/dist/lib/permissions.d.ts +0 -58
- package/dist/lib/permissions.js +10 -10
- package/dist/lib/plugins.d.ts +75 -34
- package/dist/lib/plugins.js +640 -133
- package/dist/lib/resource-patterns.d.ts +41 -0
- package/dist/lib/resource-patterns.js +82 -0
- package/dist/lib/resources/index.d.ts +17 -0
- package/dist/lib/resources/index.js +7 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/resources/workflows.d.ts +24 -0
- package/dist/lib/resources/workflows.js +110 -0
- package/dist/lib/resources.d.ts +6 -1
- package/dist/lib/resources.js +12 -2
- package/dist/lib/session/db.d.ts +18 -0
- package/dist/lib/session/db.js +106 -7
- package/dist/lib/session/discover.d.ts +6 -0
- package/dist/lib/session/discover.js +28 -17
- package/dist/lib/shims.d.ts +3 -51
- package/dist/lib/shims.js +18 -10
- package/dist/lib/sqlite.js +10 -4
- package/dist/lib/state.d.ts +15 -2
- package/dist/lib/state.js +29 -8
- package/dist/lib/types.d.ts +43 -14
- package/dist/lib/versions.d.ts +3 -0
- package/dist/lib/versions.js +139 -27
- package/dist/lib/workflows.d.ts +79 -0
- package/dist/lib/workflows.js +233 -0
- package/package.json +1 -5
- package/scripts/postinstall.js +59 -58
- package/dist/commands/fork.d.ts +0 -10
- package/dist/commands/fork.js +0 -146
package/dist/lib/cloud/rush.js
CHANGED
|
@@ -15,14 +15,15 @@ import { parseSSE } from './stream.js';
|
|
|
15
15
|
import { listInstalledVersions, getVersionHomePath } from '../versions.js';
|
|
16
16
|
import { getAccountInfo } from '../agents.js';
|
|
17
17
|
import { loadClaudeOauth } from '../usage.js';
|
|
18
|
+
import { selectBalancedVersion } from '../rotate.js';
|
|
18
19
|
const PROXY_BASE = 'https://api.prix.dev';
|
|
19
20
|
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
20
21
|
// Persistent consent record for uploading Claude OAuth blobs to Rush Cloud.
|
|
21
22
|
// Created on first explicit consent (env var or flag); subsequent dispatches
|
|
22
23
|
// see it and proceed without re-prompting.
|
|
23
|
-
const RUSH_CONSENT_PATH = path.join(getCloudDir(), 'rush-consent.json');
|
|
24
|
+
export const RUSH_CONSENT_PATH = path.join(getCloudDir(), 'rush-consent.json');
|
|
24
25
|
const RUSH_CONSENT_ENV = 'AGENTS_RUSH_UPLOAD_TOKENS';
|
|
25
|
-
function hasRushUploadConsent(opts) {
|
|
26
|
+
export function hasRushUploadConsent(opts) {
|
|
26
27
|
if (process.env[RUSH_CONSENT_ENV] === '1')
|
|
27
28
|
return true;
|
|
28
29
|
const po = opts?.providerOptions;
|
|
@@ -184,20 +185,37 @@ async function readClaudeCredentialsBlob(home) {
|
|
|
184
185
|
* Returns null when no Claude versions are signed in (the dispatch falls back
|
|
185
186
|
* to the platform-wide key, current behavior).
|
|
186
187
|
*/
|
|
187
|
-
export async function buildAccountManifest() {
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
188
|
+
export async function buildAccountManifest(strategy) {
|
|
189
|
+
let candidateVersions;
|
|
190
|
+
if (strategy === 'balanced') {
|
|
191
|
+
// Use the same health-checked, deduped-by-email set that `agents run --balanced` uses.
|
|
192
|
+
// `result.healthy` contains one candidate per unique email, ordered by remaining capacity.
|
|
193
|
+
const result = await selectBalancedVersion('claude');
|
|
194
|
+
if (!result || result.healthy.length === 0)
|
|
195
|
+
return null;
|
|
196
|
+
candidateVersions = result.healthy
|
|
197
|
+
.filter((c) => !!c.email)
|
|
198
|
+
.map((c) => ({ version: c.version, email: c.email }));
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Default: all installed versions that have a signed-in account.
|
|
202
|
+
const versions = listInstalledVersions('claude');
|
|
203
|
+
if (versions.length === 0)
|
|
204
|
+
return null;
|
|
205
|
+
const rows = await Promise.all(versions.map(async (version) => {
|
|
206
|
+
const home = getVersionHomePath('claude', version);
|
|
207
|
+
const info = await getAccountInfo('claude', home);
|
|
208
|
+
return info.email ? { version, email: info.email } : null;
|
|
209
|
+
}));
|
|
210
|
+
candidateVersions = rows.filter((r) => r !== null);
|
|
211
|
+
}
|
|
191
212
|
const entries = [];
|
|
192
|
-
for (const version of
|
|
213
|
+
for (const { version, email } of candidateVersions) {
|
|
193
214
|
const home = getVersionHomePath('claude', version);
|
|
194
|
-
const info = await getAccountInfo('claude', home);
|
|
195
|
-
if (!info.email)
|
|
196
|
-
continue;
|
|
197
215
|
const blob = await readClaudeCredentialsBlob(home);
|
|
198
216
|
if (!blob)
|
|
199
217
|
continue;
|
|
200
|
-
entries.push({ version, email
|
|
218
|
+
entries.push({ version, email, cred_fp: sha256(blob) });
|
|
201
219
|
}
|
|
202
220
|
if (entries.length === 0)
|
|
203
221
|
return null;
|
|
@@ -237,6 +255,7 @@ export function buildDispatchBody(input) {
|
|
|
237
255
|
prompt: input.prompt,
|
|
238
256
|
repos: input.resolvedRepos,
|
|
239
257
|
mode: input.mode,
|
|
258
|
+
...(input.strategy ? { strategy: input.strategy } : {}),
|
|
240
259
|
};
|
|
241
260
|
if (input.resolvedRepos.length === 1) {
|
|
242
261
|
body.installation_id = primary.installation_id;
|
|
@@ -251,6 +270,37 @@ export function buildDispatchBody(input) {
|
|
|
251
270
|
}
|
|
252
271
|
return body;
|
|
253
272
|
}
|
|
273
|
+
/** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
|
|
274
|
+
export async function listRemoteAccounts() {
|
|
275
|
+
const token = readToken();
|
|
276
|
+
const res = await api('GET', '/api/v1/cloud-accounts', token);
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
throw new Error(`Failed to list accounts (${res.status}): ${sanitizeErrorBody(await res.text())}`);
|
|
279
|
+
}
|
|
280
|
+
const data = await res.json();
|
|
281
|
+
return data.accounts ?? [];
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
|
|
285
|
+
* The server validates the token against the Anthropic usage API and stores it
|
|
286
|
+
* encrypted in Vault. Returns the account metadata (no token).
|
|
287
|
+
*/
|
|
288
|
+
export async function addRemoteAccount(provider, pastedToken) {
|
|
289
|
+
const token = readToken();
|
|
290
|
+
const res = await api('POST', '/api/v1/cloud-accounts', token, { provider, token: pastedToken });
|
|
291
|
+
if (!res.ok) {
|
|
292
|
+
throw new Error(`Failed to add account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
|
|
293
|
+
}
|
|
294
|
+
return await res.json();
|
|
295
|
+
}
|
|
296
|
+
/** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
|
|
297
|
+
export async function removeRemoteAccount(id) {
|
|
298
|
+
const token = readToken();
|
|
299
|
+
const res = await api('DELETE', `/api/v1/cloud-accounts/${encodeURIComponent(id)}`, token);
|
|
300
|
+
if (!res.ok) {
|
|
301
|
+
throw new Error(`Failed to remove account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
254
304
|
export class RushCloudProvider {
|
|
255
305
|
id = 'rush';
|
|
256
306
|
name = 'Rush Cloud';
|
|
@@ -289,13 +339,18 @@ export class RushCloudProvider {
|
|
|
289
339
|
repo_owner: r.owner,
|
|
290
340
|
repo_name: r.name,
|
|
291
341
|
})));
|
|
292
|
-
const
|
|
342
|
+
const strategy = options.providerOptions?.strategy;
|
|
343
|
+
// When balanced, the server owns the pool and rotates internally — no
|
|
344
|
+
// client-side manifest needed. We just forward the strategy so the server
|
|
345
|
+
// knows to load from Vault instead of waiting for a manifest.
|
|
346
|
+
const accountManifest = strategy === 'balanced' ? null : await buildAccountManifest();
|
|
293
347
|
const body = buildDispatchBody({
|
|
294
348
|
agent: options.agent,
|
|
295
349
|
prompt: options.prompt,
|
|
296
350
|
mode: options.providerOptions?.mode,
|
|
297
351
|
resolvedRepos,
|
|
298
352
|
accountManifest,
|
|
353
|
+
strategy,
|
|
299
354
|
});
|
|
300
355
|
let res = await api('POST', '/api/v1/cloud-runs', token, body);
|
|
301
356
|
// Server detects drift (new account or rotated token) by comparing the
|
|
@@ -317,7 +372,7 @@ export class RushCloudProvider {
|
|
|
317
372
|
``,
|
|
318
373
|
`To consent, re-run with one of:`,
|
|
319
374
|
` AGENTS_RUSH_UPLOAD_TOKENS=1 agents cloud run ...`,
|
|
320
|
-
` agents cloud run --upload-account-tokens
|
|
375
|
+
` agents cloud run --upload-account-tokens ...`,
|
|
321
376
|
``,
|
|
322
377
|
`Consent will be recorded at ${RUSH_CONSENT_PATH} so you won't be asked again.`,
|
|
323
378
|
`Remove that file to revoke.`,
|
package/dist/lib/commands.d.ts
CHANGED
|
@@ -35,10 +35,6 @@ export interface InstalledCommand {
|
|
|
35
35
|
path: string;
|
|
36
36
|
description?: string;
|
|
37
37
|
}
|
|
38
|
-
/** Parse command metadata (name, description) from YAML frontmatter or TOML headers. */
|
|
39
|
-
export declare function parseCommandMetadata(filePath: string): CommandMetadata | null;
|
|
40
|
-
/** Validate command metadata, returning errors and warnings. */
|
|
41
|
-
export declare function validateCommandMetadata(metadata: CommandMetadata | null, commandName: string): ValidationResult;
|
|
42
38
|
/** Discover all command markdown files in a repository's commands/ directory. */
|
|
43
39
|
export declare function discoverCommands(repoPath: string): DiscoveredCommand[];
|
|
44
40
|
/** Find the source path for a command in a repository. */
|
|
@@ -98,17 +94,6 @@ export declare function iterCommandsCapableVersions(filter?: {
|
|
|
98
94
|
}>;
|
|
99
95
|
/** Remove a command from an agent's config directory. */
|
|
100
96
|
export declare function uninstallCommand(agentId: AgentId, commandName: string): boolean;
|
|
101
|
-
/** List command names installed for an agent in the active version home. */
|
|
102
|
-
export declare function listInstalledCommands(agentId: AgentId): string[];
|
|
103
|
-
/**
|
|
104
|
-
* Check if a command exists for an agent.
|
|
105
|
-
*/
|
|
106
|
-
export declare function commandExists(agentId: AgentId, commandName: string): boolean;
|
|
107
|
-
/**
|
|
108
|
-
* Check if installed command content matches source content.
|
|
109
|
-
* Handles format conversion (markdown to TOML for Gemini).
|
|
110
|
-
*/
|
|
111
|
-
export declare function commandContentMatches(agentId: AgentId, commandName: string, sourcePath: string): boolean;
|
|
112
97
|
/**
|
|
113
98
|
* List installed commands with scope information.
|
|
114
99
|
* Pass options.home to read from a version-managed agent's home directory.
|
package/dist/lib/commands.js
CHANGED
|
@@ -15,7 +15,7 @@ import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAge
|
|
|
15
15
|
import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
|
|
16
16
|
import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
|
|
17
17
|
/** Parse command metadata (name, description) from YAML frontmatter or TOML headers. */
|
|
18
|
-
|
|
18
|
+
function parseCommandMetadata(filePath) {
|
|
19
19
|
if (!fs.existsSync(filePath)) {
|
|
20
20
|
return null;
|
|
21
21
|
}
|
|
@@ -51,7 +51,7 @@ export function parseCommandMetadata(filePath) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
/** Validate command metadata, returning errors and warnings. */
|
|
54
|
-
|
|
54
|
+
function validateCommandMetadata(metadata, commandName) {
|
|
55
55
|
const errors = [];
|
|
56
56
|
const warnings = [];
|
|
57
57
|
if (!metadata) {
|
|
@@ -336,7 +336,7 @@ export function uninstallCommand(agentId, commandName) {
|
|
|
336
336
|
return false;
|
|
337
337
|
}
|
|
338
338
|
/** List command names installed for an agent in the active version home. */
|
|
339
|
-
|
|
339
|
+
function listInstalledCommands(agentId) {
|
|
340
340
|
const agent = AGENTS[agentId];
|
|
341
341
|
const home = getEffectiveHome(agentId);
|
|
342
342
|
const commandsDir = path.join(home, `.${agentId}`, agent.commandsSubdir);
|
|
@@ -352,7 +352,7 @@ export function listInstalledCommands(agentId) {
|
|
|
352
352
|
/**
|
|
353
353
|
* Check if a command exists for an agent.
|
|
354
354
|
*/
|
|
355
|
-
|
|
355
|
+
function commandExists(agentId, commandName) {
|
|
356
356
|
const agent = AGENTS[agentId];
|
|
357
357
|
const home = getEffectiveHome(agentId);
|
|
358
358
|
const commandsDir = path.join(home, `.${agentId}`, agent.commandsSubdir);
|
|
@@ -370,7 +370,7 @@ function normalizeContent(content) {
|
|
|
370
370
|
* Check if installed command content matches source content.
|
|
371
371
|
* Handles format conversion (markdown to TOML for Gemini).
|
|
372
372
|
*/
|
|
373
|
-
|
|
373
|
+
function commandContentMatches(agentId, commandName, sourcePath) {
|
|
374
374
|
const agent = AGENTS[agentId];
|
|
375
375
|
const home = getEffectiveHome(agentId);
|
|
376
376
|
const commandsDir = path.join(home, `.${agentId}`, agent.commandsSubdir);
|
package/dist/lib/hooks.js
CHANGED
|
@@ -14,16 +14,15 @@ import * as TOML from 'smol-toml';
|
|
|
14
14
|
import { AGENTS, HOOKS_CAPABLE_AGENTS } from './agents.js';
|
|
15
15
|
import { supports, explainSkip } from './capabilities.js';
|
|
16
16
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
|
|
17
|
-
import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir } from './state.js';
|
|
17
|
+
import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos } from './state.js';
|
|
18
18
|
function getCentralHooksDir() { return getUserHooksDir(); }
|
|
19
19
|
/**
|
|
20
|
-
* Resolve a hook script's absolute path
|
|
21
|
-
*
|
|
22
|
-
* system dir (where npm-shipped defaults live). Returns null if neither
|
|
23
|
-
* exists. Mirrors the precedence used by `listCentralHooks`.
|
|
20
|
+
* Resolve a hook script's absolute path. Checks user dir first, then enabled
|
|
21
|
+
* extra repos in insertion order, then system dir. Returns null if not found.
|
|
24
22
|
*/
|
|
25
23
|
function resolveHookScriptPath(script) {
|
|
26
|
-
|
|
24
|
+
const extraDirs = getEnabledExtraRepos().map(e => e.dir);
|
|
25
|
+
for (const root of [getUserAgentsDir(), ...extraDirs, getSystemAgentsDir()]) {
|
|
27
26
|
const candidate = path.join(root, 'hooks', script);
|
|
28
27
|
if (fs.existsSync(candidate))
|
|
29
28
|
return candidate;
|
|
@@ -32,14 +31,15 @@ function resolveHookScriptPath(script) {
|
|
|
32
31
|
}
|
|
33
32
|
/**
|
|
34
33
|
* Prefixes used for stale-entry cleanup in agent settings files. A registered
|
|
35
|
-
* hook command is considered "managed
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* collected on rewrite.
|
|
34
|
+
* hook command is considered "managed" if it lives under any known hooks dir
|
|
35
|
+
* (user, extra repos, or system). Entries from removed extra repos are also
|
|
36
|
+
* garbage-collected because they won't appear in this list any more.
|
|
39
37
|
*/
|
|
40
38
|
function getManagedHookPrefixes() {
|
|
39
|
+
const extraDirs = getEnabledExtraRepos().map(e => e.dir);
|
|
41
40
|
return [
|
|
42
41
|
path.join(getUserAgentsDir(), 'hooks') + path.sep,
|
|
42
|
+
...extraDirs.map(d => path.join(d, 'hooks') + path.sep),
|
|
43
43
|
path.join(getSystemAgentsDir(), 'hooks') + path.sep,
|
|
44
44
|
];
|
|
45
45
|
}
|
|
@@ -616,16 +616,29 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
|
|
|
616
616
|
return { registered: [], errors: [] };
|
|
617
617
|
}
|
|
618
618
|
const overrideRoots = agentsDirOverride ? [agentsDirOverride] : null;
|
|
619
|
+
// Scripts are copied into the version home during sync — prefer that stable
|
|
620
|
+
// local path so registered commands don't break when source dirs change.
|
|
621
|
+
const localHooksDir = !overrideRoots
|
|
622
|
+
? path.join(versionHome, `.${agentId}`, AGENTS[agentId].hooksDir)
|
|
623
|
+
: null;
|
|
619
624
|
const resolveScript = (script) => {
|
|
620
625
|
if (overrideRoots) {
|
|
621
626
|
const candidate = path.join(overrideRoots[0], 'hooks', script);
|
|
622
627
|
return fs.existsSync(candidate) ? candidate : null;
|
|
623
628
|
}
|
|
629
|
+
if (localHooksDir) {
|
|
630
|
+
const local = path.join(localHooksDir, script);
|
|
631
|
+
if (fs.existsSync(local))
|
|
632
|
+
return local;
|
|
633
|
+
}
|
|
624
634
|
return resolveHookScriptPath(script);
|
|
625
635
|
};
|
|
626
636
|
const managedPrefixes = overrideRoots
|
|
627
637
|
? [path.join(overrideRoots[0], 'hooks') + path.sep]
|
|
628
|
-
:
|
|
638
|
+
: [
|
|
639
|
+
...getManagedHookPrefixes(),
|
|
640
|
+
...(localHooksDir ? [localHooksDir + path.sep] : []),
|
|
641
|
+
];
|
|
629
642
|
if (agentId === 'claude') {
|
|
630
643
|
return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes);
|
|
631
644
|
}
|
|
@@ -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/dist/lib/migrate.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as fs from 'fs';
|
|
|
8
8
|
import * as path from 'path';
|
|
9
9
|
import * as os from 'os';
|
|
10
10
|
import * as yaml from 'yaml';
|
|
11
|
-
const HOME = os.homedir();
|
|
11
|
+
const HOME = process.env.HOME ?? os.homedir();
|
|
12
12
|
const SYSTEM_DIR = path.join(HOME, '.agents-system');
|
|
13
13
|
const USER_DIR = path.join(HOME, '.agents');
|
|
14
14
|
const HISTORY_DIR = path.join(USER_DIR, '.history');
|
|
@@ -1325,6 +1325,63 @@ function warnSystemOrphans() {
|
|
|
1325
1325
|
return;
|
|
1326
1326
|
console.error(`~/.agents-system/ has unexpected entries (not part of the npm-shipped defaults): ${orphans.join(', ')}`);
|
|
1327
1327
|
}
|
|
1328
|
+
const VERSION_RESOURCE_FLAT_KEYS = ['commands', 'skills', 'hooks', 'memory', 'subagents', 'plugins', 'workflows', 'permissions', 'mcp'];
|
|
1329
|
+
/**
|
|
1330
|
+
* Convert agents.yaml versions: entries from the old flat name-list format to
|
|
1331
|
+
* the new pattern format. Flat entries are detected by checking whether all
|
|
1332
|
+
* items in the array lack a ':' separator (plain names have no source prefix).
|
|
1333
|
+
*
|
|
1334
|
+
* The rulesPreset field is preserved. Flat resource lists are dropped — the
|
|
1335
|
+
* next `agents sync` will write default patterns (system:* user:* project:*).
|
|
1336
|
+
*
|
|
1337
|
+
* Idempotent: entries already in pattern format are left untouched.
|
|
1338
|
+
*/
|
|
1339
|
+
function migrateVersionResourcesToPatterns() {
|
|
1340
|
+
const metaFile = path.join(USER_DIR, 'agents.yaml');
|
|
1341
|
+
if (!fs.existsSync(metaFile))
|
|
1342
|
+
return;
|
|
1343
|
+
let meta;
|
|
1344
|
+
try {
|
|
1345
|
+
const raw = fs.readFileSync(metaFile, 'utf-8');
|
|
1346
|
+
meta = yaml.parse(raw) || {};
|
|
1347
|
+
}
|
|
1348
|
+
catch {
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const versions = meta.versions;
|
|
1352
|
+
if (!versions || typeof versions !== 'object')
|
|
1353
|
+
return;
|
|
1354
|
+
let changed = false;
|
|
1355
|
+
for (const agentVersions of Object.values(versions)) {
|
|
1356
|
+
if (!agentVersions || typeof agentVersions !== 'object')
|
|
1357
|
+
continue;
|
|
1358
|
+
for (const vr of Object.values(agentVersions)) {
|
|
1359
|
+
if (!vr || typeof vr !== 'object')
|
|
1360
|
+
continue;
|
|
1361
|
+
for (const key of VERSION_RESOURCE_FLAT_KEYS) {
|
|
1362
|
+
const val = vr[key];
|
|
1363
|
+
if (!Array.isArray(val) || val.length === 0)
|
|
1364
|
+
continue;
|
|
1365
|
+
// Detect legacy: all items are plain names (no ':' separator)
|
|
1366
|
+
if (val.every(item => typeof item === 'string' && !item.includes(':'))) {
|
|
1367
|
+
if (key === 'memory') {
|
|
1368
|
+
// memory was a single-element array holding the preset name — move to rulesPreset
|
|
1369
|
+
if (val.length === 1 && !vr['rulesPreset']) {
|
|
1370
|
+
vr['rulesPreset'] = val[0];
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
delete vr[key];
|
|
1374
|
+
changed = true;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
if (changed) {
|
|
1380
|
+
const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n\n';
|
|
1381
|
+
fs.writeFileSync(metaFile, META_HEADER + yaml.stringify(meta), 'utf-8');
|
|
1382
|
+
console.error('Migrated agents.yaml versions: entries to pattern format');
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1328
1385
|
/** Run all idempotent migrations. Safe to call multiple times. */
|
|
1329
1386
|
export async function runMigration() {
|
|
1330
1387
|
migrateAgentsYaml();
|
|
@@ -1344,6 +1401,7 @@ export async function runMigration() {
|
|
|
1344
1401
|
cleanupEmptyTopLevelRuns();
|
|
1345
1402
|
foldUserHooksYamlIntoAgentsYaml();
|
|
1346
1403
|
foldBrowserProfilesIntoAgentsYaml();
|
|
1404
|
+
migrateVersionResourcesToPatterns();
|
|
1347
1405
|
// Bucket moves: collapse runtime state into ~/.agents/.history and ~/.agents/.cache.
|
|
1348
1406
|
migrateRuntimeToHistory();
|
|
1349
1407
|
migrateRuntimeToCache();
|