@joycostudio/studio 0.1.2 → 0.1.4

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/cli.js CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { spawn } from "child_process";
5
- import { createRequire } from "module";
6
- import path4 from "path";
7
- import { parseArgs } from "util";
5
+ import { createRequire as createRequire2 } from "module";
6
+ import path8 from "path";
7
+ import { parseArgs as parseArgs2 } from "util";
8
8
 
9
9
  // src/core/tool.ts
10
+ var hasConfig = (tool2) => tool2.controls !== void 0;
10
11
  var DEFAULT_PORT = 5179;
11
12
 
12
13
  // src/runtime/runtime.ts
@@ -34,35 +35,35 @@ function kind(value) {
34
35
  if (Array.isArray(value)) return "array";
35
36
  return typeof value;
36
37
  }
37
- function shapeMismatch(reference, value, path5) {
38
+ function shapeMismatch(reference, value, path9) {
38
39
  const expected = kind(reference);
39
- if (kind(value) !== expected) return `${path5}: expected ${expected}, got ${kind(value)}`;
40
- if (expected === "number" && !Number.isFinite(value)) return `${path5}: expected a finite number`;
40
+ if (kind(value) !== expected) return `${path9}: expected ${expected}, got ${kind(value)}`;
41
+ if (expected === "number" && !Number.isFinite(value)) return `${path9}: expected a finite number`;
41
42
  if (expected === "array") {
42
43
  const [first] = reference;
43
44
  if (first === void 0) return null;
44
45
  for (const [index, item] of value.entries()) {
45
- const nested = shapeMismatch(first, item, `${path5}[${index}]`);
46
+ const nested = shapeMismatch(first, item, `${path9}[${index}]`);
46
47
  if (nested) return nested;
47
48
  }
48
49
  }
49
50
  if (expected === "object") {
50
51
  const ref = reference;
51
52
  const obj = value;
52
- for (const key of Object.keys(obj)) if (!(key in ref)) return `${path5}.${key}: unknown key`;
53
+ for (const key of Object.keys(obj)) if (!(key in ref)) return `${path9}.${key}: unknown key`;
53
54
  for (const key of Object.keys(ref)) {
54
- if (!(key in obj)) return `${path5}.${key}: missing`;
55
- const nested = shapeMismatch(ref[key], obj[key], `${path5}.${key}`);
55
+ if (!(key in obj)) return `${path9}.${key}: missing`;
56
+ const nested = shapeMismatch(ref[key], obj[key], `${path9}.${key}`);
56
57
  if (nested) return nested;
57
58
  }
58
59
  }
59
60
  return null;
60
61
  }
61
62
  var isRgb = (value) => typeof value === "object" && value !== null && ["r", "g", "b"].every((channel) => Number.isFinite(value[channel]));
62
- function invalidControl(schema, path5 = "") {
63
+ function invalidControl(schema, path9 = "") {
63
64
  for (const [key, field] of Object.entries(schema)) {
64
65
  if (field.kind === "button") continue;
65
- const name = `${path5}${field.label ?? key}`;
66
+ const name = `${path9}${field.label ?? key}`;
66
67
  if (field.kind === "folder") {
67
68
  const nested = invalidControl(field.definition.schema(), `${name}/`);
68
69
  if (nested) return nested;
@@ -101,14 +102,14 @@ var exists = (target) => stat(target).then(
101
102
  () => false
102
103
  );
103
104
  async function resolveToolFile(root, file) {
104
- const fail = (reason) => new Error(`[studio] tool file "${file}" ${reason}`);
105
- if (path.isAbsolute(file)) throw fail("must be relative to the project root");
106
- if (path.extname(file) !== ".json") throw fail("must be a .json file");
105
+ const fail2 = (reason) => new Error(`[studio] tool file "${file}" ${reason}`);
106
+ if (path.isAbsolute(file)) throw fail2("must be relative to the project root");
107
+ if (path.extname(file) !== ".json") throw fail2("must be a .json file");
107
108
  const absolute = path.resolve(root, file);
108
- if (!inside(root, absolute)) throw fail("escapes the project root");
109
+ if (!inside(root, absolute)) throw fail2("escapes the project root");
109
110
  let probe = absolute;
110
111
  while (!await exists(probe)) probe = path.dirname(probe);
111
- if (probe !== root && !inside(await realpath(root), await realpath(probe))) throw fail("escapes the project root");
112
+ if (probe !== root && !inside(await realpath(root), await realpath(probe))) throw fail2("escapes the project root");
112
113
  return absolute;
113
114
  }
114
115
  var hashOf = (content) => createHash("sha256").update(content).digest("hex").slice(0, 16);
@@ -150,12 +151,14 @@ async function load(tool2, file) {
150
151
  throw new Error(`[studio] ${path2.basename(file)} is not a valid "${tool2.id}" config: ${parsed.message}`);
151
152
  return { value: parsed.data, hash: disk.hash };
152
153
  }
153
- async function createRuntime({ root, tools }) {
154
- root = path2.resolve(root);
154
+ async function prepare(root, tools) {
155
155
  const entries = /* @__PURE__ */ new Map();
156
156
  const files = /* @__PURE__ */ new Set();
157
+ const ids = /* @__PURE__ */ new Set();
157
158
  for (const tool2 of tools) {
158
- if (entries.has(tool2.id)) throw new Error(`[studio] duplicate tool id "${tool2.id}"`);
159
+ if (ids.has(tool2.id)) throw new Error(`[studio] duplicate tool id "${tool2.id}"`);
160
+ ids.add(tool2.id);
161
+ if (!hasConfig(tool2)) continue;
159
162
  const file = await resolveToolFile(root, tool2.file);
160
163
  if (files.has(file)) throw new Error(`[studio] tool "${tool2.id}" shares its file with another tool`);
161
164
  files.add(file);
@@ -173,6 +176,12 @@ async function createRuntime({ root, tools }) {
173
176
  }
174
177
  });
175
178
  }
179
+ return { entries, ids };
180
+ }
181
+ async function createRuntime({ root, tools }) {
182
+ root = path2.resolve(root);
183
+ let { entries, ids } = await prepare(root, tools);
184
+ let changing = Promise.resolve();
176
185
  const listeners = /* @__PURE__ */ new Set();
177
186
  const commit = (entry, next, origin) => {
178
187
  const state = { ...entry.state, ...next };
@@ -190,7 +199,7 @@ async function createRuntime({ root, tools }) {
190
199
  };
191
200
  const handlers = {
192
201
  "tools.list": async () => success(
193
- [...entries.values()].map(({ tool: tool2 }) => ({
202
+ tools.map((tool2) => ({
194
203
  id: tool2.id,
195
204
  title: tool2.title ?? tool2.id,
196
205
  file: tool2.file,
@@ -236,7 +245,8 @@ async function createRuntime({ root, tools }) {
236
245
  };
237
246
  async function withEntry(id, task) {
238
247
  const entry = typeof id === "string" ? entries.get(id) : void 0;
239
- return entry ? task(entry) : error("unknown_tool", `no tool "${String(id)}"`);
248
+ if (entry) return task(entry);
249
+ return ids.has(id) ? error("no_config", `tool "${String(id)}" is preview-only and has no config`) : error("unknown_tool", `no tool "${String(id)}"`);
240
250
  }
241
251
  const reload = (entry) => serial(entry, async () => {
242
252
  const disk = await readDisk(entry.file);
@@ -258,27 +268,76 @@ async function createRuntime({ root, tools }) {
258
268
  });
259
269
  const timers = /* @__PURE__ */ new Map();
260
270
  const watchers = [];
261
- const byDirectory = Map.groupBy(entries.values(), (entry) => path2.dirname(entry.file));
262
- for (const [directory, group] of byDirectory) {
263
- try {
264
- const watcher = watch(directory, (_, filename) => {
265
- for (const entry of group) {
266
- if (filename && filename !== path2.basename(entry.file)) continue;
267
- clearTimeout(timers.get(entry));
268
- timers.set(
269
- entry,
270
- setTimeout(() => void reload(entry).catch((cause) => console.error("[studio] reload failed", cause)), 50)
271
+ const bindWatchers = () => {
272
+ for (const watcher of watchers.splice(0)) watcher.close();
273
+ for (const timer of timers.values()) clearTimeout(timer);
274
+ timers.clear();
275
+ const byDirectory = Map.groupBy(entries.values(), (entry) => path2.dirname(entry.file));
276
+ for (const [directory, group] of byDirectory) {
277
+ try {
278
+ const watcher = watch(directory, (_, filename) => {
279
+ for (const entry of group) {
280
+ if (filename && filename !== path2.basename(entry.file)) continue;
281
+ clearTimeout(timers.get(entry));
282
+ timers.set(
283
+ entry,
284
+ setTimeout(() => {
285
+ void changing.then(async () => {
286
+ if (entries.get(entry.tool.id) === entry) await reload(entry);
287
+ }).catch((cause) => console.error("[studio] reload failed", cause));
288
+ }, 50)
289
+ );
290
+ }
291
+ });
292
+ watchers.push(watcher);
293
+ } catch {
294
+ console.warn(`[studio] not watching ${path2.relative(root, directory)} (it does not exist yet)`);
295
+ }
296
+ }
297
+ };
298
+ bindWatchers();
299
+ const replaceTools = (nextTools) => {
300
+ const update = changing.then(async () => {
301
+ await Promise.all([...entries.values()].map((entry) => entry.queue));
302
+ const prepared = await prepare(root, nextTools);
303
+ for (const [id, old] of entries) {
304
+ const next = prepared.entries.get(id);
305
+ if ((!next || next.file !== old.file) && old.state.dirty)
306
+ throw new Error(`Save or discard "${id}" before removing it or moving its config.`);
307
+ if (!next || next.file !== old.file) continue;
308
+ const parsed = validateConfig(next.tool, old.state.draft.value);
309
+ if (!parsed.ok && old.state.dirty)
310
+ throw new Error(
311
+ `Draft for "${id}" is incompatible with the new controls; save/discard it first: ${parsed.message}`
271
312
  );
313
+ if (parsed.ok && old.state.dirty) {
314
+ next.state = {
315
+ ...old.state,
316
+ saved: next.state.saved,
317
+ conflict: old.state.conflict || next.state.saved.hash !== old.state.saved.hash,
318
+ draft: { value: parsed.data, revision: old.state.draft.revision + 1 }
319
+ };
320
+ } else {
321
+ next.state.draft.revision = old.state.draft.revision + 1;
272
322
  }
273
- });
274
- watchers.push(watcher);
275
- } catch {
276
- console.warn(`[studio] not watching ${path2.relative(root, directory)} (it does not exist yet)`);
277
- }
278
- }
323
+ }
324
+ entries = prepared.entries;
325
+ ids = prepared.ids;
326
+ tools = nextTools;
327
+ bindWatchers();
328
+ for (const entry of entries.values()) commit(entry, {});
329
+ });
330
+ changing = update.catch(() => {
331
+ });
332
+ return update;
333
+ };
279
334
  return {
280
335
  root,
281
- run: (op, params, origin) => handlers[op](params, origin),
336
+ run: async (op, params, origin) => {
337
+ await changing;
338
+ return handlers[op](params, origin);
339
+ },
340
+ replaceTools,
282
341
  subscribe(listener) {
283
342
  listeners.add(listener);
284
343
  return () => listeners.delete(listener);
@@ -446,7 +505,7 @@ function createMcpServer(runtime, previews) {
446
505
  server.registerTool(
447
506
  "studio_list_tools",
448
507
  {
449
- description: "List registered Studio tools with their IDs, titles, config files, and preview routes.",
508
+ description: "List registered Studio tools with their IDs, titles, config files, and preview routes. Tools without a file are preview-only: they have no config to read, edit, or capture.",
450
509
  inputSchema: z.object({}),
451
510
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
452
511
  },
@@ -601,19 +660,273 @@ function serveBridge(runtime, { port, host = "127.0.0.1", origins, appUrl }) {
601
660
  });
602
661
  }
603
662
 
604
- // src/runtime/load.ts
663
+ // src/discovery/generate.ts
664
+ import { existsSync as existsSync2, readFileSync, readdirSync, realpathSync } from "fs";
665
+ import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
666
+ import path4 from "path";
667
+ import ts from "typescript";
668
+
669
+ // src/discovery/settings.ts
605
670
  import { existsSync } from "fs";
606
671
  import path3 from "path";
607
672
  import { createJiti } from "jiti";
608
673
  var CANDIDATES = ["studio.config.ts", "studio.config.mts", "studio.config.js", "studio.config.mjs"];
609
- async function loadConfig(cwd, explicit) {
610
- const file = explicit ? path3.resolve(cwd, explicit) : CANDIDATES.map((name) => path3.join(cwd, name)).find((candidate) => existsSync(candidate));
611
- if (!file || !existsSync(file)) throw new Error(`[studio] no ${explicit ?? "studio.config.ts"} found in ${cwd}`);
612
- const jiti = createJiti(import.meta.url, { moduleCache: false });
613
- const config = await jiti.import(file, { default: true });
614
- if (!Array.isArray(config?.tools))
615
- throw new Error(`[studio] ${path3.basename(file)} must default-export defineConfig({ tools })`);
616
- return { config, root: path3.dirname(file) };
674
+ function importer(root) {
675
+ return createJiti(import.meta.url, {
676
+ moduleCache: false,
677
+ fsCache: false,
678
+ tryNative: false,
679
+ tsconfigPaths: existsSync(path3.join(root, "tsconfig.json")) ? path3.join(root, "tsconfig.json") : false
680
+ });
681
+ }
682
+ async function readSettings(cwd, explicit) {
683
+ const file = explicit ? path3.resolve(cwd, explicit) : CANDIDATES.map((name) => path3.join(cwd, name)).find(existsSync);
684
+ if (explicit && !existsSync(file)) throw new Error(`[studio] no ${explicit} found in ${cwd}`);
685
+ const root = file ? path3.dirname(file) : path3.resolve(cwd);
686
+ const config = file ? await importer(root).import(file, { default: true }) : {};
687
+ if (!config || typeof config !== "object" || config.tools !== void 0 && !Array.isArray(config.tools))
688
+ throw new Error("[studio] config must default-export defineConfig({ ... })");
689
+ if (config.tools !== void 0 && config.toolsDir !== void 0)
690
+ throw new Error("[studio] Use either tools (manual registration) or toolsDir (discovery), not both.");
691
+ return { config, root, file };
692
+ }
693
+
694
+ // src/discovery/generate.ts
695
+ var slash = (value) => value.split(path4.sep).join("/");
696
+ var relativeImport = (from, to) => {
697
+ const relative = slash(path4.relative(path4.dirname(from), to)).replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
698
+ return /^\.\.?\//.test(relative) ? relative : `./${relative}`;
699
+ };
700
+ var inside2 = (root, file) => {
701
+ const relative = path4.relative(root, file);
702
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${path4.sep}`) && !path4.isAbsolute(relative);
703
+ };
704
+ var fail = (file, message) => {
705
+ throw new Error(`[studio] ${file}: ${message}`);
706
+ };
707
+ var HEADER = "// Generated by Studio. Do not edit.\n";
708
+ var DiscoveryInputError = class extends Error {
709
+ constructor(cause, inputs) {
710
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
711
+ this.inputs = inputs;
712
+ }
713
+ inputs;
714
+ };
715
+ function shape(value) {
716
+ if (value === null) return "null";
717
+ if (Array.isArray(value)) return `[${[...new Set(value.map(shape))].sort().join("|")}]`;
718
+ if (typeof value === "object")
719
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${shape(item)}`).join(",")}}`;
720
+ return typeof value;
721
+ }
722
+ function declaration(file) {
723
+ const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
724
+ const exported = source.statements.find(ts.isExportAssignment);
725
+ const call = exported?.expression;
726
+ if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression) || call.expression.text !== "defineTool")
727
+ return fail(file, "default-export defineTool({ id, controls }) directly so discovery can inspect it.");
728
+ const object = call.arguments[0];
729
+ if (!object || !ts.isObjectLiteralExpression(object)) return fail(file, "defineTool requires an object literal.");
730
+ const properties = /* @__PURE__ */ new Map();
731
+ for (const member of object.properties) {
732
+ if (ts.isSpreadAssignment(member) || !member.name || ts.isComputedPropertyName(member.name))
733
+ return fail(file, "spread/computed tool options are not supported; put dynamic logic inside controls.");
734
+ const key = member.name.getText(source).replace(/^['"]|['"]$/g, "");
735
+ if (properties.has(key)) return fail(file, `duplicate option ${key}`);
736
+ properties.set(key, member);
737
+ }
738
+ const literal = (key) => {
739
+ const member = properties.get(key);
740
+ if (!member) return void 0;
741
+ if (!ts.isPropertyAssignment(member) || !ts.isStringLiteral(member.initializer))
742
+ return fail(file, `${key} must be a static string literal.`);
743
+ return member.initializer.text;
744
+ };
745
+ const id = literal("id");
746
+ if (!id || !/^[a-z0-9][a-z0-9-]*$/.test(id)) return fail(file, "id must be an explicit kebab-case string.");
747
+ if (properties.has("file") || properties.has("initial"))
748
+ return fail(file, "discovered tools use config instead of file/initial; omit config for adjacent config.json.");
749
+ const configurable = properties.has("controls");
750
+ const config = literal("config");
751
+ if (config !== void 0 && !configurable) return fail(file, "config requires controls.");
752
+ return { id, config, configurable, preview: literal("preview"), source };
753
+ }
754
+ function pageRoute(app, file, explicit) {
755
+ if (explicit) return explicit;
756
+ const directory = path4.dirname(file);
757
+ if (!app || !inside2(app, directory)) return fail(file, "tools outside the app tree require an explicit preview URL.");
758
+ if (!["tsx", "ts", "jsx", "js"].some((ext) => existsSync2(path4.join(directory, `page.${ext}`))))
759
+ return fail(file, "add an adjacent page.tsx or specify preview explicitly.");
760
+ const segments = slash(path4.relative(app, directory)).split("/").filter(Boolean);
761
+ if (segments.some((part) => part.includes("[") || part.startsWith("@") || /^\(\./.test(part) || part.startsWith("_")))
762
+ return fail(file, "dynamic, parallel, intercepted, and private routes require an explicit preview URL.");
763
+ return "/" + segments.filter((part) => !/^\(.+\)$/.test(part)).join("/");
764
+ }
765
+ function walk(directory) {
766
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
767
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.isSymbolicLink()) return [];
768
+ const file = path4.join(directory, entry.name);
769
+ return entry.isDirectory() ? walk(file) : entry.name === "studio.tool.ts" ? [file] : [];
770
+ }).sort();
771
+ }
772
+ function compilerConfig(root, dependencies) {
773
+ const configFile = path4.join(root, "tsconfig.json");
774
+ if (!existsSync2(configFile)) return fail(root, "discovery requires tsconfig.json.");
775
+ const host = {
776
+ ...ts.sys,
777
+ readDirectory: () => [],
778
+ readFile(file) {
779
+ dependencies.add(path4.resolve(file));
780
+ return ts.sys.readFile(file);
781
+ },
782
+ onUnRecoverableConfigFileDiagnostic(error2) {
783
+ throw new Error(ts.flattenDiagnosticMessageText(error2.messageText, "\n"));
784
+ }
785
+ };
786
+ const parsed = ts.getParsedCommandLineOfConfigFile(configFile, { resolveJsonModule: true }, host);
787
+ if (!parsed) return fail(configFile, "could not read TypeScript settings.");
788
+ return parsed.options;
789
+ }
790
+ async function writeChanged(file, content) {
791
+ if (await readFile2(file, "utf8").catch(() => "") === content) return;
792
+ await writeAtomic(file, content);
793
+ }
794
+ async function generate(cwd, explicit, publish = true, previous) {
795
+ const { root, config, file: settings } = await readSettings(cwd, explicit);
796
+ if (config.tools !== void 0) return void 0;
797
+ const app = ["app", "src/app"].map((dir) => path4.join(root, dir)).find(existsSync2);
798
+ const toolsDir = config.toolsDir === void 0 ? app : path4.resolve(root, config.toolsDir);
799
+ if (!toolsDir || !existsSync2(toolsDir))
800
+ return fail(root, "no tools directory found; create app/ or configure toolsDir.");
801
+ if (!inside2(realpathSync(root), realpathSync(toolsDir)))
802
+ return fail(toolsDir, "toolsDir must stay inside the project.");
803
+ const sources = new Set(walk(toolsDir));
804
+ const dependencies = /* @__PURE__ */ new Set();
805
+ if (settings) dependencies.add(settings);
806
+ const options = compilerConfig(root, dependencies);
807
+ const output = path4.join(root, ".studio");
808
+ const registry = path4.join(output, "registry.generated.ts");
809
+ const types = path4.join(output, "types.generated.ts");
810
+ const imports = [
811
+ HEADER,
812
+ '/// <reference path="./types.generated.ts" />',
813
+ "import type { StudioTool, DiscoveredToolConfigs } from '@joycostudio/studio'"
814
+ ];
815
+ const typeImports = [HEADER, "import '@joycostudio/studio'"];
816
+ const declarations = [];
817
+ const definitions = [];
818
+ const mappings = [];
819
+ const ids = /* @__PURE__ */ new Set();
820
+ const destinations = /* @__PURE__ */ new Set();
821
+ const seeds = /* @__PURE__ */ new Map();
822
+ const trackImports = (file) => {
823
+ if (dependencies.has(file)) return;
824
+ dependencies.add(file);
825
+ if (!/\.[cm]?[jt]sx?$/.test(file)) return;
826
+ for (const imported of ts.preProcessFile(readFileSync(file, "utf8")).importedFiles) {
827
+ const resolved = ts.resolveModuleName(imported.fileName, file, options, ts.sys).resolvedModule?.resolvedFileName;
828
+ if (resolved && inside2(root, resolved) && !resolved.includes(`${path4.sep}node_modules${path4.sep}`) && !inside2(output, resolved))
829
+ trackImports(resolved);
830
+ }
831
+ };
832
+ for (const [index, file] of [...sources].entries()) {
833
+ const tool2 = declaration(file);
834
+ if (ids.has(tool2.id)) return fail(file, `duplicate tool id "${tool2.id}".`);
835
+ ids.add(tool2.id);
836
+ trackImports(file);
837
+ const preview = pageRoute(app, file, tool2.preview);
838
+ const title = (preview.split("/").filter(Boolean).at(-1) || tool2.id).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
839
+ imports.push(`import tool${index} from ${JSON.stringify(relativeImport(registry, file))}`);
840
+ let values = "";
841
+ const resolvedTool = { source: file, title, preview };
842
+ if (tool2.configurable) {
843
+ const inputs = /* @__PURE__ */ new Set();
844
+ try {
845
+ const specifier = tool2.config ?? "./config.json";
846
+ const resolutionHost = {
847
+ ...ts.sys,
848
+ fileExists(candidate) {
849
+ if (inside2(root, candidate) && !candidate.includes(`${path4.sep}node_modules${path4.sep}`))
850
+ inputs.add(candidate);
851
+ return ts.sys.fileExists(candidate);
852
+ },
853
+ directoryExists(directory) {
854
+ const exists2 = ts.sys.directoryExists(directory);
855
+ if (!exists2 && inside2(root, directory) && !directory.includes(`${path4.sep}node_modules${path4.sep}`))
856
+ inputs.add(directory);
857
+ return exists2;
858
+ }
859
+ };
860
+ const resolved = ts.resolveModuleName(specifier, file, options, resolutionHost).resolvedModule?.resolvedFileName;
861
+ if (!resolved || !resolved.endsWith(".json"))
862
+ return fail(file, `cannot resolve JSON config "${specifier}" through tsconfig.json.`);
863
+ const target = await resolveToolFile(root, slash(path4.relative(root, resolved)));
864
+ inputs.add(target);
865
+ const real = realpathSync(target);
866
+ if (destinations.has(real)) return fail(file, "another tool already writes this config file.");
867
+ destinations.add(real);
868
+ const value = JSON.parse(readFileSync(target, "utf8"));
869
+ if (!value || typeof value !== "object" || Array.isArray(value))
870
+ return fail(file, "config must be a JSON object.");
871
+ resolvedTool.file = slash(path4.relative(root, target));
872
+ resolvedTool.initial = value;
873
+ dependencies.add(target);
874
+ const key = `${tool2.id}:${real}`;
875
+ const prior = previous?.seeds.get(key);
876
+ const seed = prior && shape(prior) === shape(value) ? prior : value;
877
+ seeds.set(key, seed);
878
+ typeImports.push(`import type config${index} from ${JSON.stringify(relativeImport(types, target))}`);
879
+ mappings.push(` ${JSON.stringify(tool2.id)}: typeof config${index}`);
880
+ values = `, file: ${JSON.stringify(slash(path4.relative(root, target)))}, initial: JSON.parse(${JSON.stringify(JSON.stringify(seed))}) as DiscoveredToolConfigs[${JSON.stringify(tool2.id)}]`;
881
+ } catch (cause) {
882
+ throw new DiscoveryInputError(cause, /* @__PURE__ */ new Set([...dependencies, ...inputs]));
883
+ }
884
+ }
885
+ declarations.push(resolvedTool);
886
+ definitions.push(
887
+ ` { title: ${JSON.stringify(title)}, ...tool${index}, preview: ${JSON.stringify(preview)}${values} }`
888
+ );
889
+ }
890
+ const write = async () => {
891
+ await mkdir2(output, { recursive: true });
892
+ await writeChanged(
893
+ types,
894
+ `${typeImports.join("\n")}
895
+
896
+ declare module '@joycostudio/studio' {
897
+ interface DiscoveredToolConfigs {
898
+ ${mappings.join("\n")}
899
+ }
900
+ }
901
+ `
902
+ );
903
+ await writeChanged(
904
+ registry,
905
+ `${imports.join("\n")}
906
+
907
+ export const tools = [
908
+ ${definitions.join(",\n")}
909
+ ] satisfies readonly StudioTool[]
910
+ `
911
+ );
912
+ };
913
+ if (publish) await write();
914
+ return { root, toolsDir, sources, dependencies, registry, seeds, declarations, write };
915
+ }
916
+
917
+ // src/runtime/load.ts
918
+ async function loadConfig(cwd, explicit, publish = true, previous) {
919
+ const { config, root } = await readSettings(cwd, explicit);
920
+ const discovery = await generate(cwd, explicit, false, previous);
921
+ const jiti = importer(root);
922
+ const tools = config.tools ?? await Promise.all(
923
+ discovery.declarations.map(async ({ source, title, preview, file, initial }) => {
924
+ const tool2 = await jiti.import(source, { default: true });
925
+ return { title, ...tool2, preview, ...file ? { file, initial } : {} };
926
+ })
927
+ );
928
+ if (publish) await discovery?.write();
929
+ return { config: { ...config, tools }, root, discovery };
617
930
  }
618
931
 
619
932
  // src/runtime/ports.ts
@@ -647,9 +960,483 @@ async function freeAppPort(hostname, port) {
647
960
  throw new Error(`[studio] ports ${port}-${port + ATTEMPTS - 1} on ${hostname} are all in use`);
648
961
  }
649
962
 
963
+ // src/init.ts
964
+ import { spawnSync } from "child_process";
965
+ import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, rmdirSync, rmSync, writeFileSync } from "fs";
966
+ import { createRequire } from "module";
967
+ import path6 from "path";
968
+ import { parseArgs } from "util";
969
+
970
+ // package.json
971
+ var package_default = {
972
+ name: "@joycostudio/studio",
973
+ version: "0.1.4",
974
+ description: "Plug project-specific tools into a /studio route, a floating debug pane, and MCP agents.",
975
+ type: "module",
976
+ license: "ISC",
977
+ author: "joyco.studio",
978
+ repository: {
979
+ type: "git",
980
+ url: "git+https://github.com/joyco-studio/studio.git",
981
+ directory: "packages/studio"
982
+ },
983
+ publishConfig: {
984
+ registry: "https://registry.npmjs.org",
985
+ access: "public"
986
+ },
987
+ files: [
988
+ "dist"
989
+ ],
990
+ bin: {
991
+ studio: "./dist/cli.js"
992
+ },
993
+ exports: {
994
+ ".": {
995
+ types: "./dist/index.d.ts",
996
+ default: "./dist/index.js"
997
+ },
998
+ "./client": {
999
+ types: "./dist/client.d.ts",
1000
+ default: "./dist/client.js"
1001
+ },
1002
+ "./react": {
1003
+ types: "./dist/react.d.ts",
1004
+ default: "./dist/react.js"
1005
+ },
1006
+ "./runtime": {
1007
+ types: "./dist/runtime.d.ts",
1008
+ default: "./dist/runtime.js"
1009
+ },
1010
+ "./styles.css": "./dist/styles.css",
1011
+ "./command-menu.css": "./dist/command-menu.css",
1012
+ "./next": {
1013
+ types: "./dist/next.d.ts",
1014
+ default: "./dist/next.js"
1015
+ },
1016
+ "./controls": {
1017
+ types: "./dist/controls.d.ts",
1018
+ default: "./dist/controls.js"
1019
+ },
1020
+ "./next/config": {
1021
+ types: "./dist/next-config.d.ts",
1022
+ default: "./dist/next-config.js"
1023
+ }
1024
+ },
1025
+ scripts: {
1026
+ build: "pnpm build:shadow-css && tsup && tailwindcss -i src/react/styles.css -o dist/styles.css --minify && cp src/react/command-menu-export.css dist/command-menu.css",
1027
+ dev: 'pnpm build:shadow-css && pnpm run --parallel "/^dev:/"',
1028
+ test: "vitest run",
1029
+ "test:browser": "STUDIO_TEST_BROWSER=1 vitest run test/browser-sync.test.ts",
1030
+ "test:capture": "STUDIO_TEST_CAPTURE=1 vitest run test/capture.test.ts",
1031
+ typecheck: "tsc --noEmit",
1032
+ "dev:js": "tsup --watch",
1033
+ "dev:css": "tailwindcss -i src/react/styles.css -o dist/styles.css --watch=always",
1034
+ "build:shadow-css": "tailwindcss -i src/react/shadow.css -o src/react/generated/studio.css --minify",
1035
+ "dev:shadow-css": "tailwindcss -i src/react/shadow.css -o src/react/generated/studio.css --minify --watch=always"
1036
+ },
1037
+ dependencies: {
1038
+ "@base-ui/react": "^1.9.0",
1039
+ "@modelcontextprotocol/node": "^2.1.1",
1040
+ "@modelcontextprotocol/server": "^2.3.1",
1041
+ "class-variance-authority": "^0.7.1",
1042
+ clsx: "^2.1.1",
1043
+ cmdk: "^1.1.1",
1044
+ cn: "^0.4.0",
1045
+ jiti: "^2.7.0",
1046
+ lucide: "^1.55.0",
1047
+ "lucide-react": "^1.55.0",
1048
+ playwright: "^1.64.0",
1049
+ "radix-ui": "^1.8.0",
1050
+ "tailwind-merge": "^3.7.0",
1051
+ ws: "^8.22.0",
1052
+ zod: "^4.6.6",
1053
+ typescript: "^5.9.3"
1054
+ },
1055
+ peerDependencies: {
1056
+ "@joycostudio/xyz": "^1.4.0",
1057
+ react: ">=18",
1058
+ "react-dom": ">=18",
1059
+ tweakpane: "^4.0.5"
1060
+ },
1061
+ peerDependenciesMeta: {
1062
+ react: {
1063
+ optional: true
1064
+ },
1065
+ tweakpane: {
1066
+ optional: true
1067
+ },
1068
+ "react-dom": {
1069
+ optional: true
1070
+ }
1071
+ },
1072
+ devDependencies: {
1073
+ "@joycostudio/xyz": "^1.4.0",
1074
+ "@modelcontextprotocol/client": "^2.3.1",
1075
+ "@tailwindcss/cli": "^4.3.3",
1076
+ "@tweakpane/core": "^2.0.5",
1077
+ "@types/node": "^24.19.2",
1078
+ "@types/react": "^19.3.0",
1079
+ "@types/react-dom": "^19.3.0",
1080
+ "@types/ws": "^8.18.2",
1081
+ react: "^19.3.0",
1082
+ "react-dom": "^19.3.0",
1083
+ tailwindcss: "^4.3.3",
1084
+ tsup: "^8.5.1",
1085
+ "tw-animate-css": "^1.4.0",
1086
+ tweakpane: "^4.0.5",
1087
+ vitest: "^5.0.3"
1088
+ },
1089
+ sideEffects: [
1090
+ "*.css"
1091
+ ]
1092
+ };
1093
+
1094
+ // src/discovery/scaffold.ts
1095
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
1096
+ import path5 from "path";
1097
+ import ts2 from "typescript";
1098
+ function nextConfigEdit(root) {
1099
+ const name = ["next.config.ts", "next.config.mjs", "next.config.js"].find((file2) => existsSync3(path5.join(root, file2)));
1100
+ if (!name)
1101
+ return {
1102
+ file: path5.join(root, "next.config.ts"),
1103
+ content: "import { withStudio } from '@joycostudio/studio/next/config'\n\nexport default withStudio({})\n"
1104
+ };
1105
+ const file = path5.join(root, name);
1106
+ const text = readFileSync2(file, "utf8");
1107
+ const source = ts2.createSourceFile(file, text, ts2.ScriptTarget.Latest, true);
1108
+ const wrapped = (node) => ts2.isCallExpression(node) && ts2.isIdentifier(node.expression) && /^withStudio\w*$/.test(node.expression.text) || (ts2.forEachChild(node, wrapped) ?? false);
1109
+ if (text.includes("@joycostudio/studio/next/config") && wrapped(source)) return { file, content: text };
1110
+ const exported = source.statements.find(ts2.isExportAssignment);
1111
+ if (exported && !exported.isExportEquals) {
1112
+ const expression = exported.expression;
1113
+ let local = "withStudioSetup";
1114
+ while (new RegExp(`\\b${local}\\b`).test(text)) local += "_";
1115
+ return {
1116
+ file,
1117
+ content: `import { withStudio as ${local} } from '@joycostudio/studio/next/config'
1118
+ ` + text.slice(0, expression.getStart(source)) + `${local}(${expression.getText(source)})` + text.slice(expression.end)
1119
+ };
1120
+ }
1121
+ const assignment = source.statements.flatMap(
1122
+ (statement) => ts2.isExpressionStatement(statement) && ts2.isBinaryExpression(statement.expression) && statement.expression.left.getText(source) === "module.exports" && statement.expression.operatorToken.kind === ts2.SyntaxKind.EqualsToken ? [statement.expression.right] : []
1123
+ );
1124
+ if (assignment.length === 1) {
1125
+ const expression = assignment[0];
1126
+ return {
1127
+ file,
1128
+ content: text.slice(0, expression.getStart(source)) + `async (...args) => { const { withStudio } = await import('@joycostudio/studio/next/config'); return withStudio(${expression.getText(source)})(...args) }` + text.slice(expression.end)
1129
+ };
1130
+ }
1131
+ throw new Error(
1132
+ `[studio] Cannot wrap ${name} automatically. Export a config object/function or wrap it with withStudio manually.`
1133
+ );
1134
+ }
1135
+
1136
+ // src/init.ts
1137
+ async function init(argv) {
1138
+ const { positionals, values } = parseArgs({
1139
+ args: argv,
1140
+ allowPositionals: true,
1141
+ options: { "no-install": { type: "boolean" } }
1142
+ });
1143
+ if (positionals.length > 1) throw new Error("Usage: studio init [directory] [--no-install]");
1144
+ const root = path6.resolve(positionals[0] ?? ".");
1145
+ const manifest = path6.join(root, "package.json");
1146
+ const pkg = JSON.parse(readFileSync3(manifest, "utf8"));
1147
+ if (!pkg.dependencies?.next && !pkg.devDependencies?.next)
1148
+ throw new Error("[studio] init supports existing Next.js App Router projects.");
1149
+ if (!existsSync4(path6.join(root, "tsconfig.json")))
1150
+ throw new Error("[studio] init currently requires a TypeScript project (tsconfig.json).");
1151
+ const app = ["app", "src/app"].find((dir) => existsSync4(path6.join(root, dir)));
1152
+ if (!app) throw new Error("[studio] No app/ or src/app/ directory found.");
1153
+ const src = app.startsWith("src/") ? "src/" : "";
1154
+ const relative = (from, to) => {
1155
+ const result = path6.relative(path6.dirname(from), to).split(path6.sep).join("/");
1156
+ return /^\.\.?\//.test(result) ? result : `./${result}`;
1157
+ };
1158
+ const client = `${src}studio.ts`;
1159
+ const tool2 = `${app}/studio-preview/studio.tool.ts`;
1160
+ const data = `${app}/studio-preview/config.json`;
1161
+ const page = `${app}/studio/page.tsx`;
1162
+ const preview = `${app}/studio-preview/page.tsx`;
1163
+ const files = {
1164
+ [client]: `import { createStudio } from '@joycostudio/studio/next'
1165
+ import { tools } from '${relative(client, ".studio/registry.generated")}'
1166
+
1167
+ export const studio = createStudio({ tools })
1168
+ `,
1169
+ [data]: '{ "scale": 1 }\n',
1170
+ [tool2]: `import { defineTool } from '@joycostudio/studio'
1171
+ import { property, slider } from '@joycostudio/studio/controls'
1172
+
1173
+ export default defineTool({
1174
+ id: 'studio-example',
1175
+ title: 'Example',
1176
+ controls: (config) => ({ scale: slider(property(config, 'scale'), { min: 0.5, max: 2 }) }),
1177
+ })
1178
+ `,
1179
+ [page]: `'use client'
1180
+
1181
+ import '@joycostudio/studio/styles.css'
1182
+ import { Studio } from '@joycostudio/studio/react'
1183
+ import { studio } from '${relative(page, client.slice(0, -3))}'
1184
+
1185
+ export default function StudioPage() {
1186
+ return <Studio studio={studio} />
1187
+ }
1188
+ `,
1189
+ [preview]: `'use client'
1190
+
1191
+ import { useToolConfig } from '@joycostudio/studio/react'
1192
+ import { studio } from '${relative(preview, client.slice(0, -3))}'
1193
+ import example from './studio.tool'
1194
+
1195
+ export default function StudioPreview() {
1196
+ const config = useToolConfig(studio.tool(example))
1197
+ return (
1198
+ <main style={{ display: 'grid', placeItems: 'center', minHeight: '100vh' }}>
1199
+ <div style={{ width: 120, height: 120, background: '#ff5930', borderRadius: 24, transform: \`scale(\${config.scale})\` }} />
1200
+ </main>
1201
+ )
1202
+ }
1203
+ `
1204
+ };
1205
+ const checkRoute = (directory, segment, allowed) => {
1206
+ for (const entry of readdirSync2(path6.join(root, directory), { withFileTypes: true })) {
1207
+ const file = `${directory}/${entry.name}`;
1208
+ if (entry.isDirectory()) {
1209
+ if (/^\([^.)][^/]*\)$/.test(entry.name)) checkRoute(file, segment, allowed);
1210
+ else if (entry.name === segment) checkRoute(file, void 0, allowed);
1211
+ } else if (!segment && /^(page|route)\.[jt]sx?$/.test(entry.name) && file !== allowed) {
1212
+ throw new Error(`[studio] Route already exists: ${file}`);
1213
+ }
1214
+ }
1215
+ };
1216
+ checkRoute(app, "studio", page);
1217
+ checkRoute(app, "studio-preview", preview);
1218
+ for (const [name, content] of Object.entries(files)) {
1219
+ const target = path6.join(root, name);
1220
+ if (existsSync4(target) && readFileSync3(target, "utf8") !== content)
1221
+ throw new Error(`[studio] Refusing to overwrite ${name}. Use the manual setup guide for existing integrations.`);
1222
+ }
1223
+ for (const ext of ["ts", "mts", "js", "mjs"]) {
1224
+ if (existsSync4(path6.join(root, `studio.config.${ext}`)))
1225
+ throw new Error(`[studio] Found studio.config.${ext}; use the manual setup guide for existing integrations.`);
1226
+ }
1227
+ const nextEdit = nextConfigEdit(root);
1228
+ const dev = pkg.scripts?.dev;
1229
+ if (!dev || /[;&|\n]/.test(dev) || !/^studio dev(?:\s|$)/.test(dev) && !/^next dev(?:\s|$)/.test(dev))
1230
+ throw new Error(
1231
+ "[studio] Expected a next dev script. Configure studio dev -- <command> manually for custom scripts."
1232
+ );
1233
+ let manager = pkg.packageManager?.split("@")[0];
1234
+ if (!manager) {
1235
+ let dir = root;
1236
+ while (true) {
1237
+ manager = [
1238
+ ["pnpm-lock.yaml", "pnpm"],
1239
+ ["yarn.lock", "yarn"],
1240
+ ["bun.lock", "bun"],
1241
+ ["bun.lockb", "bun"],
1242
+ ["package-lock.json", "npm"]
1243
+ ].find(([lock]) => existsSync4(path6.join(dir, lock)))?.[1];
1244
+ if (manager || path6.dirname(dir) === dir) break;
1245
+ dir = path6.dirname(dir);
1246
+ }
1247
+ }
1248
+ manager ??= "npm";
1249
+ if (!["npm", "pnpm", "yarn", "bun"].includes(manager))
1250
+ throw new Error(`[studio] Unsupported package manager: ${manager}`);
1251
+ if (pkg.dependencies?.["@joycostudio/studio"] || pkg.devDependencies?.["@joycostudio/studio"]) {
1252
+ try {
1253
+ const require2 = createRequire(manifest);
1254
+ for (const entry of ["next", "next/config", "controls", "react"]) require2.resolve(`@joycostudio/studio/${entry}`);
1255
+ } catch {
1256
+ throw new Error(
1257
+ "[studio] The declared Studio dependency is missing or lacks the required exports. Install the same version (or preview) as this initializer, then rerun init. No files were changed."
1258
+ );
1259
+ }
1260
+ }
1261
+ const dependencies = {
1262
+ "@joycostudio/studio": `^${package_default.version}`,
1263
+ "@joycostudio/xyz": package_default.peerDependencies["@joycostudio/xyz"],
1264
+ tweakpane: package_default.peerDependencies.tweakpane
1265
+ };
1266
+ pkg.dependencies ??= {};
1267
+ for (const [name, version] of Object.entries(dependencies)) {
1268
+ if (!pkg.dependencies[name] && !pkg.devDependencies?.[name]) pkg.dependencies[name] = version;
1269
+ }
1270
+ if (!/^studio dev(?:\s|$)/.test(dev)) pkg.scripts.dev = `studio dev -- ${dev}`;
1271
+ const typecheck = pkg.scripts.typecheck ?? "tsc --noEmit";
1272
+ if (!typecheck.startsWith("studio generate")) pkg.scripts.typecheck = `studio generate && ${typecheck}`;
1273
+ const ignoreFile = path6.join(root, ".gitignore");
1274
+ const targets = [
1275
+ ...Object.keys(files).map((name) => path6.join(root, name)),
1276
+ nextEdit.file,
1277
+ ignoreFile,
1278
+ manifest,
1279
+ path6.join(root, ".studio/types.generated.ts"),
1280
+ path6.join(root, ".studio/registry.generated.ts")
1281
+ ];
1282
+ const originals = new Map(targets.map((file) => [file, existsSync4(file) ? readFileSync3(file) : void 0]));
1283
+ const createdDirectories = /* @__PURE__ */ new Set();
1284
+ for (const file of targets) {
1285
+ for (let dir = path6.dirname(file); !existsSync4(dir); dir = path6.dirname(dir)) createdDirectories.add(dir);
1286
+ }
1287
+ try {
1288
+ for (const [name, content] of Object.entries(files)) {
1289
+ const target = path6.join(root, name);
1290
+ if (existsSync4(target)) continue;
1291
+ mkdirSync(path6.dirname(target), { recursive: true });
1292
+ writeFileSync(target, content, { flag: "wx" });
1293
+ }
1294
+ writeFileSync(nextEdit.file, nextEdit.content);
1295
+ const ignore = originals.get(ignoreFile)?.toString() ?? "";
1296
+ if (!ignore.split(/\r?\n/).some((line) => [".studio", ".studio/", "/.studio/"].includes(line)))
1297
+ writeFileSync(ignoreFile, `${ignore}${ignore.endsWith("\n") || !ignore ? "" : "\n"}/.studio/
1298
+ `);
1299
+ await generate(root);
1300
+ writeFileSync(manifest, `${JSON.stringify(pkg, null, 2)}
1301
+ `);
1302
+ } catch (cause) {
1303
+ for (const [file, original] of originals) {
1304
+ if (original === void 0) rmSync(file, { force: true });
1305
+ else if (!existsSync4(file) || !readFileSync3(file).equals(original)) writeFileSync(file, original);
1306
+ }
1307
+ for (const dir of [...createdDirectories].sort((a, b) => b.length - a.length)) {
1308
+ try {
1309
+ rmdirSync(dir);
1310
+ } catch (error2) {
1311
+ if (!["ENOENT", "ENOTEMPTY"].includes(error2.code ?? "")) throw error2;
1312
+ }
1313
+ }
1314
+ throw cause;
1315
+ }
1316
+ if (!values["no-install"]) {
1317
+ const result = spawnSync(manager, ["install"], { cwd: root, stdio: "inherit", shell: process.platform === "win32" });
1318
+ if (result.error || result.status !== 0)
1319
+ throw new Error(`[studio] Files created, but installation failed. Run ${manager} install in ${root} to retry.`);
1320
+ }
1321
+ console.log(`[studio] Ready. ${values["no-install"] ? `Run ${manager} install, then ` : "Run "}${manager} run dev and open /studio.
1322
+ The example at /studio-preview reacts to edits; Save writes ${data}.
1323
+ To connect your app, read studio.tool(yourTool).config or useToolConfig(studio.tool(yourTool)).
1324
+ Optional: mount <StudioDebug studio={studio} /> or <StudioCommandMenu studio={studio} /> from @joycostudio/studio/react.
1325
+ For MCP, use the endpoint printed by studio dev. Preview capture additionally requires appUrl and studio install-browser.`);
1326
+ }
1327
+
1328
+ // src/discovery/watch.ts
1329
+ import { existsSync as existsSync5, statSync, watch as watch2 } from "fs";
1330
+ import path7 from "path";
1331
+ function watchDiscovery(initial, refresh) {
1332
+ let current = initial;
1333
+ let retryInputs = /* @__PURE__ */ new Set();
1334
+ let watchers = [];
1335
+ let timer;
1336
+ let running = false;
1337
+ let pending = false;
1338
+ let closed = false;
1339
+ const schedule = () => {
1340
+ if (closed) return;
1341
+ pending = true;
1342
+ clearTimeout(timer);
1343
+ timer = setTimeout(() => void run(), 80);
1344
+ };
1345
+ const run = async () => {
1346
+ if (running || closed) return;
1347
+ pending = false;
1348
+ running = true;
1349
+ try {
1350
+ const next = await refresh();
1351
+ if (next && !closed) {
1352
+ current = next;
1353
+ retryInputs.clear();
1354
+ bind();
1355
+ }
1356
+ } catch (cause) {
1357
+ if (cause instanceof DiscoveryInputError && !closed) {
1358
+ retryInputs = cause.inputs;
1359
+ bind();
1360
+ }
1361
+ console.error("[studio] discovery update rejected:", cause instanceof Error ? cause.message : cause);
1362
+ } finally {
1363
+ running = false;
1364
+ if (pending) schedule();
1365
+ }
1366
+ };
1367
+ const bind = () => {
1368
+ for (const watcher of watchers) watcher.close();
1369
+ watchers = [];
1370
+ const pages = new Map(
1371
+ [...current.sources].flatMap(
1372
+ (file) => ["tsx", "ts", "jsx", "js"].map((ext) => {
1373
+ const page = path7.join(path7.dirname(file), `page.${ext}`);
1374
+ return [page, existsSync5(page)];
1375
+ })
1376
+ )
1377
+ );
1378
+ const add = (directory, recursive, listener) => {
1379
+ if (!existsSync5(directory)) return;
1380
+ const watcher = watch2(directory, { recursive }, listener);
1381
+ watcher.on("error", (cause) => console.error("[studio] discovery watcher:", cause.message));
1382
+ watcher.unref();
1383
+ watchers.push(watcher);
1384
+ };
1385
+ add(current.toolsDir, true, (event, filename) => {
1386
+ if (!filename) return schedule();
1387
+ const relative = filename.toString();
1388
+ if (relative.split(path7.sep).some((part) => part.startsWith(".") || part === "node_modules")) return;
1389
+ const file = path7.resolve(current.toolsDir, relative);
1390
+ if (path7.basename(file) === "studio.tool.ts" || current.dependencies.has(file)) return schedule();
1391
+ if (pages.has(file)) {
1392
+ if (pages.get(file) !== existsSync5(file)) schedule();
1393
+ return;
1394
+ }
1395
+ if (event === "rename" && (/^page\.[jt]sx?$/.test(path7.basename(file)) || !path7.extname(file) || statSync(file, { throwIfNoEntry: false })?.isDirectory()))
1396
+ schedule();
1397
+ });
1398
+ const targets = /* @__PURE__ */ new Set([
1399
+ ...current.dependencies,
1400
+ ...retryInputs,
1401
+ ...["studio.config.ts", "studio.config.mts", "studio.config.js", "studio.config.mjs", "tsconfig.json"].map(
1402
+ (file) => path7.join(current.root, file)
1403
+ )
1404
+ ]);
1405
+ const byDirectory = /* @__PURE__ */ new Map();
1406
+ for (const file of targets) {
1407
+ let directory = path7.dirname(file);
1408
+ while (!existsSync5(directory) && path7.dirname(directory) !== directory) directory = path7.dirname(directory);
1409
+ const names = byDirectory.get(directory) ?? /* @__PURE__ */ new Set();
1410
+ names.add(path7.relative(directory, file).split(path7.sep)[0]);
1411
+ byDirectory.set(directory, names);
1412
+ }
1413
+ for (const [directory, names] of byDirectory) {
1414
+ add(directory, false, (_, filename) => {
1415
+ if (!filename || names.has(filename.toString())) schedule();
1416
+ });
1417
+ }
1418
+ const directoryId = statSync(current.toolsDir, { throwIfNoEntry: false })?.ino;
1419
+ add(path7.dirname(current.toolsDir), false, (_, filename) => {
1420
+ if ((!filename || filename.toString() === path7.basename(current.toolsDir)) && statSync(current.toolsDir, { throwIfNoEntry: false })?.ino !== directoryId)
1421
+ schedule();
1422
+ });
1423
+ };
1424
+ bind();
1425
+ return {
1426
+ retry: schedule,
1427
+ close() {
1428
+ closed = true;
1429
+ clearTimeout(timer);
1430
+ for (const watcher of watchers) watcher.close();
1431
+ }
1432
+ };
1433
+ }
1434
+
650
1435
  // src/cli.ts
651
1436
  var HELP = `Usage: studio dev [--config studio.config.ts] [--port ${DEFAULT_PORT}] [--app-url http://localhost:3000] [-- <command...>]
1437
+ studio generate [--config studio.config.ts]
652
1438
  studio install-browser
1439
+ studio init [directory] [--no-install]
653
1440
 
654
1441
  Starts the Studio runtime with a WebSocket bridge and an HTTP MCP endpoint at /mcp,
655
1442
  moving to the next free port when another project's Studio already uses it.
@@ -659,10 +1446,11 @@ Set appUrl in the config or pass --app-url to enable MCP preview capture. A conf
659
1446
  appUrl port is passed to the command as PORT, moved to the next free port when taken.
660
1447
  Run "studio install-browser" once to install Chromium for captures.`;
661
1448
  async function main(argv) {
1449
+ if (argv[0] === "init") return init(argv.slice(1));
662
1450
  if (argv[0] === "install-browser") {
663
- const require2 = createRequire(import.meta.url);
664
- const cli = path4.join(
665
- path4.dirname(require2.resolve("playwright/package.json")),
1451
+ const require2 = createRequire2(import.meta.url);
1452
+ const cli = path8.join(
1453
+ path8.dirname(require2.resolve("playwright/package.json")),
666
1454
  require2("playwright/package.json").bin.playwright
667
1455
  );
668
1456
  const child2 = spawn(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
@@ -676,7 +1464,7 @@ async function main(argv) {
676
1464
  const split = argv.indexOf("--");
677
1465
  const own = split === -1 ? argv : argv.slice(0, split);
678
1466
  const command = split === -1 ? [] : argv.slice(split + 1);
679
- const { positionals, values } = parseArgs({
1467
+ const { positionals, values } = parseArgs2({
680
1468
  args: own,
681
1469
  allowPositionals: true,
682
1470
  options: {
@@ -686,8 +1474,13 @@ async function main(argv) {
686
1474
  help: { type: "boolean", short: "h" }
687
1475
  }
688
1476
  });
1477
+ if (positionals[0] === "generate") {
1478
+ await generate(process.cwd(), values.config);
1479
+ console.log("[studio] generated tool registry and types");
1480
+ return;
1481
+ }
689
1482
  if (values.help || positionals[0] !== "dev") return console.log(HELP);
690
- const { config, root } = await loadConfig(process.cwd(), values.config);
1483
+ const { config, root, discovery } = await loadConfig(process.cwd(), values.config);
691
1484
  const [bin, ...args] = command;
692
1485
  const env = { ...process.env };
693
1486
  let appUrl = values["app-url"] ?? config.appUrl;
@@ -703,10 +1496,30 @@ async function main(argv) {
703
1496
  const bridge = await serveBridgeNear(runtime, { port, origins: config.origins, appUrl });
704
1497
  if (bridge.port !== port) console.log(`[studio] port ${port} is in use, probably by another project's Studio`);
705
1498
  console.log(
706
- `[studio] runtime ready on ws://127.0.0.1:${bridge.port} \u2014 ${config.tools.length} tool(s) in ${path4.relative(process.cwd(), root) || "."}`
1499
+ `[studio] runtime ready on ws://127.0.0.1:${bridge.port} \u2014 ${config.tools.length} tool(s) in ${path8.relative(process.cwd(), root) || "."}`
707
1500
  );
708
1501
  console.log(`[studio] MCP ready on http://127.0.0.1:${bridge.port}/mcp`);
1502
+ let rejected = false;
1503
+ let accepted = discovery;
1504
+ const watcher = discovery && watchDiscovery(discovery, async () => {
1505
+ try {
1506
+ const next = await loadConfig(process.cwd(), values.config, false, accepted);
1507
+ await runtime.replaceTools(next.config.tools);
1508
+ await next.discovery?.write();
1509
+ accepted = next.discovery;
1510
+ rejected = false;
1511
+ return next.discovery;
1512
+ } catch (cause) {
1513
+ rejected = true;
1514
+ throw cause;
1515
+ }
1516
+ });
1517
+ const unsubscribe = runtime.subscribe((state) => {
1518
+ if (rejected && !state.dirty) watcher?.retry();
1519
+ });
709
1520
  const shutdown = async (code = 0) => {
1521
+ watcher?.close();
1522
+ unsubscribe();
710
1523
  runtime.close();
711
1524
  await bridge.close();
712
1525
  process.exit(code);
@@ -716,7 +1529,16 @@ async function main(argv) {
716
1529
  process.once("SIGTERM", () => void shutdown());
717
1530
  return;
718
1531
  }
719
- const child = spawn(bin, args, { stdio: "inherit", env: { ...env, STUDIO_PORT: String(bridge.port) } });
1532
+ const child = spawn(bin, args, {
1533
+ stdio: "inherit",
1534
+ env: {
1535
+ ...env,
1536
+ STUDIO_PORT: String(bridge.port),
1537
+ NEXT_PUBLIC_STUDIO_RUNTIME: `ws://127.0.0.1:${bridge.port}`,
1538
+ STUDIO_DISCOVERY_MANAGED: discovery ? "1" : "",
1539
+ ...values.config ? { STUDIO_CONFIG: path8.resolve(values.config) } : {}
1540
+ }
1541
+ });
720
1542
  child.once("exit", (code) => void shutdown(code ?? 0));
721
1543
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => child.kill(signal));
722
1544
  }