@stage-labs/metro 0.1.0-beta.305 → 0.1.0-beta.306
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/memory.js +2 -3
- package/dist/runtime-install.js +3 -11
- package/dist/voice.js +1 -1
- package/{runtime/marketplace/plugin/bin → hooks}/guard.mjs +9 -4
- package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
- package/package.json +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +13 -10
- package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +37 -30
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
- package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -16
- package/runtime/marketplace/plugin/.mcp.json +0 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -24
- package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -100
- /package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +0 -0
- /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
- /package/{runtime/marketplace/plugin → skills}/STAGE.md +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { MCP_CALLS_MAX, MCP_FIND_DEFAULT, MCP_FIND_MAX, TOOL_CALL, TOOL_CALL_EXAMPLE, TOOL_SEARCH } from './mcp-server-protocol.js';
|
|
2
|
+
|
|
3
|
+
export interface McpServerTool {
|
|
4
|
+
name: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
inputSchema: Record<string, unknown>;
|
|
8
|
+
annotations: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const searchSchema = {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
query: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'What to look for: "select:<connector>.<tool>" (comma separated) loads exact tools, keywords rank tools, "+word" requires that word in the tool name.',
|
|
17
|
+
},
|
|
18
|
+
max_results: { type: 'number', description: `Maximum number of results to return (default: ${String(MCP_FIND_DEFAULT)}, at most ${String(MCP_FIND_MAX)})`, default: MCP_FIND_DEFAULT },
|
|
19
|
+
},
|
|
20
|
+
required: ['query'],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const callSchema = {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
calls: {
|
|
28
|
+
type: 'array',
|
|
29
|
+
minItems: 1,
|
|
30
|
+
maxItems: MCP_CALLS_MAX,
|
|
31
|
+
description: `1 to ${String(MCP_CALLS_MAX)} independent calls. They run in parallel and are answered in order.`,
|
|
32
|
+
items: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
tool: { type: 'string', description: 'The tool name exactly as tool_search returned it: <connector>.<tool>.' },
|
|
36
|
+
arguments: { type: 'object', description: 'The tool\'s arguments, matching the parameters tool_search returned.', additionalProperties: true },
|
|
37
|
+
approval: { type: 'string', pattern: '^[a-km-z]{5}$', description: 'The 5-letter approval id, only when repeating a call the owner approved.' },
|
|
38
|
+
},
|
|
39
|
+
required: ['tool'],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ['calls'],
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function connectorsLine(keys: string[] | null): string {
|
|
49
|
+
if (keys === null) return 'The connectors of this agent are named here once Metro reaches mcp.metro.box.';
|
|
50
|
+
return keys.length === 0 ? 'This agent has no connectors it may use yet.' : `This agent's connectors: ${keys.join(', ')}.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const toolSearchDescription = (keys: string[] | null): string =>
|
|
54
|
+
[
|
|
55
|
+
'Search the tools of every connector assigned to this agent: third-party services such as Notion, n8n, Linear or Gmail, run by mcp.metro.box. Same design as Claude Code\'s ToolSearch, for connectors.',
|
|
56
|
+
connectorsLine(keys),
|
|
57
|
+
'Three query forms, one example each:',
|
|
58
|
+
'- "select:notion.search" loads exact tools by name (comma separated for several).',
|
|
59
|
+
'- "notion create page" ranks tools by the words in their name and description.',
|
|
60
|
+
'- "+linear issue" requires "linear" in the tool name and ranks by the other words.',
|
|
61
|
+
'Results are full definitions (name, description and the JSON schema of the arguments) in a <functions> block. Each name is <connector>.<tool>: pass it exactly as "tool" to tool_call. Tools the owner blocked are never returned.',
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
export const TOOL_CALL_DESCRIPTION = [
|
|
65
|
+
'Run connector tools that tool_search found. Always find a tool with tool_search first: its exact name and argument schema come from there.',
|
|
66
|
+
`Shape: ${TOOL_CALL_EXAMPLE}`,
|
|
67
|
+
`Up to ${String(MCP_CALLS_MAX)} independent calls run in parallel in one tool_call and are answered in order. Each result starts with "[n] <connector>.<tool>", then the tool's own content, or one line saying why it did not run:`,
|
|
68
|
+
'- not_found: no such connector or tool here. Run tool_search, then use a name it returned.',
|
|
69
|
+
'- needs_approval: the owner was asked in chat and on metro.box. Tell the person what is waiting, wait for the channel message "Approved: <tool> (<id>)", then call again with the same tool and arguments plus "approval": "<id>". An approval works once.',
|
|
70
|
+
'- denied or blocked: the owner refused or blocked this call. Do not retry it.',
|
|
71
|
+
'- signin: the connector must be signed in again. Tell the owner to do it on the organization\'s Connectors page on metro.box.',
|
|
72
|
+
'- unavailable: the connector or Metro could not be reached. Try again later.',
|
|
73
|
+
'- vendor_error: the connector answered with its own error, shown as it came.',
|
|
74
|
+
].join('\n');
|
|
75
|
+
|
|
76
|
+
export function mcpServerTools(keys: string[] | null): McpServerTool[] {
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
name: TOOL_SEARCH,
|
|
80
|
+
title: 'Search connector tools',
|
|
81
|
+
description: toolSearchDescription(keys),
|
|
82
|
+
inputSchema: searchSchema,
|
|
83
|
+
annotations: { title: 'Search connector tools', readOnlyHint: true, openWorldHint: false },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: TOOL_CALL,
|
|
87
|
+
title: 'Call connector tools',
|
|
88
|
+
description: TOOL_CALL_DESCRIPTION,
|
|
89
|
+
inputSchema: callSchema,
|
|
90
|
+
annotations: { title: 'Call connector tools', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
package/runtime/runtime.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: metro
|
|
3
|
-
description: How this box's agent works on metro. Acknowledge, delegate, relay, answer over chat, never block on the terminal. Loaded at every session start by
|
|
3
|
+
description: How this box's agent works on metro. Acknowledge, delegate, relay, answer over chat, never block on the terminal. Loaded at every session start by a metro hook; edit it here to change the rules on this box.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
You are the main thread of a Claude Code session that is connected to chat through metro, on a machine nobody watches. The main thread is an orchestrator: it may only delegate and talk. A guard
|
|
6
|
+
You are the main thread of a Claude Code session that is connected to chat through metro, on a machine nobody watches. The main thread is an orchestrator: it may only delegate and talk. A guard metro installs denies every other tool on this thread, so this is the shape of the work, not a preference.
|
|
7
7
|
|
|
8
8
|
**Inbound.** A message arrives as a `<channel source="metro" …>` block. Read `addressed` first: `direct`, `mention` or `reply` means it is for you; without it the message merely happened in a room you watch, so treat it as context and stay silent unless the person plainly wants you. For a message that is for you, the first tool call is `react` on it (👀, then 👍 or ✅ once done) so the person knows you saw it, and any reply goes on the same `line`, passed verbatim. Exception: when the person asks you to close something ("mark as done"), skip the reaction and the acknowledgement: close it directly and confirm once when it is finished.
|
|
9
9
|
|
|
@@ -15,7 +15,12 @@ You are the main thread of a Claude Code session that is connected to chat throu
|
|
|
15
15
|
|
|
16
16
|
**Calls that need the owner's approval run in a worker.** The owner can make a metro tool on a channel wait for approval. On the main thread such a call is refused, because waiting for the answer would stop the whole session: hand that exact call to a background worker (`run_in_background: true`) and keep answering. The worker waits for the owner's answer on its own.
|
|
17
17
|
|
|
18
|
-
**Connectors
|
|
18
|
+
**Connectors.** Third-party tools the organization gave this agent (Notion, n8n, Linear, Gmail and the like) are reached only through two metro tools, in two steps:
|
|
19
|
+
|
|
20
|
+
1. `tool_search` finds a tool and returns its full definition: `select:notion.search` for an exact name, words (`notion create page`) to rank, or `+linear issue` to require `linear` in the name. Each name is `<connector>.<tool>`.
|
|
21
|
+
2. `tool_call` runs it, with the name exactly as found and arguments that match its schema. After `tool_search` with `select:notion.search` returned `notion.search`: `{"calls": [{"tool": "notion.search", "arguments": {"query": "roadmap"}}]}`.
|
|
22
|
+
|
|
23
|
+
Put up to 10 independent calls in one `tool_call`; they run in parallel and come back in order, each under `[n] <tool>`. `tool_search` may run on this thread, but never call `tool_call` here: delegate it to a worker, as all other work. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker repeat the same call with `"approval": "<id>"` added to it.
|
|
19
24
|
|
|
20
25
|
**Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
|
|
21
26
|
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "metro",
|
|
3
|
-
"description": "Metro — connector relay and chat bridge for agents",
|
|
4
|
-
"owner": { "name": "Bonustrack", "url": "https://metro.box" },
|
|
5
|
-
"plugins": [
|
|
6
|
-
{
|
|
7
|
-
"name": "metro",
|
|
8
|
-
"source": "./plugin",
|
|
9
|
-
"description": "Every connector on this box as its own MCP server through metro's local relay, the orchestrator guard, and the standing rules loaded at session start."
|
|
10
|
-
}
|
|
11
|
-
]
|
|
12
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.305",
|
|
4
|
-
"description": "Metro for Claude Code, installed by the metro daemon on this machine: one `mcp` server on mcp.metro.box for the organization connectors (find_tools, call_tool), the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever reaches this machine.",
|
|
5
|
-
"author": {
|
|
6
|
-
"name": "Bonustrack",
|
|
7
|
-
"url": "https://metro.box"
|
|
8
|
-
},
|
|
9
|
-
"repository": "https://github.com/bonustrack/metro",
|
|
10
|
-
"license": "MIT",
|
|
11
|
-
"keywords": [
|
|
12
|
-
"metro",
|
|
13
|
-
"mcp",
|
|
14
|
-
"connectors"
|
|
15
|
-
]
|
|
16
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
|
|
7
|
-
const MCP_TOKEN = /^mcp_[A-Za-z0-9_-]{43}$/;
|
|
8
|
-
|
|
9
|
-
function readMcpToken() {
|
|
10
|
-
try {
|
|
11
|
-
const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
|
|
12
|
-
return MCP_TOKEN.test(token) ? token : null;
|
|
13
|
-
} catch {
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
if (process.argv[2] !== 'mcp-headers') {
|
|
19
|
-
process.stderr.write('usage: metro-plugin.mjs mcp-headers\n');
|
|
20
|
-
process.exit(1);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const token = readMcpToken();
|
|
24
|
-
if (token !== null) process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"hooks": {
|
|
3
|
-
"PreToolUse": [
|
|
4
|
-
{
|
|
5
|
-
"hooks": [
|
|
6
|
-
{
|
|
7
|
-
"type": "command",
|
|
8
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/guard.mjs\"",
|
|
9
|
-
"statusMessage": "Checking the orchestrator boundary"
|
|
10
|
-
}
|
|
11
|
-
]
|
|
12
|
-
}
|
|
13
|
-
],
|
|
14
|
-
"SessionStart": [
|
|
15
|
-
{
|
|
16
|
-
"hooks": [
|
|
17
|
-
{
|
|
18
|
-
"type": "command",
|
|
19
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/session-start.mjs\""
|
|
20
|
-
}
|
|
21
|
-
]
|
|
22
|
-
}
|
|
23
|
-
]
|
|
24
|
-
}
|
|
25
|
-
}
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync, realpathSync } from '../agent-user/agent-fs.js';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
-
import { log } from '@metro-labs/core/log';
|
|
7
|
-
import { agentMarketplaceDir, agentUser, asAgent, claudeBin } from '../agent-user/user.js';
|
|
8
|
-
|
|
9
|
-
const MARKETPLACE = 'metro';
|
|
10
|
-
const PLUGIN = 'metro@metro';
|
|
11
|
-
const STEP_MS = 120_000;
|
|
12
|
-
const SAID_MAX = 300;
|
|
13
|
-
|
|
14
|
-
export interface Answer {
|
|
15
|
-
status: number;
|
|
16
|
-
stdout: string;
|
|
17
|
-
stderr: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type Run = (args: string[]) => Promise<Answer>;
|
|
21
|
-
|
|
22
|
-
export interface PluginInstallDeps {
|
|
23
|
-
run: Run | null;
|
|
24
|
-
marketplaceDir: string | null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type PluginOutcome = 'skipped' | 'unchanged' | 'installed' | 'updated' | 'failed';
|
|
28
|
-
|
|
29
|
-
function storeMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
30
|
-
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
31
|
-
if (store === '') return null;
|
|
32
|
-
const dir = join(store, 'marketplace');
|
|
33
|
-
return existsSync(join(dir, '.claude-plugin', 'marketplace.json')) ? dir : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function stagedMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
37
|
-
const store = storeMarketplaceDir(env);
|
|
38
|
-
const user = agentUser();
|
|
39
|
-
if (store === null || user === null) return store;
|
|
40
|
-
const copy = agentMarketplaceDir(user);
|
|
41
|
-
return existsSync(join(copy, '.claude-plugin', 'marketplace.json')) ? copy : null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function stagedPluginVersion(dir: string): string | null {
|
|
45
|
-
try {
|
|
46
|
-
const parsed: unknown = JSON.parse(readFileSync(join(dir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
47
|
-
return isRecord(parsed) && typeof parsed.version === 'string' ? parsed.version : null;
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const runner =
|
|
54
|
-
(bin: string): Run =>
|
|
55
|
-
(args) =>
|
|
56
|
-
new Promise((resolve) => {
|
|
57
|
-
execFile(...asAgent(bin, args), { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
58
|
-
const status = err === null ? 0 : typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 1;
|
|
59
|
-
resolve({ status, stdout, stderr });
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
async function findClaude(candidates = agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()]): Promise<Run | null> {
|
|
64
|
-
for (const bin of candidates) {
|
|
65
|
-
const run = runner(bin);
|
|
66
|
-
if ((await run(['--version'])).status === 0) return run;
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function listOf(stdout: string): Record<string, unknown>[] {
|
|
72
|
-
try {
|
|
73
|
-
const parsed: unknown = JSON.parse(stdout);
|
|
74
|
-
return Array.isArray(parsed) ? parsed.filter(isRecord) : [];
|
|
75
|
-
} catch {
|
|
76
|
-
return [];
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const realish = (path: string): string => {
|
|
81
|
-
try {
|
|
82
|
-
return realpathSync(path);
|
|
83
|
-
} catch {
|
|
84
|
-
return path;
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const samePath = (a: string | null, b: string): boolean => a !== null && realish(a) === realish(b);
|
|
89
|
-
|
|
90
|
-
async function step(run: Run, args: string[]): Promise<boolean> {
|
|
91
|
-
const answer = await run(args);
|
|
92
|
-
if (answer.status === 0) {
|
|
93
|
-
log.info({ args }, 'plugin: claude');
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'plugin: claude refused');
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function marketplacePath(run: Run): Promise<string | null | undefined> {
|
|
101
|
-
const mine = listOf((await run(['plugin', 'marketplace', 'list', '--json'])).stdout).find((m) => m.name === MARKETPLACE);
|
|
102
|
-
if (mine === undefined) return undefined;
|
|
103
|
-
const at = mine.installLocation ?? mine.path;
|
|
104
|
-
return typeof at === 'string' ? at : null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function ensureMarketplace(run: Run, dir: string): Promise<boolean> {
|
|
108
|
-
const at = await marketplacePath(run);
|
|
109
|
-
if (at !== undefined && samePath(at, dir)) return true;
|
|
110
|
-
if (at !== undefined) await step(run, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
|
|
111
|
-
return step(run, ['plugin', 'marketplace', 'add', dir]);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function installedVersion(run: Run): Promise<string | null> {
|
|
115
|
-
const mine = listOf((await run(['plugin', 'list', '--json'])).stdout).find((p) => p.id === PLUGIN);
|
|
116
|
-
return mine !== undefined && typeof mine.version === 'string' ? mine.version : null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function ensureInstalled(run: Run, wanted: string | null): Promise<PluginOutcome> {
|
|
120
|
-
const have = await installedVersion(run);
|
|
121
|
-
if (have === null) return (await step(run, ['plugin', 'install', PLUGIN, '-y', '--scope', 'user'])) ? 'installed' : 'failed';
|
|
122
|
-
if (wanted === null || have === wanted) return 'unchanged';
|
|
123
|
-
await step(run, ['plugin', 'marketplace', 'update', MARKETPLACE]);
|
|
124
|
-
return (await step(run, ['plugin', 'update', PLUGIN, '-y'])) ? 'updated' : 'failed';
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function explainMissingMarketplace(env: NodeJS.ProcessEnv = process.env): void {
|
|
128
|
-
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
129
|
-
if (store === '') return;
|
|
130
|
-
log.warn(
|
|
131
|
-
{ store },
|
|
132
|
-
'plugin: the runtime store holds no plugin marketplace, so metro cannot install its Claude Code plugin; restart the metro service once so `metro serve` stages it again',
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export async function ensureMetroPlugin(deps?: Partial<PluginInstallDeps>): Promise<PluginOutcome> {
|
|
137
|
-
const dir = deps?.marketplaceDir === undefined ? stagedMarketplaceDir() : deps.marketplaceDir;
|
|
138
|
-
if (dir === null) {
|
|
139
|
-
if (deps?.marketplaceDir === undefined) explainMissingMarketplace();
|
|
140
|
-
return 'skipped';
|
|
141
|
-
}
|
|
142
|
-
const run = deps?.run === undefined ? await findClaude() : deps.run;
|
|
143
|
-
if (run === null) {
|
|
144
|
-
log.info('plugin: claude is not on this machine, so metro leaves its Claude Code plugin alone');
|
|
145
|
-
return 'skipped';
|
|
146
|
-
}
|
|
147
|
-
if (!(await ensureMarketplace(run, dir))) return 'failed';
|
|
148
|
-
return ensureInstalled(run, stagedPluginVersion(dir));
|
|
149
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { existsSync, readdirEntries, readFileSync } from '../agent-user/agent-fs.js';
|
|
2
|
-
import { writeHomeInPlace } from '../agent-user/home-fs.js';
|
|
3
|
-
import { agentMarketplaceDir, agentUser } from '../agent-user/user.js';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
|
-
import { claudeDir } from '../claude/files.js';
|
|
7
|
-
import { MCP_SERVER_KEY, mcpServerUrl } from '../mcp-server/config.js';
|
|
8
|
-
|
|
9
|
-
const MARKER = join('bin', 'metro-plugin.mjs');
|
|
10
|
-
const FILE = '.mcp.json';
|
|
11
|
-
const MCP_HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" mcp-headers';
|
|
12
|
-
|
|
13
|
-
interface PluginServer {
|
|
14
|
-
type: 'http';
|
|
15
|
-
url: string;
|
|
16
|
-
headersHelper: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const pluginServers = (url: string | null): Record<string, PluginServer> =>
|
|
20
|
-
url === null ? {} : { [MCP_SERVER_KEY]: { type: 'http', url, headersHelper: MCP_HELPER } };
|
|
21
|
-
|
|
22
|
-
const SEARCH_DEPTH = 5;
|
|
23
|
-
|
|
24
|
-
function pluginRootsUnder(dir: string, depth: number, out: string[]): void {
|
|
25
|
-
if (depth < 0 || out.length >= 8) return;
|
|
26
|
-
let entries;
|
|
27
|
-
try {
|
|
28
|
-
entries = readdirEntries(dir);
|
|
29
|
-
} catch {
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
if (entries.some((e) => e.name === 'bin' && e.isDirectory()) && existsSync(join(dir, MARKER))) {
|
|
33
|
-
out.push(dir);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
for (const entry of entries)
|
|
37
|
-
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
|
|
38
|
-
pluginRootsUnder(join(dir, entry.name), depth - 1, out);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function stagedPluginDir(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
42
|
-
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
43
|
-
if (store === '') return null;
|
|
44
|
-
const user = agentUser();
|
|
45
|
-
return join(user === null ? join(store, 'marketplace') : agentMarketplaceDir(user), 'plugin');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function installedPluginFiles(dir = claudeDir(), staged = stagedPluginDir()): string[] {
|
|
49
|
-
const roots: string[] = [];
|
|
50
|
-
const root = join(dir, 'plugins');
|
|
51
|
-
if (existsSync(root)) pluginRootsUnder(root, SEARCH_DEPTH, roots);
|
|
52
|
-
if (staged !== null && existsSync(join(staged, MARKER)) && !roots.includes(staged)) roots.push(staged);
|
|
53
|
-
return roots.map((found) => join(found, FILE));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const readOrNull = (path: string): string | null => {
|
|
57
|
-
try {
|
|
58
|
-
return readFileSync(path, 'utf8');
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
function writeIfChanged(path: string, text: string): boolean {
|
|
65
|
-
if (readOrNull(path) === text) return false;
|
|
66
|
-
writeHomeInPlace(path, text, 0o644);
|
|
67
|
-
return true;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface PluginSyncOptions {
|
|
71
|
-
dir?: string;
|
|
72
|
-
staged?: string | null;
|
|
73
|
-
url?: string | null;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function filesFor(opts: PluginSyncOptions): string[] {
|
|
77
|
-
const staged = opts.staged === undefined ? stagedPluginDir() : opts.staged;
|
|
78
|
-
return installedPluginFiles(opts.dir ?? claudeDir(), staged);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function syncPluginServers(opts: PluginSyncOptions = {}): number {
|
|
82
|
-
const files = filesFor(opts);
|
|
83
|
-
if (files.length === 0) return 0;
|
|
84
|
-
const servers = pluginServers(opts.url === undefined ? mcpServerUrl() : opts.url);
|
|
85
|
-
const text = `${JSON.stringify(servers, null, 2)}\n`;
|
|
86
|
-
let written = 0;
|
|
87
|
-
for (const file of files) {
|
|
88
|
-
try {
|
|
89
|
-
if (writeIfChanged(file, text)) written += 1;
|
|
90
|
-
} catch (err) {
|
|
91
|
-
log.warn({ file, err: errMsg(err) }, 'plugin: could not write the MCP servers');
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (written > 0)
|
|
95
|
-
log.info(
|
|
96
|
-
{ files: written, servers: Object.keys(servers) },
|
|
97
|
-
'plugin: MCP servers written; run /reload-plugins --force to pick them up in a running session',
|
|
98
|
-
);
|
|
99
|
-
return written;
|
|
100
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|