@stage-labs/metro 0.1.0-beta.104 → 0.1.0-beta.105
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/claude.js +3 -4
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +4 -3
- package/runtime/marketplace/plugin/bin/guard.mjs +50 -0
- package/runtime/marketplace/plugin/bin/session-start.mjs +31 -0
- package/runtime/marketplace/plugin/hooks/hooks.json +25 -0
- package/runtime/marketplace/plugin/orchestrator.md +16 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +22 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +170 -0
- package/runtime/runtime.json +1 -1
package/dist/claude.js
CHANGED
|
@@ -135,11 +135,10 @@ function gatewayLaunchEnv(key, port) {
|
|
|
135
135
|
process.stderr.write(`metro claude: inference goes through the daemon at http://127.0.0.1:${String(port)}/gateway (the Model page decides where)\n`);
|
|
136
136
|
const routed = gatewayEnv(process.env, key, port);
|
|
137
137
|
const env = credentialEnv(routed, key, claudeSignedIn());
|
|
138
|
-
if (env !== routed)
|
|
138
|
+
if (env !== routed)
|
|
139
139
|
process.stderr.write("metro claude: Claude Code has no login of its own here, so metro's key stands in as its credential; the Model page must route to Bedrock, OpenRouter or Codex\n");
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
140
|
+
if (seedChannels() === 'marked')
|
|
141
|
+
process.stderr.write('metro claude: marked Channels as available in Claude Code cached flags, since a box that sends no telemetry never fetches them\n');
|
|
143
142
|
if (markOnboardingDone() === 'marked')
|
|
144
143
|
process.stderr.write("metro claude: skipped Claude Code's first-run setup, since it already has a credential here\n");
|
|
145
144
|
return env;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.105",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
4
|
-
"description": "Metro
|
|
3
|
+
"version": "0.1.0-beta.105",
|
|
4
|
+
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, the standing rules loaded at each session start, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
7
7
|
"url": "https://metro.box"
|
|
@@ -14,5 +14,6 @@
|
|
|
14
14
|
"connectors",
|
|
15
15
|
"relay"
|
|
16
16
|
],
|
|
17
|
-
"skills": "./skills/"
|
|
17
|
+
"skills": "./skills/",
|
|
18
|
+
"hooks": "./hooks/hooks.json"
|
|
18
19
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
const BLOCKING = new Set(['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode']);
|
|
5
|
+
const ORCHESTRATION = new Set([
|
|
6
|
+
'Agent',
|
|
7
|
+
'Workflow',
|
|
8
|
+
'ToolSearch',
|
|
9
|
+
'Skill',
|
|
10
|
+
'ScheduleWakeup',
|
|
11
|
+
'Monitor',
|
|
12
|
+
'CronCreate',
|
|
13
|
+
'CronList',
|
|
14
|
+
'CronDelete',
|
|
15
|
+
'TaskCreate',
|
|
16
|
+
'TaskGet',
|
|
17
|
+
'TaskList',
|
|
18
|
+
'TaskOutput',
|
|
19
|
+
'TaskStop',
|
|
20
|
+
'TaskUpdate',
|
|
21
|
+
]);
|
|
22
|
+
const IMAGE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
23
|
+
|
|
24
|
+
function deny(reason) {
|
|
25
|
+
process.stdout.write(
|
|
26
|
+
`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } })}\n`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function verdict(payload) {
|
|
31
|
+
const tool = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
32
|
+
const input = payload.tool_input !== null && typeof payload.tool_input === 'object' ? payload.tool_input : {};
|
|
33
|
+
if (BLOCKING.has(tool))
|
|
34
|
+
return `${tool} blocks the session waiting on the terminal, and nobody is watching it. Decide with your best judgement and state the assumption, or ask over chat with the metro tools and keep working in the meantime.`;
|
|
35
|
+
if (typeof payload.agent_id === 'string' && payload.agent_id !== '') return null;
|
|
36
|
+
if (tool === 'Agent' && input.run_in_background === false)
|
|
37
|
+
return '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.';
|
|
38
|
+
if (ORCHESTRATION.has(tool) || tool.startsWith('mcp__')) return null;
|
|
39
|
+
if (tool === 'Read' && typeof input.file_path === 'string' && IMAGE.test(input.file_path)) return null;
|
|
40
|
+
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.`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let payload = {};
|
|
44
|
+
try {
|
|
45
|
+
payload = JSON.parse(readFileSync(0, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
payload = {};
|
|
48
|
+
}
|
|
49
|
+
const reason = verdict(payload !== null && typeof payload === 'object' ? payload : {});
|
|
50
|
+
if (reason !== null) deny(reason);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const given = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
|
|
8
|
+
const claudeDir = given(process.env.CLAUDE_CONFIG_DIR) ?? join(given(process.env.HOME) ?? homedir(), '.claude');
|
|
9
|
+
const SKILL = join(claudeDir, 'skills', 'metro-orchestrator', 'SKILL.md');
|
|
10
|
+
const FALLBACK = join(dirname(dirname(fileURLToPath(import.meta.url))), 'orchestrator.md');
|
|
11
|
+
|
|
12
|
+
const body = (text) => text.replace(/^---[\s\S]*?\n---\n/, '').trim();
|
|
13
|
+
|
|
14
|
+
function guidance() {
|
|
15
|
+
for (const path of [SKILL, FALLBACK]) {
|
|
16
|
+
if (!existsSync(path)) continue;
|
|
17
|
+
try {
|
|
18
|
+
const text = body(readFileSync(path, 'utf8'));
|
|
19
|
+
if (text !== '') return text;
|
|
20
|
+
} catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const text = guidance();
|
|
28
|
+
if (text !== '')
|
|
29
|
+
process.stdout.write(
|
|
30
|
+
`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `This machine runs as a metro agent. Standing rules, kept in the metro-orchestrator skill:\n\n${text}` } })}\n`,
|
|
31
|
+
);
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: metro-orchestrator
|
|
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 the metro plugin; edit it here to change the rules on this box.
|
|
4
|
+
---
|
|
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 in the metro plugin denies every other tool on this thread, so this is the shape of the work, not a preference.
|
|
7
|
+
|
|
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.
|
|
9
|
+
|
|
10
|
+
**Delegate everything that is work.** Reading files, running commands, searching, fetching pages, editing: hand it to a subagent with the `Agent` tool, `subagent_type: "worker"`, always `run_in_background: true`, then keep answering chat while it runs and relay the result when the task notification arrives. A foreground subagent blinds you to chat and the guard refuses it. You may read image files yourself, so a screenshot someone sends is yours to look at.
|
|
11
|
+
|
|
12
|
+
**Never wait on the terminal.** `AskUserQuestion`, plan mode and anything that blocks for a person at the keyboard are denied everywhere, main thread and subagents alike. When something is ambiguous, do what does not depend on it, choose the most reasonable reading, say so in your reply, and if a real decision is needed ask over chat with `send` and carry on. Tool-approval prompts reach the chat too; the person answers `yes <id>` or `no <id>` there.
|
|
13
|
+
|
|
14
|
+
**Report like a relay.** A worker's report is the only evidence you have, so pass on what it verified and say plainly what it did not. Short messages on chat: lead with the outcome, one idea per sentence, no tool names, no file paths unless the person needs them. Send a file out with `create_upload` (the curl line it hands back runs in a worker) and then `send` with the upload id.
|
|
15
|
+
|
|
16
|
+
**Keep going.** Long tasks run as background agents and loops; check in over chat when something lands rather than going quiet. The session is restarted by the daemon if it exits, so an interrupted task is resumed from chat, not from memory.
|
|
@@ -24,6 +24,7 @@ import { ConnectorWatch } from '../connectors/watch.js';
|
|
|
24
24
|
import { syncPluginServers } from '../connectors/plugin-sync.js';
|
|
25
25
|
import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
26
26
|
import { unwatchSession, watchSession } from '../claude/session.js';
|
|
27
|
+
import { tryClaudeSetup } from '../claude/setup.js';
|
|
27
28
|
import { applyLocalOwner } from './local-owner.js';
|
|
28
29
|
import { localOwner } from '../agents/file-admin.js';
|
|
29
30
|
import { ensureStationDeps } from '../stations/runtime-deps.js';
|
|
@@ -160,6 +161,7 @@ async function main(): Promise<void> {
|
|
|
160
161
|
.catch((err: unknown) => {
|
|
161
162
|
log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
|
|
162
163
|
});
|
|
164
|
+
tryClaudeSetup();
|
|
163
165
|
watchSession();
|
|
164
166
|
}
|
|
165
167
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
startClaudeLogin,
|
|
22
22
|
type LoginDeps,
|
|
23
23
|
} from './login.js';
|
|
24
|
+
import { claudeSetupStatus, ensureClaudeSetup, setPrivacy, type SetupDeps } from './setup.js';
|
|
24
25
|
import {
|
|
25
26
|
ensureSession,
|
|
26
27
|
sessionStatus,
|
|
@@ -50,6 +51,7 @@ export interface ClaudeApiDeps {
|
|
|
50
51
|
dir?: () => string;
|
|
51
52
|
login?: LoginDeps;
|
|
52
53
|
session?: SessionDeps;
|
|
54
|
+
setup?: SetupDeps;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
function projectOf(query: URLSearchParams): string {
|
|
@@ -111,6 +113,19 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
|
|
|
111
113
|
|
|
112
114
|
const LOGIN = 'login';
|
|
113
115
|
const SESSION = 'session';
|
|
116
|
+
const SETUP = 'setup';
|
|
117
|
+
|
|
118
|
+
async function setupAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
|
|
119
|
+
const setup = deps.setup ?? {};
|
|
120
|
+
const method = req.method ?? 'GET';
|
|
121
|
+
if (method === 'GET') return claudeSetupStatus(setup);
|
|
122
|
+
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
123
|
+
const body = await readJsonBody(req);
|
|
124
|
+
if (!isRecord(body) || typeof body.privacy !== 'boolean') throw new ApiError('privacy must be true or false', 400);
|
|
125
|
+
setPrivacy(body.privacy, setup.agents);
|
|
126
|
+
ensureClaudeSetup(setup);
|
|
127
|
+
return claudeSetupStatus(setup);
|
|
128
|
+
}
|
|
114
129
|
|
|
115
130
|
function sessionCommand(body: Record<string, unknown>, session: SessionDeps): unknown {
|
|
116
131
|
if (body.action === 'start') {
|
|
@@ -170,11 +185,17 @@ function answer(method: string, path: string, query: URLSearchParams, dir: strin
|
|
|
170
185
|
return handler(query, dir, item);
|
|
171
186
|
}
|
|
172
187
|
|
|
188
|
+
const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps) => Promise<unknown>> = {
|
|
189
|
+
[SESSION]: sessionAnswer,
|
|
190
|
+
[SETUP]: setupAnswer,
|
|
191
|
+
};
|
|
192
|
+
|
|
173
193
|
function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
|
|
174
194
|
const dir = (deps.dir ?? claudeDir)();
|
|
175
195
|
const [head = '', item = ''] = parts(path);
|
|
176
196
|
if (head === LOGIN) return loginAnswer(req, item, deps);
|
|
177
|
-
|
|
197
|
+
const single = item === '' ? SINGLETONS[head] : undefined;
|
|
198
|
+
if (single !== undefined) return single(req, deps);
|
|
178
199
|
if (req.method === 'PUT') return writeAnswer(req, path, dir);
|
|
179
200
|
if (req.method === 'POST') return created(req, path, dir);
|
|
180
201
|
return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
+
import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
7
|
+
import { agentsDir } from '../agents/files.js';
|
|
8
|
+
import { claudeDir } from './files.js';
|
|
9
|
+
import { stagedMarketplaceDir } from './plugin-install.js';
|
|
10
|
+
|
|
11
|
+
export const PRIVACY_ENV: Record<string, string> = {
|
|
12
|
+
DISABLE_TELEMETRY: '1',
|
|
13
|
+
DISABLE_ERROR_REPORTING: '1',
|
|
14
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
15
|
+
CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF: '1',
|
|
16
|
+
};
|
|
17
|
+
export const RETENTION_DAYS = 7;
|
|
18
|
+
export const SKILL_NAME = 'metro-orchestrator';
|
|
19
|
+
const STATE_FILE = 'claude-setup.json';
|
|
20
|
+
const GUIDANCE = 'orchestrator.md';
|
|
21
|
+
|
|
22
|
+
export const WORKER_AGENT = `---
|
|
23
|
+
name: worker
|
|
24
|
+
description: Default execution agent for delegated work. Full tool access and maximum reasoning effort. Use for any substantive task the orchestrator main thread cannot perform itself - reading and writing code, running commands, research, analysis.
|
|
25
|
+
effort: xhigh
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
You are a worker agent. The main thread that dispatched you is an orchestrator with no file, shell or network access; it can only delegate and relay messages. That means you are where the actual work happens, and the quality of your output is the quality of the result.
|
|
29
|
+
|
|
30
|
+
Work to completion. Do not hand back a partial answer with a suggestion that someone else finish it; there is no one else. If part of the task is genuinely blocked, complete every other part in full and say plainly what you left undone and why.
|
|
31
|
+
|
|
32
|
+
Never call AskUserQuestion or ExitPlanMode. They are blocked by policy, because nobody is watching the terminal around the clock and a blocking prompt stalls indefinitely. When you hit an ambiguity: do everything that does not depend on it, then choose the most reasonable interpretation, state the assumption explicitly in your report, and continue.
|
|
33
|
+
|
|
34
|
+
Your final message is a report to the orchestrator, not a message to a human. It gets relayed onward, so make it self-contained and concise. Lead with the outcome. Include concrete evidence, command output, file paths with line numbers, test results, for anything you claim to have verified. Report failures faithfully: if a test failed, say so and show the output; if you skipped a step, say that too.
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export interface SetupDeps {
|
|
38
|
+
dir?: string;
|
|
39
|
+
agents?: string;
|
|
40
|
+
guidance?: string;
|
|
41
|
+
env?: NodeJS.ProcessEnv;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type Placed = 'written' | 'present' | 'missing';
|
|
45
|
+
export type SettingsOutcome = 'written' | 'unchanged' | 'unreadable';
|
|
46
|
+
|
|
47
|
+
export interface SetupReport {
|
|
48
|
+
privacy: boolean;
|
|
49
|
+
guard: 'plugin';
|
|
50
|
+
worker: Placed;
|
|
51
|
+
skill: Placed;
|
|
52
|
+
settings: SettingsOutcome;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SetupStatus {
|
|
56
|
+
privacy: boolean;
|
|
57
|
+
guard: 'plugin';
|
|
58
|
+
worker: boolean;
|
|
59
|
+
skill: boolean;
|
|
60
|
+
privacyApplied: boolean;
|
|
61
|
+
retentionDays: number | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const statePath = (agents: string): string => join(agents, STATE_FILE);
|
|
65
|
+
|
|
66
|
+
export function privacyEnabled(agents = agentsDir()): boolean {
|
|
67
|
+
const raw = readJson<unknown>(statePath(agents), null);
|
|
68
|
+
return !isRecord(raw) || raw.privacy !== false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function setPrivacy(enabled: boolean, agents = agentsDir()): void {
|
|
72
|
+
writeJson(statePath(agents), { privacy: enabled });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function guidancePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
76
|
+
const staged = stagedMarketplaceDir(env);
|
|
77
|
+
if (staged !== null) {
|
|
78
|
+
const path = join(staged, 'plugin', GUIDANCE);
|
|
79
|
+
if (existsSync(path)) return path;
|
|
80
|
+
}
|
|
81
|
+
return fileURLToPath(new URL(`../../../../plugin/${GUIDANCE}`, import.meta.url));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ensureFile(path: string, text: string): Placed {
|
|
85
|
+
if (existsSync(path)) return 'present';
|
|
86
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
87
|
+
writeFileSync(path, text, { mode: 0o644 });
|
|
88
|
+
return 'written';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readSettings(path: string): Record<string, unknown> | null {
|
|
92
|
+
if (!existsSync(path)) return {};
|
|
93
|
+
try {
|
|
94
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
95
|
+
return isRecord(parsed) ? parsed : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function withPrivacy(settings: Record<string, unknown>, enabled: boolean): Record<string, unknown> {
|
|
102
|
+
const current = isRecord(settings.env) ? settings.env : {};
|
|
103
|
+
if (enabled)
|
|
104
|
+
return { ...settings, env: { ...current, ...PRIVACY_ENV }, cleanupPeriodDays: settings.cleanupPeriodDays ?? RETENTION_DAYS };
|
|
105
|
+
const env = Object.fromEntries(Object.entries(current).filter(([key]) => !(key in PRIVACY_ENV)));
|
|
106
|
+
const rest = Object.fromEntries(Object.entries(settings).filter(([key]) => key !== 'env'));
|
|
107
|
+
return Object.keys(env).length === 0 ? rest : { ...rest, env };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function applyPrivacy(dir: string, enabled: boolean): SettingsOutcome {
|
|
111
|
+
const path = join(dir, 'settings.json');
|
|
112
|
+
const current = readSettings(path);
|
|
113
|
+
if (current === null) return 'unreadable';
|
|
114
|
+
const next = withPrivacy(current, enabled);
|
|
115
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
116
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === text) return 'unchanged';
|
|
117
|
+
mkdirSync(dir, { recursive: true });
|
|
118
|
+
const tmp = `${path}.metro-${String(process.pid)}.tmp`;
|
|
119
|
+
writeFileSync(tmp, text, { mode: 0o644 });
|
|
120
|
+
renameSync(tmp, path);
|
|
121
|
+
return 'written';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const skillPath = (dir: string): string => join(dir, 'skills', SKILL_NAME, 'SKILL.md');
|
|
125
|
+
const workerPath = (dir: string): string => join(dir, 'agents', 'worker.md');
|
|
126
|
+
|
|
127
|
+
function placeSkill(dir: string, guidance: string): Placed {
|
|
128
|
+
if (!existsSync(guidance)) return 'missing';
|
|
129
|
+
return ensureFile(skillPath(dir), readFileSync(guidance, 'utf8'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
|
|
133
|
+
const dir = deps.dir ?? claudeDir();
|
|
134
|
+
const agents = deps.agents ?? agentsDir();
|
|
135
|
+
const privacy = privacyEnabled(agents);
|
|
136
|
+
const report: SetupReport = {
|
|
137
|
+
privacy,
|
|
138
|
+
guard: 'plugin',
|
|
139
|
+
worker: ensureFile(workerPath(dir), WORKER_AGENT),
|
|
140
|
+
skill: placeSkill(dir, deps.guidance ?? guidancePath(deps.env)),
|
|
141
|
+
settings: applyPrivacy(dir, privacy),
|
|
142
|
+
};
|
|
143
|
+
if (report.worker === 'written' || report.skill === 'written' || report.settings === 'written')
|
|
144
|
+
log.info(report, 'claude-setup: applied the Claude Code setup for a metro box');
|
|
145
|
+
if (report.settings === 'unreadable') log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: settings.json is not valid JSON, so the privacy settings were not written');
|
|
146
|
+
return report;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
|
|
150
|
+
const dir = deps.dir ?? claudeDir();
|
|
151
|
+
const settings = readSettings(join(dir, 'settings.json'));
|
|
152
|
+
const env = settings !== null && isRecord(settings.env) ? settings.env : {};
|
|
153
|
+
const days = settings?.cleanupPeriodDays;
|
|
154
|
+
return {
|
|
155
|
+
privacy: privacyEnabled(deps.agents ?? agentsDir()),
|
|
156
|
+
guard: 'plugin',
|
|
157
|
+
worker: existsSync(workerPath(dir)),
|
|
158
|
+
skill: existsSync(skillPath(dir)),
|
|
159
|
+
privacyApplied: Object.keys(PRIVACY_ENV).every((key) => env[key] === '1'),
|
|
160
|
+
retentionDays: typeof days === 'number' ? days : null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function tryClaudeSetup(deps: SetupDeps = {}): void {
|
|
165
|
+
try {
|
|
166
|
+
ensureClaudeSetup(deps);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
log.warn({ err: errMsg(err) }, 'claude-setup: could not apply the Claude Code setup');
|
|
169
|
+
}
|
|
170
|
+
}
|
package/runtime/runtime.json
CHANGED