@uniqbit/mate-core 0.17.0-canary.8 → 0.17.1

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 (103) hide show
  1. package/package.json +11 -5
  2. package/src/cli/commands/artifact/finish/index.ts +0 -9
  3. package/src/cli/commands/artifact/finish/openspec.ts +2 -2
  4. package/src/cli/commands/artifact/finish/registry.ts +1 -1
  5. package/src/cli/commands/artifact/pending/discovery.ts +1 -1
  6. package/src/cli/commands/artifact/pending/index.ts +0 -16
  7. package/src/cli/commands/cap/graphify.ts +1 -1
  8. package/src/cli/commands/cap/index-cmd.ts +1 -1
  9. package/src/cli/commands/cap/tokensave.ts +4 -3
  10. package/src/cli/commands/companion/companion.ts +4 -0
  11. package/src/cli/commands/companion/hub.ts +2 -1
  12. package/src/cli/commands/companion/list.ts +2 -1
  13. package/src/cli/commands/companion/prepare.ts +2 -2
  14. package/src/cli/commands/companion/update.ts +334 -0
  15. package/src/cli/commands/doctor.ts +2 -1
  16. package/src/cli/commands/install.ts +59 -5
  17. package/src/cli/commands/launch/shared.ts +2 -1
  18. package/src/cli/commands/plugin/plugin.ts +4 -0
  19. package/src/cli/commands/plugin/verify.ts +25 -0
  20. package/src/cli/commands/report/highlight.ts +1 -4
  21. package/src/cli/commands/report/renderer.ts +0 -2
  22. package/src/cli/commands/studio/access.ts +201 -0
  23. package/src/cli/commands/studio/index.ts +207 -26
  24. package/src/cli/commands/studio/openspec-cli.ts +1 -1
  25. package/src/cli/commands/studio/selection.ts +127 -11
  26. package/src/cli/commands/studio/server.ts +355 -64
  27. package/src/cli/commands/studio/terminal-assets.ts +50 -0
  28. package/src/cli/commands/studio/terminal-launch.ts +71 -0
  29. package/src/cli/commands/studio/terminal.ts +593 -0
  30. package/src/cli/commands/studio/vault.ts +320 -75
  31. package/src/cli/commands/studio/views/client.ts +90 -9
  32. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  33. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  34. package/src/cli/commands/studio/views/document.tsx +411 -107
  35. package/src/cli/commands/studio/views/model.ts +14 -1
  36. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  37. package/src/cli/commands/studio/views/styles.ts +106 -13
  38. package/src/cli/commands/studio/views/terminal.tsx +426 -0
  39. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  40. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  41. package/src/cli/commands/update.ts +11 -8
  42. package/src/cli/companion-link-wizard.tsx +1 -1
  43. package/src/cli/companion-selector.tsx +1 -1
  44. package/src/cli/launch-selector.tsx +1 -1
  45. package/src/cli/main.ts +2 -1
  46. package/src/cli/usage.ts +7 -3
  47. package/src/cli/write-json-stdout.ts +2 -2
  48. package/src/lib/components/spinner.tsx +2 -2
  49. package/src/lib/components/startup-progress.tsx +1 -1
  50. package/src/lib/context-mode-package.ts +1 -1
  51. package/src/lib/opencode-plugin-package.ts +3 -0
  52. package/src/lib/orchestrator/adapters/opencode.ts +53 -13
  53. package/src/lib/orchestrator/companion-hub.ts +3 -3
  54. package/src/lib/orchestrator/companion-registry-store.ts +1 -1
  55. package/src/lib/orchestrator/config-store.ts +2 -2
  56. package/src/lib/orchestrator/editor.ts +4 -12
  57. package/src/lib/orchestrator/engine-guard.ts +9 -1
  58. package/src/lib/orchestrator/projection-entries.ts +1 -1
  59. package/src/lib/orchestrator/projection-record.ts +1 -1
  60. package/src/lib/orchestrator/projection-types.ts +0 -4
  61. package/src/lib/orchestrator/repo-local-registry.ts +0 -1
  62. package/src/lib/orchestrator/root-context.ts +1 -1
  63. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  64. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  65. package/src/lib/orchestrator/types.ts +1 -1
  66. package/src/lib/orchestrator/working-repo-projection.ts +1 -1
  67. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  68. package/src/lib/package-paths.ts +1 -11
  69. package/src/lib/package-root.ts +43 -0
  70. package/src/lib/prebuilt-workspace.ts +1 -1
  71. package/src/lib/public-npm.ts +1 -1
  72. package/src/opencode/companion-hooks.ts +131 -153
  73. package/src/opencode/index.ts +12 -6
  74. package/src/opencode/tui.tsx +46 -49
  75. package/src/playbooks/companion-guidance.ts +1 -28
  76. package/src/runtime/companion-sync.ts +0 -3
  77. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  78. package/src/templates/providers/opencode/opencode.json +6 -4
  79. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
  80. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  81. package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
  82. package/src/tools/setup/capabilities/graphify.ts +2 -2
  83. package/src/tools/setup/capabilities/openspec.ts +2 -7
  84. package/src/tools/setup/capabilities/react-doctor.ts +1 -1
  85. package/src/tools/setup/capabilities/rtk.ts +2 -2
  86. package/src/tools/setup/capabilities/tokensave.ts +98 -12
  87. package/src/tools/setup/context-services.ts +3 -6
  88. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  89. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  90. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  91. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  92. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  93. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  94. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  95. package/src/tools/setup/mate.ts +1 -1
  96. package/src/tools/setup/providers/claude-format.ts +1 -1
  97. package/src/tools/setup/providers/claude.ts +1 -1
  98. package/src/tools/setup/providers/opencode-format.ts +220 -25
  99. package/src/tools/setup/providers/opencode.ts +63 -21
  100. package/src/tools/setup/runtime-documents.ts +9 -11
  101. package/src/tools/setup.ts +0 -4
  102. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  103. package/src/templates/providers/opencode/tui.json +0 -3
@@ -10,6 +10,7 @@ import { createStartupProgress } from "../../../lib/components/startup-progress"
10
10
  import { ensureUnambiguousCompanion, launchAmbiguityDeps } from "../shared/companion-selection";
11
11
  import { runIndexCapCommand } from "../cap/index-cmd";
12
12
  import type { LaunchTarget } from "../../launch-selector";
13
+ import { writeJsonStdout } from "../../write-json-stdout";
13
14
 
14
15
  const STEP_LABELS = {
15
16
  sync: "Syncing mate",
@@ -127,7 +128,7 @@ export async function runLaunchToolCommand(
127
128
  * The printed result stays useful, but the status is what a supervisor
128
129
  * reads: a failed agent must fail the launch.
129
130
  */
130
- console.log(JSON.stringify(result, null, 2));
131
+ await writeJsonStdout(result);
131
132
  if (result.exitCode !== 0) process.exitCode = result.exitCode;
132
133
  } catch (error) {
133
134
  progress?.failCurrent();
@@ -1,5 +1,6 @@
1
1
  import { usage } from "../../usage";
2
2
  import { runPluginInstallCommand } from "./install";
3
+ import { runPluginVerifyCommand } from "./verify";
3
4
 
4
5
  export async function runPluginCommand(
5
6
  subcommand: string | undefined,
@@ -9,6 +10,9 @@ export async function runPluginCommand(
9
10
  case "install":
10
11
  await runPluginInstallCommand(argv);
11
12
  return;
13
+ case "verify":
14
+ await runPluginVerifyCommand();
15
+ return;
12
16
  default:
13
17
  console.error(`Unknown plugin command: ${subcommand ?? ""}`);
14
18
  console.error(usage());
@@ -0,0 +1,25 @@
1
+ import { FRAMEWORK_NAME } from "../../../framework";
2
+ import { resolveInstallContext } from "../../../lib/install";
3
+ import { verifyDeclaredPlugins } from "../../../tools/setup/dynamic-plugins/verify";
4
+
5
+ /**
6
+ * @command mate plugin verify
7
+ * @description Strict, installation-free check that every plugin declared by the companion is allowed by `MATE_ALLOWED_PLUGINS`, installed, and loadable with the current environment. Exits non-zero naming each failing package.
8
+ */
9
+ export async function runPluginVerifyCommand(cwd = process.cwd()): Promise<boolean> {
10
+ const context = await resolveInstallContext(cwd);
11
+ if ((context.kind !== "companion" && context.kind !== "hub") || !context.companionPath) {
12
+ process.stderr.write(`${FRAMEWORK_NAME}: \`plugin verify\` requires a companion context.\n`);
13
+ process.exitCode = 1;
14
+ return false;
15
+ }
16
+ const failures = await verifyDeclaredPlugins(context.companionPath);
17
+ for (const failure of failures) {
18
+ process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
19
+ }
20
+ if (failures.length > 0) {
21
+ process.exitCode = 1;
22
+ return false;
23
+ }
24
+ return true;
25
+ }
@@ -4,10 +4,7 @@ import githubLight from "@tanstack/highlight/themes/github-light";
4
4
 
5
5
  /** Sole boundary against `@tanstack/highlight`, so an upstream API break stays a single-file fix. */
6
6
 
7
- /** Language every unknown or unsupported candidate resolves to. */
8
- export const PLAIN_LANGUAGE = "plaintext";
9
-
10
- export function resolveHighlightLanguage(candidate: string | undefined): string {
7
+ function resolveHighlightLanguage(candidate: string | undefined): string {
11
8
  return normalizeLanguage(candidate);
12
9
  }
13
10
 
@@ -456,5 +456,3 @@ const VISUAL_SECTION_CSS = `
456
456
  }`;
457
457
 
458
458
  type ReportMetricsSection = Extract<ReportSection, { type: "metrics" }>;
459
-
460
- export { escapeHTML };
@@ -0,0 +1,201 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+
3
+ /** Shortest operator-supplied token accepted; a generated one is 43 characters. */
4
+ export const MIN_TOKEN_LENGTH = 32;
5
+
6
+ const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
7
+
8
+ export type StudioInvocation = "interactive" | "serve";
9
+
10
+ export interface StudioAccessOptions {
11
+ invocation: StudioInvocation;
12
+ terminal: boolean;
13
+ /** Bound interface as passed to the server. */
14
+ hostname: string;
15
+ /** Read at request time: the port is known only once the server has bound. */
16
+ port: () => number;
17
+ allowedHosts?: readonly string[];
18
+ publicOrigin?: string | null;
19
+ token?: string | null;
20
+ }
21
+
22
+ /**
23
+ * One server's Host, Origin, and token policy. `null` token means the server
24
+ * is unguarded and issues none.
25
+ */
26
+ export interface StudioAccess {
27
+ invocation: StudioInvocation;
28
+ guarded: boolean;
29
+ token: string | null;
30
+ cookieName(): string;
31
+ /** Accepted origin for this request, or `null` when its Host is not one Studio answers to. */
32
+ acceptedOrigin(request: Request): string | null;
33
+ originMatches(request: Request, acceptedOrigin: string): boolean;
34
+ hasToken(request: Request): boolean;
35
+ /** Whether a request of this kind needs the token under this invocation. */
36
+ requires(kind: "read" | "save" | "terminal"): boolean;
37
+ }
38
+
39
+ export function generateStudioToken(): string {
40
+ return randomBytes(32).toString("base64url");
41
+ }
42
+
43
+ export function validateStudioToken(value: string): string | null {
44
+ if (value.trim().length < MIN_TOKEN_LENGTH) {
45
+ return `the supplied token must be at least ${MIN_TOKEN_LENGTH} characters`;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** `null` for anything other than an exact `http(s)://host[:port]` origin. */
51
+ export function normalizeOrigin(value: string): string | null {
52
+ let parsed: URL;
53
+ try {
54
+ parsed = new URL(value);
55
+ } catch {
56
+ return null;
57
+ }
58
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
59
+ if (parsed.username || parsed.password) return null;
60
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash || value.endsWith("/")) return null;
61
+ return parsed.origin;
62
+ }
63
+
64
+ /** Lower-cased `host[:port]` with the HTTP default port dropped, or `null` when unparsable. */
65
+ export function normalizeAuthority(value: string): string | null {
66
+ const trimmed = value.trim();
67
+ if (!trimmed || /[\s/?#@\\]/.test(trimmed)) return null;
68
+ try {
69
+ const parsed = new URL(`http://${trimmed}`);
70
+ return parsed.host;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function bracketed(hostname: string): string {
77
+ return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
78
+ }
79
+
80
+ function digest(value: string): Buffer {
81
+ return createHash("sha256").update(value).digest();
82
+ }
83
+
84
+ /** Hashing first keeps the comparison constant-time for inputs of any length. */
85
+ export function tokensEqual(candidate: string, expected: string): boolean {
86
+ return timingSafeEqual(digest(candidate), digest(expected));
87
+ }
88
+
89
+ function readCookie(request: Request, name: string): string | null {
90
+ const header = request.headers.get("cookie");
91
+ if (!header) return null;
92
+ for (const part of header.split(";")) {
93
+ const index = part.indexOf("=");
94
+ if (index < 0) continue;
95
+ if (part.slice(0, index).trim() === name) return part.slice(index + 1).trim();
96
+ }
97
+ return null;
98
+ }
99
+
100
+ export function createStudioAccess(options: StudioAccessOptions): StudioAccess {
101
+ const guarded = options.invocation === "serve" || options.terminal;
102
+ const token = guarded ? (options.token ?? generateStudioToken()) : null;
103
+ const publicOrigin = options.publicOrigin ? normalizeOrigin(options.publicOrigin) : null;
104
+ const publicAuthority = publicOrigin ? new URL(publicOrigin).host : null;
105
+ const configured = new Set<string>();
106
+ for (const host of options.allowedHosts ?? []) {
107
+ const authority = normalizeAuthority(host);
108
+ if (authority) configured.add(authority);
109
+ }
110
+ if (publicAuthority) configured.add(publicAuthority);
111
+
112
+ const accepted = (): Set<string> => {
113
+ const port = options.port();
114
+ const hosts = new Set(configured);
115
+ for (const hostname of [...LOOPBACK_HOSTNAMES, bracketed(options.hostname)]) {
116
+ const authority = normalizeAuthority(`${hostname}:${port}`);
117
+ if (authority) hosts.add(authority);
118
+ }
119
+ return hosts;
120
+ };
121
+
122
+ return {
123
+ invocation: options.invocation,
124
+ guarded,
125
+ token,
126
+ cookieName: () => `mate_studio_${options.port()}`,
127
+ acceptedOrigin(request) {
128
+ const raw = request.headers.get("host") ?? new URL(request.url).host;
129
+ const authority = normalizeAuthority(raw);
130
+ if (!authority || !accepted().has(authority)) return null;
131
+ if (publicOrigin && authority === publicAuthority) return publicOrigin;
132
+ return `http://${authority}`;
133
+ },
134
+ originMatches(request, acceptedOrigin) {
135
+ const origin = request.headers.get("origin");
136
+ if (!origin || origin === "null") return false;
137
+ return normalizeOrigin(origin) === acceptedOrigin;
138
+ },
139
+ hasToken(request) {
140
+ if (!token) return false;
141
+ const value = readCookie(request, `mate_studio_${options.port()}`);
142
+ return value !== null && tokensEqual(value, token);
143
+ },
144
+ requires(kind) {
145
+ if (!guarded) return false;
146
+ if (options.invocation === "serve") return true;
147
+ return kind !== "read";
148
+ },
149
+ };
150
+ }
151
+
152
+ const EXCHANGE_HEADERS = {
153
+ "cache-control": "no-store",
154
+ "referrer-policy": "no-referrer",
155
+ };
156
+
157
+ /**
158
+ * Trades a `?token=` navigation for the cookie and a relative redirect, so the
159
+ * browser never leaves the Host it asked for. `null` when the request carries
160
+ * no token parameter.
161
+ */
162
+ export function exchangeStudioToken(
163
+ request: Request,
164
+ access: StudioAccess,
165
+ acceptedOrigin: string,
166
+ ): Response | null {
167
+ const url = new URL(request.url);
168
+ if (!url.searchParams.has("token")) return null;
169
+ if (request.method !== "GET" || !access.token) {
170
+ return new Response("the access token is exchanged only by opening its address", {
171
+ status: 400,
172
+ headers: EXCHANGE_HEADERS,
173
+ });
174
+ }
175
+ const candidate = url.searchParams.get("token") ?? "";
176
+ if (!tokensEqual(candidate, access.token)) {
177
+ return new Response("the access token does not match; open the address Studio printed", {
178
+ status: 403,
179
+ headers: EXCHANGE_HEADERS,
180
+ });
181
+ }
182
+ url.searchParams.delete("token");
183
+ const secure = acceptedOrigin.startsWith("https:") ? "; Secure" : "";
184
+ return new Response(null, {
185
+ status: 303,
186
+ headers: {
187
+ ...EXCHANGE_HEADERS,
188
+ location: `${url.pathname}${url.search}`,
189
+ "set-cookie": `${access.cookieName()}=${access.token}; Path=/; HttpOnly; SameSite=Strict${secure}`,
190
+ },
191
+ });
192
+ }
193
+
194
+ /** Address the operator opens: loopback for wildcard or loopback binds, carrying the token only when it was generated. */
195
+ export function studioAddress(hostname: string, port: number, token: string | null): string {
196
+ const wildcard = hostname === "0.0.0.0" || hostname === "::" || hostname === "[::]";
197
+ const loopback = ["127.0.0.1", "localhost", "::1", "[::1]"].includes(hostname);
198
+ const host = wildcard || loopback ? "localhost" : bracketed(hostname);
199
+ const base = `http://${host}:${port}`;
200
+ return token ? `${base}/?token=${token}` : base;
201
+ }
@@ -1,5 +1,9 @@
1
+ import path from "node:path";
2
+
1
3
  import { FRAMEWORK_NAME } from "../../../framework";
2
4
  import { openReportInBrowser } from "../report/delivery";
5
+ import { normalizeAuthority, normalizeOrigin, validateStudioToken } from "./access";
6
+ import { collectStudioInventory, type StudioInventory } from "./inventory";
3
7
  import {
4
8
  serveUntilInterrupted,
5
9
  startStudioServer,
@@ -7,49 +11,196 @@ import {
7
11
  type StudioServerHandle,
8
12
  type StudioServerOptions,
9
13
  } from "./server";
14
+ import { terminalUnsupportedReason } from "./terminal";
10
15
 
11
16
  export interface StudioCommandDeps {
12
17
  startStudioServer?: typeof startStudioServer;
13
18
  serveUntilInterrupted?: typeof serveUntilInterrupted;
14
19
  openInBrowser?: (url: string) => Promise<void>;
20
+ collectStudioInventory?: () => Promise<StudioInventory>;
21
+ terminalUnsupportedReason?: () => string | null;
22
+ env?: NodeJS.ProcessEnv;
15
23
  log?: (message: string) => void;
16
24
  warn?: (message: string) => void;
17
25
  }
18
26
 
19
27
  export type StudioServeArgs = StudioServerOptions & { port: number; hostname: string };
20
28
 
21
- export function parseStudioServeArgs(argv: string[]): StudioServeArgs | { error: string } {
29
+ export type StudioInteractiveArgs = Pick<
30
+ StudioServerOptions,
31
+ "writable" | "terminal" | "detachMinutes"
32
+ >;
33
+
34
+ interface ParseState {
35
+ writable: boolean;
36
+ terminal: boolean;
37
+ detachMinutes?: number;
38
+ }
39
+
40
+ /**
41
+ * Reads one `--flag value` / `--flag=value` pair. `undefined` when `arg` is not
42
+ * that flag; `{ error }` when its value is missing.
43
+ */
44
+ function flagValue(
45
+ argv: string[],
46
+ index: number,
47
+ flag: string,
48
+ ): { value: string; consumed: number } | { error: string } | undefined {
49
+ const arg = argv[index]!;
50
+ if (arg !== flag && !arg.startsWith(`${flag}=`)) return undefined;
51
+ const value = arg === flag ? argv[index + 1] : arg.slice(flag.length + 1);
52
+ if (!value || value.startsWith("--")) return { error: `${flag} requires a value` };
53
+ return { value, consumed: arg === flag ? 2 : 1 };
54
+ }
55
+
56
+ /** Flags shared by both invocations; returns how many arguments were consumed, 0 when none. */
57
+ function parseSharedFlag(
58
+ argv: string[],
59
+ index: number,
60
+ state: ParseState,
61
+ ): number | { error: string } {
62
+ const arg = argv[index]!;
63
+ if (arg === "--writable") {
64
+ state.writable = true;
65
+ return 1;
66
+ }
67
+ if (arg === "--terminal") {
68
+ state.terminal = true;
69
+ return 1;
70
+ }
71
+ const detach = flagValue(argv, index, "--detach-timeout");
72
+ if (!detach) return 0;
73
+ if ("error" in detach) return detach;
74
+ if (!/^\d+$/.test(detach.value) || Number(detach.value) < 1) {
75
+ return { error: `invalid --detach-timeout value: ${detach.value} (whole minutes, at least 1)` };
76
+ }
77
+ state.detachMinutes = Number(detach.value);
78
+ return detach.consumed;
79
+ }
80
+
81
+ function checkTerminal(state: ParseState): { error: string } | null {
82
+ if (state.terminal && !state.writable) {
83
+ return { error: "--terminal requires --writable" };
84
+ }
85
+ return null;
86
+ }
87
+
88
+ export function parseStudioArgs(argv: string[]): StudioInteractiveArgs | { error: string } {
89
+ const state: ParseState = { writable: false, terminal: false };
90
+ for (let index = 0; index < argv.length;) {
91
+ const consumed = parseSharedFlag(argv, index, state);
92
+ if (typeof consumed === "object") return consumed;
93
+ if (consumed === 0) return { error: `unrecognized studio argument: ${argv[index]}` };
94
+ index += consumed;
95
+ }
96
+ return (
97
+ checkTerminal(state) ?? {
98
+ writable: state.writable,
99
+ ...(state.terminal ? { terminal: true } : {}),
100
+ ...(state.detachMinutes !== undefined ? { detachMinutes: state.detachMinutes } : {}),
101
+ }
102
+ );
103
+ }
104
+
105
+ export function parseStudioServeArgs(
106
+ argv: string[],
107
+ env: NodeJS.ProcessEnv = {},
108
+ ): StudioServeArgs | { error: string } {
22
109
  let port: number | undefined;
23
110
  let hostname = STUDIO_HOSTNAME;
111
+ let token: string | undefined;
112
+ let publicOrigin: string | undefined;
113
+ let companion: string | undefined;
114
+ let noGit = false;
115
+ const allowedHosts: string[] = [];
116
+ const state: ParseState = { writable: false, terminal: false };
24
117
 
25
- for (let index = 0; index < argv.length; index += 1) {
118
+ for (let index = 0; index < argv.length;) {
26
119
  const arg = argv[index]!;
27
- if (arg === "--writable") {
120
+ const shared = parseSharedFlag(argv, index, state);
121
+ if (typeof shared === "object") return shared;
122
+ if (shared > 0) {
123
+ index += shared;
124
+ continue;
125
+ }
126
+ if (arg === "--no-git") {
127
+ noGit = true;
128
+ index += 1;
28
129
  continue;
29
130
  }
30
- if (arg === "--port" || arg.startsWith("--port=")) {
31
- const value = arg === "--port" ? argv[index + 1] : arg.slice("--port=".length);
32
- if (!value || value.startsWith("--")) return { error: "--port requires a value" };
33
- const parsed = Number(value);
34
- if (!/^\d+$/.test(value) || !Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
35
- return { error: `invalid --port value: ${value}` };
131
+ const portFlag = flagValue(argv, index, "--port");
132
+ if (portFlag) {
133
+ if ("error" in portFlag) return portFlag;
134
+ const parsed = Number(portFlag.value);
135
+ if (!/^\d+$/.test(portFlag.value) || parsed < 1 || parsed > 65535) {
136
+ return { error: `invalid --port value: ${portFlag.value}` };
36
137
  }
37
138
  port = parsed;
38
- if (arg === "--port") index += 1;
139
+ index += portFlag.consumed;
39
140
  continue;
40
141
  }
41
- if (arg === "--host" || arg.startsWith("--host=")) {
42
- const value = arg === "--host" ? argv[index + 1] : arg.slice("--host=".length);
43
- if (!value || value.startsWith("--")) return { error: "--host requires a value" };
44
- hostname = value;
45
- if (arg === "--host") index += 1;
142
+ const valued = (
143
+ [
144
+ ["--host", (value: string) => (hostname = value)],
145
+ ["--token", (value: string) => (token = value)],
146
+ ["--allowed-host", (value: string) => allowedHosts.push(...splitHosts(value))],
147
+ ["--public-origin", (value: string) => (publicOrigin = value)],
148
+ ["--companion", (value: string) => (companion = value)],
149
+ ] as const
150
+ ).find(([flag]) => flagValue(argv, index, flag) !== undefined);
151
+ if (valued) {
152
+ const read = flagValue(argv, index, valued[0])!;
153
+ if ("error" in read) return read;
154
+ valued[1](read.value);
155
+ index += read.consumed;
46
156
  continue;
47
157
  }
48
158
  return { error: `unknown studio serve option: ${arg}` };
49
159
  }
50
160
 
51
161
  if (port === undefined) return { error: "studio serve requires --port" };
52
- return { port, hostname, writable: argv.includes("--writable") };
162
+ const terminalError = checkTerminal(state);
163
+ if (terminalError) return terminalError;
164
+
165
+ token ??= env.MATE_STUDIO_TOKEN || undefined;
166
+ if (token !== undefined) {
167
+ const weak = validateStudioToken(token);
168
+ if (weak) return { error: `--token / MATE_STUDIO_TOKEN: ${weak}` };
169
+ }
170
+ if (env.MATE_STUDIO_ALLOWED_HOSTS)
171
+ allowedHosts.push(...splitHosts(env.MATE_STUDIO_ALLOWED_HOSTS));
172
+ for (const host of allowedHosts) {
173
+ if (!normalizeAuthority(host)) {
174
+ return { error: `--allowed-host / MATE_STUDIO_ALLOWED_HOSTS: invalid host: ${host}` };
175
+ }
176
+ }
177
+ publicOrigin ??= env.MATE_STUDIO_PUBLIC_ORIGIN || undefined;
178
+ if (publicOrigin !== undefined && !normalizeOrigin(publicOrigin)) {
179
+ return {
180
+ error: `--public-origin / MATE_STUDIO_PUBLIC_ORIGIN: expected http(s)://host[:port], got ${publicOrigin}`,
181
+ };
182
+ }
183
+
184
+ return {
185
+ port,
186
+ hostname,
187
+ writable: state.writable,
188
+ invocation: "serve",
189
+ ...(state.terminal ? { terminal: true } : {}),
190
+ ...(state.detachMinutes !== undefined ? { detachMinutes: state.detachMinutes } : {}),
191
+ ...(token !== undefined ? { token } : {}),
192
+ ...(allowedHosts.length > 0 ? { allowedHosts } : {}),
193
+ ...(publicOrigin !== undefined ? { publicOrigin } : {}),
194
+ ...(companion !== undefined ? { launchCompanion: path.resolve(companion) } : {}),
195
+ ...(noGit ? { noGit: true } : {}),
196
+ };
197
+ }
198
+
199
+ function splitHosts(value: string): string[] {
200
+ return value
201
+ .split(",")
202
+ .map((host) => host.trim())
203
+ .filter(Boolean);
53
204
  }
54
205
 
55
206
  /**
@@ -60,8 +211,10 @@ export function parseStudioServeArgs(argv: string[]): StudioServeArgs | { error:
60
211
  * browser at it. Runs in the foreground on an operating-system-assigned
61
212
  * loopback port and dies with the process: no daemon, no stop command, and no
62
213
  * state written anywhere.
63
- * @remarks Both invocations are read-only unless passed `--writable`. Neither
64
- * invocation resolves a Repository Link, so it runs from any directory.
214
+ * @remarks Both invocations are read-only unless passed `--writable`.
215
+ * `--terminal` adds the agent terminal and guards Studio with a per-start
216
+ * access token; `serve` is always guarded. Neither invocation resolves a
217
+ * Repository Link, so it runs from any directory.
65
218
  */
66
219
  export async function runStudioCommand(
67
220
  argv: string[] = [],
@@ -75,11 +228,13 @@ export async function runStudioCommand(
75
228
  return;
76
229
  }
77
230
 
78
- if (argv.some((arg) => arg !== "--writable")) {
79
- warn(`${FRAMEWORK_NAME}: \`studio\` takes no arguments; unrecognized: ${argv.join(" ")}`);
231
+ const parsed = parseStudioArgs(argv);
232
+ if ("error" in parsed) {
233
+ warn(`${FRAMEWORK_NAME}: ${parsed.error}`);
80
234
  process.exitCode = 1;
81
235
  return;
82
236
  }
237
+ if (!terminalAvailable(parsed, deps, warn)) return;
83
238
 
84
239
  const start = deps.startStudioServer ?? startStudioServer;
85
240
  const serve = deps.serveUntilInterrupted ?? serveUntilInterrupted;
@@ -87,7 +242,7 @@ export async function runStudioCommand(
87
242
 
88
243
  let server;
89
244
  try {
90
- server = start({}, { writable: argv.includes("--writable") });
245
+ server = start({}, parsed);
91
246
  } catch (error) {
92
247
  warn(
93
248
  `${FRAMEWORK_NAME}: studio could not bind a port: ${
@@ -98,35 +253,60 @@ export async function runStudioCommand(
98
253
  return;
99
254
  }
100
255
 
101
- log(server.url);
256
+ log(server.address);
102
257
  log("Press Ctrl+C to stop.");
103
258
 
104
259
  try {
105
- await open(server.url);
260
+ await open(server.address);
106
261
  } catch (error) {
107
262
  warn(
108
263
  `${FRAMEWORK_NAME}: studio could not open a browser: ${
109
264
  error instanceof Error ? error.message : String(error)
110
265
  }`,
111
266
  );
112
- warn(`${FRAMEWORK_NAME}: open ${server.url} manually; the server keeps serving.`);
267
+ warn(`${FRAMEWORK_NAME}: open ${server.address} manually; the server keeps serving.`);
113
268
  }
114
269
 
115
270
  await serve(server);
116
271
  }
117
272
 
273
+ function terminalAvailable(
274
+ options: Pick<StudioServerOptions, "terminal">,
275
+ deps: StudioCommandDeps,
276
+ warn: (message: string) => void,
277
+ ): boolean {
278
+ if (!options.terminal) return true;
279
+ const reason = (deps.terminalUnsupportedReason ?? terminalUnsupportedReason)();
280
+ if (!reason) return true;
281
+ warn(`${FRAMEWORK_NAME}: --terminal ${reason}`);
282
+ process.exitCode = 1;
283
+ return false;
284
+ }
285
+
118
286
  async function runStudioServeCommand(
119
287
  argv: string[],
120
288
  deps: StudioCommandDeps,
121
289
  log: (message: string) => void,
122
290
  warn: (message: string) => void,
123
291
  ): Promise<void> {
124
- const parsed = parseStudioServeArgs(argv);
292
+ const parsed = parseStudioServeArgs(argv, deps.env ?? process.env);
125
293
  if ("error" in parsed) {
126
294
  warn(`${FRAMEWORK_NAME}: ${parsed.error}`);
127
295
  process.exitCode = 1;
128
296
  return;
129
297
  }
298
+ if (!terminalAvailable(parsed, deps, warn)) return;
299
+
300
+ if (parsed.launchCompanion) {
301
+ const inventory = await (deps.collectStudioInventory ?? collectStudioInventory)();
302
+ if (!inventory.companions.some((companion) => companion.path === parsed.launchCompanion)) {
303
+ warn(
304
+ `${FRAMEWORK_NAME}: --companion ${parsed.launchCompanion} is not a registered Companion Repository`,
305
+ );
306
+ process.exitCode = 1;
307
+ return;
308
+ }
309
+ }
130
310
 
131
311
  const start = deps.startStudioServer ?? startStudioServer;
132
312
  const serve = deps.serveUntilInterrupted ?? serveUntilInterrupted;
@@ -143,6 +323,7 @@ async function runStudioServeCommand(
143
323
  return;
144
324
  }
145
325
 
146
- log(server.url);
326
+ log(server.address);
327
+ if (parsed.token) log("Access token: the value pinned by --token / MATE_STUDIO_TOKEN.");
147
328
  await serve(server);
148
329
  }
@@ -27,7 +27,7 @@ export const WORKFLOW_CAPABILITY_ID = "openspec";
27
27
  /** Upper bound on one collection call; a hung wrapper must not hold a request open. */
28
28
  const OPENSPEC_TIMEOUT_MS = 60_000;
29
29
 
30
- export function openSpecWrapperPath(): string {
30
+ function openSpecWrapperPath(): string {
31
31
  return path.join(getWrapperBinPath(), "openspec");
32
32
  }
33
33