@phnx-labs/agents-cli 1.20.27 → 1.20.29
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 +3 -0
- package/dist/commands/doctor.js +57 -4
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +198 -15
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +108 -29
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +8 -24
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +3 -1
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +44 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- package/dist/lib/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/doctor-diff.d.ts +12 -0
- package/dist/lib/doctor-diff.js +89 -2
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugins.d.ts +10 -0
- package/dist/lib/plugins.js +1 -1
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +24 -0
- package/dist/lib/session/db.js +80 -5
- package/dist/lib/session/discover.d.ts +28 -0
- package/dist/lib/session/discover.js +303 -4
- package/dist/lib/session/parse.d.ts +7 -0
- package/dist/lib/session/parse.js +110 -0
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +31 -3
- package/dist/lib/session/remote.js +121 -14
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/types.d.ts +21 -0
- package/dist/lib/versions.d.ts +6 -2
- package/dist/lib/versions.js +8 -4
- package/package.json +1 -1
- package/scripts/postinstall.js +62 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device registry.
|
|
3
|
+
*
|
|
4
|
+
* Manages the persistent registry of SSH device profiles stored at
|
|
5
|
+
* ~/.agents/.history/devices/registry.json. Each profile records what we had
|
|
6
|
+
* to re-derive by hand the first time we reached a host: its platform (so we
|
|
7
|
+
* know PowerShell vs POSIX), the login user, how to address it (Tailscale
|
|
8
|
+
* DNS name / IP), and how to authenticate (pubkey, or a password pulled from
|
|
9
|
+
* a secrets bundle).
|
|
10
|
+
*
|
|
11
|
+
* Like the team registry this is per-machine runtime state (it embeds a host
|
|
12
|
+
* list + addresses) and lives under .history/ so it is NOT pulled in by
|
|
13
|
+
* `agents repo push`. The load/save/lock plumbing is a deliberate clone of
|
|
14
|
+
* src/lib/teams/registry.ts so the data-loss guarantees match exactly.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'fs/promises';
|
|
17
|
+
import * as fsSync from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { randomBytes } from 'crypto';
|
|
20
|
+
import lockfile from 'proper-lockfile';
|
|
21
|
+
import { getDevicesRegistryPath } from '../state.js';
|
|
22
|
+
function registryPath() {
|
|
23
|
+
return getDevicesRegistryPath();
|
|
24
|
+
}
|
|
25
|
+
/** Valid logical device name: the ssh-alias charset, so it renders into an
|
|
26
|
+
* unambiguous `Host` stanza and is safe as an ssh target. */
|
|
27
|
+
const DEVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
28
|
+
/** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
|
|
29
|
+
export function assertValidDeviceName(name) {
|
|
30
|
+
if (!DEVICE_NAME_RE.test(name)) {
|
|
31
|
+
throw new Error(`Invalid device name ${JSON.stringify(name)}. Use letters, digits, '.', '_', '-' (no spaces) — e.g. 'win-mini'.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Map a Tailscale `OS` field to our platform enum. */
|
|
35
|
+
export function platformFromOs(os) {
|
|
36
|
+
switch ((os ?? '').toLowerCase()) {
|
|
37
|
+
case 'windows':
|
|
38
|
+
return 'windows';
|
|
39
|
+
case 'linux':
|
|
40
|
+
return 'linux';
|
|
41
|
+
case 'macos':
|
|
42
|
+
case 'darwin':
|
|
43
|
+
return 'macos';
|
|
44
|
+
default:
|
|
45
|
+
return 'unknown';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The remote shell a platform speaks. */
|
|
49
|
+
export function shellForPlatform(platform) {
|
|
50
|
+
return platform === 'windows' ? 'powershell' : 'posix';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Atomic JSON write: write to a unique sibling tmp file then rename over the
|
|
54
|
+
* target. rename(2) is atomic on POSIX, so a crashed write leaves the old file
|
|
55
|
+
* untouched instead of producing a half-written registry that loadDevices()
|
|
56
|
+
* would reject.
|
|
57
|
+
*/
|
|
58
|
+
async function atomicWriteJson(p, data) {
|
|
59
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
60
|
+
const tmp = `${p}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
61
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
|
|
62
|
+
try {
|
|
63
|
+
await fs.rename(tmp, p);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
await fs.unlink(tmp).catch(() => { });
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Run `fn` while holding an exclusive cross-process lock on the registry file.
|
|
72
|
+
* proper-lockfile requires the target to exist, so we touch it first. Stale
|
|
73
|
+
* locks (from crashed callers) auto-expire after `stale` ms.
|
|
74
|
+
*/
|
|
75
|
+
async function withRegistryLock(p, fn) {
|
|
76
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
77
|
+
if (!fsSync.existsSync(p)) {
|
|
78
|
+
try {
|
|
79
|
+
await fs.writeFile(p, '{}', { flag: 'wx' });
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (err && err.code !== 'EEXIST')
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const release = await lockfile.lock(p, {
|
|
87
|
+
retries: { retries: 60, minTimeout: 25, maxTimeout: 250, factor: 1.5 },
|
|
88
|
+
stale: 10_000,
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
return await fn();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await release();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Load all devices from the registry file. Returns an empty object only when
|
|
99
|
+
* the file does not exist. A malformed file is a hard error — silently
|
|
100
|
+
* returning {} would let the next write wipe the user's device list.
|
|
101
|
+
*/
|
|
102
|
+
export async function loadDevices() {
|
|
103
|
+
const p = registryPath();
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = await fs.readFile(p, 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err && err.code === 'ENOENT')
|
|
110
|
+
return {};
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
throw new Error(`Device registry corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function saveDevices(reg) {
|
|
121
|
+
await atomicWriteJson(registryPath(), reg);
|
|
122
|
+
}
|
|
123
|
+
/** Get a single device profile, or null if it is not registered. */
|
|
124
|
+
export async function getDevice(name) {
|
|
125
|
+
const reg = await loadDevices();
|
|
126
|
+
return reg[name] ?? null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Create the device if absent, otherwise merge the supplied fields into the
|
|
130
|
+
* existing profile. `shell` is always re-derived from the (possibly new)
|
|
131
|
+
* platform so the two can never drift. Returns the resulting profile.
|
|
132
|
+
*/
|
|
133
|
+
export async function upsertDevice(name, input) {
|
|
134
|
+
assertValidDeviceName(name);
|
|
135
|
+
const p = registryPath();
|
|
136
|
+
return withRegistryLock(p, async () => {
|
|
137
|
+
const reg = await loadDevices();
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
const prev = reg[name];
|
|
140
|
+
const platform = input.platform ?? prev?.platform ?? 'unknown';
|
|
141
|
+
const merged = {
|
|
142
|
+
name,
|
|
143
|
+
platform,
|
|
144
|
+
shell: shellForPlatform(platform),
|
|
145
|
+
user: input.user ?? prev?.user,
|
|
146
|
+
address: input.address ?? prev?.address ?? { via: 'manual' },
|
|
147
|
+
auth: input.auth ?? prev?.auth ?? { method: 'key' },
|
|
148
|
+
tailscale: input.tailscale ?? prev?.tailscale,
|
|
149
|
+
createdAt: prev?.createdAt ?? now,
|
|
150
|
+
updatedAt: now,
|
|
151
|
+
};
|
|
152
|
+
reg[name] = merged;
|
|
153
|
+
await saveDevices(reg);
|
|
154
|
+
return merged;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Remove a device. Returns false if it was not registered. */
|
|
158
|
+
export async function removeDevice(name) {
|
|
159
|
+
const p = registryPath();
|
|
160
|
+
return withRegistryLock(p, async () => {
|
|
161
|
+
const reg = await loadDevices();
|
|
162
|
+
if (!reg[name])
|
|
163
|
+
return false;
|
|
164
|
+
delete reg[name];
|
|
165
|
+
await saveDevices(reg);
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render the device registry into an OpenSSH `ssh_config` include block.
|
|
3
|
+
*
|
|
4
|
+
* Writing a managed include (e.g. `~/.ssh/config.d/agents`) makes every tool
|
|
5
|
+
* that speaks ssh — plain `ssh`/`scp`/`rsync`/`git`, and `agents sessions
|
|
6
|
+
* --host` — resolve the registry's logical device names transparently, without
|
|
7
|
+
* each of them learning about the registry. `agents ssh` stays the value-add
|
|
8
|
+
* layer (preflight, password-from-bundle auth, platform-aware exec) on top.
|
|
9
|
+
*
|
|
10
|
+
* `renderSshConfig` is a pure function (registry in, config text out) so the
|
|
11
|
+
* exact rendering is unit-testable.
|
|
12
|
+
*/
|
|
13
|
+
import { type DeviceProfile, type DeviceRegistry } from './registry.js';
|
|
14
|
+
/** The HostName an ssh client should dial for a device: DNS name first, then IP. */
|
|
15
|
+
export declare function hostNameFor(device: DeviceProfile): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Render the whole registry into ssh_config text. Devices are emitted in
|
|
18
|
+
* stable alphabetical order (so the file does not churn between runs) and
|
|
19
|
+
* addressless devices are skipped.
|
|
20
|
+
*/
|
|
21
|
+
export declare function renderSshConfig(reg: DeviceRegistry): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const HEADER = [
|
|
2
|
+
'# Managed by `agents devices` — do not edit by hand.',
|
|
3
|
+
'# Regenerate with: agents devices render',
|
|
4
|
+
'# Include from ~/.ssh/config with: Include config.d/agents',
|
|
5
|
+
].join('\n');
|
|
6
|
+
/** The HostName an ssh client should dial for a device: DNS name first, then IP. */
|
|
7
|
+
export function hostNameFor(device) {
|
|
8
|
+
return device.address.dnsName ?? device.address.ip;
|
|
9
|
+
}
|
|
10
|
+
/** Render a single device into an ssh_config `Host` stanza, or null if it has no address. */
|
|
11
|
+
function renderHost(device) {
|
|
12
|
+
const hostName = hostNameFor(device);
|
|
13
|
+
if (!hostName)
|
|
14
|
+
return null;
|
|
15
|
+
const lines = [`Host ${device.name}`, ` HostName ${hostName}`];
|
|
16
|
+
if (device.user)
|
|
17
|
+
lines.push(` User ${device.user}`);
|
|
18
|
+
return lines.join('\n');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Render the whole registry into ssh_config text. Devices are emitted in
|
|
22
|
+
* stable alphabetical order (so the file does not churn between runs) and
|
|
23
|
+
* addressless devices are skipped.
|
|
24
|
+
*/
|
|
25
|
+
export function renderSshConfig(reg) {
|
|
26
|
+
const stanzas = [];
|
|
27
|
+
for (const name of Object.keys(reg).sort()) {
|
|
28
|
+
const stanza = renderHost(reg[name]);
|
|
29
|
+
if (stanza)
|
|
30
|
+
stanzas.push(stanza);
|
|
31
|
+
}
|
|
32
|
+
return [HEADER, '', ...stanzas, ''].join('\n');
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type DeviceInput, type DevicePlatform } from './registry.js';
|
|
2
|
+
/** A single node distilled from `tailscale status --json`. */
|
|
3
|
+
export interface TailscaleNode {
|
|
4
|
+
name: string;
|
|
5
|
+
platform: DevicePlatform;
|
|
6
|
+
dnsName?: string;
|
|
7
|
+
ip?: string;
|
|
8
|
+
online: boolean;
|
|
9
|
+
direct: boolean;
|
|
10
|
+
relay?: string;
|
|
11
|
+
lastSeen?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Slugify a raw Tailscale HostName into a valid logical device name (ssh alias
|
|
15
|
+
* charset). Used only as a fallback — when a node has a DNSName we prefer its
|
|
16
|
+
* first label, which is the canonical slug Tailscale itself derived.
|
|
17
|
+
*/
|
|
18
|
+
export declare function slugifyHostName(hostName: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Parse `tailscale status --json` output into one node per tailnet device,
|
|
21
|
+
* including Self. Throws on malformed JSON. Nodes without a HostName are
|
|
22
|
+
* skipped (they cannot be addressed by a logical name).
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseTailscaleStatus(json: string): TailscaleNode[];
|
|
25
|
+
/** Turn a parsed Tailscale node into the registry fields it can populate. */
|
|
26
|
+
export declare function nodeToDeviceInput(node: TailscaleNode): DeviceInput;
|
|
27
|
+
/**
|
|
28
|
+
* Run `tailscale status --json` and return its raw stdout. Throws a clear
|
|
29
|
+
* error when the binary is missing or the daemon is not reachable.
|
|
30
|
+
*/
|
|
31
|
+
export declare function tailscaleStatusJson(): string;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailscale ingestion for the device registry.
|
|
3
|
+
*
|
|
4
|
+
* `tailscale status --json` already hands us most of a device registry for
|
|
5
|
+
* free — per node: `OS` (→ platform), `Online`/`LastSeen` (→ reachability),
|
|
6
|
+
* `Relay` vs `CurAddr` (→ direct-vs-relayed latency hint), `DNSName`, and
|
|
7
|
+
* `TailscaleIPs`. `parseTailscaleStatus` turns that JSON into draft device
|
|
8
|
+
* profiles so `agents devices sync` can self-populate instead of you
|
|
9
|
+
* hand-entering hosts. Kept a pure function (JSON in, profiles out) so it is
|
|
10
|
+
* unit-testable without a live tailnet.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
import { platformFromOs, } from './registry.js';
|
|
14
|
+
/** Strip MagicDNS's trailing dot so the name is usable as an ssh HostName. */
|
|
15
|
+
function trimDnsDot(dns) {
|
|
16
|
+
if (!dns)
|
|
17
|
+
return undefined;
|
|
18
|
+
return dns.endsWith('.') ? dns.slice(0, -1) : dns;
|
|
19
|
+
}
|
|
20
|
+
/** First IPv4 in the node's address list (preferred over IPv6 for ssh). */
|
|
21
|
+
function firstIpv4(ips) {
|
|
22
|
+
if (!ips)
|
|
23
|
+
return undefined;
|
|
24
|
+
return ips.find((ip) => /^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) ?? ips[0];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Slugify a raw Tailscale HostName into a valid logical device name (ssh alias
|
|
28
|
+
* charset). Used only as a fallback — when a node has a DNSName we prefer its
|
|
29
|
+
* first label, which is the canonical slug Tailscale itself derived.
|
|
30
|
+
*/
|
|
31
|
+
export function slugifyHostName(hostName) {
|
|
32
|
+
return hostName
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/['’"]/g, '') // drop quotes/apostrophes (so "Bisma's" → "bismas", matching MagicDNS)
|
|
35
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
36
|
+
.replace(/^-+|-+$/g, '');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The logical name for a node. macOS computer names contain spaces and
|
|
40
|
+
* apostrophes ("Bisma's MacBook Pro") and iOS devices all report HostName
|
|
41
|
+
* "localhost" — both break as ssh aliases and the latter collides in the
|
|
42
|
+
* registry. The MagicDNS label (first segment of DNSName) is already a unique,
|
|
43
|
+
* valid slug per device, so prefer it; fall back to a slugified HostName.
|
|
44
|
+
*/
|
|
45
|
+
function deviceNameFor(raw, dnsName) {
|
|
46
|
+
const label = dnsName?.split('.')[0];
|
|
47
|
+
if (label && label.length > 0)
|
|
48
|
+
return label;
|
|
49
|
+
const host = raw.HostName?.trim();
|
|
50
|
+
if (!host)
|
|
51
|
+
return null;
|
|
52
|
+
const slug = slugifyHostName(host);
|
|
53
|
+
return slug.length > 0 ? slug : null;
|
|
54
|
+
}
|
|
55
|
+
function toNode(raw) {
|
|
56
|
+
const dnsName = trimDnsDot(raw.DNSName);
|
|
57
|
+
const name = deviceNameFor(raw, dnsName);
|
|
58
|
+
if (!name)
|
|
59
|
+
return null;
|
|
60
|
+
// A non-empty CurAddr means the last handshake was a direct connection;
|
|
61
|
+
// an empty CurAddr with a Relay means traffic is going through DERP.
|
|
62
|
+
const direct = Boolean(raw.CurAddr && raw.CurAddr.length > 0);
|
|
63
|
+
return {
|
|
64
|
+
name,
|
|
65
|
+
platform: platformFromOs(raw.OS),
|
|
66
|
+
dnsName,
|
|
67
|
+
ip: firstIpv4(raw.TailscaleIPs),
|
|
68
|
+
online: Boolean(raw.Online),
|
|
69
|
+
direct,
|
|
70
|
+
relay: raw.Relay || undefined,
|
|
71
|
+
lastSeen: raw.LastSeen,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Parse `tailscale status --json` output into one node per tailnet device,
|
|
76
|
+
* including Self. Throws on malformed JSON. Nodes without a HostName are
|
|
77
|
+
* skipped (they cannot be addressed by a logical name).
|
|
78
|
+
*/
|
|
79
|
+
export function parseTailscaleStatus(json) {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(json);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new Error(`Could not parse tailscale status JSON: ${err?.message ?? err}`);
|
|
86
|
+
}
|
|
87
|
+
const out = [];
|
|
88
|
+
if (parsed.Self) {
|
|
89
|
+
const self = toNode(parsed.Self);
|
|
90
|
+
if (self)
|
|
91
|
+
out.push(self);
|
|
92
|
+
}
|
|
93
|
+
for (const raw of Object.values(parsed.Peer ?? {})) {
|
|
94
|
+
const node = toNode(raw);
|
|
95
|
+
if (node)
|
|
96
|
+
out.push(node);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** Turn a parsed Tailscale node into the registry fields it can populate. */
|
|
101
|
+
export function nodeToDeviceInput(node) {
|
|
102
|
+
return {
|
|
103
|
+
platform: node.platform,
|
|
104
|
+
address: { via: 'tailscale', dnsName: node.dnsName, ip: node.ip },
|
|
105
|
+
tailscale: {
|
|
106
|
+
online: node.online,
|
|
107
|
+
direct: node.direct,
|
|
108
|
+
relay: node.relay,
|
|
109
|
+
lastSeen: node.lastSeen,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Run `tailscale status --json` and return its raw stdout. Throws a clear
|
|
115
|
+
* error when the binary is missing or the daemon is not reachable.
|
|
116
|
+
*/
|
|
117
|
+
export function tailscaleStatusJson() {
|
|
118
|
+
const res = spawnSync('tailscale', ['status', '--json'], { encoding: 'utf-8' });
|
|
119
|
+
if (res.error && res.error.code === 'ENOENT') {
|
|
120
|
+
throw new Error('tailscale not found on PATH. Install Tailscale, or add devices manually with `agents devices add`.');
|
|
121
|
+
}
|
|
122
|
+
if (res.status !== 0) {
|
|
123
|
+
throw new Error(`tailscale status failed: ${(res.stderr || res.stdout || '').trim() || `exit ${res.status}`}`);
|
|
124
|
+
}
|
|
125
|
+
return res.stdout ?? '';
|
|
126
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* against the compiled artifact, not the raw source file.
|
|
21
21
|
*/
|
|
22
22
|
import type { AgentId } from './types.js';
|
|
23
|
+
import type { DiscoveredPlugin } from './types.js';
|
|
23
24
|
export type DoctorKind = 'commands' | 'skills' | 'hooks' | 'rules' | 'mcp' | 'permissions' | 'subagents' | 'plugins' | 'promptcuts';
|
|
24
25
|
export type DiffStatus = 'ok' | 'diff' | 'missing' | 'extra';
|
|
25
26
|
export type SourceLayer = 'project' | 'user' | 'system' | 'extra';
|
|
@@ -32,6 +33,9 @@ export interface ResourceDiff {
|
|
|
32
33
|
sourcePath?: string;
|
|
33
34
|
/** Absolute path to the file/dir inside the version home (when present). */
|
|
34
35
|
homePath?: string;
|
|
36
|
+
/** Human-readable specifics for a divergent row — e.g. for a stale plugin:
|
|
37
|
+
* "0.6.1→0.7.0, missing skills: ship, learn". Currently set for plugins. */
|
|
38
|
+
detail?: string;
|
|
35
39
|
}
|
|
36
40
|
export interface VersionResourceReport {
|
|
37
41
|
agent: AgentId;
|
|
@@ -55,6 +59,14 @@ export interface VersionResourceReport {
|
|
|
55
59
|
extra: number;
|
|
56
60
|
};
|
|
57
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Describe how a version's marketplace MIRROR of a plugin diverges from its
|
|
64
|
+
* central source — the detail presence-only checks miss. Surfaces a stale mirror
|
|
65
|
+
* version, a Claude-invalid manifest, and (the part users care about) the
|
|
66
|
+
* plugin's own skills/commands that never made it into the mirror. Returns null
|
|
67
|
+
* when the mirror faithfully matches source.
|
|
68
|
+
*/
|
|
69
|
+
export declare function describePluginDrift(central: DiscoveredPlugin, mirrorDir: string): string | null;
|
|
58
70
|
export interface DiffOptions {
|
|
59
71
|
cwd?: string;
|
|
60
72
|
/** Restrict to specific kinds; undefined = all. */
|
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -23,7 +23,9 @@ import * as fs from 'fs';
|
|
|
23
23
|
import * as path from 'path';
|
|
24
24
|
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
25
25
|
import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, } from './state.js';
|
|
26
|
-
import { getAvailableResources, getActuallySyncedResources, getVersionHomePath, } from './versions.js';
|
|
26
|
+
import { getAvailableResources, getActuallySyncedResources, getVersionHomePath, compareVersions, } from './versions.js';
|
|
27
|
+
import { discoverPlugins, marketplaceSpecForName } from './plugins.js';
|
|
28
|
+
import { pluginInstallDir, repairableManifestFields } from './plugin-marketplace.js';
|
|
27
29
|
import { markdownToToml } from './convert.js';
|
|
28
30
|
import { resolveImports, supportsRulesImports } from './rules/compile.js';
|
|
29
31
|
import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
|
|
@@ -425,6 +427,91 @@ function diffPresenceOnly(kind, available, synced) {
|
|
|
425
427
|
}
|
|
426
428
|
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
427
429
|
}
|
|
430
|
+
// ─── plugins (content-aware) ───────────────────────────────────────────────
|
|
431
|
+
function listPluginSkillDirs(pluginDir) {
|
|
432
|
+
const d = path.join(pluginDir, 'skills');
|
|
433
|
+
try {
|
|
434
|
+
return fs.readdirSync(d, { withFileTypes: true })
|
|
435
|
+
.filter((e) => (e.isDirectory() || e.isSymbolicLink()) && fs.existsSync(path.join(d, e.name, 'SKILL.md')))
|
|
436
|
+
.map((e) => e.name);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function listPluginCommandFiles(pluginDir) {
|
|
443
|
+
const d = path.join(pluginDir, 'commands');
|
|
444
|
+
try {
|
|
445
|
+
return fs.readdirSync(d).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Describe how a version's marketplace MIRROR of a plugin diverges from its
|
|
453
|
+
* central source — the detail presence-only checks miss. Surfaces a stale mirror
|
|
454
|
+
* version, a Claude-invalid manifest, and (the part users care about) the
|
|
455
|
+
* plugin's own skills/commands that never made it into the mirror. Returns null
|
|
456
|
+
* when the mirror faithfully matches source.
|
|
457
|
+
*/
|
|
458
|
+
export function describePluginDrift(central, mirrorDir) {
|
|
459
|
+
if (!fs.existsSync(mirrorDir))
|
|
460
|
+
return 'mirror missing';
|
|
461
|
+
const parts = [];
|
|
462
|
+
let mManifest = null;
|
|
463
|
+
try {
|
|
464
|
+
mManifest = JSON.parse(fs.readFileSync(path.join(mirrorDir, '.claude-plugin', 'plugin.json'), 'utf-8'));
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
mManifest = null;
|
|
468
|
+
}
|
|
469
|
+
const mVer = mManifest && typeof mManifest.version === 'string' ? mManifest.version : undefined;
|
|
470
|
+
const cVer = central.manifest.version;
|
|
471
|
+
if (mVer && cVer && compareVersions(cVer, mVer) > 0)
|
|
472
|
+
parts.push(`${mVer}→${cVer}`);
|
|
473
|
+
if (mManifest && repairableManifestFields(mManifest).length > 0)
|
|
474
|
+
parts.push('invalid manifest');
|
|
475
|
+
const mirrorSkills = new Set(listPluginSkillDirs(mirrorDir));
|
|
476
|
+
const missSkills = listPluginSkillDirs(central.root).filter((s) => !mirrorSkills.has(s)).sort();
|
|
477
|
+
const mirrorCmds = new Set(listPluginCommandFiles(mirrorDir));
|
|
478
|
+
const missCmds = listPluginCommandFiles(central.root).filter((c) => !mirrorCmds.has(c)).sort();
|
|
479
|
+
if (missSkills.length)
|
|
480
|
+
parts.push(`missing skill${missSkills.length > 1 ? 's' : ''}: ${missSkills.join(', ')}`);
|
|
481
|
+
if (missCmds.length)
|
|
482
|
+
parts.push(`missing command${missCmds.length > 1 ? 's' : ''}: ${missCmds.join(', ')}`);
|
|
483
|
+
return parts.length ? parts.join(', ') : null;
|
|
484
|
+
}
|
|
485
|
+
function diffPlugins(agent, version, cwd) {
|
|
486
|
+
const versionHome = getVersionHomePath(agent, version);
|
|
487
|
+
const synced = new Set(getActuallySyncedResources(agent, version, { cwd }).plugins);
|
|
488
|
+
const rows = [];
|
|
489
|
+
const seen = new Set();
|
|
490
|
+
for (const p of discoverPlugins({ cwd })) {
|
|
491
|
+
if (seen.has(p.name))
|
|
492
|
+
continue; // dedupe across marketplaces for the readout
|
|
493
|
+
seen.add(p.name);
|
|
494
|
+
if (!synced.has(p.name)) {
|
|
495
|
+
rows.push({ kind: 'plugins', name: p.name, status: 'missing', sourcePath: p.root });
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const mirror = pluginInstallDir(p, marketplaceSpecForName(p.marketplace), agent, versionHome);
|
|
499
|
+
const detail = describePluginDrift(p, mirror);
|
|
500
|
+
rows.push({
|
|
501
|
+
kind: 'plugins',
|
|
502
|
+
name: p.name,
|
|
503
|
+
status: detail ? 'diff' : 'ok',
|
|
504
|
+
sourcePath: p.root,
|
|
505
|
+
homePath: mirror,
|
|
506
|
+
...(detail ? { detail } : {}),
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
for (const name of synced) {
|
|
510
|
+
if (!seen.has(name))
|
|
511
|
+
rows.push({ kind: 'plugins', name, status: 'extra' });
|
|
512
|
+
}
|
|
513
|
+
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
514
|
+
}
|
|
428
515
|
function diffPromptcuts() {
|
|
429
516
|
const sourcePath = getEffectivePromptcutsPath();
|
|
430
517
|
if (!fs.existsSync(sourcePath))
|
|
@@ -468,7 +555,7 @@ export function diffVersionResources(agent, version, options = {}) {
|
|
|
468
555
|
if (requested.has('subagents'))
|
|
469
556
|
empty.subagents = diffPresenceOnly('subagents', available.subagents, synced.subagents);
|
|
470
557
|
if (requested.has('plugins'))
|
|
471
|
-
empty.plugins =
|
|
558
|
+
empty.plugins = diffPlugins(agent, version, cwd);
|
|
472
559
|
if (requested.has('promptcuts'))
|
|
473
560
|
empty.promptcuts = diffPromptcuts();
|
|
474
561
|
let ok = 0, diff = 0, missing = 0, extra = 0;
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -80,6 +80,14 @@ export interface ExecOptions {
|
|
|
80
80
|
addDirs?: string[];
|
|
81
81
|
timeout?: string;
|
|
82
82
|
sessionId?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Resume the conversation named by `sessionId` using the agent's NATIVE resume
|
|
85
|
+
* form (claude `--resume`, codex `resume`) instead of the default `--session-id`
|
|
86
|
+
* create. Only set for agents where `nativeResume` returns true; other agents
|
|
87
|
+
* resume via a `/continue <id>` first message (Tier 2), which needs no flag and
|
|
88
|
+
* leaves this unset.
|
|
89
|
+
*/
|
|
90
|
+
resume?: boolean;
|
|
83
91
|
verbose?: boolean;
|
|
84
92
|
env?: Record<string, string>;
|
|
85
93
|
/**
|
|
@@ -150,6 +158,19 @@ export interface AgentCommandTemplate {
|
|
|
150
158
|
modelFlag?: string;
|
|
151
159
|
printFlags?: string[];
|
|
152
160
|
verboseFlag?: string;
|
|
161
|
+
/**
|
|
162
|
+
* How this agent natively resumes a prior conversation. Presence here is the
|
|
163
|
+
* single source of truth for `nativeResume(agent)` — agents without it fall
|
|
164
|
+
* back to the universal `/continue <id>` replay (Tier 2). Two shapes:
|
|
165
|
+
* { flag } — append `<flag> <id>` (e.g. claude `--resume <id>`)
|
|
166
|
+
* { subcommand } — replace the headless base subcommand with `<subcommand> <id>`
|
|
167
|
+
* (codex: `codex exec` -> `codex exec resume <id>`)
|
|
168
|
+
*/
|
|
169
|
+
resume?: {
|
|
170
|
+
flag: string;
|
|
171
|
+
} | {
|
|
172
|
+
subcommand: string;
|
|
173
|
+
};
|
|
153
174
|
}
|
|
154
175
|
/**
|
|
155
176
|
* CLI command templates for every supported agent.
|
|
@@ -158,6 +179,12 @@ export interface AgentCommandTemplate {
|
|
|
158
179
|
* AGENTS[agent].capabilities.modes. A test in exec.test.ts asserts this.
|
|
159
180
|
*/
|
|
160
181
|
export declare const AGENT_COMMANDS: Record<AgentId, AgentCommandTemplate>;
|
|
182
|
+
/**
|
|
183
|
+
* Whether `agent` has a native resume form (Tier 1). Derived solely from the
|
|
184
|
+
* command template's `resume` field — the single source of truth. Agents that
|
|
185
|
+
* return false resume via the universal Tier-2 `/continue` replay instead.
|
|
186
|
+
*/
|
|
187
|
+
export declare function nativeResume(agent: AgentId): boolean;
|
|
161
188
|
/** Assemble the full CLI argument array for an agent invocation. */
|
|
162
189
|
export declare function buildExecCommand(options: ExecOptions): string[];
|
|
163
190
|
/** Spawn an agent and return its exit code. Convenience wrapper over spawnAgent. */
|