@phnx-labs/agents-cli 1.20.29 → 1.20.31
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/dist/commands/computer-actions.js +6 -2
- package/dist/commands/computer.d.ts +12 -0
- package/dist/commands/computer.js +88 -13
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/models.js +8 -2
- package/dist/commands/sessions-picker.js +35 -10
- package/dist/commands/sessions.js +164 -44
- package/dist/commands/setup.js +8 -0
- package/dist/commands/ssh.js +123 -15
- package/dist/commands/sync.js +70 -14
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +122 -22
- package/dist/lib/browser/drivers/ssh.js +4 -35
- package/dist/lib/computer-rpc.d.ts +6 -1
- package/dist/lib/computer-rpc.js +86 -3
- package/dist/lib/devices/registry.d.ts +11 -0
- package/dist/lib/devices/registry.js +53 -1
- package/dist/lib/devices/sync.d.ts +42 -0
- package/dist/lib/devices/sync.js +85 -0
- package/dist/lib/exec.js +14 -0
- package/dist/lib/models.js +138 -5
- package/dist/lib/runner.js +7 -7
- package/dist/lib/session/active.d.ts +15 -0
- package/dist/lib/session/active.js +108 -19
- package/dist/lib/session/cloud.js +2 -0
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +62 -5
- package/dist/lib/session/digest.d.ts +50 -0
- package/dist/lib/session/digest.js +170 -0
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +81 -0
- package/dist/lib/session/parse.d.ts +15 -0
- package/dist/lib/session/parse.js +22 -2
- package/dist/lib/session/remote.d.ts +1 -1
- package/dist/lib/session/remote.js +8 -3
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +83 -10
- package/dist/lib/session/state.d.ts +82 -0
- package/dist/lib/session/state.js +221 -0
- package/dist/lib/session/tail.d.ts +18 -0
- package/dist/lib/session/tail.js +57 -0
- package/dist/lib/session/types.d.ts +9 -0
- package/dist/lib/session/width.d.ts +29 -0
- package/dist/lib/session/width.js +91 -0
- package/dist/lib/shims.d.ts +17 -1
- package/dist/lib/shims.js +130 -6
- package/dist/lib/ssh-tunnel.d.ts +127 -0
- package/dist/lib/ssh-tunnel.js +346 -0
- package/dist/lib/state.d.ts +4 -0
- package/dist/lib/state.js +19 -1
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +10 -0
- package/dist/lib/teams/agents.d.ts +11 -1
- package/dist/lib/teams/agents.js +16 -2
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/versions.d.ts +19 -0
- package/dist/lib/versions.js +84 -24
- package/package.json +1 -1
package/dist/commands/ssh.js
CHANGED
|
@@ -16,8 +16,11 @@ import * as path from 'path';
|
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import ora from 'ora';
|
|
18
18
|
import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
|
|
19
|
-
import {
|
|
19
|
+
import { machineId } from '../lib/session/sync/config.js';
|
|
20
|
+
import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
|
|
20
21
|
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
|
|
22
|
+
import { planDeviceReconciliation, runDeviceSync } from '../lib/devices/sync.js';
|
|
23
|
+
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
21
24
|
import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
22
25
|
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
23
26
|
/** Parse `user@host` or `host` into pieces. */
|
|
@@ -27,8 +30,9 @@ function parseTarget(target) {
|
|
|
27
30
|
return { host: target };
|
|
28
31
|
return { user: target.slice(0, at), host: target.slice(at + 1) };
|
|
29
32
|
}
|
|
30
|
-
/** One-line summary of a device for `list`.
|
|
31
|
-
|
|
33
|
+
/** One-line summary of a device for `list`. `isSelf` marks the machine this
|
|
34
|
+
* command is running on so it stands out from the rest of the tailnet. */
|
|
35
|
+
function deviceSummary(d, isSelf = false) {
|
|
32
36
|
const addr = hostNameFor(d) ?? chalk.gray('no address');
|
|
33
37
|
const online = d.tailscale
|
|
34
38
|
? d.tailscale.online
|
|
@@ -36,7 +40,10 @@ function deviceSummary(d) {
|
|
|
36
40
|
: chalk.gray('offline')
|
|
37
41
|
: chalk.gray('unknown');
|
|
38
42
|
const reach = d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' (relayed)') : '';
|
|
39
|
-
|
|
43
|
+
const marker = isSelf ? chalk.cyan('▸ ') : ' ';
|
|
44
|
+
const name = isSelf ? chalk.bold.cyan(d.name.padEnd(16)) : chalk.bold(d.name.padEnd(16));
|
|
45
|
+
const here = isSelf ? chalk.cyan(' ← this machine') : '';
|
|
46
|
+
return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}`;
|
|
40
47
|
}
|
|
41
48
|
/** Resolve a device or exit with a clear error. */
|
|
42
49
|
async function mustGetDevice(name) {
|
|
@@ -47,6 +54,72 @@ async function mustGetDevice(name) {
|
|
|
47
54
|
}
|
|
48
55
|
return d;
|
|
49
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Interactive `agents devices sync`: discover tailscale nodes, present a
|
|
59
|
+
* checkbox pre-checked with what's already registered, and reconcile the
|
|
60
|
+
* choice. Checked = registered (and un-ignored). Unchecked = removed from the
|
|
61
|
+
* registry AND added to the ignore-list, so auto-discovery never re-suggests
|
|
62
|
+
* it — this is the "click to register/unregister" surface, with dismissals that
|
|
63
|
+
* stick.
|
|
64
|
+
*/
|
|
65
|
+
async function runInteractiveDeviceSync() {
|
|
66
|
+
const spinner = ora('Reading tailscale status...').start();
|
|
67
|
+
let nodes;
|
|
68
|
+
try {
|
|
69
|
+
nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
spinner.fail(err.message);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const [reg, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
|
|
76
|
+
const registered = new Set(Object.keys(reg));
|
|
77
|
+
spinner.stop();
|
|
78
|
+
if (nodes.length === 0) {
|
|
79
|
+
console.log(chalk.gray('No tailscale nodes found.'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const { checkbox } = await import('@inquirer/prompts');
|
|
83
|
+
let selected;
|
|
84
|
+
try {
|
|
85
|
+
selected = await checkbox({
|
|
86
|
+
// Everything not already dismissed starts checked, so pressing Enter keeps
|
|
87
|
+
// the fleet as-is (matching what auto-sync would register). Unchecking a
|
|
88
|
+
// device removes it AND dismisses it so auto-sync never re-adds it.
|
|
89
|
+
message: 'Your fleet — uncheck a device to remove and stop suggesting it:',
|
|
90
|
+
pageSize: Math.min(nodes.length, 20),
|
|
91
|
+
choices: nodes.map((n) => {
|
|
92
|
+
const flags = [n.platform, n.online ? undefined : 'offline', ignored.has(n.name) ? 'ignored' : undefined]
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.join(', ');
|
|
95
|
+
return { value: n.name, name: `${n.name} ${chalk.gray(`(${flags})`)}`, checked: !ignored.has(n.name) };
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (isPromptCancelled(err)) {
|
|
101
|
+
console.log(chalk.gray('Cancelled — no changes.'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
const byName = new Map(nodes.map((n) => [n.name, n]));
|
|
107
|
+
const plan = planDeviceReconciliation(byName.keys(), selected, registered, ignored);
|
|
108
|
+
for (const name of plan.toRegister)
|
|
109
|
+
await upsertDevice(name, nodeToDeviceInput(byName.get(name)));
|
|
110
|
+
for (const name of plan.toUnignore)
|
|
111
|
+
await removeIgnored(name);
|
|
112
|
+
for (const name of plan.toRemove)
|
|
113
|
+
await removeDevice(name);
|
|
114
|
+
for (const name of plan.toIgnore)
|
|
115
|
+
await addIgnored(name);
|
|
116
|
+
const parts = [
|
|
117
|
+
chalk.green(`${plan.toRegister.length} registered`),
|
|
118
|
+
plan.toRemove.length ? chalk.yellow(`${plan.toRemove.length} removed`) : null,
|
|
119
|
+
plan.toIgnore.length ? chalk.gray(`${plan.toIgnore.length} ignored`) : null,
|
|
120
|
+
].filter(Boolean);
|
|
121
|
+
console.log(parts.join(chalk.gray(' · ')));
|
|
122
|
+
}
|
|
50
123
|
/** Register the `agents devices` command tree. */
|
|
51
124
|
function registerDevicesCommands(program) {
|
|
52
125
|
const devicesCmd = program
|
|
@@ -54,43 +127,78 @@ function registerDevicesCommands(program) {
|
|
|
54
127
|
.description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
|
|
55
128
|
.addHelpText('after', `
|
|
56
129
|
Typical workflow:
|
|
57
|
-
agents devices sync #
|
|
130
|
+
agents devices sync # curate: pick which tailscale nodes to keep (TTY)
|
|
131
|
+
agents devices sync --yes # non-interactive: register all non-ignored nodes
|
|
58
132
|
agents devices list # see what's registered
|
|
133
|
+
agents devices ignore ipad165 # dismiss a node so it's never re-suggested
|
|
59
134
|
agents devices set win-mini --auth password --bundle muqsit
|
|
60
135
|
agents devices render --write # write ~/.ssh/config.d/agents include
|
|
61
136
|
`);
|
|
62
137
|
devicesCmd
|
|
63
138
|
.command('sync')
|
|
64
|
-
.description('Ingest `tailscale status --json`
|
|
65
|
-
.
|
|
139
|
+
.description('Ingest `tailscale status --json` into device profiles. In a terminal, opens a checkbox to register/unregister nodes; with --yes, registers every non-ignored node.')
|
|
140
|
+
.option('--yes', 'skip the picker; register all discovered non-ignored nodes')
|
|
141
|
+
.action(async (opts) => {
|
|
142
|
+
if (isInteractiveTerminal() && !opts.yes) {
|
|
143
|
+
await runInteractiveDeviceSync();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
66
146
|
const spinner = ora('Reading tailscale status...').start();
|
|
67
147
|
try {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
await upsertDevice(node.name, nodeToDeviceInput(node));
|
|
72
|
-
}
|
|
73
|
-
spinner.succeed(`Synced ${nodes.length} device${nodes.length === 1 ? '' : 's'} from Tailscale`);
|
|
148
|
+
const res = await runDeviceSync();
|
|
149
|
+
const extra = res.pending.length ? chalk.gray(` (${res.pending.length} new)`) : '';
|
|
150
|
+
spinner.succeed(`Synced ${res.synced} device${res.synced === 1 ? '' : 's'} from Tailscale${extra}`);
|
|
74
151
|
}
|
|
75
152
|
catch (err) {
|
|
76
153
|
spinner.fail(err.message);
|
|
77
154
|
process.exit(1);
|
|
78
155
|
}
|
|
79
156
|
});
|
|
157
|
+
devicesCmd
|
|
158
|
+
.command('ignore <name>')
|
|
159
|
+
.description('Dismiss a node from auto-discovery so it is never re-suggested (and remove it from the registry if present).')
|
|
160
|
+
.action(async (name) => {
|
|
161
|
+
try {
|
|
162
|
+
await removeDevice(name);
|
|
163
|
+
await addIgnored(name);
|
|
164
|
+
console.log(chalk.green(`Ignored '${name}'`) + chalk.gray(" — it won't be suggested again. Undo with `agents devices unignore`."));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.error(chalk.red(err.message));
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
devicesCmd
|
|
172
|
+
.command('unignore <name>')
|
|
173
|
+
.description('Undo `ignore`: allow a node to be discovered and registered again.')
|
|
174
|
+
.action(async (name) => {
|
|
175
|
+
const ok = await removeIgnored(name);
|
|
176
|
+
if (!ok) {
|
|
177
|
+
console.error(chalk.gray(`'${name}' was not ignored.`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
|
|
181
|
+
});
|
|
80
182
|
devicesCmd
|
|
81
183
|
.command('list')
|
|
82
184
|
.alias('ls')
|
|
83
185
|
.description('List registered devices with platform, address, and reachability.')
|
|
84
|
-
.
|
|
186
|
+
.option('--json', 'output the registry as a JSON array (for scripts and hooks)')
|
|
187
|
+
.action(async (opts) => {
|
|
85
188
|
const reg = await loadDevices();
|
|
86
189
|
const names = Object.keys(reg).sort();
|
|
190
|
+
if (opts.json) {
|
|
191
|
+
process.stdout.write(JSON.stringify(names.map((n) => reg[n]), null, 2) + '\n');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
87
194
|
if (names.length === 0) {
|
|
88
195
|
console.log(chalk.gray("No devices. Run 'agents devices sync' or 'agents devices add <name> <user@host>'."));
|
|
89
196
|
return;
|
|
90
197
|
}
|
|
198
|
+
const self = machineId();
|
|
91
199
|
console.log(chalk.bold(`Devices (${names.length})`));
|
|
92
200
|
for (const name of names)
|
|
93
|
-
console.log(deviceSummary(reg[name]));
|
|
201
|
+
console.log(deviceSummary(reg[name], name === self));
|
|
94
202
|
});
|
|
95
203
|
devicesCmd
|
|
96
204
|
.command('show <name>')
|
package/dist/commands/sync.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
import * as path from 'path';
|
|
31
31
|
import chalk from 'chalk';
|
|
32
32
|
import { agentLabel, resolveAgentName } from '../lib/agents.js';
|
|
33
|
-
import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, } from '../lib/versions.js';
|
|
33
|
+
import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildRepoScopedSelection, listRepoNames, } from '../lib/versions.js';
|
|
34
34
|
import { compileRulesForProject } from '../lib/rules/compile.js';
|
|
35
35
|
import { runLaunchSync } from '../lib/project-launch.js';
|
|
36
36
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
@@ -38,11 +38,12 @@ import { runUmbrellaSync } from '../lib/sync-umbrella.js';
|
|
|
38
38
|
/** Register the `agents sync` command. */
|
|
39
39
|
export function registerSyncCommand(program) {
|
|
40
40
|
program
|
|
41
|
-
.command('sync [agentSpec]')
|
|
41
|
+
.command('sync [agentSpec] [repo]')
|
|
42
42
|
.summary('Make this machine current, or sync resources into one agent')
|
|
43
|
-
.description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142",
|
|
43
|
+
.description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", a selector: @latest / @oldest / @pinned (= @default), or @all for every installed version.\n\nAppend a [repo] (or pass --repo) to scope the sync to a single DotAgent repo — system / user / project / <alias>. e.g. "agents sync claude@all system" reconciles only the system repo\'s resources into every installed Claude.\n\nWith NO agent, runs the umbrella verb: fetch remote state (config repos + secrets + sessions) then reconcile it into every installed agent. Scope it with --repos / --secrets / --sessions, --cloud (fetch only), or --local (reconcile only).')
|
|
44
44
|
.option('--agent <agent>', 'Agent identifier (legacy form; prefer the positional spec)')
|
|
45
45
|
.option('--agent-version <version>', 'Version to sync into (legacy form; prefer "agent@version")')
|
|
46
|
+
.option('--repo <name>', 'Scope the sync to a single DotAgent repo: system / user / project / <alias> (also accepted as a positional)')
|
|
46
47
|
.option('--project-dir <path>', 'Path to project-level .agents/ directory containing project-scoped resources')
|
|
47
48
|
.option('--cwd <path>', 'Working directory for discovering project manifest and resources')
|
|
48
49
|
.option('--launch', 'Hot-path mode (shim only): skip version-home reconciliation, run project-scoped compile + workspace mirror + plugin marketplaces', false)
|
|
@@ -55,8 +56,8 @@ export function registerSyncCommand(program) {
|
|
|
55
56
|
.option('--sessions', 'Umbrella: sync session transcripts across machines', false)
|
|
56
57
|
.option('--cloud', 'Umbrella: fetch all remote state but skip the local reconcile', false)
|
|
57
58
|
.option('--local', "Umbrella: reconcile resources into installed agents only (no fetch)", false)
|
|
58
|
-
.action(async (agentSpec, opts) => {
|
|
59
|
-
await runSync(agentSpec, opts);
|
|
59
|
+
.action(async (agentSpec, repo, opts) => {
|
|
60
|
+
await runSync(agentSpec, repo, opts);
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
63
|
/**
|
|
@@ -109,7 +110,7 @@ async function runUmbrella(opts, quiet, outLog, errLog) {
|
|
|
109
110
|
process.exitCode = 1;
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
|
-
async function runSync(agentSpec, opts) {
|
|
113
|
+
async function runSync(agentSpec, repoArg, opts) {
|
|
113
114
|
const quiet = !!opts.quiet;
|
|
114
115
|
const errLog = (msg) => { if (!quiet)
|
|
115
116
|
console.error(msg); };
|
|
@@ -119,16 +120,17 @@ async function runSync(agentSpec, opts) {
|
|
|
119
120
|
let agentId;
|
|
120
121
|
let version;
|
|
121
122
|
// A positional @selector typed by the user (latest/oldest/pinned/default/
|
|
122
|
-
// explicit). parseAgentSpec defaults a missing version to 'latest', so a
|
|
123
|
-
// `agents sync claude` and `agents sync claude@latest` are
|
|
124
|
-
// after parsing — we only treat the version as a selector
|
|
125
|
-
// actually typed, keeping bare `claude` on the
|
|
123
|
+
// all/explicit). parseAgentSpec defaults a missing version to 'latest', so a
|
|
124
|
+
// bare `agents sync claude` and `agents sync claude@latest` are
|
|
125
|
+
// indistinguishable after parsing — we only treat the version as a selector
|
|
126
|
+
// when an '@' was actually typed, keeping bare `claude` on the
|
|
127
|
+
// default-version path.
|
|
126
128
|
let selector;
|
|
127
129
|
if (agentSpec) {
|
|
128
130
|
const parsed = parseAgentSpec(agentSpec);
|
|
129
131
|
if (!parsed) {
|
|
130
132
|
errLog(chalk.red(`Invalid agent spec '${agentSpec}'.`));
|
|
131
|
-
errLog(chalk.gray('Examples: claude, claude@2.1.142, claude@latest, claude@oldest, claude@pinned'));
|
|
133
|
+
errLog(chalk.gray('Examples: claude, claude@2.1.142, claude@latest, claude@oldest, claude@pinned, claude@all'));
|
|
132
134
|
process.exitCode = 1;
|
|
133
135
|
return;
|
|
134
136
|
}
|
|
@@ -136,6 +138,18 @@ async function runSync(agentSpec, opts) {
|
|
|
136
138
|
if (agentSpec.includes('@'))
|
|
137
139
|
selector = parsed.version;
|
|
138
140
|
}
|
|
141
|
+
// Repo scope: --repo flag wins over the positional. Validate against the
|
|
142
|
+
// known DotAgent repos so a typo fails loudly instead of syncing nothing.
|
|
143
|
+
const repoScope = opts.repo || repoArg;
|
|
144
|
+
if (repoScope !== undefined) {
|
|
145
|
+
const known = listRepoNames();
|
|
146
|
+
if (!known.includes(repoScope)) {
|
|
147
|
+
errLog(chalk.red(`Unknown repo '${repoScope}'.`));
|
|
148
|
+
errLog(chalk.gray(`Known repos: ${known.join(', ')}`));
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
139
153
|
if (opts.agent) {
|
|
140
154
|
const resolved = resolveAgentName(opts.agent);
|
|
141
155
|
if (!resolved) {
|
|
@@ -156,6 +170,37 @@ async function runSync(agentSpec, opts) {
|
|
|
156
170
|
await runUmbrella(opts, quiet, outLog, errLog);
|
|
157
171
|
return;
|
|
158
172
|
}
|
|
173
|
+
const projectDir = opts.projectDir;
|
|
174
|
+
const cwd = opts.cwd || process.cwd();
|
|
175
|
+
const force = !!opts.force;
|
|
176
|
+
// ---------- 2a. @all: reconcile every installed version of this agent ----------
|
|
177
|
+
// Non-interactive by design — fanning an interactive preview across N
|
|
178
|
+
// versions is unusable. Honors an optional repo scope.
|
|
179
|
+
if (selector === 'all') {
|
|
180
|
+
const installed = listInstalledVersions(agentId);
|
|
181
|
+
if (installed.length === 0) {
|
|
182
|
+
errLog(chalk.red(`No ${agentLabel(agentId)} versions installed.`));
|
|
183
|
+
errLog(chalk.gray(`Install one: agents add ${agentId}@latest`));
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
let selection;
|
|
188
|
+
if (repoScope) {
|
|
189
|
+
selection = buildRepoScopedSelection(repoScope, cwd);
|
|
190
|
+
if (Object.keys(selection).length === 0) {
|
|
191
|
+
outLog(chalk.gray(`Nothing from repo '${repoScope}' to sync.`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const scopeLabel = repoScope ? chalk.gray(` (repo: ${repoScope})`) : '';
|
|
196
|
+
outLog(chalk.cyan(`Syncing ${installed.length} ${agentLabel(agentId)} version(s)${scopeLabel}.`));
|
|
197
|
+
for (const v of installed) {
|
|
198
|
+
const result = syncResourcesToVersion(agentId, v, selection, { projectDir, cwd, force });
|
|
199
|
+
if (!quiet)
|
|
200
|
+
printSyncDetail(result, agentId, v, cwd);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
159
204
|
// ---------- 2. Resolve version (project pin → global default → sole installed) ----------
|
|
160
205
|
// A positional @selector wins over the default-resolution below.
|
|
161
206
|
// @latest / @oldest → newest / oldest installed (process.exit if none)
|
|
@@ -197,15 +242,26 @@ async function runSync(agentSpec, opts) {
|
|
|
197
242
|
process.exitCode = 1;
|
|
198
243
|
return;
|
|
199
244
|
}
|
|
200
|
-
const projectDir = opts.projectDir;
|
|
201
|
-
const cwd = opts.cwd || process.cwd();
|
|
202
245
|
// ---------- 3. --launch mode bypasses everything below ----------
|
|
203
246
|
if (opts.launch) {
|
|
204
247
|
runLaunchMode(agentId, version, cwd, quiet);
|
|
205
248
|
return;
|
|
206
249
|
}
|
|
250
|
+
// ---------- 3b. Repo-scoped single-version sync ----------
|
|
251
|
+
// An explicit --repo / positional repo is a targeted request, so skip the
|
|
252
|
+
// interactive preview and reconcile just that repo's resources.
|
|
253
|
+
if (repoScope) {
|
|
254
|
+
const scoped = buildRepoScopedSelection(repoScope, cwd);
|
|
255
|
+
if (Object.keys(scoped).length === 0) {
|
|
256
|
+
outLog(chalk.gray(`Nothing from repo '${repoScope}' to sync into ${agentLabel(agentId)}@${version}.`));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const result = syncResourcesToVersion(agentId, version, scoped, { projectDir, cwd, force });
|
|
260
|
+
if (!quiet)
|
|
261
|
+
printSyncDetail(result, agentId, version, cwd);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
207
264
|
// ---------- 4. Decide selection (interactive preview vs auto) ----------
|
|
208
|
-
const force = !!opts.force;
|
|
209
265
|
const yes = !!opts.yes;
|
|
210
266
|
const interactive = !quiet && !yes && isInteractiveTerminal();
|
|
211
267
|
let selection;
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -111,10 +111,6 @@ export interface AccountInfo {
|
|
|
111
111
|
}
|
|
112
112
|
/** Return the email address associated with the agent's auth config, or null. */
|
|
113
113
|
export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
|
|
114
|
-
/**
|
|
115
|
-
* Extract full account information (identity, plan, usage status, credits) from
|
|
116
|
-
* the agent's local auth/config files. Supports Claude, Codex, and Gemini.
|
|
117
|
-
*/
|
|
118
114
|
export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
|
|
119
115
|
/**
|
|
120
116
|
* Determine when the agent was last used by checking session file mtimes,
|
package/dist/lib/agents.js
CHANGED
|
@@ -410,6 +410,7 @@ export const AGENTS = {
|
|
|
410
410
|
npmPackage: '',
|
|
411
411
|
installScript: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
|
412
412
|
configDir: path.join(HOME, '.gemini', 'antigravity-cli'),
|
|
413
|
+
authFiles: ['antigravity-oauth-token'],
|
|
413
414
|
commandsDir: path.join(HOME, '.gemini', 'antigravity-cli', 'commands'),
|
|
414
415
|
commandsSubdir: 'commands',
|
|
415
416
|
skillsDir: path.join(HOME, '.gemini', 'antigravity-cli', 'skills'),
|
|
@@ -470,6 +471,7 @@ export const AGENTS = {
|
|
|
470
471
|
npmPackage: '@moonshot-ai/kimi-code',
|
|
471
472
|
installScript: 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash',
|
|
472
473
|
configDir: path.join(HOME, '.kimi-code'),
|
|
474
|
+
authFiles: ['credentials/kimi-code.json'],
|
|
473
475
|
commandsDir: '',
|
|
474
476
|
commandsSubdir: '',
|
|
475
477
|
skillsDir: path.join(HOME, '.kimi-code', 'skills'),
|
|
@@ -494,7 +496,8 @@ export const AGENTS = {
|
|
|
494
496
|
},
|
|
495
497
|
// Factory AI Droid CLI (`droid`) — agentic coding CLI from factory.ai.
|
|
496
498
|
// Install: `curl -fsSL https://app.factory.ai/cli | sh` (no npm package).
|
|
497
|
-
// Binary is NOT in node_modules/.bin —
|
|
499
|
+
// Binary is NOT in node_modules/.bin — the shim resolves the fixed install
|
|
500
|
+
// path ~/.local/bin/droid directly (see the droid branch in shims.ts).
|
|
498
501
|
// Config: `~/.factory/` (settings.json, mcp.json, droids/, commands/).
|
|
499
502
|
// Memory: native AGENTS.md. Subagents = custom droids (top-level .md files
|
|
500
503
|
// in ~/.factory/droids/). Config isolation rides the ~/.factory symlink
|
|
@@ -508,6 +511,7 @@ export const AGENTS = {
|
|
|
508
511
|
npmPackage: '',
|
|
509
512
|
installScript: 'curl -fsSL https://app.factory.ai/cli | sh',
|
|
510
513
|
configDir: path.join(HOME, '.factory'),
|
|
514
|
+
authFiles: ['auth.v2.file', 'auth.v2.key'],
|
|
511
515
|
commandsDir: path.join(HOME, '.factory', 'commands'),
|
|
512
516
|
commandsSubdir: 'commands',
|
|
513
517
|
skillsDir: '', // no skills concept
|
|
@@ -707,6 +711,7 @@ export const UNMANAGED_DETECTION_CANDIDATES = [
|
|
|
707
711
|
'gemini',
|
|
708
712
|
'grok',
|
|
709
713
|
'copilot',
|
|
714
|
+
'droid',
|
|
710
715
|
];
|
|
711
716
|
/**
|
|
712
717
|
* Detect existing agent installations that are NOT yet managed by agents-cli.
|
|
@@ -770,6 +775,64 @@ export async function getAccountEmail(agentId, home) {
|
|
|
770
775
|
* Extract full account information (identity, plan, usage status, credits) from
|
|
771
776
|
* the agent's local auth/config files. Supports Claude, Codex, and Gemini.
|
|
772
777
|
*/
|
|
778
|
+
/**
|
|
779
|
+
* Resolve a file-auth agent's credential file. Sign-in is account-global, but
|
|
780
|
+
* each installed version gets an isolated home; the credential physically lives
|
|
781
|
+
* only in the home the user logged in under (the one the `~/.<config>` symlink
|
|
782
|
+
* targets). Check the per-version `base` first, then fall back to the active
|
|
783
|
+
* config location under the real HOME so every installed version reflects the
|
|
784
|
+
* true account state (droid/antigravity/kimi all stored login per-version-home
|
|
785
|
+
* and showed non-active versions as "not signed in"). Returns the first
|
|
786
|
+
* existing path, or null.
|
|
787
|
+
*/
|
|
788
|
+
function resolveAccountCredentialPath(base, ...segments) {
|
|
789
|
+
const perVersion = path.join(base, ...segments);
|
|
790
|
+
try {
|
|
791
|
+
if (fs.existsSync(perVersion))
|
|
792
|
+
return perVersion;
|
|
793
|
+
}
|
|
794
|
+
catch { /* unreadable */ }
|
|
795
|
+
const active = path.join(process.env.AGENTS_REAL_HOME || os.homedir(), ...segments);
|
|
796
|
+
if (active !== perVersion) {
|
|
797
|
+
try {
|
|
798
|
+
if (fs.existsSync(active))
|
|
799
|
+
return active;
|
|
800
|
+
}
|
|
801
|
+
catch { /* unreadable */ }
|
|
802
|
+
}
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
let cachedAgyKeychainSignedIn;
|
|
806
|
+
/**
|
|
807
|
+
* Antigravity (`agy`, a Codeium/Windsurf-based CLI) stores its OAuth token in
|
|
808
|
+
* the macOS keychain — service `gemini`, account `antigravity` — NOT a file.
|
|
809
|
+
* The file path (`antigravity-oauth-token`) only exists on Linux, where the Go
|
|
810
|
+
* keyring falls back to disk. Probe the keychain for existence (metadata only;
|
|
811
|
+
* `-w` omitted so it never prompts). Cached per process — the keychain is
|
|
812
|
+
* account-global, so one probe covers every installed version. Returns false on
|
|
813
|
+
* non-macOS (the file path handles those).
|
|
814
|
+
*/
|
|
815
|
+
async function antigravityKeychainSignedIn() {
|
|
816
|
+
if (cachedAgyKeychainSignedIn !== undefined)
|
|
817
|
+
return cachedAgyKeychainSignedIn;
|
|
818
|
+
// Test isolation: the real macOS keychain can't be sandboxed per-test, so
|
|
819
|
+
// allow suites asserting "signed out" to opt out of the probe (same spirit as
|
|
820
|
+
// AGENTS_REAL_HOME). Not cached, so tests can toggle it.
|
|
821
|
+
if (process.env.AGENTS_NO_KEYCHAIN_PROBE === '1')
|
|
822
|
+
return false;
|
|
823
|
+
if (process.platform !== 'darwin') {
|
|
824
|
+
cachedAgyKeychainSignedIn = false;
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
await execFileAsync('security', ['find-generic-password', '-s', 'gemini', '-a', 'antigravity'], { timeout: 3000 });
|
|
829
|
+
cachedAgyKeychainSignedIn = true;
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
cachedAgyKeychainSignedIn = false;
|
|
833
|
+
}
|
|
834
|
+
return cachedAgyKeychainSignedIn;
|
|
835
|
+
}
|
|
773
836
|
export async function getAccountInfo(agentId, home) {
|
|
774
837
|
const base = home || os.homedir();
|
|
775
838
|
const empty = {
|
|
@@ -906,40 +969,60 @@ export async function getAccountInfo(agentId, home) {
|
|
|
906
969
|
return { ...empty, email, signedIn: !!email, lastActive };
|
|
907
970
|
}
|
|
908
971
|
case 'grok': {
|
|
909
|
-
// Grok stores auth in ~/.grok/auth.json
|
|
972
|
+
// Grok stores auth in ~/.grok/auth.json as a map keyed by
|
|
973
|
+
// "<oidc_issuer>::<client_id>" -> { email, user_id, refresh_token,
|
|
974
|
+
// create_time, expires_at, team_id, ... }. (Older builds wrote a flat
|
|
975
|
+
// object with a top-level email.) The old code only read a TOP-LEVEL
|
|
976
|
+
// `email`, so the current nested format always looked signed-out even
|
|
977
|
+
// when logged in. Read the newest account record: a refresh token means
|
|
978
|
+
// signed in, and we surface the email/ids like claude/codex.
|
|
979
|
+
const authPath = resolveAccountCredentialPath(base, '.grok', 'auth.json');
|
|
980
|
+
if (!authPath)
|
|
981
|
+
return { ...empty, lastActive };
|
|
910
982
|
try {
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
983
|
+
const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
|
|
984
|
+
const records = (data && typeof data === 'object' ? [data, ...Object.values(data)] : [])
|
|
985
|
+
.filter((r) => !!r && typeof r === 'object');
|
|
986
|
+
const account = records
|
|
987
|
+
.filter(r => typeof r.refresh_token === 'string' || typeof r.email === 'string')
|
|
988
|
+
.sort((a, b) => String(b.create_time || '').localeCompare(String(a.create_time || '')))[0];
|
|
989
|
+
if (account) {
|
|
990
|
+
const email = typeof account.email === 'string' ? account.email : null;
|
|
991
|
+
const accountId = normalizeIdentityPart(account.user_id ?? account.principal_id);
|
|
992
|
+
const organizationId = normalizeIdentityPart(account.team_id);
|
|
993
|
+
const accountKey = buildIdentityKey(agentId, [['user', accountId], ['org', organizationId]]);
|
|
994
|
+
return { ...empty, email, accountId, organizationId, accountKey, signedIn: true, lastActive };
|
|
916
995
|
}
|
|
917
996
|
}
|
|
918
997
|
catch { }
|
|
919
998
|
return { ...empty, lastActive };
|
|
920
999
|
}
|
|
921
1000
|
case 'antigravity': {
|
|
922
|
-
// Antigravity (`agy`) stores a Google OAuth
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1001
|
+
// Antigravity (`agy`) stores a consumer Google OAuth grant (access +
|
|
1002
|
+
// refresh token, no id_token) — presence of a refresh token is the only
|
|
1003
|
+
// signed-in signal we can derive without a network call. Storage is
|
|
1004
|
+
// platform-split: on Linux it's a file at
|
|
1005
|
+
// ~/.gemini/antigravity-cli/antigravity-oauth-token; on macOS the Go
|
|
1006
|
+
// keyring puts it in the keychain (service 'gemini', account
|
|
1007
|
+
// 'antigravity'), so no file exists — check both.
|
|
1008
|
+
const tokenPath = resolveAccountCredentialPath(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
|
|
1009
|
+
if (tokenPath) {
|
|
1010
|
+
const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
|
|
1011
|
+
if (typeof data?.token?.refresh_token === 'string' && data.token.refresh_token) {
|
|
1012
|
+
return { ...empty, signedIn: true, lastActive };
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (await antigravityKeychainSignedIn())
|
|
1016
|
+
return { ...empty, signedIn: true, lastActive };
|
|
1017
|
+
return { ...empty, lastActive };
|
|
935
1018
|
}
|
|
936
1019
|
case 'kimi': {
|
|
937
1020
|
// Kimi Code stores OAuth credentials at
|
|
938
1021
|
// ~/.kimi-code/credentials/kimi-code.json. The access token is a JWT
|
|
939
1022
|
// whose payload carries an opaque user_id (no email), so we report
|
|
940
1023
|
// signed-in state plus a stable account key for usage dedup.
|
|
941
|
-
const credPath =
|
|
942
|
-
if (!
|
|
1024
|
+
const credPath = resolveAccountCredentialPath(base, '.kimi-code', 'credentials', 'kimi-code.json');
|
|
1025
|
+
if (!credPath)
|
|
943
1026
|
return { ...empty, lastActive };
|
|
944
1027
|
const data = JSON.parse(await fs.promises.readFile(credPath, 'utf-8'));
|
|
945
1028
|
const accessToken = data?.access_token;
|
|
@@ -950,6 +1033,18 @@ export async function getAccountInfo(agentId, home) {
|
|
|
950
1033
|
const accountKey = buildIdentityKey(agentId, [['user', userId]]);
|
|
951
1034
|
return { ...empty, signedIn: true, accountId: userId, accountKey, lastActive };
|
|
952
1035
|
}
|
|
1036
|
+
case 'droid': {
|
|
1037
|
+
// Factory Droid stores auth at ~/.factory/auth.v2.file (+ auth.v2.key,
|
|
1038
|
+
// an encrypted blob). No email/JWT is readable locally, so presence of
|
|
1039
|
+
// the auth file is the only signed-in signal we can derive without a
|
|
1040
|
+
// network call — same pattern as antigravity/kimi. `.factory` is the
|
|
1041
|
+
// config dir on every platform (macOS/Linux ~/.factory, Windows
|
|
1042
|
+
// %USERPROFILE%\.factory), so path.join keeps this cross-platform.
|
|
1043
|
+
const authPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
|
|
1044
|
+
if (!authPath)
|
|
1045
|
+
return { ...empty, lastActive };
|
|
1046
|
+
return { ...empty, signedIn: true, lastActive };
|
|
1047
|
+
}
|
|
953
1048
|
default:
|
|
954
1049
|
return { ...empty, lastActive };
|
|
955
1050
|
}
|
|
@@ -1051,6 +1146,8 @@ function getSessionDir(agentId, base) {
|
|
|
1051
1146
|
// Copilot persists sessions at ~/.copilot/session-state/<id>/events.jsonl.
|
|
1052
1147
|
// The events.jsonl is the canonical NDJSON event stream per session.
|
|
1053
1148
|
return path.join(base, '.copilot', 'session-state');
|
|
1149
|
+
case 'droid':
|
|
1150
|
+
return path.join(base, '.factory', 'sessions');
|
|
1054
1151
|
default:
|
|
1055
1152
|
return null;
|
|
1056
1153
|
}
|
|
@@ -1061,6 +1158,7 @@ function getSessionExtension(agentId) {
|
|
|
1061
1158
|
case 'claude':
|
|
1062
1159
|
case 'codex':
|
|
1063
1160
|
case 'copilot':
|
|
1161
|
+
case 'droid':
|
|
1064
1162
|
return '.jsonl';
|
|
1065
1163
|
case 'gemini':
|
|
1066
1164
|
return '.json';
|
|
@@ -1661,6 +1759,8 @@ export const AGENT_NAME_ALIASES = {
|
|
|
1661
1759
|
gk: 'grok',
|
|
1662
1760
|
kimi: 'kimi',
|
|
1663
1761
|
'kimi-code': 'kimi',
|
|
1762
|
+
factory: 'droid',
|
|
1763
|
+
'factory-ai': 'droid',
|
|
1664
1764
|
};
|
|
1665
1765
|
/**
|
|
1666
1766
|
* Resolve a user-provided agent name (alias, shorthand, or canonical) to its AgentId.
|
|
@@ -8,6 +8,10 @@ import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
|
|
|
8
8
|
// so existing importers of `shellQuote` from this module keep working.
|
|
9
9
|
import { shellQuote } from '../../ssh-exec.js';
|
|
10
10
|
export { shellQuote };
|
|
11
|
+
// The `ssh -L` tunnel spawn is shared with `agents computer --host`; it lives in
|
|
12
|
+
// the single ssh-tunnel helper. Calling it with no options preserves this
|
|
13
|
+
// driver's original foreground, stderr-captured behavior exactly.
|
|
14
|
+
import { startSSHTunnel } from '../../ssh-tunnel.js';
|
|
11
15
|
export async function connectSSH(endpoint, profile) {
|
|
12
16
|
const url = new URL(endpoint);
|
|
13
17
|
if (url.protocol !== 'ssh:') {
|
|
@@ -102,41 +106,6 @@ export async function connectSSH(endpoint, profile) {
|
|
|
102
106
|
},
|
|
103
107
|
};
|
|
104
108
|
}
|
|
105
|
-
function startSSHTunnel(user, host, localPort, remotePort) {
|
|
106
|
-
return new Promise((resolve, reject) => {
|
|
107
|
-
const args = [
|
|
108
|
-
'-L',
|
|
109
|
-
`${localPort}:127.0.0.1:${remotePort}`,
|
|
110
|
-
`${user}@${host}`,
|
|
111
|
-
'-N',
|
|
112
|
-
'-o',
|
|
113
|
-
'StrictHostKeyChecking=accept-new',
|
|
114
|
-
'-o',
|
|
115
|
-
'BatchMode=yes',
|
|
116
|
-
'-o',
|
|
117
|
-
'ConnectTimeout=10',
|
|
118
|
-
];
|
|
119
|
-
const tunnel = spawn('ssh', args, {
|
|
120
|
-
stdio: ['ignore', 'ignore', 'pipe'],
|
|
121
|
-
detached: false,
|
|
122
|
-
});
|
|
123
|
-
let stderr = '';
|
|
124
|
-
tunnel.stderr?.on('data', (data) => {
|
|
125
|
-
stderr += data.toString();
|
|
126
|
-
});
|
|
127
|
-
tunnel.on('error', (err) => {
|
|
128
|
-
reject(new Error(`SSH tunnel failed: ${err.message}`));
|
|
129
|
-
});
|
|
130
|
-
setTimeout(() => {
|
|
131
|
-
if (tunnel.killed) {
|
|
132
|
-
reject(new Error(`SSH tunnel died: ${stderr}`));
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
resolve(tunnel);
|
|
136
|
-
}
|
|
137
|
-
}, 500);
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
109
|
async function waitForPort(port, timeoutMs) {
|
|
141
110
|
const start = Date.now();
|
|
142
111
|
while (Date.now() - start < timeoutMs) {
|