@phnx-labs/agents-cli 1.20.67 → 1.20.69
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 +100 -0
- package/README.md +14 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/check.js +96 -2
- package/dist/commands/computer.d.ts +26 -0
- package/dist/commands/computer.js +173 -149
- package/dist/commands/doctor.js +37 -9
- package/dist/commands/exec.d.ts +18 -0
- package/dist/commands/exec.js +126 -6
- package/dist/commands/fork.d.ts +9 -0
- package/dist/commands/fork.js +67 -0
- package/dist/commands/plugins.js +11 -2
- package/dist/commands/repo.js +47 -2
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +131 -0
- package/dist/commands/setup-browser.d.ts +18 -0
- package/dist/commands/setup-browser.js +142 -0
- package/dist/commands/setup-computer.d.ts +19 -0
- package/dist/commands/setup-computer.js +133 -0
- package/dist/commands/setup-share.d.ts +17 -0
- package/dist/commands/setup-share.js +85 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +58 -1
- package/dist/commands/share.d.ts +15 -0
- package/dist/commands/share.js +10 -4
- package/dist/commands/ssh.js +267 -2
- package/dist/commands/teams.d.ts +11 -0
- package/dist/commands/teams.js +39 -1
- package/dist/commands/view.d.ts +4 -14
- package/dist/commands/view.js +39 -36
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +21 -5
- package/dist/lib/agents.js +59 -24
- package/dist/lib/auth-health.d.ts +103 -0
- package/dist/lib/auth-health.js +232 -0
- package/dist/lib/browser/chrome.d.ts +9 -0
- package/dist/lib/browser/chrome.js +22 -0
- package/dist/lib/browser/profiles.js +4 -25
- package/dist/lib/cli-entry.d.ts +23 -0
- package/dist/lib/cli-entry.js +116 -0
- package/dist/lib/computer/download.d.ts +51 -0
- package/dist/lib/computer/download.js +145 -0
- package/dist/lib/computer-rpc.js +9 -3
- package/dist/lib/daemon.d.ts +2 -2
- package/dist/lib/daemon.js +8 -89
- package/dist/lib/devices/connect.d.ts +15 -0
- package/dist/lib/devices/connect.js +17 -5
- package/dist/lib/devices/fleet.d.ts +23 -0
- package/dist/lib/devices/fleet.js +38 -0
- package/dist/lib/devices/health-report.d.ts +46 -0
- package/dist/lib/devices/health-report.js +159 -0
- package/dist/lib/devices/health.d.ts +14 -4
- package/dist/lib/devices/health.js +49 -8
- package/dist/lib/devices/terminfo.d.ts +44 -0
- package/dist/lib/devices/terminfo.js +167 -0
- package/dist/lib/git.d.ts +10 -0
- package/dist/lib/git.js +23 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/ready.js +5 -3
- package/dist/lib/hosts/remote-cmd.d.ts +12 -0
- package/dist/lib/hosts/remote-cmd.js +17 -1
- package/dist/lib/mcp.d.ts +21 -0
- package/dist/lib/mcp.js +278 -13
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/resources/mcp.js +20 -342
- package/dist/lib/rotate.d.ts +12 -8
- package/dist/lib/rotate.js +22 -23
- package/dist/lib/routines.d.ts +13 -0
- package/dist/lib/routines.js +21 -0
- package/dist/lib/runner.js +18 -40
- package/dist/lib/secrets/agent.js +11 -15
- package/dist/lib/session/fork.d.ts +32 -0
- package/dist/lib/session/fork.js +101 -0
- package/dist/lib/share/capture.js +21 -3
- package/dist/lib/signin-badge.d.ts +41 -0
- package/dist/lib/signin-badge.js +64 -0
- package/dist/lib/ssh-exec.d.ts +5 -0
- package/dist/lib/ssh-exec.js +55 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/usage.d.ts +40 -3
- package/dist/lib/usage.js +147 -16
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
|
|
|
50
50
|
// module on each invocation (which loaded the whole ~50-module tree before the
|
|
51
51
|
// first byte of output), the registry maps a command name to a thunk that
|
|
52
52
|
// imports only what that command needs. See src/lib/startup/command-registry.ts.
|
|
53
|
-
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadShare, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
53
|
+
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadShare, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
54
54
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
55
55
|
import { renderWhatsNew } from './lib/whats-new.js';
|
|
56
56
|
import { emit, redactArgs } from './lib/events.js';
|
|
@@ -690,6 +690,7 @@ async function registerAllEagerCommands() {
|
|
|
690
690
|
await reg(loadRoutines);
|
|
691
691
|
await reg(loadMonitors);
|
|
692
692
|
await reg(loadRun);
|
|
693
|
+
await reg(loadFork);
|
|
693
694
|
await reg(loadDefaults);
|
|
694
695
|
await reg(loadModels);
|
|
695
696
|
await reg(loadPrune);
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -139,13 +139,25 @@ export interface AccountInfo {
|
|
|
139
139
|
*/
|
|
140
140
|
export declare function formatClaudeOrgLabel(orgType: string | null | undefined): string | null;
|
|
141
141
|
/**
|
|
142
|
-
* Short badge identifying which
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
142
|
+
* Short badge identifying which ORG an account belongs to — "Turing Labs" for a
|
|
143
|
+
* multi-seat Team/Enterprise org, whose name is real identity that disambiguates
|
|
144
|
+
* a Team seat from a same-email personal plan. Returns null for personal plans
|
|
145
|
+
* (Max/Pro/Free): the tier label now lives in the aligned plan column, so a badge
|
|
146
|
+
* would only duplicate it, and a personal org's name is auto-generated boilerplate
|
|
147
|
+
* ("<email>'s Organization"), not identity. Also null when the account carries no
|
|
148
|
+
* organizationType (signed out, non-Claude agents, configs predating the field).
|
|
147
149
|
*/
|
|
148
150
|
export declare function accountOrgBadge(info?: Pick<AccountInfo, 'organizationType' | 'organizationName'> | null): string | null;
|
|
151
|
+
/** Agents whose local credential formats expose enough state for account selection. */
|
|
152
|
+
export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "grok", "antigravity", "kimi", "droid", "opencode"];
|
|
153
|
+
/** Whether agents-cli can determine this agent's per-version sign-in state. */
|
|
154
|
+
export declare function supportsAccountInspection(agentId: AgentId): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Human-readable account identity shared by every account-aware surface.
|
|
157
|
+
* Prefer email, append a multi-seat Claude organization name when present,
|
|
158
|
+
* then fall back to a non-secret account id or a generic signed-in label.
|
|
159
|
+
*/
|
|
160
|
+
export declare function accountDisplayLabel(info?: Pick<AccountInfo, 'email' | 'accountId' | 'signedIn' | 'organizationType' | 'organizationName'> | null): string;
|
|
149
161
|
/** Return the email address associated with the agent's auth config, or null. */
|
|
150
162
|
export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
|
|
151
163
|
/** Decrypted contents of Droid's auth.v2.file (subset we consume). */
|
|
@@ -303,6 +315,10 @@ export declare function getUserMcpConfigPath(agentId: AgentId): string;
|
|
|
303
315
|
* Get MCP config path for a specific HOME directory (used for version-managed agents).
|
|
304
316
|
*/
|
|
305
317
|
export declare function getMcpConfigPathForHome(agentId: AgentId, home: string): string;
|
|
318
|
+
/**
|
|
319
|
+
* Get project-scoped MCP config path for an agent.
|
|
320
|
+
*/
|
|
321
|
+
export declare function getProjectMcpConfigPath(agentId: AgentId, cwd?: string): string;
|
|
306
322
|
/**
|
|
307
323
|
* Parse MCP config based on agent type.
|
|
308
324
|
*/
|
package/dist/lib/agents.js
CHANGED
|
@@ -978,20 +978,51 @@ export function formatClaudeOrgLabel(orgType) {
|
|
|
978
978
|
.join(' ');
|
|
979
979
|
}
|
|
980
980
|
/**
|
|
981
|
-
* Short badge identifying which
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
981
|
+
* Short badge identifying which ORG an account belongs to — "Turing Labs" for a
|
|
982
|
+
* multi-seat Team/Enterprise org, whose name is real identity that disambiguates
|
|
983
|
+
* a Team seat from a same-email personal plan. Returns null for personal plans
|
|
984
|
+
* (Max/Pro/Free): the tier label now lives in the aligned plan column, so a badge
|
|
985
|
+
* would only duplicate it, and a personal org's name is auto-generated boilerplate
|
|
986
|
+
* ("<email>'s Organization"), not identity. Also null when the account carries no
|
|
987
|
+
* organizationType (signed out, non-Claude agents, configs predating the field).
|
|
986
988
|
*/
|
|
987
989
|
export function accountOrgBadge(info) {
|
|
988
|
-
const label = formatClaudeOrgLabel(info?.organizationType);
|
|
989
|
-
if (!label)
|
|
990
|
-
return null;
|
|
991
990
|
const isMultiSeat = info?.organizationType === 'claude_team' || info?.organizationType === 'claude_enterprise';
|
|
992
991
|
if (isMultiSeat && info?.organizationName)
|
|
993
|
-
return
|
|
994
|
-
return
|
|
992
|
+
return info.organizationName;
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
/** Agents whose local credential formats expose enough state for account selection. */
|
|
996
|
+
export const ACCOUNT_INSPECTION_AGENT_IDS = [
|
|
997
|
+
'claude',
|
|
998
|
+
'codex',
|
|
999
|
+
'gemini',
|
|
1000
|
+
'grok',
|
|
1001
|
+
'antigravity',
|
|
1002
|
+
'kimi',
|
|
1003
|
+
'droid',
|
|
1004
|
+
'opencode',
|
|
1005
|
+
];
|
|
1006
|
+
const ACCOUNT_INSPECTION_AGENTS = new Set(ACCOUNT_INSPECTION_AGENT_IDS);
|
|
1007
|
+
/** Whether agents-cli can determine this agent's per-version sign-in state. */
|
|
1008
|
+
export function supportsAccountInspection(agentId) {
|
|
1009
|
+
return ACCOUNT_INSPECTION_AGENTS.has(agentId);
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Human-readable account identity shared by every account-aware surface.
|
|
1013
|
+
* Prefer email, append a multi-seat Claude organization name when present,
|
|
1014
|
+
* then fall back to a non-secret account id or a generic signed-in label.
|
|
1015
|
+
*/
|
|
1016
|
+
export function accountDisplayLabel(info) {
|
|
1017
|
+
if (!info)
|
|
1018
|
+
return '';
|
|
1019
|
+
if (info.email) {
|
|
1020
|
+
const badge = accountOrgBadge(info);
|
|
1021
|
+
return badge ? `${info.email} (${badge})` : info.email;
|
|
1022
|
+
}
|
|
1023
|
+
if (info.signedIn)
|
|
1024
|
+
return info.accountId ? `id:${info.accountId}` : 'signed in';
|
|
1025
|
+
return '';
|
|
995
1026
|
}
|
|
996
1027
|
/** Return the email address associated with the agent's auth config, or null. */
|
|
997
1028
|
export async function getAccountEmail(agentId, home) {
|
|
@@ -1321,19 +1352,23 @@ export async function getAccountInfo(agentId, home) {
|
|
|
1321
1352
|
['org', organizationId],
|
|
1322
1353
|
]);
|
|
1323
1354
|
const usageKey = buildIdentityKey(agentId, [['org', organizationId]]);
|
|
1324
|
-
// Plan is derived from .claude.json's
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1355
|
+
// Plan tier is derived from .claude.json's organizationType, which carries
|
|
1356
|
+
// the TRUE tier (claude_max → "Max", claude_pro → "Pro", claude_team →
|
|
1357
|
+
// "Team") and is already in-hand from the config we just read — no Keychain
|
|
1358
|
+
// prompt. billingType only distinguishes "has a Stripe subscription" and so
|
|
1359
|
+
// mislabels every Max account as "Pro"; keep it as a fallback for older
|
|
1360
|
+
// configs predating organizationType. (Reading subscriptionType from the
|
|
1361
|
+
// keychain item would force a macOS Keychain ACL prompt on every `agents
|
|
1362
|
+
// run`, so we deliberately avoid it — organizationType gives us the tier
|
|
1363
|
+
// without that cost.)
|
|
1364
|
+
let plan = formatClaudeOrgLabel(oa?.organizationType);
|
|
1365
|
+
if (!plan) {
|
|
1366
|
+
if (oa?.billingType === 'stripe_subscription') {
|
|
1367
|
+
plan = 'Pro';
|
|
1368
|
+
}
|
|
1369
|
+
else if (oa?.billingType) {
|
|
1370
|
+
plan = oa.billingType;
|
|
1371
|
+
}
|
|
1337
1372
|
}
|
|
1338
1373
|
// usageStatus is NOT derived from cachedExtraUsageDisabledReason. That
|
|
1339
1374
|
// field reports why pay-as-you-go overage is off (out_of_credits = no
|
|
@@ -2194,7 +2229,7 @@ export function getMcpConfigPathForHome(agentId, home) {
|
|
|
2194
2229
|
/**
|
|
2195
2230
|
* Get project-scoped MCP config path for an agent.
|
|
2196
2231
|
*/
|
|
2197
|
-
function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
|
|
2232
|
+
export function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
|
|
2198
2233
|
switch (agentId) {
|
|
2199
2234
|
case 'claude':
|
|
2200
2235
|
// Claude uses .mcp.json at project root for project-scoped MCPs
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type AccountInfo } from './agents.js';
|
|
2
|
+
import type { AgentId } from './types.js';
|
|
3
|
+
import { type ProviderProbe } from './usage.js';
|
|
4
|
+
/**
|
|
5
|
+
* - `live` — completed an authenticated request (200).
|
|
6
|
+
* - `revoked` — the server rejected the token (401/403).
|
|
7
|
+
* - `expired` — locally-detected expiry; not network-verified (no refresh on the read path).
|
|
8
|
+
* - `rate_limited`— token works but is throttled right now (429).
|
|
9
|
+
* - `unverified` — credential present, not locally expired, but this agent has no in-repo probe endpoint (codex/grok).
|
|
10
|
+
* - `unconfigured`— no usable credential on disk.
|
|
11
|
+
* - `error` — network/other failure; verdict indeterminate (keep the last known one).
|
|
12
|
+
*/
|
|
13
|
+
export type AuthVerdict = 'live' | 'revoked' | 'expired' | 'rate_limited' | 'unverified' | 'unconfigured' | 'error';
|
|
14
|
+
export interface AuthHealth {
|
|
15
|
+
verdict: AuthVerdict;
|
|
16
|
+
/** epoch ms of the probe. */
|
|
17
|
+
checkedAt: number;
|
|
18
|
+
/** optional short human detail (e.g. "HTTP 401", a network error). */
|
|
19
|
+
detail?: string;
|
|
20
|
+
/** account label for display (email / id), when known. Never part of the key. */
|
|
21
|
+
account?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Agents with a live network probe wired up today. The rest are best-effort. */
|
|
24
|
+
export declare const LIVE_PROBE_AGENTS: ReadonlySet<AgentId>;
|
|
25
|
+
/** Map an HTTP status from a live probe to a verdict. */
|
|
26
|
+
export declare function classifyHttpStatus(status: number): AuthVerdict;
|
|
27
|
+
/** Turn a raw provider probe (from usage.ts) into a verdict. */
|
|
28
|
+
export declare function verdictFromProbe(probe: ProviderProbe): AuthVerdict;
|
|
29
|
+
/** A short human detail line for a probe result (rendered under --verbose). */
|
|
30
|
+
export declare function probeDetail(probe: ProviderProbe): string | undefined;
|
|
31
|
+
/** Uncolored glyph for a verdict (color is applied by the caller). */
|
|
32
|
+
export declare function verdictGlyph(verdict: AuthVerdict): string;
|
|
33
|
+
/** One-word label for matrices/verbose output. */
|
|
34
|
+
export declare function verdictLabel(verdict: AuthVerdict): string;
|
|
35
|
+
/** Roll a set of verdicts (one host×agent's installs) into counts for a matrix cell. */
|
|
36
|
+
export interface VerdictSummary {
|
|
37
|
+
live: number;
|
|
38
|
+
/** revoked — the server rejected the token (401/403). Genuinely needs re-login. */
|
|
39
|
+
bad: number;
|
|
40
|
+
/**
|
|
41
|
+
* expired / rate_limited / unverified / error — degraded or unknown, but NOT
|
|
42
|
+
* "re-login now". `expired` is soft for kimi/droid (their CLIs refresh the
|
|
43
|
+
* token on next launch; we don't refresh on the read path), so it must not be
|
|
44
|
+
* lumped with revoked or we'd cry wolf on a self-healing token.
|
|
45
|
+
*/
|
|
46
|
+
warn: number;
|
|
47
|
+
total: number;
|
|
48
|
+
}
|
|
49
|
+
export declare function summarizeVerdicts(verdicts: AuthVerdict[]): VerdictSummary;
|
|
50
|
+
/** Verdicts that mean "this token was rejected by the server — re-login required". */
|
|
51
|
+
export declare function isDeadVerdict(verdict: AuthVerdict): boolean;
|
|
52
|
+
/** Human "3m ago" style age for a checkedAt timestamp. */
|
|
53
|
+
export declare function formatCheckedAge(checkedAt: number, now?: number): string;
|
|
54
|
+
/**
|
|
55
|
+
* Human account label for display (email, else id). NOT used in the cache key —
|
|
56
|
+
* two installs on one host can hold the same account with independently valid
|
|
57
|
+
* tokens, so the key is keyed by version (below), not account.
|
|
58
|
+
*/
|
|
59
|
+
export declare function authAccountLabel(info: Pick<AccountInfo, 'email' | 'accountId' | 'userId'> | null | undefined): string | undefined;
|
|
60
|
+
/** Cache key: one entry per install — (host, agent, version). Unique per token. */
|
|
61
|
+
export declare function authCacheKey(host: string, agent: AgentId | string, version: string): string;
|
|
62
|
+
/** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
|
|
63
|
+
export declare function readAuthHealthCache(): Record<string, AuthHealth>;
|
|
64
|
+
/** Read one entry, or null. */
|
|
65
|
+
export declare function readAuthHealth(host: string, agent: AgentId | string, version: string): AuthHealth | null;
|
|
66
|
+
/**
|
|
67
|
+
* Merge entries into the cache. An incoming `error` verdict (a network blip,
|
|
68
|
+
* not a server rejection) is indeterminate, so it must NOT clobber a prior
|
|
69
|
+
* known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
|
|
70
|
+
* exactly the "cry wolf" the verdict model avoids for `expired`. This is the
|
|
71
|
+
* behaviour promised by the `error` doc on AuthVerdict ("keep the last known
|
|
72
|
+
* one"). Pure, so it's unit-tested directly. */
|
|
73
|
+
export declare function mergeAuthHealthEntries(current: Record<string, AuthHealth>, incoming: Record<string, AuthHealth>): Record<string, AuthHealth>;
|
|
74
|
+
/** Merge one or more entries into the cache (best-effort write). */
|
|
75
|
+
export declare function writeAuthHealthEntries(entries: Record<string, AuthHealth>): void;
|
|
76
|
+
/**
|
|
77
|
+
* Complete a live auth probe for one (agent, home). For claude/kimi/droid this
|
|
78
|
+
* hits the provider; for everyone else it reports a best-effort local verdict
|
|
79
|
+
* (`unverified` when a credential is present, `unconfigured` otherwise) — never
|
|
80
|
+
* masquerading as `live`.
|
|
81
|
+
*/
|
|
82
|
+
export declare function probeAuthHealth(agent: AgentId, home: string | undefined, opts?: {
|
|
83
|
+
cliVersion?: string | null;
|
|
84
|
+
info?: AccountInfo | null;
|
|
85
|
+
}): Promise<AuthHealth>;
|
|
86
|
+
/** One probed install on a host. */
|
|
87
|
+
export interface AuthProbeRow {
|
|
88
|
+
agent: AgentId;
|
|
89
|
+
version: string;
|
|
90
|
+
account?: string;
|
|
91
|
+
health: AuthHealth;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Enumerate every installed (agent, version) on THIS host, probe each in
|
|
95
|
+
* parallel, and return the rows (installs with no credential at all are
|
|
96
|
+
* dropped). Shared by `agents fleet ping --local` and the daemon refresh.
|
|
97
|
+
*/
|
|
98
|
+
export declare function probeLocalFleetAuth(opts?: {
|
|
99
|
+
cliVersion?: string | null;
|
|
100
|
+
agents?: readonly AgentId[];
|
|
101
|
+
}): Promise<AuthProbeRow[]>;
|
|
102
|
+
/** Persist a host's probed rows into the cache (keyed by host+agent+version). */
|
|
103
|
+
export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[]): void;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live auth-health: does an agent account's stored credential actually complete
|
|
3
|
+
* an authenticated request right now?
|
|
4
|
+
*
|
|
5
|
+
* The rest of the CLI reports "signed in" from a local heuristic — a credential
|
|
6
|
+
* file is present and its email decodes — which cannot distinguish a good token
|
|
7
|
+
* from a revoked-but-unexpired one. This module completes a real request per
|
|
8
|
+
* (agent, account) and records the verdict in a small cache that `agents view`,
|
|
9
|
+
* `agents fleet status`, and the run rotation all read. The daemon and
|
|
10
|
+
* `agents fleet ping` are the writers; everyone else reads.
|
|
11
|
+
*
|
|
12
|
+
* The network probes themselves live in lib/usage.ts (where the per-provider
|
|
13
|
+
* token loaders + endpoints already are); this module classifies their result,
|
|
14
|
+
* covers the best-effort (non-networked) providers, and owns the cache.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import { ALL_AGENT_IDS, getAccountInfo } from './agents.js';
|
|
19
|
+
import { getCacheDir } from './state.js';
|
|
20
|
+
import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, } from './usage.js';
|
|
21
|
+
import { getVersionHomePath, listInstalledVersions } from './versions.js';
|
|
22
|
+
/** Agents with a live network probe wired up today. The rest are best-effort. */
|
|
23
|
+
export const LIVE_PROBE_AGENTS = new Set(['claude', 'kimi', 'droid']);
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Pure classifiers / render (unit-tested; no network, no fs)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** Map an HTTP status from a live probe to a verdict. */
|
|
28
|
+
export function classifyHttpStatus(status) {
|
|
29
|
+
if (status >= 200 && status < 300)
|
|
30
|
+
return 'live';
|
|
31
|
+
if (status === 401 || status === 403)
|
|
32
|
+
return 'revoked';
|
|
33
|
+
if (status === 429)
|
|
34
|
+
return 'rate_limited';
|
|
35
|
+
return 'error';
|
|
36
|
+
}
|
|
37
|
+
/** Turn a raw provider probe (from usage.ts) into a verdict. */
|
|
38
|
+
export function verdictFromProbe(probe) {
|
|
39
|
+
if (probe.token === 'missing')
|
|
40
|
+
return 'unconfigured';
|
|
41
|
+
if (probe.token === 'expired')
|
|
42
|
+
return 'expired';
|
|
43
|
+
if (probe.status == null)
|
|
44
|
+
return 'error';
|
|
45
|
+
return classifyHttpStatus(probe.status);
|
|
46
|
+
}
|
|
47
|
+
/** A short human detail line for a probe result (rendered under --verbose). */
|
|
48
|
+
export function probeDetail(probe) {
|
|
49
|
+
if (probe.status != null && (probe.status < 200 || probe.status >= 300))
|
|
50
|
+
return `HTTP ${probe.status}`;
|
|
51
|
+
if (probe.error)
|
|
52
|
+
return probe.error;
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const VERDICT_GLYPHS = {
|
|
56
|
+
live: '●', // ●
|
|
57
|
+
revoked: '○', // ○
|
|
58
|
+
expired: '○', // ○
|
|
59
|
+
rate_limited: '◐', // ◐
|
|
60
|
+
unverified: '◐', // ◐
|
|
61
|
+
unconfigured: '·', // ·
|
|
62
|
+
error: '·', // ·
|
|
63
|
+
};
|
|
64
|
+
/** Uncolored glyph for a verdict (color is applied by the caller). */
|
|
65
|
+
export function verdictGlyph(verdict) {
|
|
66
|
+
return VERDICT_GLYPHS[verdict] ?? '·';
|
|
67
|
+
}
|
|
68
|
+
/** One-word label for matrices/verbose output. */
|
|
69
|
+
export function verdictLabel(verdict) {
|
|
70
|
+
switch (verdict) {
|
|
71
|
+
case 'live': return 'live';
|
|
72
|
+
case 'revoked': return 'revoked';
|
|
73
|
+
case 'expired': return 'expired';
|
|
74
|
+
case 'rate_limited': return 'limited';
|
|
75
|
+
case 'unverified': return 'unverified';
|
|
76
|
+
case 'unconfigured': return '—';
|
|
77
|
+
case 'error': return '?';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function summarizeVerdicts(verdicts) {
|
|
81
|
+
let live = 0;
|
|
82
|
+
let bad = 0;
|
|
83
|
+
let warn = 0;
|
|
84
|
+
for (const v of verdicts) {
|
|
85
|
+
if (v === 'live')
|
|
86
|
+
live++;
|
|
87
|
+
else if (v === 'revoked')
|
|
88
|
+
bad++;
|
|
89
|
+
else
|
|
90
|
+
warn++;
|
|
91
|
+
}
|
|
92
|
+
return { live, bad, warn, total: verdicts.length };
|
|
93
|
+
}
|
|
94
|
+
/** Verdicts that mean "this token was rejected by the server — re-login required". */
|
|
95
|
+
export function isDeadVerdict(verdict) {
|
|
96
|
+
return verdict === 'revoked';
|
|
97
|
+
}
|
|
98
|
+
/** Human "3m ago" style age for a checkedAt timestamp. */
|
|
99
|
+
export function formatCheckedAge(checkedAt, now = Date.now()) {
|
|
100
|
+
const secs = Math.max(0, Math.round((now - checkedAt) / 1000));
|
|
101
|
+
if (secs < 60)
|
|
102
|
+
return `${secs}s ago`;
|
|
103
|
+
const mins = Math.round(secs / 60);
|
|
104
|
+
if (mins < 60)
|
|
105
|
+
return `${mins}m ago`;
|
|
106
|
+
const hours = Math.round(mins / 60);
|
|
107
|
+
if (hours < 24)
|
|
108
|
+
return `${hours}h ago`;
|
|
109
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Cache identity + IO (single source of truth read by view/fleet/rotation)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
/**
|
|
115
|
+
* Human account label for display (email, else id). NOT used in the cache key —
|
|
116
|
+
* two installs on one host can hold the same account with independently valid
|
|
117
|
+
* tokens, so the key is keyed by version (below), not account.
|
|
118
|
+
*/
|
|
119
|
+
export function authAccountLabel(info) {
|
|
120
|
+
return info?.email || info?.accountId || info?.userId || undefined;
|
|
121
|
+
}
|
|
122
|
+
/** Cache key: one entry per install — (host, agent, version). Unique per token. */
|
|
123
|
+
export function authCacheKey(host, agent, version) {
|
|
124
|
+
return `${host}:${agent}:${version}`;
|
|
125
|
+
}
|
|
126
|
+
function cacheFilePath() {
|
|
127
|
+
return path.join(getCacheDir(), '.auth-health.json');
|
|
128
|
+
}
|
|
129
|
+
/** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
|
|
130
|
+
export function readAuthHealthCache() {
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(fs.readFileSync(cacheFilePath(), 'utf-8'));
|
|
133
|
+
if (parsed && parsed.entries && typeof parsed.entries === 'object')
|
|
134
|
+
return parsed.entries;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// missing or corrupt — treat as empty
|
|
138
|
+
}
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
141
|
+
/** Read one entry, or null. */
|
|
142
|
+
export function readAuthHealth(host, agent, version) {
|
|
143
|
+
return readAuthHealthCache()[authCacheKey(host, agent, version)] ?? null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Merge entries into the cache. An incoming `error` verdict (a network blip,
|
|
147
|
+
* not a server rejection) is indeterminate, so it must NOT clobber a prior
|
|
148
|
+
* known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
|
|
149
|
+
* exactly the "cry wolf" the verdict model avoids for `expired`. This is the
|
|
150
|
+
* behaviour promised by the `error` doc on AuthVerdict ("keep the last known
|
|
151
|
+
* one"). Pure, so it's unit-tested directly. */
|
|
152
|
+
export function mergeAuthHealthEntries(current, incoming) {
|
|
153
|
+
const merged = { ...current };
|
|
154
|
+
for (const [key, health] of Object.entries(incoming)) {
|
|
155
|
+
if (health.verdict === 'error' && merged[key])
|
|
156
|
+
continue; // keep last known
|
|
157
|
+
merged[key] = health;
|
|
158
|
+
}
|
|
159
|
+
return merged;
|
|
160
|
+
}
|
|
161
|
+
/** Merge one or more entries into the cache (best-effort write). */
|
|
162
|
+
export function writeAuthHealthEntries(entries) {
|
|
163
|
+
try {
|
|
164
|
+
const dir = getCacheDir();
|
|
165
|
+
if (!fs.existsSync(dir))
|
|
166
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
167
|
+
const merged = {
|
|
168
|
+
version: 1,
|
|
169
|
+
entries: mergeAuthHealthEntries(readAuthHealthCache(), entries),
|
|
170
|
+
};
|
|
171
|
+
fs.writeFileSync(cacheFilePath(), JSON.stringify(merged, null, 2));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// best-effort; a failed write just means the next reader falls back to heuristics
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// The probe (writer side)
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
/**
|
|
181
|
+
* Complete a live auth probe for one (agent, home). For claude/kimi/droid this
|
|
182
|
+
* hits the provider; for everyone else it reports a best-effort local verdict
|
|
183
|
+
* (`unverified` when a credential is present, `unconfigured` otherwise) — never
|
|
184
|
+
* masquerading as `live`.
|
|
185
|
+
*/
|
|
186
|
+
export async function probeAuthHealth(agent, home, opts) {
|
|
187
|
+
const checkedAt = Date.now();
|
|
188
|
+
if (LIVE_PROBE_AGENTS.has(agent)) {
|
|
189
|
+
let probe;
|
|
190
|
+
if (agent === 'claude')
|
|
191
|
+
probe = await probeClaudeStatus(home, opts?.cliVersion);
|
|
192
|
+
else if (agent === 'kimi')
|
|
193
|
+
probe = await probeKimiStatus(home);
|
|
194
|
+
else
|
|
195
|
+
probe = await probeDroidStatus(home);
|
|
196
|
+
return { verdict: verdictFromProbe(probe), checkedAt, detail: probeDetail(probe) };
|
|
197
|
+
}
|
|
198
|
+
const info = opts?.info !== undefined ? opts.info : await getAccountInfo(agent, home).catch(() => null);
|
|
199
|
+
return { verdict: info?.signedIn ? 'unverified' : 'unconfigured', checkedAt };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Enumerate every installed (agent, version) on THIS host, probe each in
|
|
203
|
+
* parallel, and return the rows (installs with no credential at all are
|
|
204
|
+
* dropped). Shared by `agents fleet ping --local` and the daemon refresh.
|
|
205
|
+
*/
|
|
206
|
+
export async function probeLocalFleetAuth(opts) {
|
|
207
|
+
const agentIds = opts?.agents ?? ALL_AGENT_IDS;
|
|
208
|
+
const tasks = [];
|
|
209
|
+
for (const agent of agentIds) {
|
|
210
|
+
for (const version of listInstalledVersions(agent)) {
|
|
211
|
+
const home = getVersionHomePath(agent, version);
|
|
212
|
+
tasks.push((async () => {
|
|
213
|
+
const info = await getAccountInfo(agent, home).catch(() => null);
|
|
214
|
+
const health = await probeAuthHealth(agent, home, { cliVersion: opts?.cliVersion, info });
|
|
215
|
+
health.account = authAccountLabel(info);
|
|
216
|
+
if (health.verdict === 'unconfigured')
|
|
217
|
+
return null;
|
|
218
|
+
return { agent, version, account: health.account, health };
|
|
219
|
+
})());
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const settled = await Promise.all(tasks);
|
|
223
|
+
return settled.filter((r) => r !== null);
|
|
224
|
+
}
|
|
225
|
+
/** Persist a host's probed rows into the cache (keyed by host+agent+version). */
|
|
226
|
+
export function writeFleetAuthRows(host, rows) {
|
|
227
|
+
const entries = {};
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
entries[authCacheKey(host, row.agent, row.version)] = row.health;
|
|
230
|
+
}
|
|
231
|
+
writeAuthHealthEntries(entries);
|
|
232
|
+
}
|
|
@@ -24,6 +24,15 @@ export declare function findFirstInstalledBrowser(platform?: string): {
|
|
|
24
24
|
browserType: BrowserType;
|
|
25
25
|
binary: string;
|
|
26
26
|
} | null;
|
|
27
|
+
/**
|
|
28
|
+
* List every installed Chromium-family browser on this machine, in the platform
|
|
29
|
+
* priority order. Used by `agents setup browser` to offer a pick when more than
|
|
30
|
+
* one is present. Returns [] if none are installed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function listInstalledBrowsers(platform?: string): {
|
|
33
|
+
browserType: BrowserType;
|
|
34
|
+
binary: string;
|
|
35
|
+
}[];
|
|
27
36
|
export interface LaunchResult {
|
|
28
37
|
pid: number;
|
|
29
38
|
port: number;
|
|
@@ -203,6 +203,28 @@ export function findFirstInstalledBrowser(platform = os.platform()) {
|
|
|
203
203
|
}
|
|
204
204
|
return null;
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* List every installed Chromium-family browser on this machine, in the platform
|
|
208
|
+
* priority order. Used by `agents setup browser` to offer a pick when more than
|
|
209
|
+
* one is present. Returns [] if none are installed.
|
|
210
|
+
*/
|
|
211
|
+
export function listInstalledBrowsers(platform = os.platform()) {
|
|
212
|
+
const priority = DEFAULT_BROWSER_PRIORITY[platform];
|
|
213
|
+
const platformPaths = BROWSER_PATHS[platform];
|
|
214
|
+
if (!priority || !platformPaths)
|
|
215
|
+
return [];
|
|
216
|
+
const found = [];
|
|
217
|
+
for (const browserType of priority) {
|
|
218
|
+
const candidates = platformPaths[browserType] || [];
|
|
219
|
+
for (const p of candidates) {
|
|
220
|
+
if (fs.existsSync(p)) {
|
|
221
|
+
found.push({ browserType, binary: resolveBrowserBinary(p) });
|
|
222
|
+
break; // one install path per browser type is enough
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return found;
|
|
227
|
+
}
|
|
206
228
|
export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary,
|
|
207
229
|
// `electron: true` distinguishes Notion / VS Code-style apps from
|
|
208
230
|
// regular Chrome — purely informational, stored in meta.json so the
|
|
@@ -228,27 +228,6 @@ function hasSshEndpoint(endpoints) {
|
|
|
228
228
|
}
|
|
229
229
|
});
|
|
230
230
|
}
|
|
231
|
-
/**
|
|
232
|
-
* True when any endpoint is an `ssh://…?os=windows` target — i.e. the browser
|
|
233
|
-
* lives on a remote Windows host. Such a profile's binary (`msedge.exe`) will
|
|
234
|
-
* never exist on this Mac, so create-time local-binary validation must be
|
|
235
|
-
* skipped; the binary is resolved on the remote at connect time instead.
|
|
236
|
-
*/
|
|
237
|
-
function hasRemoteWindowsEndpoint(endpoints) {
|
|
238
|
-
const targets = Array.isArray(endpoints)
|
|
239
|
-
? endpoints
|
|
240
|
-
: Object.values(endpoints).map((preset) => preset.target);
|
|
241
|
-
return targets.some((target) => {
|
|
242
|
-
try {
|
|
243
|
-
const url = new URL(target);
|
|
244
|
-
return (url.protocol === 'ssh:' &&
|
|
245
|
-
(url.searchParams.get('os') || '').toLowerCase() === 'windows');
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
return false;
|
|
249
|
-
}
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
231
|
export async function createProfile(profile) {
|
|
253
232
|
const meta = readMeta();
|
|
254
233
|
if (meta.browser?.[profile.name]) {
|
|
@@ -276,10 +255,10 @@ export async function createProfile(profile) {
|
|
|
276
255
|
// deferring the failure to the first task. `findBrowserPath` short-circuits
|
|
277
256
|
// for browser=custom without a binary by throwing — same outcome.
|
|
278
257
|
//
|
|
279
|
-
// Skip for
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
if (!
|
|
258
|
+
// Skip for SSH profiles: the browser binary lives on the remote host, so a
|
|
259
|
+
// local lookup would validate the wrong machine. The remote launcher resolves
|
|
260
|
+
// it at connect time.
|
|
261
|
+
if (!hasSshEndpoint(profile.endpoints)) {
|
|
283
262
|
findBrowserPath(profile.browser, profile.binary);
|
|
284
263
|
}
|
|
285
264
|
meta.browser = meta.browser ?? {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const BUN_VIRTUAL_ROOT: RegExp;
|
|
2
|
+
export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* A CLI entry must be launched through the Node runtime when it is a Node
|
|
5
|
+
* script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
|
|
6
|
+
* shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
|
|
7
|
+
* (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
|
|
8
|
+
* extension check alone misses them and they get run directly. A real compiled
|
|
9
|
+
* binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the direct branch
|
|
10
|
+
* and owns its own runtime resolution.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isNodeScriptEntry(agentsBin: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Build the `{ command, args }` to re-invoke this CLI with `sub` as its argv,
|
|
15
|
+
* resolving the JS-vs-standalone shape above. This is the single primitive behind
|
|
16
|
+
* both the daemon launch and the secrets-broker spawn — never hand-roll
|
|
17
|
+
* `[process.execPath, process.argv[1], …]`, which appends the bun virtual entry
|
|
18
|
+
* as a bogus subcommand on standalone builds.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getCliLaunch(sub: string[], agentsBin?: string): {
|
|
21
|
+
command: string;
|
|
22
|
+
args: string[];
|
|
23
|
+
};
|