@stage-labs/metro 0.1.0-beta.109 → 0.1.0-beta.110
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 +8 -6
- package/dist/route.js +12 -0
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +26 -5
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +26 -3
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +14 -1
- package/runtime/runtime.json +1 -1
package/dist/claude.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { markOnboardingDone, seedChannels } from './onboarding.js';
|
|
3
3
|
import { writeMcpConfig } from './mcp-config.js';
|
|
4
|
-
import { currentRoute, routeModelEnv } from './route.js';
|
|
4
|
+
import { currentRoute, permissionMode, routeModelEnv } from './route.js';
|
|
5
5
|
import { settingsConflicts, settingsFiles } from './claude-settings.js';
|
|
6
6
|
import { localAgents, pickLocalAgent } from './local.js';
|
|
7
7
|
import { PROVIDER_FLAGS } from './provider-flags.js';
|
|
8
8
|
import { localPort, localUrl } from './runtime.js';
|
|
9
9
|
const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
|
|
10
|
-
const
|
|
10
|
+
const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
|
|
11
11
|
const KEY_HEADER = 'x-metro-key';
|
|
12
12
|
const PROBE_MS = 3_000;
|
|
13
|
-
export const claudeArgs = (extra, mcpConfig) => [
|
|
13
|
+
export const claudeArgs = (extra, mcpConfig, mode = 'auto') => [
|
|
14
14
|
...CHANNEL_FLAGS,
|
|
15
|
-
|
|
15
|
+
'--permission-mode',
|
|
16
|
+
PERMISSION_MODE_FLAG[mode],
|
|
16
17
|
...(mcpConfig === undefined ? [] : ['--mcp-config', mcpConfig]),
|
|
17
18
|
...extra,
|
|
18
19
|
];
|
|
@@ -150,12 +151,13 @@ export async function launchClaude(extra) {
|
|
|
150
151
|
const decision = await verdict();
|
|
151
152
|
const port = localPort();
|
|
152
153
|
const mcp = mcpConfigFor(await servedKey(decision), port);
|
|
154
|
+
const mode = permissionMode();
|
|
153
155
|
try {
|
|
154
156
|
if ('skip' in decision) {
|
|
155
157
|
process.stderr.write(`metro claude: ${decision.skip}\n`);
|
|
156
|
-
return await runClaude(claudeArgs(extra, mcp?.path), process.env);
|
|
158
|
+
return await runClaude(claudeArgs(extra, mcp?.path, mode), process.env);
|
|
157
159
|
}
|
|
158
|
-
return await runClaude(claudeArgs(extra, mcp?.path), gatewayLaunchEnv(decision.key, port));
|
|
160
|
+
return await runClaude(claudeArgs(extra, mcp?.path, mode), gatewayLaunchEnv(decision.key, port));
|
|
159
161
|
}
|
|
160
162
|
finally {
|
|
161
163
|
mcp?.cleanup();
|
package/dist/route.js
CHANGED
|
@@ -24,3 +24,15 @@ export function routeModelEnv(env, route) {
|
|
|
24
24
|
return env;
|
|
25
25
|
return { ...env, ANTHROPIC_MODEL: route };
|
|
26
26
|
}
|
|
27
|
+
export function permissionMode(dir = agentsDir()) {
|
|
28
|
+
const path = join(dir, 'claude-setup.json');
|
|
29
|
+
if (!existsSync(path))
|
|
30
|
+
return 'auto';
|
|
31
|
+
try {
|
|
32
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
33
|
+
return state.permissionMode === 'bypass' ? 'bypass' : 'auto';
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return 'auto';
|
|
37
|
+
}
|
|
38
|
+
}
|
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.110",
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.110",
|
|
4
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",
|
|
@@ -21,9 +21,10 @@ import {
|
|
|
21
21
|
startClaudeLogin,
|
|
22
22
|
type LoginDeps,
|
|
23
23
|
} from './login.js';
|
|
24
|
-
import { claudeSetupStatus, ensureClaudeSetup, setPrivacy, type SetupDeps } from './setup.js';
|
|
24
|
+
import { claudeSetupStatus, ensureClaudeSetup, isPermissionMode, permissionMode, setPermissionMode, setPrivacy, type PermissionMode, type SetupDeps } from './setup.js';
|
|
25
25
|
import {
|
|
26
26
|
ensureSession,
|
|
27
|
+
sessionRunning,
|
|
27
28
|
sessionStatus,
|
|
28
29
|
setAutostart,
|
|
29
30
|
startSession,
|
|
@@ -115,15 +116,35 @@ const LOGIN = 'login';
|
|
|
115
116
|
const SESSION = 'session';
|
|
116
117
|
const SETUP = 'setup';
|
|
117
118
|
|
|
119
|
+
interface SetupChange {
|
|
120
|
+
privacy?: boolean;
|
|
121
|
+
permissionMode?: PermissionMode;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function setupChange(body: unknown): SetupChange {
|
|
125
|
+
if (!isRecord(body)) throw new ApiError('a body is required', 400);
|
|
126
|
+
if (body.privacy !== undefined && typeof body.privacy !== 'boolean') throw new ApiError('privacy must be true or false', 400);
|
|
127
|
+
if (body.permissionMode !== undefined && !isPermissionMode(body.permissionMode)) throw new ApiError('permissionMode must be auto or bypass', 400);
|
|
128
|
+
if (body.privacy === undefined && body.permissionMode === undefined) throw new ApiError('nothing to change', 400);
|
|
129
|
+
return { ...(typeof body.privacy === 'boolean' ? { privacy: body.privacy } : {}), ...(isPermissionMode(body.permissionMode) ? { permissionMode: body.permissionMode } : {}) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function applySetupChange(change: SetupChange, deps: ClaudeApiDeps): void {
|
|
133
|
+
const setup = deps.setup ?? {};
|
|
134
|
+
if (change.privacy !== undefined) setPrivacy(change.privacy, setup.agents);
|
|
135
|
+
if (change.permissionMode !== undefined && change.permissionMode !== permissionMode(setup.agents)) {
|
|
136
|
+
setPermissionMode(change.permissionMode, setup.agents);
|
|
137
|
+
if (sessionRunning(deps.session?.tmux ?? 'tmux')) stopSession(deps.session ?? {});
|
|
138
|
+
}
|
|
139
|
+
ensureClaudeSetup(setup);
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
async function setupAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
|
|
119
143
|
const setup = deps.setup ?? {};
|
|
120
144
|
const method = req.method ?? 'GET';
|
|
121
145
|
if (method === 'GET') return claudeSetupStatus(setup);
|
|
122
146
|
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
123
|
-
|
|
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);
|
|
147
|
+
applySetupChange(setupChange(await readJsonBody(req)), deps);
|
|
127
148
|
return claudeSetupStatus(setup);
|
|
128
149
|
}
|
|
129
150
|
|
|
@@ -55,6 +55,7 @@ export interface SetupReport {
|
|
|
55
55
|
|
|
56
56
|
export interface SetupStatus {
|
|
57
57
|
privacy: boolean;
|
|
58
|
+
permissionMode: PermissionMode;
|
|
58
59
|
guard: 'plugin';
|
|
59
60
|
worker: boolean;
|
|
60
61
|
skill: boolean;
|
|
@@ -64,13 +65,34 @@ export interface SetupStatus {
|
|
|
64
65
|
|
|
65
66
|
const statePath = (agents: string): string => join(agents, STATE_FILE);
|
|
66
67
|
|
|
67
|
-
export
|
|
68
|
+
export const PERMISSION_MODES = ['auto', 'bypass'] as const;
|
|
69
|
+
export type PermissionMode = (typeof PERMISSION_MODES)[number];
|
|
70
|
+
|
|
71
|
+
function readState(agents: string): Record<string, unknown> {
|
|
68
72
|
const raw = readJson<unknown>(statePath(agents), null);
|
|
69
|
-
return
|
|
73
|
+
return isRecord(raw) ? raw : {};
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
function writeState(agents: string, patch: Record<string, unknown>): void {
|
|
77
|
+
mkdirSync(agents, { recursive: true });
|
|
78
|
+
writeJson(statePath(agents), { ...readState(agents), ...patch });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const privacyEnabled = (agents = agentsDir()): boolean => readState(agents).privacy !== false;
|
|
82
|
+
|
|
72
83
|
export function setPrivacy(enabled: boolean, agents = agentsDir()): void {
|
|
73
|
-
|
|
84
|
+
writeState(agents, { privacy: enabled });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const isPermissionMode = (value: unknown): value is PermissionMode => PERMISSION_MODES.some((m) => m === value);
|
|
88
|
+
|
|
89
|
+
export function permissionMode(agents = agentsDir()): PermissionMode {
|
|
90
|
+
const mode = readState(agents).permissionMode;
|
|
91
|
+
return isPermissionMode(mode) ? mode : 'auto';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function setPermissionMode(mode: PermissionMode, agents = agentsDir()): void {
|
|
95
|
+
writeState(agents, { permissionMode: mode });
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
export function guidancePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -155,6 +177,7 @@ export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
|
|
|
155
177
|
const days = settings?.cleanupPeriodDays;
|
|
156
178
|
return {
|
|
157
179
|
privacy: privacyEnabled(deps.agents ?? agentsDir()),
|
|
180
|
+
permissionMode: permissionMode(deps.agents ?? agentsDir()),
|
|
158
181
|
guard: 'plugin',
|
|
159
182
|
worker: existsSync(workerPath(dir)),
|
|
160
183
|
skill: existsSync(skillPath(dir)),
|
|
@@ -8,6 +8,8 @@ import { mintTerminalTicket } from './tickets.js';
|
|
|
8
8
|
|
|
9
9
|
const PREFIX = '/api/terminal';
|
|
10
10
|
const TICKETS = `${PREFIX}/tickets`;
|
|
11
|
+
const BUFFER = `${PREFIX}/buffer`;
|
|
12
|
+
const BUFFER_MAX = 1024 * 1024;
|
|
11
13
|
const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
12
14
|
|
|
13
15
|
export const tmuxCommand = (session: string, home = homedir()): string[] => [
|
|
@@ -37,6 +39,7 @@ export const tmuxCommand = (session: string, home = homedir()): string[] => [
|
|
|
37
39
|
];
|
|
38
40
|
|
|
39
41
|
export interface TerminalApiDeps {
|
|
42
|
+
tmux?: string;
|
|
40
43
|
authorize: (subject: string) => void;
|
|
41
44
|
command?: (session: string) => string[];
|
|
42
45
|
}
|
|
@@ -64,9 +67,15 @@ export function sessionOf(raw: unknown): string {
|
|
|
64
67
|
return raw;
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
export function tmuxBuffer(tmux = 'tmux'): string {
|
|
71
|
+
const run = spawnSync(tmux, ['save-buffer', '-'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: BUFFER_MAX });
|
|
72
|
+
if (run.error !== undefined || run.status !== 0) throw new ApiError('tmux holds nothing copied yet: select text in the terminal first', 404);
|
|
73
|
+
return run.stdout;
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
export function handleTerminalRequest(req: IncomingMessage, res: ServerResponse, deps: TerminalApiDeps): boolean {
|
|
68
77
|
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
69
|
-
if (path !== PREFIX && path !== TICKETS) return false;
|
|
78
|
+
if (path !== PREFIX && path !== TICKETS && path !== BUFFER) return false;
|
|
70
79
|
if (req.method === 'OPTIONS') {
|
|
71
80
|
res.writeHead(204, cors(req)).end();
|
|
72
81
|
return true;
|
|
@@ -84,6 +93,10 @@ export function handleTerminalRequest(req: IncomingMessage, res: ServerResponse,
|
|
|
84
93
|
sendJson(req, res, 200, { available: tmuxAvailable(deps), sessions: tmuxSessions() });
|
|
85
94
|
return;
|
|
86
95
|
}
|
|
96
|
+
if (path === BUFFER) {
|
|
97
|
+
sendJson(req, res, 200, { text: tmuxBuffer(deps.tmux) });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
87
100
|
const wanted = sessionOf(bodyField(await readJsonBody(req), 'session'));
|
|
88
101
|
const minted = mintTerminalTicket(session.subject, wanted);
|
|
89
102
|
log.info({ subject: session.subject, session: wanted }, 'terminal: ticket minted');
|
package/runtime/runtime.json
CHANGED