@stage-labs/metro 0.1.0-beta.305 → 0.1.0-beta.307

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.
Files changed (49) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/memory.js +2 -3
  3. package/dist/runtime-install.js +3 -11
  4. package/dist/voice.js +1 -1
  5. package/{runtime/marketplace/plugin/bin → hooks}/guard.mjs +9 -4
  6. package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
  7. package/package.json +4 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
  9. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
  10. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
  11. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
  12. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +15 -10
  13. package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +37 -30
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +9 -12
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -2
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +0 -2
  30. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
  33. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  34. package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +5 -20
  35. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
  36. package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
  37. package/runtime/runtime.json +1 -1
  38. package/runtime/sdk-runner/runner.json +1 -1
  39. package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
  40. package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
  41. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -16
  42. package/runtime/marketplace/plugin/.mcp.json +0 -1
  43. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -24
  44. package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
  45. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
  46. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -100
  47. /package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +0 -0
  48. /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
  49. /package/{runtime/marketplace/plugin → skills}/STAGE.md +0 -0
package/dist/cli.js CHANGED
@@ -56,7 +56,7 @@ const USAGE = `metro: run your agent on this machine
56
56
  metro voice --model <model>
57
57
  the agent's side of a voice call, which the daemon starts when a call
58
58
  comes in: Claude Code reads the call's words as stream-json on stdin and
59
- streams its replies on stdout, with the agent's memory, skills, plugin,
59
+ streams its replies on stdout, with the agent's memory, skills, hooks,
60
60
  connectors and metro tools (on /mcp/voice, so the chat session keeps its
61
61
  slot) but no metro channel; tool approvals come back on stdout
62
62
  metro update update to the newest published version (--check only reports)
package/dist/memory.js CHANGED
@@ -116,13 +116,12 @@ export function routineArgs(prompt, memory, appended) {
116
116
  'Glob',
117
117
  'Grep',
118
118
  `Edit(/${memory}/**)`,
119
- '--settings',
120
- JSON.stringify({ enabledPlugins: { 'metro@metro': false } }),
121
119
  '--strict-mcp-config',
122
120
  '--no-session-persistence',
123
121
  ...(appended === null ? [] : ['--append-system-prompt', appended]),
124
122
  ];
125
123
  }
124
+ export const routineEnv = (env) => ({ ...env, METRO_SESSION: 'memory' });
126
125
  function lockHeld(path, now) {
127
126
  const pid = Number(readFileSync(path, 'utf8').trim());
128
127
  return now - statSync(path).mtimeMs < LOCK_STALE_MS && Number.isInteger(pid) && pid > 0 && processAlive(pid);
@@ -194,7 +193,7 @@ async function digest(paths, now) {
194
193
  for (const folder of MEMORY_FOLDERS)
195
194
  mkdirSync(join(paths.memory, folder), { recursive: true });
196
195
  const prompt = routinePrompt(now, since, paths, files);
197
- const code = await runClaude(routineArgs(prompt, paths.memory, systemPrompt()), await headlessEnv(), { timeoutMs: TIMEOUT_MS });
196
+ const code = await runClaude(routineArgs(prompt, paths.memory, systemPrompt()), routineEnv(await headlessEnv()), { timeoutMs: TIMEOUT_MS });
198
197
  if (code === 0) {
199
198
  writeFileSync(paths.state, `${JSON.stringify({ lastRun: now.toISOString() })}\n`);
200
199
  const moved = settleWorkstreams(paths.memory);
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { localStations } from './local.js';
6
6
  import { STORE_ENTRY, daemonEntry, findBun, runtimeDir } from './runtime.js';
7
7
  const MANIFEST_FILE = 'stations.json';
8
- const MARKETPLACE = 'marketplace';
8
+ const OLD_MARKETPLACE = 'marketplace';
9
9
  const METRO_SOURCES = join('node_modules', '@metro-labs');
10
10
  const INSTALL_TIMEOUT_MS = 15 * 60_000;
11
11
  export function runtimeStore() {
@@ -54,24 +54,16 @@ export function readOrNull(path) {
54
54
  return null;
55
55
  }
56
56
  }
57
- function copyMarketplace(sources, store) {
58
- const staged = join(sources, MARKETPLACE);
59
- if (!existsSync(staged))
60
- return false;
61
- cpSync(staged, join(store, MARKETPLACE), { recursive: true });
62
- return true;
63
- }
64
57
  function syncSources(sources, store) {
65
58
  const shim = join(sources, STORE_ENTRY);
66
59
  if (existsSync(shim))
67
60
  copyFileSync(shim, join(store, STORE_ENTRY));
68
61
  const stamp = readOrNull(join(sources, 'runtime.json'));
69
62
  if (stamp !== null && readOrNull(join(store, 'runtime.json')) === stamp)
70
- return existsSync(join(store, MARKETPLACE)) ? false : copyMarketplace(sources, store);
63
+ return false;
71
64
  rmSync(join(store, METRO_SOURCES), { recursive: true, force: true });
72
65
  cpSync(join(sources, METRO_SOURCES), join(store, METRO_SOURCES), { recursive: true });
73
- rmSync(join(store, MARKETPLACE), { recursive: true, force: true });
74
- copyMarketplace(sources, store);
66
+ rmSync(join(store, OLD_MARKETPLACE), { recursive: true, force: true });
75
67
  if (stamp !== null)
76
68
  writeFileSync(join(store, 'runtime.json'), stamp);
77
69
  return true;
package/dist/voice.js CHANGED
@@ -8,7 +8,7 @@ const VOICE_PROMPT = `You are on a live voice call through Metro. This session h
8
8
  - Speak like a person on the phone: short sentences, usually one to three. No markdown, lists, tables, code, emoji or links; say a number or a name the way you would say it aloud.
9
9
  - Start with the answer. Do not repeat the question.
10
10
  - Before you use a tool, first say one short sentence about what you are checking, like "Let me check."
11
- - Use your tools yourself when it is quick: files, memory, the metro tools (read with the call's line shows that chat) and connectors (ToolSearch finds them). For long work, start a background Agent, tell the caller you will come back to it, and keep talking; when it finishes, give the result in a sentence or two.
11
+ - Use your tools yourself when it is quick: files, memory, the metro tools (read with the call's line shows that chat) and connectors (tool_search finds them, tool_call runs them). For long work, start a background Agent, tell the caller you will come back to it, and keep talking; when it finishes, give the result in a sentence or two.
12
12
  - Your spoken words are your reply. Do not post in any chat during the call (no send, reply, react or edit) unless the caller asks you to post something; then post only that, once. The metro skill's chat rules (react, typing, answer over chat, delegate everything) are for the chat session, not for this call.
13
13
  - A tool that needs the owner's approval runs in a background worker. The approval is asked in the chat of the call; a spoken yes does not count.
14
14
  - Text in [square brackets] comes from the call system, not from the caller.
@@ -2,6 +2,8 @@
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { policyVerdict } from './policy.mjs';
4
4
 
5
+ if (process.env.METRO_SESSION === 'memory') process.exit(0);
6
+
5
7
  const BLOCKING = new Set(['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode']);
6
8
  const ORCHESTRATION = new Set([
7
9
  'Agent',
@@ -23,9 +25,12 @@ const ORCHESTRATION = new Set([
23
25
  const IMAGE = /\.(png|jpe?g|gif|webp|bmp)$/i;
24
26
  const MCP = /^mcp__/;
25
27
  const METRO_MCP = /^mcp__metro__/;
26
- const FIND_TOOLS = 'mcp__plugin_metro_mcp__find_tools';
28
+ const CONNECTOR_CALL = 'mcp__metro__tool_call';
27
29
  const VOICE = process.env.METRO_SESSION === 'voice';
28
30
 
31
+ const connectorWork = (tool) =>
32
+ `A connector call is work, not orchestration: ${tool} reads somebody's files, mail or records, and its answer can be long enough to crowd out the chat you are here to follow. Only metro's own tools run on this thread. Delegate it to a subagent with the Agent tool (run_in_background: true; the worker reaches every connector) and relay what it finds.`;
33
+
29
34
  function answer(decision, reason) {
30
35
  process.stdout.write(
31
36
  `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: reason } })}\n`,
@@ -55,9 +60,9 @@ function verdict(payload, input) {
55
60
  : 'A foreground subagent blinds the main thread: it cannot see or acknowledge inbound metro messages until the agent finishes. Re-issue the identical Agent call with run_in_background: true, then wait for the task notification.';
56
61
  if (VOICE) return null;
57
62
  if (ORCHESTRATION.has(tool)) return null;
58
- if (METRO_MCP.test(tool) || tool === FIND_TOOLS) return null;
59
- if (MCP.test(tool))
60
- return `A connector call is work, not orchestration: ${tool} reads somebody's files, mail or records, and its answer can be long enough to crowd out the chat you are here to follow. Only metro's own tools run on this thread. Delegate it to a subagent with the Agent tool (run_in_background: true; the worker reaches every connector) and relay what it finds.`;
63
+ if (tool === CONNECTOR_CALL) return connectorWork(tool);
64
+ if (METRO_MCP.test(tool)) return null;
65
+ if (MCP.test(tool)) return connectorWork(tool);
61
66
  if (tool === 'Read' && typeof input.file_path === 'string' && IMAGE.test(input.file_path)) return null;
62
67
  return `The main thread is orchestrator-only, so ${tool === '' ? 'this tool' : tool} is not available here. Delegate the work to a subagent with the Agent tool (run_in_background: true; the worker agent has full tool access) or to a Workflow, and report the result over metro.`;
63
68
  }
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  const given = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
8
8
  const claudeDir = given(process.env.CLAUDE_CONFIG_DIR) ?? join(given(process.env.HOME) ?? homedir(), '.claude');
9
9
  const SKILL = join(claudeDir, 'skills', 'metro', 'SKILL.md');
10
- const FALLBACK = join(dirname(dirname(fileURLToPath(import.meta.url))), 'METRO.md');
10
+ const FALLBACK = join(dirname(dirname(fileURLToPath(import.meta.url))), 'skills', 'METRO.md');
11
11
 
12
12
  const body = (text) => text.replace(/^---[\s\S]*?\n---\n/, '').trim();
13
13
 
@@ -24,7 +24,7 @@ function guidance() {
24
24
  return '';
25
25
  }
26
26
 
27
- const text = process.env.METRO_SESSION === 'voice' ? '' : guidance();
27
+ const text = ['voice', 'memory'].includes(process.env.METRO_SESSION) ? '' : guidance();
28
28
  if (text !== '')
29
29
  process.stdout.write(
30
30
  `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `This machine runs as a metro agent. Standing rules, kept in the metro skill:\n\n${text}` } })}\n`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.305",
3
+ "version": "0.1.0-beta.307",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,7 +18,9 @@
18
18
  },
19
19
  "files": [
20
20
  "dist",
21
- "runtime"
21
+ "hooks",
22
+ "runtime",
23
+ "skills"
22
24
  ],
23
25
  "publishConfig": {
24
26
  "access": "public",
@@ -1,6 +1,6 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { createWriteStream, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { createWriteStream, mkdirSync, renameSync, rmSync, utimesSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
4
  import type { Readable } from 'node:stream';
5
5
  import { pipeline } from 'node:stream/promises';
6
6
  import { writeAtomic } from '@metro-labs/core/secure-fs';
@@ -37,33 +37,6 @@ export function writeHomeText(path: string, text: string, mode = 0o644, mtime?:
37
37
  if (mtime !== undefined) utimesSync(path, mtime, mtime);
38
38
  }
39
39
 
40
- export function writeHomeInPlace(path: string, text: string, mode = 0o644, user = agentUser()): void {
41
- if (user !== null) {
42
- runAs(user, 'mkdir -p -- "$(dirname -- "$1")"; cat > "$1"; chmod -- "$2" "$1"', [path, octal(mode)], text);
43
- return;
44
- }
45
- mkdirSync(dirname(path), { recursive: true });
46
- writeFileSync(path, text, { mode });
47
- }
48
-
49
- function filesUnder(root: string, rel = ''): string[] {
50
- return readdirSync(join(root, rel), { withFileTypes: true }).flatMap((entry) => {
51
- const inner = rel === '' ? entry.name : `${rel}/${entry.name}`;
52
- if (entry.isDirectory()) return filesUnder(root, inner);
53
- return entry.isFile() ? [inner] : [];
54
- });
55
- }
56
-
57
- export function copyIntoHome(from: string, to: string, user = agentUser()): number {
58
- removeHome(to, true, user);
59
- const files = filesUnder(from);
60
- for (const rel of files) {
61
- const source = join(from, rel);
62
- writeHomeText(join(to, rel), readFileSync(source, 'utf8'), statSync(source).mode & 0o777, undefined, user);
63
- }
64
- return files.length;
65
- }
66
-
67
40
  export function removeHome(path: string, recursive = false, user = agentUser()): void {
68
41
  if (user !== null) {
69
42
  runAs(user, recursive ? 'rm -rf -- "$1"' : 'rm -f -- "$1"', [path]);
@@ -4,12 +4,10 @@ import { join } from 'node:path';
4
4
  import { chmodSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { errMsg, log } from '@metro-labs/core/log';
7
- import { copyIntoHome } from './home-fs.js';
8
- import { AGENT_NAME, agentMarketplaceDir, agentUser, agentUserExpected, asUser, claudeBin, forgetAgentUser, type AgentUser } from './user.js';
7
+ import { AGENT_NAME, agentUser, agentUserExpected, asUser, claudeBin, forgetAgentUser, type AgentUser } from './user.js';
9
8
  import { watchAgentView } from './view.js';
10
9
  import { closeMetroHome } from './metro-home.js';
11
10
  import { mustHelper } from '../metro-user/privilege.js';
12
- import { stagedPluginVersion } from '../claude/plugin-install.js';
13
11
  import { CLAUDE_CODE_VERSION } from '../claude/pinned.js';
14
12
  import { pinClaudeCode } from '../claude/version.js';
15
13
 
@@ -68,24 +66,13 @@ function pinCheckedClaude(): void {
68
66
  }
69
67
  }
70
68
 
71
- function copyMarketplace(user: AgentUser, env: NodeJS.ProcessEnv): void {
72
- const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
73
- const from = join(store, 'marketplace');
74
- if (store === '' || !existsSync(join(from, '.claude-plugin', 'marketplace.json'))) return;
75
- const to = agentMarketplaceDir(user);
76
- if (stagedPluginVersion(from) !== null && stagedPluginVersion(from) === stagedPluginVersion(to)) return;
77
- const files = copyIntoHome(from, to, user);
78
- log.info({ files, version: stagedPluginVersion(from) }, 'agent-user: copied the metro plugin where the agent user can load it');
79
- }
80
-
81
- export async function provisionAgentUser(env: NodeJS.ProcessEnv = process.env): Promise<Provisioned> {
69
+ export async function provisionAgentUser(): Promise<Provisioned> {
82
70
  if (!agentUserExpected()) return 'off';
83
71
  try {
84
72
  const user = ensureUser();
85
73
  if (user === null) throw new Error(`the user ${AGENT_NAME} could not be found after creating it`);
86
74
  await installClaude(user);
87
75
  pinCheckedClaude();
88
- copyMarketplace(user, env);
89
76
  watchAgentView();
90
77
  return 'ready';
91
78
  } catch (err) {
@@ -87,5 +87,4 @@ export function claudeBin(user = agentUser()): string {
87
87
 
88
88
  export const claudeHome = (user = agentUser()): string | null => (user === null ? null : user.home);
89
89
 
90
- export const agentMarketplaceDir = (user: AgentUser): string => join(user.home, '.metro', 'marketplace');
91
90
  export const agentViewDir = (user: AgentUser): string => join(user.home, '.metro', 'agents');
@@ -3,13 +3,13 @@ import { join } from 'node:path';
3
3
  import { errMsg, log } from '@metro-labs/core/log';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { agentsDir } from '../agents/files.js';
6
- import { mcpServerUrl } from '../mcp-server/config.js';
7
6
  import { MCP_TOKEN_FILE } from '../mcp-server/token.js';
8
7
  import { removeHome, writeHomeText } from './home-fs.js';
9
8
  import { closeMetroHome } from './metro-home.js';
10
9
  import { agentUser, agentViewDir, type AgentUser } from './user.js';
11
10
 
12
11
  const COPIED = ['policy.json', 'claude-setup.json', 'system-prompt.md'] as const;
12
+ const NEVER_COPIED = [MCP_TOKEN_FILE] as const;
13
13
  const SYNC_MS = 5_000;
14
14
 
15
15
  const readText = (path: string): string | null => {
@@ -51,8 +51,8 @@ export function viewFiles(dir = agentsDir()): Map<string, string | null> {
51
51
  ['agent.json', agentFile(dir)],
52
52
  ['model.json', modelFile(dir)],
53
53
  ]);
54
- const copied = mcpServerUrl() === null ? COPIED : [...COPIED, MCP_TOKEN_FILE];
55
- for (const name of copied) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
54
+ for (const name of COPIED) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
55
+ for (const name of NEVER_COPIED) files.set(name, null);
56
56
  return files;
57
57
  }
58
58
 
@@ -21,11 +21,12 @@ import {
21
21
  trainEventToMetroEvent,
22
22
  } from '../routes/http.js';
23
23
  import { agentsDir, fileSource } from '../agents/files.js';
24
- import { syncPluginServers } from '../connectors/plugin-sync.js';
25
24
  import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
25
+ import { refreshConnectorTools, watchConnectorTools } from '../mcp-server/connector-tools.js';
26
26
  import { setAnnouncedFile } from '../mcp-server/announced.js';
27
27
  import { holdServerApproval } from '../mcp/server-approvals.js';
28
- import { ensureMetroPlugin } from '../claude/plugin-install.js';
28
+ import { removeConnectorLeftovers } from '../connectors/leftovers.js';
29
+ import { removeMetroPlugin } from '../claude/plugin-cleanup.js';
29
30
  import { runningAsRoot } from '../metro-user/privilege.js';
30
31
  import { provisionAgentUser } from '../agent-user/provision.js';
31
32
  import { applyVault } from '../vault/index.js';
@@ -139,6 +140,7 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
139
140
 
140
141
 
141
142
  async function main(): Promise<void> {
143
+ removeConnectorLeftovers();
142
144
  applyLocalOwner();
143
145
  installBearerSessions(agentsDir(), localOwner);
144
146
  watchPolicySnapshot();
@@ -165,7 +167,13 @@ installBearerSessions(agentsDir(), localOwner);
165
167
  hold: (approval, answer) => {
166
168
  holdServerApproval(approval, answer, emit);
167
169
  },
170
+ registered: () => {
171
+ refreshConnectorTools().catch((err: unknown) => {
172
+ log.warn({ err: errMsg(err) }, 'mcp-server: the connector tools could not be read after the token was registered');
173
+ });
174
+ },
168
175
  });
176
+ watchConnectorTools();
169
177
  startCloudWatchPublisher();
170
178
  watchFallbackUsage();
171
179
  announceLocalEndpoint();
@@ -194,14 +202,11 @@ async function startClaude(): Promise<void> {
194
202
  const vault = (await applyVault()).status;
195
203
  if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
196
204
  }
197
- ensureMetroPlugin()
198
- .then((outcome) => {
199
- log.info({ outcome }, 'plugin: metro plugin for Claude Code');
200
- if (outcome !== 'skipped') syncPluginServers();
201
- })
202
- .catch((err: unknown) => {
203
- log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
204
- });
205
+ const plugin = await removeMetroPlugin().catch((err: unknown) => {
206
+ log.warn({ err: errMsg(err) }, 'claude-plugin: could not check for the old metro plugin');
207
+ return 'failed';
208
+ });
209
+ if (plugin !== 'done') log.info({ outcome: plugin }, 'claude-plugin: checked for the old metro Claude Code plugin');
205
210
  tryClaudeSetup();
206
211
  try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
207
212
  tryMemoryJob(memoryRoutine());
@@ -0,0 +1,28 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const CHECKOUT = fileURLToPath(new URL('../../../../packages/cli', import.meta.url));
6
+ const HOOKS = 'hooks';
7
+ const SKILLS = 'skills';
8
+ export const GUARD_HOOK = 'guard.mjs';
9
+ export const SESSION_START_HOOK = 'session-start.mjs';
10
+ const UNSAFE_PATH = /["$`\\\n]/;
11
+
12
+ export function cliPackageDir(env: NodeJS.ProcessEnv = process.env): string {
13
+ const bin = env.METRO_CLI_BIN?.trim() ?? '';
14
+ if (bin === '') return CHECKOUT;
15
+ try {
16
+ return dirname(dirname(realpathSync(bin)));
17
+ } catch {
18
+ return CHECKOUT;
19
+ }
20
+ }
21
+
22
+ export const skillsDir = (cli: string): string => join(cli, SKILLS);
23
+
24
+ export function hooksDir(cli: string): string | null {
25
+ const dir = join(cli, HOOKS);
26
+ if (UNSAFE_PATH.test(dir)) return null;
27
+ return existsSync(join(dir, GUARD_HOOK)) && existsSync(join(dir, SESSION_START_HOOK)) ? dir : null;
28
+ }
@@ -0,0 +1,60 @@
1
+ import { join } from 'node:path';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { GUARD_HOOK, SESSION_START_HOOK } from './cli-files.js';
4
+
5
+ interface HookCommand {
6
+ type: 'command';
7
+ command: string;
8
+ statusMessage?: string;
9
+ }
10
+
11
+ const METRO_COMMAND = /^node "[^"]*\/hooks\/(guard|session-start)\.mjs"$/;
12
+ const METRO_GUARD = /^node "[^"]*\/hooks\/guard\.mjs"$/;
13
+
14
+ const commandOf = (hook: unknown): string => (isRecord(hook) && typeof hook.command === 'string' ? hook.command : '');
15
+ const metroHook = (hook: unknown): boolean => METRO_COMMAND.test(commandOf(hook));
16
+ const run = (dir: string, file: string): string => `node "${join(dir, file)}"`;
17
+
18
+ function metroHooks(dir: string): Record<string, HookCommand> {
19
+ return {
20
+ PreToolUse: { type: 'command', command: run(dir, GUARD_HOOK), statusMessage: 'Checking the orchestrator boundary' },
21
+ SessionStart: { type: 'command', command: run(dir, SESSION_START_HOOK) },
22
+ };
23
+ }
24
+
25
+ function withHook(groups: unknown[], wanted: HookCommand): unknown[] {
26
+ let placed = false;
27
+ const out: unknown[] = [];
28
+ for (const group of groups) {
29
+ if (!isRecord(group) || !Array.isArray(group.hooks) || !group.hooks.some(metroHook)) {
30
+ out.push(group);
31
+ continue;
32
+ }
33
+ const kept = group.hooks.flatMap((hook: unknown) => {
34
+ if (!metroHook(hook)) return [hook];
35
+ if (placed) return [];
36
+ placed = true;
37
+ return [wanted];
38
+ });
39
+ if (kept.length > 0) out.push({ ...group, hooks: kept });
40
+ }
41
+ return placed ? out : [...out, { hooks: [wanted] }];
42
+ }
43
+
44
+ export function withMetroHooks(settings: Record<string, unknown>, dir: string): Record<string, unknown> | null {
45
+ const hooks = settings.hooks ?? {};
46
+ if (!isRecord(hooks)) return null;
47
+ const next: Record<string, unknown> = { ...hooks };
48
+ for (const [event, wanted] of Object.entries(metroHooks(dir))) {
49
+ const groups = hooks[event] ?? [];
50
+ if (!Array.isArray(groups)) return null;
51
+ next[event] = withHook(groups, wanted);
52
+ }
53
+ return { ...settings, hooks: next };
54
+ }
55
+
56
+ export function hasMetroGuard(settings: Record<string, unknown> | null): boolean {
57
+ const groups = settings !== null && isRecord(settings.hooks) ? settings.hooks.PreToolUse : undefined;
58
+ if (!Array.isArray(groups)) return false;
59
+ return groups.some((group) => isRecord(group) && Array.isArray(group.hooks) && group.hooks.some((hook) => METRO_GUARD.test(commandOf(hook))));
60
+ }
@@ -0,0 +1,121 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { errMsg, log } from '@metro-labs/core/log';
6
+ import { existsSync } from '../agent-user/agent-fs.js';
7
+ import { removeHome } from '../agent-user/home-fs.js';
8
+ import { agentUser, asAgent, claudeBin } from '../agent-user/user.js';
9
+ import { agentsDir } from '../agents/files.js';
10
+ import { readSetupState, writeSetupState } from './setup-state.js';
11
+
12
+ const PLUGIN = 'metro@metro';
13
+ const MARKETPLACE = 'metro';
14
+ const DONE = 'pluginRemoved';
15
+ const STEP_MS = 60_000;
16
+ const SAID_MAX = 300;
17
+
18
+ export interface Answer {
19
+ status: number;
20
+ stdout: string;
21
+ stderr: string;
22
+ }
23
+
24
+ export type Run = (args: string[]) => Promise<Answer>;
25
+
26
+ export interface CleanupDeps {
27
+ run?: Run | null;
28
+ agents?: string;
29
+ leftover?: string | null;
30
+ }
31
+
32
+ export type CleanupOutcome = 'done' | 'clean' | 'removed' | 'skipped' | 'failed';
33
+
34
+ export const claudeAt =
35
+ (bin: string): Run =>
36
+ (args) =>
37
+ new Promise((resolve) => {
38
+ execFile(...asAgent(bin, args), { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
39
+ const code = (err as { code?: unknown } | null)?.code;
40
+ resolve({ status: err === null ? 0 : typeof code === 'number' ? code : 1, stdout, stderr });
41
+ });
42
+ });
43
+
44
+ async function findClaude(): Promise<Run | null> {
45
+ const candidates = agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()];
46
+ for (const bin of candidates) {
47
+ const run = claudeAt(bin);
48
+ if ((await run(['--version'])).status === 0) return run;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ function listOf(stdout: string): Record<string, unknown>[] {
54
+ try {
55
+ const parsed: unknown = JSON.parse(stdout);
56
+ return Array.isArray(parsed) ? parsed.filter(isRecord) : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ async function listed(run: Run, args: string[], key: string, wanted: string): Promise<boolean | null> {
63
+ const answer = await run(args);
64
+ if (answer.status !== 0) {
65
+ log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'claude-plugin: claude could not list');
66
+ return null;
67
+ }
68
+ return listOf(answer.stdout).some((entry) => entry[key] === wanted);
69
+ }
70
+
71
+ async function step(run: Run, args: string[]): Promise<boolean> {
72
+ const answer = await run(args);
73
+ if (answer.status === 0) {
74
+ log.info({ args }, 'claude-plugin: removed what the old metro plugin left');
75
+ return true;
76
+ }
77
+ log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'claude-plugin: claude refused');
78
+ return false;
79
+ }
80
+
81
+ async function removeIfListed(run: Run, list: string[], key: string, wanted: string, remove: string[]): Promise<'gone' | 'removed' | 'failed'> {
82
+ const present = await listed(run, list, key, wanted);
83
+ if (present === null) return 'failed';
84
+ if (!present) return 'gone';
85
+ return (await step(run, remove)) ? 'removed' : 'failed';
86
+ }
87
+
88
+ function leftoverDir(): string | null {
89
+ const user = agentUser();
90
+ return user === null ? null : join(user.home, '.metro', 'marketplace');
91
+ }
92
+
93
+ function removeLeftover(dir: string | null): boolean {
94
+ if (dir === null || !existsSync(dir)) return true;
95
+ try {
96
+ removeHome(dir, true);
97
+ log.info({ dir }, 'claude-plugin: removed the old plugin marketplace copy');
98
+ return true;
99
+ } catch (err) {
100
+ log.warn({ dir, err: errMsg(err) }, 'claude-plugin: could not remove the old plugin marketplace copy');
101
+ return false;
102
+ }
103
+ }
104
+
105
+ async function cleanUp(run: Run, leftover: string | null): Promise<'removed' | 'clean' | 'failed'> {
106
+ const plugin = await removeIfListed(run, ['plugin', 'list', '--json'], 'id', PLUGIN, ['plugin', 'uninstall', PLUGIN]);
107
+ const market = await removeIfListed(run, ['plugin', 'marketplace', 'list', '--json'], 'name', MARKETPLACE, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
108
+ const folder = removeLeftover(leftover);
109
+ if (plugin === 'failed' || market === 'failed' || !folder) return 'failed';
110
+ return plugin === 'removed' || market === 'removed' ? 'removed' : 'clean';
111
+ }
112
+
113
+ export async function removeMetroPlugin(deps: CleanupDeps = {}): Promise<CleanupOutcome> {
114
+ const agents = deps.agents ?? agentsDir();
115
+ if (readSetupState(agents)[DONE] === true) return 'done';
116
+ const run = deps.run === undefined ? await findClaude() : deps.run;
117
+ if (run === null) return 'skipped';
118
+ const outcome = await cleanUp(run, deps.leftover === undefined ? leftoverDir() : deps.leftover);
119
+ if (outcome !== 'failed') writeSetupState(agents, { [DONE]: true });
120
+ return outcome;
121
+ }
@@ -352,7 +352,7 @@ export function ensureSession(deps: SessionDeps = {}): Ensured {
352
352
  const held = holdsBack(deps, r.now);
353
353
  if (held !== null) return held;
354
354
  if (found === 'stale') {
355
- log.info({ was: startedVersion(r.agents), now: r.version }, 'claude-session: restarting the session an older metro started, so the new plugin and flags load');
355
+ log.info({ was: startedVersion(r.agents), now: r.version }, 'claude-session: restarting the session an older metro started, so the new hooks and flags load');
356
356
  stopSession(deps);
357
357
  }
358
358
  startSession(deps);