@phnx-labs/agents-cli 1.20.27 → 1.20.28
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 +177 -6
- 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 +15 -23
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +7 -24
- 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 +2 -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/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/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +23 -0
- package/dist/lib/session/db.js +76 -1
- package/dist/lib/session/discover.d.ts +26 -0
- package/dist/lib/session/discover.js +75 -4
- 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/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.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
package/dist/index.js
CHANGED
|
@@ -51,7 +51,7 @@ if (IS_DEV_BUILD) {
|
|
|
51
51
|
// module on each invocation (which loaded the whole ~50-module tree before the
|
|
52
52
|
// first byte of output), the registry maps a command name to a thunk that
|
|
53
53
|
// imports only what that command needs. See src/lib/startup/command-registry.ts.
|
|
54
|
-
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
|
|
54
|
+
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadHosts, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
|
|
55
55
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
56
56
|
import { IS_WINDOWS } from './lib/platform/index.js';
|
|
57
57
|
// Transparent shim delegate: the generated Windows `.cmd` shims invoke
|
|
@@ -783,6 +783,7 @@ async function registerAllEagerCommands() {
|
|
|
783
783
|
await reg(loadTmux);
|
|
784
784
|
await reg(loadBrowser);
|
|
785
785
|
await reg(loadComputer);
|
|
786
|
+
await reg(loadHosts);
|
|
786
787
|
registerJobsCronAliasCommand(program, 'jobs');
|
|
787
788
|
registerJobsCronAliasCommand(program, 'cron');
|
|
788
789
|
registerUpgradeCommand(program);
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -107,6 +107,7 @@ export interface AccountInfo {
|
|
|
107
107
|
currency: string;
|
|
108
108
|
} | null;
|
|
109
109
|
lastActive: Date | null;
|
|
110
|
+
signedIn: boolean;
|
|
110
111
|
}
|
|
111
112
|
/** Return the email address associated with the agent's auth config, or null. */
|
|
112
113
|
export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
|
package/dist/lib/agents.js
CHANGED
|
@@ -15,6 +15,7 @@ import * as path from 'path';
|
|
|
15
15
|
import * as os from 'os';
|
|
16
16
|
import * as TOML from 'smol-toml';
|
|
17
17
|
import chalk from 'chalk';
|
|
18
|
+
import { needsWindowsShell } from './platform/index.js';
|
|
18
19
|
import { latestFileMtimeMs } from './fs-walk.js';
|
|
19
20
|
import { damerauLevenshtein } from './fuzzy.js';
|
|
20
21
|
import { getCacheDir, getVersionsDir, getShimsDir, getCliVersionCachePath } from './state.js';
|
|
@@ -782,6 +783,7 @@ export async function getAccountInfo(agentId, home) {
|
|
|
782
783
|
usageStatus: null,
|
|
783
784
|
overageCredits: null,
|
|
784
785
|
lastActive: null,
|
|
786
|
+
signedIn: false,
|
|
785
787
|
};
|
|
786
788
|
const configFiles = {
|
|
787
789
|
claude: path.join(base, '.claude.json'),
|
|
@@ -853,6 +855,7 @@ export async function getAccountInfo(agentId, home) {
|
|
|
853
855
|
usageStatus,
|
|
854
856
|
overageCredits,
|
|
855
857
|
lastActive,
|
|
858
|
+
signedIn: !!email,
|
|
856
859
|
};
|
|
857
860
|
}
|
|
858
861
|
case 'codex': {
|
|
@@ -894,11 +897,13 @@ export async function getAccountInfo(agentId, home) {
|
|
|
894
897
|
usageStatus,
|
|
895
898
|
overageCredits: null,
|
|
896
899
|
lastActive,
|
|
900
|
+
signedIn: !!email,
|
|
897
901
|
};
|
|
898
902
|
}
|
|
899
903
|
case 'gemini': {
|
|
900
904
|
const data = JSON.parse(await fs.promises.readFile(path.join(base, '.gemini', 'google_accounts.json'), 'utf-8'));
|
|
901
|
-
|
|
905
|
+
const email = data.active || null;
|
|
906
|
+
return { ...empty, email, signedIn: !!email, lastActive };
|
|
902
907
|
}
|
|
903
908
|
case 'grok': {
|
|
904
909
|
// Grok stores auth in ~/.grok/auth.json
|
|
@@ -907,12 +912,44 @@ export async function getAccountInfo(agentId, home) {
|
|
|
907
912
|
if (fs.existsSync(authPath)) {
|
|
908
913
|
const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
|
|
909
914
|
const email = data.email || data.user?.email || data.account?.email || null;
|
|
910
|
-
return { ...empty, email, lastActive };
|
|
915
|
+
return { ...empty, email, signedIn: !!email, lastActive };
|
|
911
916
|
}
|
|
912
917
|
}
|
|
913
918
|
catch { }
|
|
914
919
|
return { ...empty, lastActive };
|
|
915
920
|
}
|
|
921
|
+
case 'antigravity': {
|
|
922
|
+
// Antigravity (`agy`) stores a Google OAuth token at
|
|
923
|
+
// ~/.gemini/antigravity-cli/antigravity-oauth-token. It's a consumer
|
|
924
|
+
// OAuth grant (access + refresh token, no id_token), so there's no email
|
|
925
|
+
// claim to read locally — presence of a refresh token is the only
|
|
926
|
+
// signed-in signal we can derive without a network call.
|
|
927
|
+
const tokenPath = path.join(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
|
|
928
|
+
if (!fs.existsSync(tokenPath))
|
|
929
|
+
return { ...empty, lastActive };
|
|
930
|
+
const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
|
|
931
|
+
const hasToken = typeof data?.token?.refresh_token === 'string' && !!data.token.refresh_token;
|
|
932
|
+
if (!hasToken)
|
|
933
|
+
return { ...empty, lastActive };
|
|
934
|
+
return { ...empty, signedIn: true, lastActive };
|
|
935
|
+
}
|
|
936
|
+
case 'kimi': {
|
|
937
|
+
// Kimi Code stores OAuth credentials at
|
|
938
|
+
// ~/.kimi-code/credentials/kimi-code.json. The access token is a JWT
|
|
939
|
+
// whose payload carries an opaque user_id (no email), so we report
|
|
940
|
+
// signed-in state plus a stable account key for usage dedup.
|
|
941
|
+
const credPath = path.join(base, '.kimi-code', 'credentials', 'kimi-code.json');
|
|
942
|
+
if (!fs.existsSync(credPath))
|
|
943
|
+
return { ...empty, lastActive };
|
|
944
|
+
const data = JSON.parse(await fs.promises.readFile(credPath, 'utf-8'));
|
|
945
|
+
const accessToken = data?.access_token;
|
|
946
|
+
if (typeof accessToken !== 'string' || !accessToken)
|
|
947
|
+
return { ...empty, lastActive };
|
|
948
|
+
const decoded = decodeJwtPayload(accessToken);
|
|
949
|
+
const userId = normalizeIdentityPart(decoded?.user_id ?? decoded?.sub);
|
|
950
|
+
const accountKey = buildIdentityKey(agentId, [['user', userId]]);
|
|
951
|
+
return { ...empty, signedIn: true, accountId: userId, accountKey, lastActive };
|
|
952
|
+
}
|
|
916
953
|
default:
|
|
917
954
|
return { ...empty, lastActive };
|
|
918
955
|
}
|
|
@@ -1151,7 +1188,10 @@ export async function registerMcp(agentId, name, command, scope = 'user', transp
|
|
|
1151
1188
|
}
|
|
1152
1189
|
// When home is specified, override HOME so MCP config writes to the version's config dir
|
|
1153
1190
|
const env = options?.home ? { ...process.env, HOME: options.home } : undefined;
|
|
1154
|
-
|
|
1191
|
+
// On Windows a bare command name / `.cmd` wrapper (the npm-installed agent
|
|
1192
|
+
// CLI) can't be exec'd directly — it needs shell:true for PATHEXT/cmd. Off
|
|
1193
|
+
// Windows this is always false, so the no-shell argv path is unchanged.
|
|
1194
|
+
await execFileAsync(bin, args, { ...(env ? { env } : {}), shell: needsWindowsShell(bin) });
|
|
1155
1195
|
return { success: true };
|
|
1156
1196
|
}
|
|
1157
1197
|
catch (err) {
|
|
@@ -1170,7 +1210,7 @@ export async function unregisterMcp(agentId, name, options) {
|
|
|
1170
1210
|
try {
|
|
1171
1211
|
const bin = options?.binary || agent.cliCommand;
|
|
1172
1212
|
const env = options?.home ? { ...process.env, HOME: options.home } : undefined;
|
|
1173
|
-
await execFileAsync(bin, ['mcp', 'remove', name], env ? { env } :
|
|
1213
|
+
await execFileAsync(bin, ['mcp', 'remove', name], { ...(env ? { env } : {}), shell: needsWindowsShell(bin) });
|
|
1174
1214
|
return { success: true };
|
|
1175
1215
|
}
|
|
1176
1216
|
catch (err) {
|
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
import { CDPClient } from '../cdp.js';
|
|
2
2
|
import type { BrowserProfile } from '../types.js';
|
|
3
|
+
import { shellQuote } from '../../ssh-exec.js';
|
|
4
|
+
export { shellQuote };
|
|
3
5
|
export interface SSHConnection {
|
|
4
6
|
cdp: CDPClient;
|
|
5
7
|
port: number;
|
|
6
8
|
pid: number;
|
|
7
9
|
cleanup: () => void;
|
|
8
10
|
}
|
|
9
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Which shell dialect the *remote* host speaks. Selected per-endpoint via the
|
|
13
|
+
* `&os=windows` query param on an `ssh://` target. POSIX is the default — the
|
|
14
|
+
* historical behavior for macOS/Linux remotes. Windows remotes run OpenSSH
|
|
15
|
+
* Server with cmd.exe as the default shell, so the launch/teardown command
|
|
16
|
+
* strings differ (no `&` backgrounding, no `lsof`, `.exe` instead of `.app`).
|
|
17
|
+
*/
|
|
18
|
+
export type RemoteOs = 'windows' | 'posix';
|
|
10
19
|
export declare function connectSSH(endpoint: string, profile: BrowserProfile): Promise<SSHConnection>;
|
|
11
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Wrap a PowerShell script as a `-EncodedCommand` invocation. Base64 of the
|
|
22
|
+
* UTF-16LE bytes is a single quote-free token, so it rides through Node spawn
|
|
23
|
+
* → Windows sshd → cmd.exe with zero escaping hazards (hand-quoted
|
|
24
|
+
* `powershell -Command "…"` is fragile the moment a path or URL is involved).
|
|
25
|
+
*/
|
|
26
|
+
export declare function encodePowerShell(script: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* The PowerShell that launches the browser on a Windows remote. Two hard
|
|
29
|
+
* requirements shaped this:
|
|
30
|
+
* 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
|
|
31
|
+
* the session's job tree on disconnect, which reaps both `start /B` and
|
|
32
|
+
* `Start-Process` children (verified against a real box). WMI
|
|
33
|
+
* `Win32_Process.Create` spawns under the WMI provider service instead,
|
|
34
|
+
* so the process survives after we drop the ssh connection and reconnect
|
|
35
|
+
* over the CDP tunnel.
|
|
36
|
+
* 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
|
|
37
|
+
* port comes up even when the user already has Edge open.
|
|
38
|
+
* CreateProcess ignores App Paths, so we resolve the real `.exe` from the
|
|
39
|
+
* registry at runtime rather than relying on a bare `msedge` name.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildWindowsLaunchScript(browserType: string, port: number, customBinary?: string): string;
|
|
42
|
+
/** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
|
|
43
|
+
export declare function buildWindowsKillScript(port: number): string;
|
|
44
|
+
/**
|
|
45
|
+
* Build the remote command that launches the browser detached with a CDP port.
|
|
46
|
+
* POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
|
|
47
|
+
* spawns it via WMI (encoded PowerShell) so it survives the ssh session.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildLaunchCmd(remoteOs: RemoteOs, browserType: string, port: number, customBinary?: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Build the remote command that kills whatever holds the CDP port.
|
|
52
|
+
* POSIX uses `lsof`+`kill`; Windows uses encoded PowerShell
|
|
53
|
+
* (Get-NetTCPConnection → Stop-Process).
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildKillCmd(remoteOs: RemoteOs, port: number): string;
|
|
56
|
+
export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;
|
|
@@ -4,11 +4,10 @@ import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.j
|
|
|
4
4
|
import { getPortOccupant } from '../chrome.js';
|
|
5
5
|
import { parseEndpointUrl } from '../profiles.js';
|
|
6
6
|
import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
7
|
+
// shellQuote lives in the shared ssh-exec helper (single choke point); re-export
|
|
8
|
+
// so existing importers of `shellQuote` from this module keep working.
|
|
9
|
+
import { shellQuote } from '../../ssh-exec.js';
|
|
10
|
+
export { shellQuote };
|
|
12
11
|
export async function connectSSH(endpoint, profile) {
|
|
13
12
|
const url = new URL(endpoint);
|
|
14
13
|
if (url.protocol !== 'ssh:') {
|
|
@@ -24,6 +23,11 @@ export async function connectSSH(endpoint, profile) {
|
|
|
24
23
|
}
|
|
25
24
|
const host = parsed.host;
|
|
26
25
|
const remotePort = parsed.port;
|
|
26
|
+
// `&os=windows` switches the remote-command dialect (cmd.exe launch via
|
|
27
|
+
// `start`, taskkill teardown). Anything else — including absent — is posix.
|
|
28
|
+
// The query param is the single source of truth so the driver never has to
|
|
29
|
+
// be threaded a separate per-profile field.
|
|
30
|
+
const remoteOs = (url.searchParams.get('os') || '').toLowerCase() === 'windows' ? 'windows' : 'posix';
|
|
27
31
|
// Bind the tunnel to the SAME local port the user configured. Using an
|
|
28
32
|
// allocated port instead made `status` print confusing rows like
|
|
29
33
|
// `port 9200 (configured 10005)` and made it impossible to predict which
|
|
@@ -40,7 +44,7 @@ export async function connectSSH(endpoint, profile) {
|
|
|
40
44
|
`Either kill that process (\`kill ${occupant.pid}\`) or change the profile's port.`);
|
|
41
45
|
}
|
|
42
46
|
try {
|
|
43
|
-
await ensureRemoteBrowser(user, host, profile.browser, remotePort, profile.binary);
|
|
47
|
+
await ensureRemoteBrowser(user, host, profile.browser, remotePort, remoteOs, profile.binary);
|
|
44
48
|
}
|
|
45
49
|
catch {
|
|
46
50
|
// Browser may already be running, continue
|
|
@@ -156,28 +160,100 @@ function tryConnect(port) {
|
|
|
156
160
|
socket.on('error', reject);
|
|
157
161
|
});
|
|
158
162
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
// macOS .app launchers — the historical POSIX table.
|
|
164
|
+
const POSIX_BROWSER_PATHS = {
|
|
165
|
+
chrome: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
166
|
+
comet: '/Applications/Comet.app/Contents/MacOS/Comet',
|
|
167
|
+
chromium: '/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
168
|
+
brave: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
169
|
+
edge: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
170
|
+
};
|
|
171
|
+
// Windows App Paths registry keys per browser. CreateProcess (used by WMI
|
|
172
|
+
// Win32_Process.Create) does not honor App Paths the way ShellExecute/`start`
|
|
173
|
+
// does, so the launch script resolves the real `.exe` path from this registry
|
|
174
|
+
// key at runtime — covering both Program Files and Program Files (x86)
|
|
175
|
+
// installs without hardcoding (or guessing) the location.
|
|
176
|
+
const WIN_BROWSER_APPPATH = {
|
|
177
|
+
chrome: 'chrome.exe',
|
|
178
|
+
chromium: 'chrome.exe',
|
|
179
|
+
brave: 'brave.exe',
|
|
180
|
+
edge: 'msedge.exe',
|
|
181
|
+
};
|
|
182
|
+
/** Single-quote a string for embedding inside a PowerShell literal. */
|
|
183
|
+
function psSingleQuote(s) {
|
|
184
|
+
return "'" + s.replace(/'/g, "''") + "'";
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Wrap a PowerShell script as a `-EncodedCommand` invocation. Base64 of the
|
|
188
|
+
* UTF-16LE bytes is a single quote-free token, so it rides through Node spawn
|
|
189
|
+
* → Windows sshd → cmd.exe with zero escaping hazards (hand-quoted
|
|
190
|
+
* `powershell -Command "…"` is fragile the moment a path or URL is involved).
|
|
191
|
+
*/
|
|
192
|
+
export function encodePowerShell(script) {
|
|
193
|
+
const b64 = Buffer.from(script, 'utf16le').toString('base64');
|
|
194
|
+
return `powershell -NoProfile -EncodedCommand ${b64}`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The PowerShell that launches the browser on a Windows remote. Two hard
|
|
198
|
+
* requirements shaped this:
|
|
199
|
+
* 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
|
|
200
|
+
* the session's job tree on disconnect, which reaps both `start /B` and
|
|
201
|
+
* `Start-Process` children (verified against a real box). WMI
|
|
202
|
+
* `Win32_Process.Create` spawns under the WMI provider service instead,
|
|
203
|
+
* so the process survives after we drop the ssh connection and reconnect
|
|
204
|
+
* over the CDP tunnel.
|
|
205
|
+
* 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
|
|
206
|
+
* port comes up even when the user already has Edge open.
|
|
207
|
+
* CreateProcess ignores App Paths, so we resolve the real `.exe` from the
|
|
208
|
+
* registry at runtime rather than relying on a bare `msedge` name.
|
|
209
|
+
*/
|
|
210
|
+
export function buildWindowsLaunchScript(browserType, port, customBinary) {
|
|
211
|
+
let exeExpr;
|
|
168
212
|
if (customBinary) {
|
|
169
|
-
|
|
213
|
+
exeExpr = psSingleQuote(customBinary);
|
|
170
214
|
}
|
|
171
215
|
else if (browserType === 'custom') {
|
|
172
216
|
throw new Error('browser: custom requires a binary path in the profile');
|
|
173
217
|
}
|
|
174
218
|
else {
|
|
175
|
-
|
|
176
|
-
if (!
|
|
177
|
-
throw new Error(`Unknown browser type: ${browserType}`);
|
|
219
|
+
const exeKey = WIN_BROWSER_APPPATH[browserType];
|
|
220
|
+
if (!exeKey)
|
|
221
|
+
throw new Error(`Unknown browser type for windows remote: ${browserType}`);
|
|
222
|
+
exeExpr =
|
|
223
|
+
`(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exeKey}').'(default)'`;
|
|
224
|
+
}
|
|
225
|
+
// Keep the `--remote-allow-origins=http://127.0.0.1:${port}` literal in
|
|
226
|
+
// source — a test asserts CDP is never opened to `*`.
|
|
227
|
+
return [
|
|
228
|
+
`$exe = ${exeExpr}`,
|
|
229
|
+
`$cl = '"' + $exe + '" --remote-debugging-port=${port}` +
|
|
230
|
+
` --remote-allow-origins=http://127.0.0.1:${port}` +
|
|
231
|
+
` --disable-background-timer-throttling --user-data-dir="' + $env:TEMP + '\\agents-browser-${port}"'`,
|
|
232
|
+
`Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $cl } | Out-Null`,
|
|
233
|
+
].join('; ');
|
|
234
|
+
}
|
|
235
|
+
/** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
|
|
236
|
+
export function buildWindowsKillScript(port) {
|
|
237
|
+
return (`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue ` +
|
|
238
|
+
`| ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Build the remote command that launches the browser detached with a CDP port.
|
|
242
|
+
* POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
|
|
243
|
+
* spawns it via WMI (encoded PowerShell) so it survives the ssh session.
|
|
244
|
+
*/
|
|
245
|
+
export function buildLaunchCmd(remoteOs, browserType, port, customBinary) {
|
|
246
|
+
if (remoteOs === 'windows') {
|
|
247
|
+
return encodePowerShell(buildWindowsLaunchScript(browserType, port, customBinary));
|
|
248
|
+
}
|
|
249
|
+
const browserPath = customBinary ?? POSIX_BROWSER_PATHS[browserType];
|
|
250
|
+
if (!browserPath) {
|
|
251
|
+
if (browserType === 'custom') {
|
|
252
|
+
throw new Error('browser: custom requires a binary path in the profile');
|
|
178
253
|
}
|
|
254
|
+
throw new Error(`Unknown browser type for posix remote: ${browserType}`);
|
|
179
255
|
}
|
|
180
|
-
|
|
256
|
+
return [
|
|
181
257
|
shellQuote(browserPath),
|
|
182
258
|
`--remote-debugging-port=${port}`,
|
|
183
259
|
shellQuote(`--remote-allow-origins=http://127.0.0.1:${port}`),
|
|
@@ -185,6 +261,20 @@ async function ensureRemoteBrowser(user, host, browserType, port, customBinary)
|
|
|
185
261
|
`--user-data-dir=/tmp/agents-browser-${port}`,
|
|
186
262
|
'</dev/null >/dev/null 2>&1 &',
|
|
187
263
|
].join(' ');
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Build the remote command that kills whatever holds the CDP port.
|
|
267
|
+
* POSIX uses `lsof`+`kill`; Windows uses encoded PowerShell
|
|
268
|
+
* (Get-NetTCPConnection → Stop-Process).
|
|
269
|
+
*/
|
|
270
|
+
export function buildKillCmd(remoteOs, port) {
|
|
271
|
+
if (remoteOs === 'windows') {
|
|
272
|
+
return encodePowerShell(buildWindowsKillScript(port));
|
|
273
|
+
}
|
|
274
|
+
return `pids=$(lsof -ti ${shellQuote(`:${port}`)} 2>/dev/null); [ -z "$pids" ] || kill -9 $pids 2>/dev/null || true`;
|
|
275
|
+
}
|
|
276
|
+
async function ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
|
|
277
|
+
const remoteCmd = buildLaunchCmd(remoteOs, browserType, port, customBinary);
|
|
188
278
|
return new Promise((resolve, reject) => {
|
|
189
279
|
const child = spawn('ssh', [
|
|
190
280
|
`${user}@${host}`,
|
|
@@ -200,12 +290,11 @@ async function ensureRemoteBrowser(user, host, browserType, port, customBinary)
|
|
|
200
290
|
}, 2000);
|
|
201
291
|
});
|
|
202
292
|
}
|
|
203
|
-
export async function restartRemoteBrowser(user, host, browserType, port, customBinary) {
|
|
293
|
+
export async function restartRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
|
|
204
294
|
// Kill any process using the remote debugging port
|
|
205
|
-
|
|
206
|
-
await runSSHCommand(user, host, killCmd);
|
|
295
|
+
await runSSHCommand(user, host, buildKillCmd(remoteOs, port));
|
|
207
296
|
await sleep(500);
|
|
208
|
-
await ensureRemoteBrowser(user, host, browserType, port, customBinary);
|
|
297
|
+
await ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary);
|
|
209
298
|
await sleep(1500);
|
|
210
299
|
}
|
|
211
300
|
function runSSHCommand(user, host, cmd) {
|
|
@@ -194,6 +194,27 @@ function hasSshEndpoint(endpoints) {
|
|
|
194
194
|
}
|
|
195
195
|
});
|
|
196
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* True when any endpoint is an `ssh://…?os=windows` target — i.e. the browser
|
|
199
|
+
* lives on a remote Windows host. Such a profile's binary (`msedge.exe`) will
|
|
200
|
+
* never exist on this Mac, so create-time local-binary validation must be
|
|
201
|
+
* skipped; the binary is resolved on the remote at connect time instead.
|
|
202
|
+
*/
|
|
203
|
+
function hasRemoteWindowsEndpoint(endpoints) {
|
|
204
|
+
const targets = Array.isArray(endpoints)
|
|
205
|
+
? endpoints
|
|
206
|
+
: Object.values(endpoints).map((preset) => preset.target);
|
|
207
|
+
return targets.some((target) => {
|
|
208
|
+
try {
|
|
209
|
+
const url = new URL(target);
|
|
210
|
+
return (url.protocol === 'ssh:' &&
|
|
211
|
+
(url.searchParams.get('os') || '').toLowerCase() === 'windows');
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
197
218
|
export async function createProfile(profile) {
|
|
198
219
|
const meta = readMeta();
|
|
199
220
|
if (meta.browser?.[profile.name]) {
|
|
@@ -220,7 +241,13 @@ export async function createProfile(profile) {
|
|
|
220
241
|
// error ("Comet not installed at /Applications/Comet.app") rather than
|
|
221
242
|
// deferring the failure to the first task. `findBrowserPath` short-circuits
|
|
222
243
|
// for browser=custom without a binary by throwing — same outcome.
|
|
223
|
-
|
|
244
|
+
//
|
|
245
|
+
// Skip for remote-Windows profiles: the browser is `msedge.exe` on the
|
|
246
|
+
// remote box, never on this Mac, so a local lookup would always (wrongly)
|
|
247
|
+
// fail. The remote launcher resolves it at connect time via App Paths.
|
|
248
|
+
if (!hasRemoteWindowsEndpoint(profile.endpoints)) {
|
|
249
|
+
findBrowserPath(profile.browser, profile.binary);
|
|
250
|
+
}
|
|
224
251
|
meta.browser = meta.browser ?? {};
|
|
225
252
|
meta.browser[profile.name] = profileToConfig(profile);
|
|
226
253
|
writeMeta(meta);
|
|
@@ -248,20 +248,40 @@ export function reapOrphanedProcesses() {
|
|
|
248
248
|
return { reaped, details };
|
|
249
249
|
}
|
|
250
250
|
function matchesCommand(pid, expectedCommand) {
|
|
251
|
+
const out = liveProcessCommand(pid);
|
|
252
|
+
if (!out)
|
|
253
|
+
return false;
|
|
254
|
+
// Match on the basename only — `/Applications/Comet.app/Contents/MacOS/Comet`
|
|
255
|
+
// vs the recorded `Comet`, vs `Google\ Chrome`, vs Windows `chrome.exe`.
|
|
256
|
+
// Case-insensitive.
|
|
257
|
+
const live = path.basename(out).toLowerCase();
|
|
258
|
+
const want = path.basename(expectedCommand).toLowerCase();
|
|
259
|
+
return live === want || live.startsWith(want) || want.startsWith(live);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The executable/image name the live `pid` is running, or null if it can't be
|
|
263
|
+
* determined. The process-listing API differs per OS: Windows has no `ps`, so
|
|
264
|
+
* we query `tasklist` (CSV image name in column 1); POSIX uses `ps -o comm=`.
|
|
265
|
+
*/
|
|
266
|
+
function liveProcessCommand(pid) {
|
|
251
267
|
try {
|
|
268
|
+
if (process.platform === 'win32') {
|
|
269
|
+
const out = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'], {
|
|
270
|
+
encoding: 'utf-8',
|
|
271
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
272
|
+
}).trim();
|
|
273
|
+
// Rows look like: "node.exe","1234","Console","1","12,345 K"
|
|
274
|
+
// A no-match prints an "INFO: No tasks..." line that won't match the regex.
|
|
275
|
+
const m = out.match(/^"([^"]+)"/);
|
|
276
|
+
return m ? m[1] : null;
|
|
277
|
+
}
|
|
252
278
|
const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
|
|
253
279
|
encoding: 'utf-8',
|
|
254
280
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
255
281
|
}).trim();
|
|
256
|
-
|
|
257
|
-
return false;
|
|
258
|
-
// Match on the basename only — `/Applications/Comet.app/Contents/MacOS/Comet`
|
|
259
|
-
// vs the recorded `Comet`, vs `Google\ Chrome`. Case-insensitive.
|
|
260
|
-
const live = path.basename(out).toLowerCase();
|
|
261
|
-
const want = path.basename(expectedCommand).toLowerCase();
|
|
262
|
-
return live === want || live.startsWith(want) || want.startsWith(live);
|
|
282
|
+
return out || null;
|
|
263
283
|
}
|
|
264
284
|
catch {
|
|
265
|
-
return
|
|
285
|
+
return null;
|
|
266
286
|
}
|
|
267
287
|
}
|
|
@@ -7,7 +7,16 @@ export type BrowserType = 'chrome' | 'comet' | 'chromium' | 'brave' | 'edge' | '
|
|
|
7
7
|
* Per-endpoint overrides take precedence over profile-level fields.
|
|
8
8
|
*/
|
|
9
9
|
export interface EndpointPreset {
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* CDP URL — `cdp://host:port` or `ssh://host?port=N`.
|
|
12
|
+
*
|
|
13
|
+
* For an SSH target whose remote is Windows, append `&os=windows` (e.g.
|
|
14
|
+
* `ssh://user@host?port=9222&os=windows`). The driver then speaks the
|
|
15
|
+
* Windows dialect (launch via WMI Win32_Process.Create so the browser
|
|
16
|
+
* survives the ssh session, teardown via Get-NetTCPConnection/Stop-Process)
|
|
17
|
+
* instead of the POSIX default. The query param is the single source of
|
|
18
|
+
* truth for remote-OS selection.
|
|
19
|
+
*/
|
|
11
20
|
target: string;
|
|
12
21
|
/** Override the profile-level binary (e.g. a remote host has no local binary). */
|
|
13
22
|
binary?: string;
|
|
@@ -116,7 +116,16 @@ export function parseCheckSpec(raw, defaultName) {
|
|
|
116
116
|
* decide whether to surface or swallow the error per file.
|
|
117
117
|
*/
|
|
118
118
|
export function parseCliManifest(contents, opts) {
|
|
119
|
-
|
|
119
|
+
// Tolerant parse. A manifest may legitimately carry OS-specific strings — a
|
|
120
|
+
// Windows path like `C:\Users\...` embedded in a double-quoted YAML scalar
|
|
121
|
+
// trips YAML's escape rules (`\U` is an invalid escape) and makes the strict
|
|
122
|
+
// `yaml.parse` throw a parser error. That throw must NOT pre-empt the
|
|
123
|
+
// security validation below: the per-field allowlist checks (unsafe tokens,
|
|
124
|
+
// non-https URLs, path traversal) are the authoritative gate on a hostile
|
|
125
|
+
// manifest. parseDocument collects those escape errors instead of throwing
|
|
126
|
+
// and still recovers the scalar values, so the dangerous content reaches
|
|
127
|
+
// assertSafeCheckToken / assertNpmPackage and is rejected on its merits.
|
|
128
|
+
const raw = yaml.parseDocument(contents, { strict: false }).toJS();
|
|
120
129
|
if (!raw || typeof raw !== 'object') {
|
|
121
130
|
throw new Error('manifest must be a YAML object');
|
|
122
131
|
}
|
|
@@ -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;
|