@uniqbit/mate-core 0.16.0 → 0.17.0-canary.10

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 (49) hide show
  1. package/package.json +2 -2
  2. package/src/cli/commands/companion/companion.ts +8 -0
  3. package/src/cli/commands/companion/prepare.ts +76 -0
  4. package/src/cli/commands/companion/register.ts +47 -0
  5. package/src/cli/commands/launch/claude.ts +4 -3
  6. package/src/cli/commands/launch/opencode.ts +4 -3
  7. package/src/cli/commands/launch/shared.ts +41 -28
  8. package/src/cli/commands/shared/companion-selection.ts +3 -1
  9. package/src/cli/commands/studio/index.ts +85 -6
  10. package/src/cli/commands/studio/routes.ts +1 -1
  11. package/src/cli/commands/studio/selection.ts +11 -2
  12. package/src/cli/commands/studio/server.ts +212 -17
  13. package/src/cli/commands/studio/vault.ts +607 -0
  14. package/src/cli/commands/studio/views/client.ts +141 -1
  15. package/src/cli/commands/studio/views/companion-selector.tsx +1 -1
  16. package/src/cli/commands/studio/views/document.tsx +137 -3
  17. package/src/cli/commands/studio/views/model.ts +13 -0
  18. package/src/cli/commands/studio/views/styles.ts +17 -0
  19. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  20. package/src/cli/commands/update.ts +6 -6
  21. package/src/cli/main.ts +56 -4
  22. package/src/cli/usage.ts +6 -3
  23. package/src/index.ts +1 -0
  24. package/src/lib/context-mode-package.ts +7 -4
  25. package/src/lib/node-engine-version.ts +15 -0
  26. package/src/lib/opencode-plugin-package.ts +10 -4
  27. package/src/lib/orchestrator/adapters/base.ts +65 -10
  28. package/src/lib/orchestrator/adapters/opencode.ts +27 -2
  29. package/src/lib/orchestrator/companion-registration.ts +73 -0
  30. package/src/lib/orchestrator/framework-context.ts +29 -3
  31. package/src/lib/orchestrator/launcher.ts +46 -22
  32. package/src/lib/orchestrator/projection-record.ts +24 -0
  33. package/src/lib/orchestrator/types.ts +11 -0
  34. package/src/lib/package-root.ts +43 -0
  35. package/src/lib/prebuilt-workspace-prepare.ts +152 -0
  36. package/src/lib/prebuilt-workspace.ts +246 -0
  37. package/src/lib/preinstalled-plugins.ts +106 -0
  38. package/src/lib/update-checker.ts +39 -0
  39. package/src/opencode/companion-hooks.ts +45 -20
  40. package/src/playbooks/companion-guidance.ts +1 -0
  41. package/src/runtime/companion-guidance.ts +61 -15
  42. package/src/runtime/env.ts +3 -3
  43. package/src/runtime/index.ts +1 -0
  44. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  45. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +127 -77
  46. package/src/tools/setup/capabilities/context-mode.ts +34 -2
  47. package/src/tools/setup/capabilities/openspec.ts +12 -2
  48. package/src/tools/setup/plugin.ts +7 -1
  49. package/src/tools/setup/providers/opencode.ts +24 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.16.0",
3
+ "version": "0.17.0-canary.10",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -23,7 +23,7 @@
23
23
  "registry": "https://registry.npmjs.org/"
24
24
  },
25
25
  "scripts": {
26
- "test": "CI=1 bun test",
26
+ "test": "CI=1 bun ../../test/run-isolated.ts",
27
27
  "typecheck": "bunx tsc --noEmit -p ../../tsconfig.json",
28
28
  "lint": "oxlint src/",
29
29
  "lint:fix": "oxlint src/ --fix",
@@ -1,5 +1,7 @@
1
1
  import { usage } from "../../usage";
2
2
  import { runCompanionLinkCommand } from "./link";
3
+ import { runCompanionRegisterCommand } from "./register";
4
+ import { runCompanionPrepareCommand } from "./prepare";
3
5
  import { runSetupCommand } from "../setup";
4
6
  import { runCompanionListCommand } from "./list";
5
7
  import { runCompanionSyncCommand } from "./sync";
@@ -14,6 +16,12 @@ export async function runCompanionCommand(
14
16
  case "link":
15
17
  await runCompanionLinkCommand(argv);
16
18
  return;
19
+ case "register":
20
+ await runCompanionRegisterCommand(argv);
21
+ return;
22
+ case "prepare":
23
+ await runCompanionPrepareCommand(argv);
24
+ return;
17
25
  case "setup":
18
26
  await runSetupCommand(argv);
19
27
  return;
@@ -0,0 +1,76 @@
1
+ import path from "node:path";
2
+
3
+ import { FRAMEWORK_NAME } from "../../../framework";
4
+ import { preparePrebuiltWorkspace } from "../../../lib/prebuilt-workspace-prepare";
5
+
6
+ export const companionPrepareCommandDeps = {
7
+ preparePrebuiltWorkspace,
8
+ };
9
+
10
+ interface ParsedPrepareArgs {
11
+ bundle?: string;
12
+ companionPath?: string;
13
+ error?: string;
14
+ }
15
+
16
+ export function parsePrepareArgs(argv: string[]): ParsedPrepareArgs {
17
+ let bundle: string | undefined;
18
+ const positional: string[] = [];
19
+
20
+ for (let index = 0; index < argv.length; index++) {
21
+ const arg = argv[index]!;
22
+ if (arg === "--from") {
23
+ const value = argv[++index];
24
+ if (!value) return { error: "`--from` requires a path to a prebuilt dependency bundle." };
25
+ bundle = value;
26
+ continue;
27
+ }
28
+ if (arg.startsWith("--from=")) {
29
+ bundle = arg.slice("--from=".length);
30
+ if (!bundle) return { error: "`--from` requires a path to a prebuilt dependency bundle." };
31
+ continue;
32
+ }
33
+ if (arg.startsWith("-")) return { error: `unknown option: ${arg}` };
34
+ positional.push(arg);
35
+ }
36
+
37
+ if (positional.length > 1) return { error: "`companion prepare` accepts at most one path." };
38
+ if (!bundle) return { error: "`companion prepare` requires `--from <bundle>`." };
39
+ return { bundle, companionPath: positional[0] };
40
+ }
41
+
42
+ /**
43
+ * @command mate companion prepare --from <bundle> [companion-path]
44
+ * @description Prepares the companion's machine-local dependency workspace by
45
+ * validating and copying a fully installed prebuilt bundle. Runs no package
46
+ * manager, no dependency resolution, and no installation script; presents no
47
+ * selection and changes none.
48
+ * @flags
49
+ * - `--from PATH` — the prebuilt, fully installed workspace to copy.
50
+ */
51
+ export async function runCompanionPrepareCommand(argv: string[] = []): Promise<void> {
52
+ const parsed = parsePrepareArgs(argv);
53
+ if (parsed.error) {
54
+ process.stderr.write(`${FRAMEWORK_NAME}: ${parsed.error}\n`);
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+
59
+ const companionPath = path.resolve(parsed.companionPath ?? process.cwd());
60
+ const result = await companionPrepareCommandDeps.preparePrebuiltWorkspace(
61
+ companionPath,
62
+ path.resolve(parsed.bundle!),
63
+ );
64
+
65
+ if (!result.ok) {
66
+ process.stderr.write(
67
+ `${FRAMEWORK_NAME}: cannot prepare local dependencies:\n${result.failures.map((failure) => ` ${failure}`).join("\n")}\n`,
68
+ );
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+
73
+ process.stdout.write(
74
+ `${result.reused ? "Reused" : "Prepared"} local dependencies at ${result.workspacePath}\n`,
75
+ );
76
+ }
@@ -0,0 +1,47 @@
1
+ import path from "node:path";
2
+
3
+ import { FRAMEWORK_NAME } from "../../../framework";
4
+ import { registerConfiguredCompanion } from "../../../lib/orchestrator/companion-registration";
5
+ import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
6
+
7
+ export const companionRegisterCommandDeps = {
8
+ registerConfiguredCompanion,
9
+ createGlobalConfigStore: () => new GlobalConfigStore(),
10
+ };
11
+
12
+ /**
13
+ * @command mate companion register [path]
14
+ * @description Registers an already-configured Companion Repository in the
15
+ * global registry. Presents no selection, reads no input, and writes nothing
16
+ * inside the companion — the path a script or container entrypoint calls.
17
+ * @remarks Defaults to the current directory. Exits non-zero when the target
18
+ * carries no companion configuration.
19
+ */
20
+ export async function runCompanionRegisterCommand(argv: string[] = []): Promise<void> {
21
+ const positional = argv.filter((arg) => !arg.startsWith("-"));
22
+ if (positional.length !== argv.length) {
23
+ process.stderr.write(`${FRAMEWORK_NAME}: \`companion register\` accepts no flags.\n`);
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+ if (positional.length > 1) {
28
+ process.stderr.write(`${FRAMEWORK_NAME}: \`companion register\` accepts at most one path.\n`);
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+
33
+ const target = path.resolve(positional[0] ?? process.cwd());
34
+ const result = await companionRegisterCommandDeps.registerConfiguredCompanion(target, {
35
+ globalConfigStore: companionRegisterCommandDeps.createGlobalConfigStore(),
36
+ });
37
+
38
+ if (!result.ok) {
39
+ process.stderr.write(`${FRAMEWORK_NAME}: ${result.reason}\n`);
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+
44
+ process.stdout.write(
45
+ `${result.alreadyRegistered ? "Already registered" : "Registered"} companion: ${result.companionPath}\n`,
46
+ );
47
+ }
@@ -2,12 +2,13 @@ import { makeLaunchCommand } from "./shared";
2
2
 
3
3
  /**
4
4
  * @command mate claude [-- ...agentArgs]
5
- * @description Launches Claude Code for the active working repository. In a
6
- * TTY, launches directly; outside a TTY, launches via
7
- * {@link runLaunchToolCommand} with confirmation.
5
+ * @description Launches Claude Code for the active working repository, or the
6
+ * Companion Repository with `-- --companion`, without an interactive confirmation.
8
7
  * @flags
9
8
  * - `-- <args>` — arguments forwarded to the launched `claude` process.
10
9
  * - `-- --no-git` — skip companion Git synchronization for this launch.
10
+ * - `-- --companion` — launch against the Companion Repository without a Working Repository.
11
+ * - `-- --yes` — accepted as a Mate compatibility no-op; launches are always prompt-free.
11
12
  * @remarks When invoked via `directPassthrough` (the top-level `mate claude`
12
13
  * alias), all args before `--` are treated as agent args rather than being
13
14
  * parsed as launch options.
@@ -2,12 +2,13 @@ import { makeLaunchCommand } from "./shared";
2
2
 
3
3
  /**
4
4
  * @command mate opencode [-- ...agentArgs]
5
- * @description Launches OpenCode for the active working repository. In a
6
- * TTY, launches directly; outside a TTY, launches via
7
- * {@link runLaunchToolCommand} with confirmation.
5
+ * @description Launches OpenCode for the active working repository, or the
6
+ * Companion Repository with `-- --companion`, without an interactive confirmation.
8
7
  * @flags
9
8
  * - `-- <args>` — arguments forwarded to the launched `opencode` process.
10
9
  * - `-- --no-git` — skip companion Git synchronization for this launch.
10
+ * - `-- --companion` — launch against the Companion Repository without a Working Repository.
11
+ * - `-- --yes` — accepted as a Mate compatibility no-op; launches are always prompt-free.
11
12
  * @remarks When invoked via `directPassthrough` (the top-level `mate
12
13
  * opencode` alias), all args before `--` are treated as agent args rather
13
14
  * than being parsed as launch options.
@@ -4,11 +4,11 @@ import {
4
4
  LaunchPreflightError,
5
5
  RepositoryNotSelectedError,
6
6
  ToolNotAllowedError,
7
+ type LaunchScope,
7
8
  } from "../../../lib/orchestrator/types";
8
9
  import { createStartupProgress } from "../../../lib/components/startup-progress";
9
10
  import { ensureUnambiguousCompanion, launchAmbiguityDeps } from "../shared/companion-selection";
10
11
  import { runIndexCapCommand } from "../cap/index-cmd";
11
- import { confirm } from "../../confirm";
12
12
  import type { LaunchTarget } from "../../launch-selector";
13
13
 
14
14
  const STEP_LABELS = {
@@ -20,6 +20,20 @@ const STEP_LABELS = {
20
20
  export interface ParsedLaunchArgs {
21
21
  agentArgs: string[];
22
22
  skipGit?: boolean;
23
+ scope?: LaunchScope;
24
+ }
25
+
26
+ const MATE_LAUNCH_OPTIONS = new Set(["--no-git", "--companion", "--yes"]);
27
+
28
+ function parseMateLaunchArgs(agentArgs: string[]): ParsedLaunchArgs {
29
+ const skipGit = agentArgs.includes("--no-git");
30
+ const companion = agentArgs.includes("--companion");
31
+
32
+ return {
33
+ agentArgs: agentArgs.filter((arg) => !MATE_LAUNCH_OPTIONS.has(arg)),
34
+ ...(skipGit ? { skipGit: true } : {}),
35
+ ...(companion ? { scope: "companion" as const } : {}),
36
+ };
23
37
  }
24
38
 
25
39
  export { ensureUnambiguousCompanion, launchAmbiguityDeps };
@@ -28,14 +42,12 @@ export interface LaunchCommandDeps {
28
42
  createLauncher: () => Pick<FrameworkLauncher, "prepare">;
29
43
  createProgress: typeof createStartupProgress;
30
44
  runIndexCapCommand: typeof runIndexCapCommand;
31
- confirm: typeof confirm;
32
45
  }
33
46
 
34
47
  export const launchCommandDeps: LaunchCommandDeps = {
35
48
  createLauncher: () => new FrameworkLauncher(),
36
49
  createProgress: createStartupProgress,
37
50
  runIndexCapCommand,
38
- confirm,
39
51
  };
40
52
 
41
53
  export function parseDirectLaunchArgs(argv: string[]): ParsedLaunchArgs {
@@ -44,10 +56,10 @@ export function parseDirectLaunchArgs(argv: string[]): ParsedLaunchArgs {
44
56
 
45
57
  const beforeSeparator = argv.slice(0, separatorIndex);
46
58
  const afterSeparator = argv.slice(separatorIndex + 1);
47
- const skipGit = afterSeparator.includes("--no-git");
59
+ const parsed = parseMateLaunchArgs(afterSeparator);
48
60
  return {
49
- agentArgs: [...beforeSeparator, ...afterSeparator.filter((arg) => arg !== "--no-git")],
50
- ...(skipGit ? { skipGit: true } : {}),
61
+ ...parsed,
62
+ agentArgs: [...beforeSeparator, ...parsed.agentArgs],
51
63
  };
52
64
  }
53
65
 
@@ -62,18 +74,14 @@ export function parseLaunchArgs(argv: string[]): ParsedLaunchArgs | null {
62
74
  return null;
63
75
  }
64
76
 
65
- const skipGit = agentArgs.includes("--no-git");
66
- return {
67
- agentArgs: agentArgs.filter((arg) => arg !== "--no-git"),
68
- ...(skipGit ? { skipGit: true } : {}),
69
- };
77
+ return parseMateLaunchArgs(agentArgs);
70
78
  }
71
79
 
72
80
  /**
73
81
  * @description Shared launch execution used by `mate claude`, `mate opencode`,
74
82
  * and the direct `mate claude` / `mate opencode` commands: resolves the launch via {@link FrameworkLauncher.prepare},
75
- * optionally confirms with the user, re-syncs capability indexes via
76
- * `runIndexCapCommand`, then executes and prints the JSON result.
83
+ * re-syncs capability indexes via `runIndexCapCommand`, then executes, prints the JSON result, and adopts the
84
+ * agent's outcome as the command's exit status (128 + signal for a signalled stop).
77
85
  * @remarks Exits non-zero with a targeted message for `ToolNotAllowedError`
78
86
  * (repo policy disallows this tool) and `RepositoryNotSelectedError` (no
79
87
  * active repo — points the user at `mate companion link`); other errors propagate.
@@ -82,8 +90,8 @@ export async function runLaunchToolCommand(
82
90
  tool: LaunchTarget,
83
91
  args: string[],
84
92
  options: {
85
- skipConfirmation?: boolean;
86
93
  skipGit?: boolean;
94
+ scope?: LaunchScope;
87
95
  } = {},
88
96
  ): Promise<void> {
89
97
  const interactiveGit = !!(process.stdin.isTTY && process.stdout.isTTY);
@@ -98,6 +106,7 @@ export async function runLaunchToolCommand(
98
106
  tool,
99
107
  args,
100
108
  skipGit: options.skipGit,
109
+ scope: options.scope,
101
110
  interactiveGit,
102
111
  });
103
112
  progress?.succeed("sync");
@@ -106,21 +115,20 @@ export async function runLaunchToolCommand(
106
115
  // Ink's line tracking, corrupting later re-renders (e.g. duplicated title).
107
116
  progress?.stop();
108
117
 
109
- if (!options.skipConfirmation) {
110
- const ok = await launchCommandDeps.confirm("Continue? [y/N] ");
111
- if (!ok) {
112
- process.stderr.write("Aborted.\n");
113
- process.exit(1);
114
- }
118
+ if (options.scope !== "companion") {
119
+ await launchCommandDeps.runIndexCapCommand([], {
120
+ onStepStart: (step) => process.stdout.write(`${STEP_LABELS[step]}...\n`),
121
+ onStepDone: (step, ok) => process.stdout.write(`${ok ? "✓" : "✗"} ${STEP_LABELS[step]}\n`),
122
+ });
115
123
  }
116
124
 
117
- await launchCommandDeps.runIndexCapCommand([], {
118
- onStepStart: (step) => process.stdout.write(`${STEP_LABELS[step]}...\n`),
119
- onStepDone: (step, ok) => process.stdout.write(`${ok ? "✓" : "✗"} ${STEP_LABELS[step]}\n`),
120
- });
121
-
122
125
  const result = await prepared.execute();
126
+ /**
127
+ * The printed result stays useful, but the status is what a supervisor
128
+ * reads: a failed agent must fail the launch.
129
+ */
123
130
  console.log(JSON.stringify(result, null, 2));
131
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
124
132
  } catch (error) {
125
133
  progress?.failCurrent();
126
134
  progress?.stop();
@@ -153,7 +161,7 @@ export async function runLaunchToolCommand(
153
161
  /**
154
162
  * Builds the `mate <tool>` command handler: parses launch args (direct
155
163
  * passthrough or flagged), guards against ambiguous companions, then launches
156
- * via {@link runLaunchToolCommand} with confirmation skipped in a TTY.
164
+ * via {@link runLaunchToolCommand} without an interactive confirmation.
157
165
  */
158
166
  export function makeLaunchCommand(tool: LaunchTarget) {
159
167
  return async function runLaunchCommand(
@@ -163,14 +171,19 @@ export function makeLaunchCommand(tool: LaunchTarget) {
163
171
  const parsed = options.directPassthrough ? parseDirectLaunchArgs(args) : parseLaunchArgs(args);
164
172
  if (!parsed) return;
165
173
 
166
- if (!(await ensureUnambiguousCompanion())) {
174
+ if (
175
+ !(await ensureUnambiguousCompanion(
176
+ process.cwd(),
177
+ parsed.scope === "companion" ? { persistSelection: false } : {},
178
+ ))
179
+ ) {
167
180
  process.exitCode = 1;
168
181
  return;
169
182
  }
170
183
 
171
184
  await runLaunchToolCommand(tool, parsed.agentArgs, {
172
- skipConfirmation: !!process.stdin.isTTY,
173
185
  skipGit: parsed.skipGit,
186
+ scope: parsed.scope,
174
187
  });
175
188
  };
176
189
  }
@@ -40,6 +40,8 @@ export interface CompanionSelectionOptions {
40
40
  ignoreProjection?: boolean;
41
41
  /** Outranks the environment, the projection, and the picker. */
42
42
  companion?: string;
43
+ /** Pin an interactive answer for this process without writing a projection. */
44
+ persistSelection?: boolean;
43
45
  }
44
46
 
45
47
  function pin(match: Pick<CompanionMatch, "companionPath" | "repositoryId">): void {
@@ -137,6 +139,6 @@ export async function ensureUnambiguousCompanion(
137
139
  }
138
140
 
139
141
  pin(chosen);
140
- await recordCompanion(cwd, chosen);
142
+ if (options.persistSelection !== false) await recordCompanion(cwd, chosen);
141
143
  return true;
142
144
  }
@@ -1,6 +1,12 @@
1
1
  import { FRAMEWORK_NAME } from "../../../framework";
2
2
  import { openReportInBrowser } from "../report/delivery";
3
- import { serveUntilInterrupted, startStudioServer } from "./server";
3
+ import {
4
+ serveUntilInterrupted,
5
+ startStudioServer,
6
+ STUDIO_HOSTNAME,
7
+ type StudioServerHandle,
8
+ type StudioServerOptions,
9
+ } from "./server";
4
10
 
5
11
  export interface StudioCommandDeps {
6
12
  startStudioServer?: typeof startStudioServer;
@@ -10,16 +16,52 @@ export interface StudioCommandDeps {
10
16
  warn?: (message: string) => void;
11
17
  }
12
18
 
19
+ export type StudioServeArgs = StudioServerOptions & { port: number; hostname: string };
20
+
21
+ export function parseStudioServeArgs(argv: string[]): StudioServeArgs | { error: string } {
22
+ let port: number | undefined;
23
+ let hostname = STUDIO_HOSTNAME;
24
+
25
+ for (let index = 0; index < argv.length; index += 1) {
26
+ const arg = argv[index]!;
27
+ if (arg === "--writable") {
28
+ continue;
29
+ }
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}` };
36
+ }
37
+ port = parsed;
38
+ if (arg === "--port") index += 1;
39
+ continue;
40
+ }
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;
46
+ continue;
47
+ }
48
+ return { error: `unknown studio serve option: ${arg}` };
49
+ }
50
+
51
+ if (port === undefined) return { error: "studio serve requires --port" };
52
+ return { port, hostname, writable: argv.includes("--writable") };
53
+ }
54
+
13
55
  /**
14
56
  * @command mate studio
15
- * @description Serves a local, read-only page over the Companion Repositories
57
+ * @description Serves a local page over the Companion Repositories
16
58
  * registered on this machine — the workflow drawn from each companion's
17
59
  * resolved schema with its OpenSpec state on it — and opens the platform
18
60
  * browser at it. Runs in the foreground on an operating-system-assigned
19
61
  * loopback port and dies with the process: no daemon, no stop command, and no
20
62
  * state written anywhere.
21
- * @remarks Takes no arguments and resolves no Repository Link, so it runs from
22
- * any directory.
63
+ * @remarks Both invocations are read-only unless passed `--writable`. Neither
64
+ * invocation resolves a Repository Link, so it runs from any directory.
23
65
  */
24
66
  export async function runStudioCommand(
25
67
  argv: string[] = [],
@@ -28,7 +70,12 @@ export async function runStudioCommand(
28
70
  const log = deps.log ?? ((message: string) => process.stdout.write(`${message}\n`));
29
71
  const warn = deps.warn ?? ((message: string) => process.stderr.write(`${message}\n`));
30
72
 
31
- if (argv.length > 0) {
73
+ if (argv[0] === "serve") {
74
+ await runStudioServeCommand(argv.slice(1), deps, log, warn);
75
+ return;
76
+ }
77
+
78
+ if (argv.some((arg) => arg !== "--writable")) {
32
79
  warn(`${FRAMEWORK_NAME}: \`studio\` takes no arguments; unrecognized: ${argv.join(" ")}`);
33
80
  process.exitCode = 1;
34
81
  return;
@@ -40,7 +87,7 @@ export async function runStudioCommand(
40
87
 
41
88
  let server;
42
89
  try {
43
- server = start();
90
+ server = start({}, { writable: argv.includes("--writable") });
44
91
  } catch (error) {
45
92
  warn(
46
93
  `${FRAMEWORK_NAME}: studio could not bind a port: ${
@@ -67,3 +114,35 @@ export async function runStudioCommand(
67
114
 
68
115
  await serve(server);
69
116
  }
117
+
118
+ async function runStudioServeCommand(
119
+ argv: string[],
120
+ deps: StudioCommandDeps,
121
+ log: (message: string) => void,
122
+ warn: (message: string) => void,
123
+ ): Promise<void> {
124
+ const parsed = parseStudioServeArgs(argv);
125
+ if ("error" in parsed) {
126
+ warn(`${FRAMEWORK_NAME}: ${parsed.error}`);
127
+ process.exitCode = 1;
128
+ return;
129
+ }
130
+
131
+ const start = deps.startStudioServer ?? startStudioServer;
132
+ const serve = deps.serveUntilInterrupted ?? serveUntilInterrupted;
133
+ let server: StudioServerHandle;
134
+ try {
135
+ server = start({}, parsed);
136
+ } catch (error) {
137
+ warn(
138
+ `${FRAMEWORK_NAME}: studio serve could not bind ${parsed.hostname}:${parsed.port}: ${
139
+ error instanceof Error ? error.message : String(error)
140
+ }`,
141
+ );
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+
146
+ log(server.url);
147
+ await serve(server);
148
+ }
@@ -1,2 +1,2 @@
1
- /** Loopback only: a payload carries absolute companion paths and change names. */
1
+ /** Default bind stays loopback because payloads carry absolute paths and names. */
2
2
  export const STUDIO_HOSTNAME = "127.0.0.1";
@@ -2,13 +2,20 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
4
4
 
5
- export type StudioView = "dashboard" | "workflow" | "specs" | "skills";
5
+ export type StudioView = "dashboard" | "workflow" | "specs" | "skills" | "vault";
6
6
 
7
- export const STUDIO_VIEWS: readonly StudioView[] = ["dashboard", "workflow", "specs", "skills"];
7
+ export const STUDIO_VIEWS: readonly StudioView[] = [
8
+ "dashboard",
9
+ "workflow",
10
+ "specs",
11
+ "skills",
12
+ "vault",
13
+ ];
8
14
 
9
15
  export const COMPANION_PARAM = "companion";
10
16
  export const VIEW_PARAM = "view";
11
17
  export const REFRESH_PARAM = "refresh";
18
+ export const FILE_PARAM = "path";
12
19
 
13
20
  /** Filled into a prompt when no change is named, so a prompt is never half-written. */
14
21
  export const CHANGE_PLACEHOLDER = "<change-name>";
@@ -23,6 +30,7 @@ export interface StudioSelection {
23
30
  * into the URL another control builds. Switching a view is not a refresh.
24
31
  */
25
32
  refresh: boolean;
33
+ openPath: string | null;
26
34
  }
27
35
 
28
36
  /**
@@ -57,5 +65,6 @@ export function parseStudioSelection(url: URL): StudioSelection {
57
65
  companionDigest: url.searchParams.get(COMPANION_PARAM)?.trim() || null,
58
66
  view: readView(url.searchParams.get(VIEW_PARAM)),
59
67
  refresh: url.searchParams.get(REFRESH_PARAM) === "1",
68
+ openPath: url.searchParams.get(FILE_PARAM)?.trim() || null,
60
69
  };
61
70
  }