@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 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 PERMISSION_FLAGS = ['--permission-mode', 'auto'];
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
- ...PERMISSION_FLAGS,
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.109",
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.109",
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
- 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);
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 function privacyEnabled(agents = agentsDir()): boolean {
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 !isRecord(raw) || raw.privacy !== false;
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
- writeJson(statePath(agents), { privacy: enabled });
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');
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.109"
2
+ "version": "0.1.0-beta.110"
3
3
  }