@unbound-app/cli 0.1.0

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.
@@ -0,0 +1 @@
1
+ export {}
package/dist/index.mjs ADDED
@@ -0,0 +1,984 @@
1
+ #!/usr/bin/env node
2
+ import { cli, command } from "cleye";
3
+ import { z } from "zod";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
5
+ import { dirname, join, relative, resolve, sep } from "node:path";
6
+ import { ControllerClient } from "@unbound-app/debugger-protocol/controller";
7
+ import { CONTROLLER_ENDPOINT_MARKER, DEFAULT_BRIDGE_PORT } from "@unbound-app/debugger-protocol";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { cancel, confirm, intro, isCancel, note, outro, select, text } from "@clack/prompts";
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { glob } from "node:fs/promises";
13
+ import pc from "picocolors";
14
+ //#region src/lib/stdin.ts
15
+ /**
16
+ * @description Whether stdin is piped rather than an interactive terminal, meaning a command may read
17
+ * its input from it.
18
+ * @returns `true` when stdin is not a TTY.
19
+ */
20
+ function isStdinPiped() {
21
+ return !process.stdin.isTTY;
22
+ }
23
+ /**
24
+ * @description Reads all of stdin to a trimmed string, for commands fed their input through a pipe.
25
+ * @returns The piped input.
26
+ */
27
+ async function readStdin() {
28
+ const chunks = [];
29
+ for await (const chunk of process.stdin) chunks.push(chunk);
30
+ return Buffer.concat(chunks).toString("utf8").trim();
31
+ }
32
+ //#endregion
33
+ //#region src/lib/config.ts
34
+ /** The filename walked up the directory tree to locate a workspace root. */
35
+ const CONFIG_FILENAME = "unbound.config.json";
36
+ const schema = z.object({
37
+ $schema: z.string().optional(),
38
+ addons: z.array(z.string()).default(["plugins/*", "themes/*"]),
39
+ build: z.string().default("bun run build"),
40
+ output: z.string().default("dist")
41
+ });
42
+ /**
43
+ * @description Walks up from `cwd` looking for an `unbound.config.json`, parsing the first one found
44
+ * against the config schema. When none exists the `cwd` is treated as a single-addon root carrying
45
+ * the schema defaults, so bare addons need no config.
46
+ * @param cwd The directory to start the upward search from.
47
+ * @returns The parsed config and the directory it resolves relative to.
48
+ */
49
+ function loadConfig(cwd = process.cwd()) {
50
+ const found = findConfigFile(cwd);
51
+ if (!found) return {
52
+ config: schema.parse({}),
53
+ dir: resolve(cwd),
54
+ bare: true
55
+ };
56
+ const raw = JSON.parse(readFileSync(found, "utf8"));
57
+ return {
58
+ config: schema.parse(raw),
59
+ dir: dirname(found),
60
+ bare: false
61
+ };
62
+ }
63
+ function findConfigFile(from) {
64
+ let dir = resolve(from);
65
+ while (true) {
66
+ const candidate = resolve(dir, CONFIG_FILENAME);
67
+ if (existsSync(candidate)) return candidate;
68
+ const parent = dirname(dir);
69
+ if (parent === dir) return void 0;
70
+ dir = parent;
71
+ }
72
+ }
73
+ //#endregion
74
+ //#region src/lib/context.ts
75
+ /** How long a one-shot command waits for the bridge to report a device before giving up. */
76
+ const DEVICE_WAIT_TIMEOUT_MS = 2e3;
77
+ /** How often the device-wait polls the controller's connection state. */
78
+ const DEVICE_WAIT_POLL_MS = 50;
79
+ /**
80
+ * @description Builds a {@link LazyContext} whose controller dials the bridge on the given port on
81
+ * first access to `client`, attaching as a controller through the shared endpoint marker rather than
82
+ * as a device. Commands that never read `client` never connect.
83
+ * @param port The port the bridge listens on.
84
+ * @returns A context that connects on demand.
85
+ */
86
+ function createContext(port) {
87
+ const url = `ws://127.0.0.1:${port}?${CONTROLLER_ENDPOINT_MARKER}`;
88
+ let client;
89
+ return {
90
+ get client() {
91
+ if (client) return client;
92
+ client = new ControllerClient(url);
93
+ client.connect();
94
+ return client;
95
+ },
96
+ get connected() {
97
+ return client !== void 0;
98
+ },
99
+ close() {
100
+ client?.close();
101
+ }
102
+ };
103
+ }
104
+ /**
105
+ * @description Waits for the bridge to report a connected device, for one-shot commands that dial in,
106
+ * run, and exit. Resolves early once a device is seen; resolves `false` once the timeout elapses so
107
+ * the caller can surface a connection error rather than hang.
108
+ * @param client The controller dialing the bridge.
109
+ * @param timeoutMs How long to wait before giving up.
110
+ * @returns Whether a device connected within the timeout.
111
+ */
112
+ function waitForDevice(client, timeoutMs = DEVICE_WAIT_TIMEOUT_MS) {
113
+ return new Promise((resolve) => {
114
+ if (client.isDeviceConnected) return resolve(true);
115
+ const deadline = Date.now() + timeoutMs;
116
+ const timer = setInterval(() => {
117
+ if (client.isDeviceConnected) {
118
+ clearInterval(timer);
119
+ resolve(true);
120
+ } else if (Date.now() >= deadline) {
121
+ clearInterval(timer);
122
+ resolve(false);
123
+ }
124
+ }, DEVICE_WAIT_POLL_MS);
125
+ });
126
+ }
127
+ //#endregion
128
+ //#region src/lib/zod-flags.ts
129
+ /** The wrappers whose `innerType` carries the underlying type a flag should be built from. */
130
+ const WRAPPER_TYPES = /* @__PURE__ */ new Set([
131
+ "default",
132
+ "optional",
133
+ "nullable"
134
+ ]);
135
+ const FLAG_CONSTRUCTORS = {
136
+ boolean: Boolean,
137
+ number: Number,
138
+ string: String
139
+ };
140
+ /**
141
+ * @description Derives a cleye flag descriptor from a zod field, reading its type through zod's own
142
+ * `def` introspection so the flag stays in sync with the schema rather than a hand-kept parallel map.
143
+ * @param field The zod field for a single schema property.
144
+ * @returns The cleye flag descriptor for that field.
145
+ */
146
+ function fieldToFlag(field) {
147
+ let def = field.def;
148
+ let defaultValue;
149
+ while (WRAPPER_TYPES.has(def.type) && def.innerType) {
150
+ if (def.type === "default") defaultValue = def.defaultValue;
151
+ def = def.innerType.def;
152
+ }
153
+ return {
154
+ type: FLAG_CONSTRUCTORS[def.type] ?? String,
155
+ description: field.description,
156
+ default: defaultValue
157
+ };
158
+ }
159
+ //#endregion
160
+ //#region src/adapters/cleye.ts
161
+ /** The value that marks a positional as fed from stdin instead of the command line. */
162
+ const STDIN_MARKER = "-";
163
+ /**
164
+ * @description Builds a cleye command from a registry definition: the fields named in
165
+ * `cli.positionals` become positional parameters, every other schema field becomes a flag whose type
166
+ * is derived from the zod field. The callback reads a stdin-fed positional when piped, validates the
167
+ * assembled input through the definition's schema, runs the handler, and exits `0`/`1`/`2` on
168
+ * success, on-device failure, and connection or usage error respectively.
169
+ * @param definition The command to adapt.
170
+ * @returns A cleye `command()` ready to register on the CLI.
171
+ */
172
+ function toCleyeCommand(definition) {
173
+ const positionalNames = (definition.cli?.positionals ?? []).map(String);
174
+ const flags = { json: {
175
+ type: Boolean,
176
+ description: "Print the raw result as JSON instead of the formatted transcript.",
177
+ default: false
178
+ } };
179
+ for (const [name, field] of Object.entries(definition.schema.shape)) {
180
+ if (positionalNames.includes(name)) continue;
181
+ flags[name] = fieldToFlag(field);
182
+ }
183
+ return command({
184
+ name: definition.name,
185
+ description: definition.description,
186
+ parameters: positionalNames.map((name) => `[${name}]`),
187
+ flags
188
+ }, async (parsed) => {
189
+ const { json, ...flagInput } = parsed.flags;
190
+ const input = { ...flagInput };
191
+ for (const name of positionalNames) input[name] = parsed._[name];
192
+ await run(definition, input, positionalNames[0], Boolean(json));
193
+ });
194
+ }
195
+ async function run(definition, input, stdinField, json) {
196
+ if (stdinField && (input[stdinField] === void 0 || input[stdinField] === STDIN_MARKER)) {
197
+ if (!isStdinPiped()) {
198
+ process.stderr.write(`Missing required argument: ${stdinField}.\n`);
199
+ process.exit(2);
200
+ }
201
+ input[stdinField] = await readStdin();
202
+ }
203
+ const parsed = definition.schema.safeParse(input);
204
+ if (!parsed.success) {
205
+ process.stderr.write(`${z.prettifyError(parsed.error)}\n`);
206
+ process.exit(2);
207
+ }
208
+ const port = parsed.data.port;
209
+ const context = createContext(port ?? NaN);
210
+ try {
211
+ const output = await definition.handler(parsed.data, context);
212
+ if (json) process.stdout.write(`${JSON.stringify(output.data ?? output)}\n`);
213
+ else if (output.ok) process.stdout.write(`${output.text}\n`);
214
+ else process.stderr.write(`${output.text}\n`);
215
+ if (output.ok) process.exit(0);
216
+ const connectionError = context.connected && !context.client.isDeviceConnected;
217
+ process.exit(connectionError ? 2 : 1);
218
+ } catch (error) {
219
+ if (error instanceof z.ZodError) {
220
+ process.stderr.write(`Invalid ${CONFIG_FILENAME}:\n${z.prettifyError(error)}\n`);
221
+ process.exit(2);
222
+ }
223
+ throw error;
224
+ } finally {
225
+ context.close();
226
+ }
227
+ }
228
+ //#endregion
229
+ //#region src/adapters/mcp.ts
230
+ /**
231
+ * @description Registers a registry definition as an MCP tool on the given server: the definition's
232
+ * schema is the tool's input schema, and its handler output maps to a tool result — the formatted
233
+ * text as the sole content block, `ok: false` surfacing as `isError`.
234
+ * @param server The MCP server to register the tool on.
235
+ * @param definition The command to expose.
236
+ * @param context The runtime the handler evaluates against.
237
+ */
238
+ function toMcpTool(server, definition, context) {
239
+ server.registerTool(definition.name, {
240
+ description: definition.description,
241
+ inputSchema: definition.schema
242
+ }, async (input) => {
243
+ const output = await definition.handler(input, context);
244
+ return {
245
+ isError: !output.ok,
246
+ content: [{
247
+ type: "text",
248
+ text: output.text
249
+ }]
250
+ };
251
+ });
252
+ }
253
+ //#endregion
254
+ //#region src/lib/templates.ts
255
+ /** The base of every documentation link surfaced by `ubd create`. */
256
+ const DOCS_BASE_URL = "https://docs.unbound.rip";
257
+ /**
258
+ * @description Resolves the bundled templates directory, walking up from this module so it works both
259
+ * from `src/` in development and from the built `dist/` on a published install, where `templates/` is
260
+ * shipped alongside via the package `files` field.
261
+ * @returns The absolute path to the `templates` directory.
262
+ */
263
+ function templatesDir() {
264
+ let dir = import.meta.dirname;
265
+ while (true) {
266
+ const candidate = join(dir, "templates");
267
+ if (existsSync(candidate)) return candidate;
268
+ const parent = dirname(dir);
269
+ if (parent === dir) throw new Error("Could not locate the bundled templates directory.");
270
+ dir = parent;
271
+ }
272
+ }
273
+ /**
274
+ * @description Resolves the absolute path to a single template directory.
275
+ * @param template The template to resolve.
276
+ * @returns The absolute path to that template.
277
+ */
278
+ function templatePath(template) {
279
+ return resolve(templatesDir(), template);
280
+ }
281
+ //#endregion
282
+ //#region src/lib/create-prompts.ts
283
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
284
+ /**
285
+ * @description Runs the interactive `ubd create` flow, prompting only for values the flags left
286
+ * unset so a fully-flagged invocation completes without a single prompt. Exits the process on
287
+ * cancellation.
288
+ * @param input The values supplied via flags.
289
+ * @param workspace The config found in or above the cwd, or `undefined` when none exists.
290
+ * @returns The fully-resolved options to scaffold from.
291
+ */
292
+ async function resolveOptions(input, workspace) {
293
+ intro("ubd create");
294
+ const shape = await resolveShape(input.shape, Boolean(workspace));
295
+ const kind = await resolveKind(input.kind);
296
+ const name = await resolveName(input.name);
297
+ const displayName = await resolveText(input.displayName, "Display name", titleCase(name));
298
+ const description = await resolveText(input.description, "Description", "An Unbound addon.");
299
+ const authorName = await resolveText(input.authorName, "Author name", "you");
300
+ const authorId = await resolveOptionalText(input.authorId, "Author Discord id (optional)", "0");
301
+ const id = await resolveText(input.id, "Addon id", `${authorName}.${name}`);
302
+ const dir = shape === "add" ? void 0 : await resolveDir(input.dir, name);
303
+ const repository = shape === "repository";
304
+ return {
305
+ shape,
306
+ kind,
307
+ name,
308
+ displayName,
309
+ description,
310
+ authorName,
311
+ authorId,
312
+ id,
313
+ dir,
314
+ gitInit: repository ? await resolveConfirm(input.gitInit, "Initialize a git repository?") : false,
315
+ install: repository ? await resolveConfirm(input.install, "Install dependencies with bun?") : false
316
+ };
317
+ }
318
+ async function resolveShape(flag, hasWorkspace) {
319
+ if (flag === "repository") return "repository";
320
+ if (flag === "addon") return hasWorkspace ? "add" : "addon";
321
+ if (hasWorkspace) return unwrap(await select({
322
+ message: "This directory is inside an Unbound workspace. What would you like to do?",
323
+ options: [{
324
+ value: "add",
325
+ label: "Add an addon to this workspace"
326
+ }, {
327
+ value: "repository",
328
+ label: "New repository"
329
+ }]
330
+ }));
331
+ return unwrap(await select({
332
+ message: "What would you like to create?",
333
+ options: [{
334
+ value: "repository",
335
+ label: "Repository",
336
+ hint: "recommended"
337
+ }, {
338
+ value: "addon",
339
+ label: "Single addon directory"
340
+ }]
341
+ }));
342
+ }
343
+ async function resolveKind(flag) {
344
+ if (flag) return flag;
345
+ return unwrap(await select({
346
+ message: "Which kind of addon?",
347
+ options: [{
348
+ value: "plugin",
349
+ label: "Plugin",
350
+ hint: `patch the client. ${DOCS_BASE_URL}/plugins/introduction`
351
+ }, {
352
+ value: "theme",
353
+ label: "Theme",
354
+ hint: `recolor the UI. ${DOCS_BASE_URL}/themes/introduction`
355
+ }]
356
+ }));
357
+ }
358
+ async function resolveName(flag) {
359
+ if (flag !== void 0) {
360
+ if (!KEBAB_CASE.test(flag)) {
361
+ cancel(`Invalid name "${flag}": must be kebab-case (lowercase, digits, hyphens).`);
362
+ process.exit(2);
363
+ }
364
+ return flag;
365
+ }
366
+ return unwrap(await text({
367
+ message: "Addon name",
368
+ placeholder: "my-addon",
369
+ validate(input) {
370
+ if (!input) return "A name is required.";
371
+ if (!KEBAB_CASE.test(input)) return "Must be kebab-case (lowercase, digits, hyphens).";
372
+ }
373
+ }));
374
+ }
375
+ async function resolveText(flag, message, initialValue) {
376
+ if (flag !== void 0) return flag;
377
+ return unwrap(await text({
378
+ message,
379
+ initialValue
380
+ }));
381
+ }
382
+ async function resolveOptionalText(flag, message, fallback) {
383
+ if (flag !== void 0) return flag;
384
+ const resolved = unwrap(await text({
385
+ message,
386
+ placeholder: fallback
387
+ }));
388
+ return resolved === "" ? fallback : resolved;
389
+ }
390
+ async function resolveDir(flag, name) {
391
+ if (flag !== void 0) return flag;
392
+ return unwrap(await text({
393
+ message: "Target directory",
394
+ initialValue: `./${name}`
395
+ }));
396
+ }
397
+ async function resolveConfirm(flag, message) {
398
+ if (flag !== void 0) return flag;
399
+ return unwrap(await confirm({
400
+ message,
401
+ initialValue: true
402
+ }));
403
+ }
404
+ function unwrap(value) {
405
+ if (isCancel(value)) {
406
+ cancel("Cancelled.");
407
+ process.exit(0);
408
+ }
409
+ return value;
410
+ }
411
+ function titleCase(name) {
412
+ return name.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
413
+ }
414
+ //#endregion
415
+ //#region src/lib/scaffold.ts
416
+ /** npm and bun drop dotfiles named `.gitignore` from tarballs, so templates store it undotted. */
417
+ const GITIGNORE_SOURCE = "gitignore";
418
+ /**
419
+ * @description Applies `{{token}}` substitution to a string, replacing every known token with its
420
+ * value and leaving unknown `{{…}}` sequences untouched.
421
+ * @param input The string to substitute over (file contents or a path segment).
422
+ * @param tokens The token values to inject.
423
+ * @returns The substituted string.
424
+ */
425
+ function substitute(input, tokens) {
426
+ return input.replace(/\{\{(\w+)\}\}/g, (match, token) => {
427
+ return token in tokens ? tokens[token] : match;
428
+ });
429
+ }
430
+ /**
431
+ * @description Recursively copies a template directory into a destination, substituting tokens across
432
+ * both file contents and file/directory names and renaming the undotted `gitignore` to `.gitignore`
433
+ * so it survives publishing yet lands as a real dotfile.
434
+ * @param from The template directory to copy.
435
+ * @param to The destination directory (created if absent).
436
+ * @param tokens The token values to inject.
437
+ */
438
+ function scaffoldTemplate(from, to, tokens) {
439
+ mkdirSync(to, { recursive: true });
440
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
441
+ const source = join(from, entry.name);
442
+ const target = join(to, entry.name === GITIGNORE_SOURCE ? ".gitignore" : substitute(entry.name, tokens));
443
+ if (entry.isDirectory()) {
444
+ scaffoldTemplate(source, target, tokens);
445
+ continue;
446
+ }
447
+ writeFileSync(target, substitute(readFileSync(source, "utf8"), tokens));
448
+ }
449
+ }
450
+ //#endregion
451
+ //#region src/lib/create.ts
452
+ /**
453
+ * @description Scaffolds the chosen shape into a validated target directory: a repository stamps the
454
+ * workspace template then the starter addon under `plugins/`/`themes/`; a single addon or an addon
455
+ * added to an existing workspace stamps only the kind's template. Runs `git init` and `bun install`
456
+ * for repositories when confirmed.
457
+ * @param options The fully-resolved create options.
458
+ * @param workspace The config found in or above the cwd, or `undefined` when none exists.
459
+ * @returns Where the addon landed, for the next-step outro.
460
+ */
461
+ function scaffold(options, workspace) {
462
+ const tokens = toTokens(options);
463
+ if (options.shape === "repository") return scaffoldRepository(options, tokens);
464
+ if (options.shape === "add") return scaffoldWorkspaceAddon(options, tokens, workspace);
465
+ return scaffoldBareAddon(options, tokens);
466
+ }
467
+ function scaffoldRepository(options, tokens) {
468
+ const dir = resolve(options.dir ?? `./${options.name}`);
469
+ assertEmpty(dir);
470
+ scaffoldTemplate(templatePath("repository"), dir, tokens);
471
+ const folder = options.kind === "plugin" ? "plugins" : "themes";
472
+ scaffoldTemplate(templatePath(options.kind), join(dir, folder, options.name), tokens);
473
+ if (options.gitInit) gitInit(dir);
474
+ if (options.install) bunInstall(dir);
475
+ return {
476
+ dir,
477
+ kind: options.kind,
478
+ name: options.name
479
+ };
480
+ }
481
+ function scaffoldBareAddon(options, tokens) {
482
+ const dir = resolve(options.dir ?? `./${options.name}`);
483
+ assertEmpty(dir);
484
+ scaffoldTemplate(templatePath(options.kind), dir, tokens);
485
+ return {
486
+ dir,
487
+ kind: options.kind,
488
+ name: options.name
489
+ };
490
+ }
491
+ function scaffoldWorkspaceAddon(options, tokens, workspace) {
492
+ if (!workspace) throw new Error("Cannot add an addon: no workspace config was found.");
493
+ const dir = join(workspaceAddonDir(workspace, options.kind), options.name);
494
+ assertEmpty(dir);
495
+ scaffoldTemplate(templatePath(options.kind), dir, tokens);
496
+ return {
497
+ dir,
498
+ kind: options.kind,
499
+ name: options.name
500
+ };
501
+ }
502
+ /**
503
+ * @description Picks the workspace directory a new addon of the given kind belongs in, matching the
504
+ * config `addons` glob whose leading segment names the kind and stripping its trailing `*` segment.
505
+ * Falls back to `plugins`/`themes` next to the config when no glob matches.
506
+ * @param workspace The resolved workspace config.
507
+ * @param kind The addon kind being added.
508
+ * @returns The absolute directory the addon folder should be created under.
509
+ */
510
+ function workspaceAddonDir(workspace, kind) {
511
+ const keyword = kind === "plugin" ? "plugin" : "theme";
512
+ const glob = workspace.config.addons.find((pattern) => pattern.includes(keyword));
513
+ const base = glob ? glob.replace(/\/?\*+.*$/, "") : `${keyword}s`;
514
+ return resolve(workspace.dir, base);
515
+ }
516
+ function assertEmpty(dir) {
517
+ if (!existsSync(dir)) return;
518
+ if (readdirSync(dir).length > 0) throw new Error(`Target directory is not empty: ${dir}`);
519
+ }
520
+ function gitInit(dir) {
521
+ spawnSync("git", ["init"], {
522
+ cwd: dir,
523
+ stdio: "inherit"
524
+ });
525
+ }
526
+ function bunInstall(dir) {
527
+ spawnSync("bun", ["install"], {
528
+ cwd: dir,
529
+ stdio: "inherit"
530
+ });
531
+ }
532
+ function toTokens(options) {
533
+ return {
534
+ name: options.name,
535
+ id: options.id,
536
+ displayName: options.displayName,
537
+ description: options.description,
538
+ authorName: options.authorName,
539
+ authorId: options.authorId,
540
+ main: options.kind === "theme" ? "theme" : "index.tsx"
541
+ };
542
+ }
543
+ //#endregion
544
+ //#region src/commands/create.ts
545
+ const createCommand = {
546
+ name: "create",
547
+ description: "Scaffold a new Unbound addon or addon workspace. Prompts for anything left unset, so a fully-flagged invocation runs without prompts for use in CI.",
548
+ surfaces: {
549
+ cli: true,
550
+ mcp: false
551
+ },
552
+ schema: z.object({
553
+ kind: z.enum(["plugin", "theme"]).optional().describe("The addon kind to scaffold."),
554
+ name: z.string().optional().describe("The addon name (kebab-case)."),
555
+ dir: z.string().optional().describe("The target directory (default ./<name>)."),
556
+ displayName: z.string().optional().describe("The human-readable addon name."),
557
+ description: z.string().optional().describe("A one-line description."),
558
+ authorName: z.string().optional().describe("The author name."),
559
+ authorId: z.string().optional().describe("The author Discord id."),
560
+ id: z.string().optional().describe("The manifest id (default <authorName>.<name>)."),
561
+ shape: z.enum(["repository", "addon"]).optional().describe("Scaffold a repository or a single addon."),
562
+ gitInit: z.boolean().optional().describe("Initialize a git repository (repository shape only)."),
563
+ install: z.boolean().optional().describe("Install dependencies with bun (repository shape only).")
564
+ }),
565
+ async handler(input) {
566
+ const config = loadConfig();
567
+ const workspace = config.bare ? void 0 : config;
568
+ const options = await resolveOptions(input, workspace);
569
+ try {
570
+ printOutro(scaffold(options, workspace));
571
+ return {
572
+ ok: true,
573
+ text: ""
574
+ };
575
+ } catch (error) {
576
+ return {
577
+ ok: false,
578
+ text: error instanceof Error ? error.message : String(error)
579
+ };
580
+ }
581
+ }
582
+ };
583
+ function printOutro(result) {
584
+ const kindDocs = `${DOCS_BASE_URL}/${result.kind}s/introduction`;
585
+ const manifestDocs = `${DOCS_BASE_URL}/addons/manifest`;
586
+ note([
587
+ `cd ${relative(process.cwd(), result.dir) || "."}`,
588
+ "ubd dev",
589
+ `Learn the ${result.kind} API: ${kindDocs}`,
590
+ `Manifest reference: ${manifestDocs}`
591
+ ].join("\n"), "Next steps");
592
+ outro(`${result.name} ready.`);
593
+ }
594
+ //#endregion
595
+ //#region src/lib/discovery.ts
596
+ /**
597
+ * @description Discovers every addon reachable through the config's `addons` globs. A directory is an
598
+ * addon iff it contains a `manifest.json`; its kind is derived from the manifest `main` (a `.json`
599
+ * entry point is a theme, anything else a plugin) and it is static when its directory carries no
600
+ * `package.json` with a `build` script.
601
+ * @param resolved The loaded config paired with the directory its globs resolve relative to.
602
+ * @returns Every discovered addon, keyed implicitly by directory.
603
+ */
604
+ async function discoverAddons(resolved) {
605
+ const { config, dir, bare } = resolved;
606
+ const dirs = /* @__PURE__ */ new Set();
607
+ if (bare) dirs.add(dir);
608
+ else for (const pattern of config.addons) for await (const match of glob(pattern, { cwd: dir })) dirs.add(resolve(dir, match));
609
+ const addons = [];
610
+ for (const addonDir of dirs) {
611
+ const addon = readAddon(addonDir, config.output);
612
+ if (addon) addons.push(addon);
613
+ }
614
+ return addons;
615
+ }
616
+ /**
617
+ * @description Reads a single directory as an addon, returning `undefined` when it has no
618
+ * `manifest.json` or the manifest lacks the fields discovery needs.
619
+ * @param addonDir The absolute directory to inspect.
620
+ * @param output The config's output directory, relative to the addon.
621
+ * @returns The discovered addon, or `undefined` if the directory is not an addon.
622
+ */
623
+ function readAddon(addonDir, output) {
624
+ const manifestPath = join(addonDir, "manifest.json");
625
+ if (!existsSync(manifestPath)) return void 0;
626
+ let manifest;
627
+ try {
628
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
629
+ } catch (error) {
630
+ process.stderr.write(`Skipping ${addonDir}: its manifest.json could not be read: ${error}\n`);
631
+ return;
632
+ }
633
+ if (typeof manifest.id !== "string" || typeof manifest.main !== "string") return void 0;
634
+ const kind = manifest.main.endsWith(".json") ? "theme" : "plugin";
635
+ const isStatic = !hasBuildScript(addonDir);
636
+ return {
637
+ id: manifest.id,
638
+ kind,
639
+ dir: addonDir,
640
+ output: isStatic ? addonDir : join(addonDir, output),
641
+ static: isStatic
642
+ };
643
+ }
644
+ function hasBuildScript(addonDir) {
645
+ const packagePath = join(addonDir, "package.json");
646
+ if (!existsSync(packagePath)) return false;
647
+ try {
648
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
649
+ return Boolean(pkg.scripts?.build);
650
+ } catch {
651
+ return false;
652
+ }
653
+ }
654
+ //#endregion
655
+ //#region src/lib/build-addon.ts
656
+ /**
657
+ * @description Builds a single addon by running the config's `build` command with cwd set to the
658
+ * addon directory. Static addons carry no build step, so they resolve immediately as successful.
659
+ * stdout and stderr are inherited so the build's own output surfaces live.
660
+ * @param addon The addon to build.
661
+ * @param buildCommand The shell command to run in the addon directory.
662
+ * @returns Whether the build succeeded (always `true` for static addons).
663
+ */
664
+ function buildAddon(addon, buildCommand) {
665
+ if (addon.static) return Promise.resolve(true);
666
+ return new Promise((resolve) => {
667
+ const child = spawn(buildCommand, {
668
+ cwd: addon.dir,
669
+ shell: true,
670
+ stdio: "inherit"
671
+ });
672
+ child.on("error", () => resolve(false));
673
+ child.on("close", (code) => resolve(code === 0));
674
+ });
675
+ }
676
+ //#endregion
677
+ //#region src/commands/build.ts
678
+ const buildCommand = {
679
+ name: "build",
680
+ description: "Build every addon in the workspace once, reporting per-addon success and failure.",
681
+ surfaces: {
682
+ cli: true,
683
+ mcp: false
684
+ },
685
+ schema: z.object({}),
686
+ async handler() {
687
+ const resolved = loadConfig();
688
+ const addons = await discoverAddons(resolved);
689
+ if (addons.length === 0) return {
690
+ ok: true,
691
+ text: "No addons found."
692
+ };
693
+ const failed = [];
694
+ for (const addon of addons) {
695
+ if (addon.static) {
696
+ process.stdout.write(`Skipping ${addon.id} (static addon).\n`);
697
+ continue;
698
+ }
699
+ process.stdout.write(`Building ${addon.id}…\n`);
700
+ if (await buildAddon(addon, resolved.config.build)) process.stdout.write(`Built ${addon.id}.\n`);
701
+ else {
702
+ failed.push(addon.id);
703
+ process.stderr.write(`Failed to build ${addon.id}.\n`);
704
+ }
705
+ }
706
+ if (failed.length > 0) return {
707
+ ok: false,
708
+ text: `Failed to build: ${failed.join(", ")}.`
709
+ };
710
+ return {
711
+ ok: true,
712
+ text: "All addons built."
713
+ };
714
+ }
715
+ };
716
+ //#endregion
717
+ //#region src/lib/format.ts
718
+ /**
719
+ * @description Renders an {@link EvalResult} into the human-readable transcript both surfaces print:
720
+ * captured console output first, then the value or error, each under its own header.
721
+ * @param result The device's eval result.
722
+ * @returns The formatted transcript.
723
+ */
724
+ function formatResult(result) {
725
+ const parts = [];
726
+ if (result.logs?.length) parts.push("--- console ---", result.logs.map(formatLog).join("\n"));
727
+ if (result.ok) parts.push("--- result ---", result.value ?? "undefined");
728
+ else parts.push("--- error ---", result.error ?? "Unknown error.");
729
+ return parts.join("\n");
730
+ }
731
+ /**
732
+ * @description Renders a single {@link LogMessage} as a level-tagged line for the console transcript.
733
+ * @param log The captured log line.
734
+ * @returns The `[level] message` line.
735
+ */
736
+ function formatLog(log) {
737
+ return `[${log.level === 3 ? "error" : log.level === 2 ? "warn" : "log"}] ${log.message}`;
738
+ }
739
+ //#endregion
740
+ //#region src/commands/eval.ts
741
+ const evalCommand = {
742
+ name: "eval",
743
+ description: "Evaluate JavaScript inside the running Unbound client on the connected device and return its result plus any console output the code produced. Use this to inspect the live runtime: read globals (e.g. `unbound.metro`), call APIs, and probe Discord internals. The last expression is the return value; `console.log`/`warn`/`error` are captured separately.",
744
+ surfaces: {
745
+ cli: true,
746
+ mcp: true
747
+ },
748
+ schema: z.object({
749
+ code: z.string().describe("The JavaScript to evaluate on the device."),
750
+ port: z.number().default(DEFAULT_BRIDGE_PORT).describe("The port the debugger bridge listens on.")
751
+ }),
752
+ cli: { positionals: ["code"] },
753
+ async handler({ code }, { client }) {
754
+ await waitForDevice(client);
755
+ if (!client.isDeviceConnected) return {
756
+ ok: false,
757
+ text: "No device is connected to the debugger. Launch the app so its debugger dials the bridge, then retry."
758
+ };
759
+ const result = await client.evaluate(code);
760
+ return {
761
+ ok: result.ok,
762
+ text: formatResult(result),
763
+ data: result
764
+ };
765
+ }
766
+ };
767
+ //#endregion
768
+ //#region src/lib/reload-transport.ts
769
+ /**
770
+ * The shipped transport: hot reload is not yet wired, so it only reports that the app must be
771
+ * restarted for the rebuild to take effect. The real mechanism swaps this out without touching
772
+ * `ubd dev`.
773
+ */
774
+ const stubReloadTransport = { async reload(addon) {
775
+ process.stdout.write(`Rebuilt ${addon.id}, but hot reload is not yet available — restart the app to load it.\n`);
776
+ } };
777
+ //#endregion
778
+ //#region src/lib/dev-server.ts
779
+ /** How long file changes inside one addon settle before that addon rebuilds. */
780
+ const DEBOUNCE_MS = 150;
781
+ /**
782
+ * Watches every discovered addon and rebuilds only the addon whose files changed, debounced per
783
+ * addon. Directory creates and deletes under the workspace re-run discovery so addons added or
784
+ * removed while the server runs are picked up. After each successful build the reload transport is
785
+ * called; static addons skip the build step but still count as a successful build for reload.
786
+ */
787
+ var DevServer = class {
788
+ resolved;
789
+ transport;
790
+ watcher = null;
791
+ addons = /* @__PURE__ */ new Map();
792
+ timers = /* @__PURE__ */ new Map();
793
+ constructor(resolved, transport) {
794
+ this.resolved = resolved;
795
+ this.transport = transport;
796
+ }
797
+ /** @description Runs initial discovery, builds every addon once, then watches until {@link stop}ped. */
798
+ async start() {
799
+ await this.rediscover();
800
+ for (const addon of this.addons.values()) await this.rebuild(addon);
801
+ this.watcher = watch(this.resolved.dir, { recursive: true }, (_event, filename) => {
802
+ if (!filename) return;
803
+ this.onChange(resolve(this.resolved.dir, filename));
804
+ });
805
+ }
806
+ /** @description Tears the server down, clearing the watcher and any pending rebuilds. Twin of {@link start}. */
807
+ stop() {
808
+ this.watcher?.close();
809
+ this.watcher = null;
810
+ for (const timer of this.timers.values()) clearTimeout(timer);
811
+ this.timers.clear();
812
+ }
813
+ onChange(changedPath) {
814
+ if (isIgnoredPath(changedPath)) return;
815
+ if (isManifestPath(changedPath)) {
816
+ this.rediscover();
817
+ return;
818
+ }
819
+ const addon = this.findOwner(changedPath);
820
+ if (!addon) return;
821
+ if (!addon.static && isInside(changedPath, addon.output)) return;
822
+ this.scheduleRebuild(addon);
823
+ }
824
+ scheduleRebuild(addon) {
825
+ const existing = this.timers.get(addon.dir);
826
+ if (existing) clearTimeout(existing);
827
+ const timer = setTimeout(() => {
828
+ this.timers.delete(addon.dir);
829
+ this.rebuild(addon);
830
+ }, DEBOUNCE_MS);
831
+ this.timers.set(addon.dir, timer);
832
+ }
833
+ async rebuild(addon) {
834
+ if (!addon.static) process.stdout.write(`Building ${addon.id}…\n`);
835
+ if (!await buildAddon(addon, this.resolved.config.build)) {
836
+ process.stderr.write(`Failed to build ${addon.id}.\n`);
837
+ return;
838
+ }
839
+ if (!addon.static) process.stdout.write(`Built ${addon.id}.\n`);
840
+ await this.transport.reload(toBuiltAddon(addon));
841
+ }
842
+ async rediscover() {
843
+ const discovered = await discoverAddons(this.resolved);
844
+ const next = new Map(discovered.map((addon) => [addon.dir, addon]));
845
+ for (const dir of this.addons.keys()) if (!next.has(dir)) process.stdout.write(`Addon at ${dir} removed.\n`);
846
+ for (const [dir, addon] of next) if (!this.addons.has(dir)) process.stdout.write(`Discovered addon ${addon.id}.\n`);
847
+ this.addons = next;
848
+ }
849
+ findOwner(changedPath) {
850
+ for (const addon of this.addons.values()) if (isInside(changedPath, addon.dir)) return addon;
851
+ }
852
+ };
853
+ /** Directory names whose contents are never source and would otherwise churn the watcher. */
854
+ const IGNORED_SEGMENTS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
855
+ function isIgnoredPath(changedPath) {
856
+ return changedPath.split(sep).some((segment) => IGNORED_SEGMENTS.has(segment));
857
+ }
858
+ function isInside(changedPath, dir) {
859
+ return changedPath === dir || changedPath.startsWith(dir + sep);
860
+ }
861
+ function isManifestPath(changedPath) {
862
+ return changedPath.endsWith(join(sep, "manifest.json"));
863
+ }
864
+ function toBuiltAddon(addon) {
865
+ return {
866
+ id: addon.id,
867
+ kind: addon.kind,
868
+ dir: addon.dir,
869
+ output: addon.output
870
+ };
871
+ }
872
+ //#endregion
873
+ //#region src/registry.ts
874
+ /**
875
+ * Every command defined once, from which both surfaces derive: the CLI adapter builds a cleye
876
+ * command per entry, the MCP adapter registers a tool for each entry opted into `surfaces.mcp`.
877
+ */
878
+ const commands = [
879
+ evalCommand,
880
+ buildCommand,
881
+ {
882
+ name: "dev",
883
+ description: "Watch every addon in the workspace and rebuild only the addon whose files change, reloading it in the running client after each successful build. Runs until interrupted.",
884
+ surfaces: {
885
+ cli: true,
886
+ mcp: false
887
+ },
888
+ schema: z.object({}),
889
+ async handler() {
890
+ const server = new DevServer(loadConfig(), stubReloadTransport);
891
+ process.on("SIGINT", () => {
892
+ server.stop();
893
+ process.exit(0);
894
+ });
895
+ await server.start();
896
+ return new Promise(() => {});
897
+ }
898
+ },
899
+ createCommand
900
+ ];
901
+ //#endregion
902
+ //#region src/commands/mcp.ts
903
+ /**
904
+ * @description The `ubd mcp` command: a stdio MCP server exposing every registry entry opted into
905
+ * `surfaces.mcp`, each riding a single long-lived controller connection to the bridge. This is the
906
+ * relocated debugger MCP, now registry-driven.
907
+ */
908
+ const mcpCommand = command({
909
+ name: "mcp",
910
+ description: "Serve the debugger as a stdio MCP server for AI clients.",
911
+ flags: { port: {
912
+ type: Number,
913
+ description: "The port the debugger bridge listens on.",
914
+ default: DEFAULT_BRIDGE_PORT
915
+ } }
916
+ }, async (parsed) => {
917
+ const context = createContext(parsed.flags.port);
918
+ const server = new McpServer({
919
+ name: "ubd",
920
+ version: "1.0.0"
921
+ });
922
+ for (const definition of commands) {
923
+ if (!definition.surfaces.mcp) continue;
924
+ toMcpTool(server, definition, context);
925
+ }
926
+ const transport = new StdioServerTransport();
927
+ await server.connect(transport);
928
+ });
929
+ //#endregion
930
+ //#region src/lib/menu.ts
931
+ /** Concise, menu-friendly summaries keyed by command name; the registry descriptions are tuned for
932
+ * the MCP surface and too long for a tidy list. Presentation copy belongs at the CLI edge. */
933
+ const SUMMARIES = {
934
+ create: "Scaffold a new addon or workspace.",
935
+ dev: "Watch addons and rebuild on change.",
936
+ build: "Build every addon in the workspace.",
937
+ eval: "Run JavaScript on the connected device.",
938
+ mcp: "Serve the debugger as an MCP server."
939
+ };
940
+ /** The order commands appear in the menu, most-reached-for first. */
941
+ const ORDER = [
942
+ "create",
943
+ "dev",
944
+ "build",
945
+ "eval",
946
+ "mcp"
947
+ ];
948
+ /**
949
+ * @description Renders the branded `ubd` menu shown when the CLI runs with no command: a colored
950
+ * banner, every command with a one-line summary, and usage hints. Command names are sourced from the
951
+ * registry (plus the standalone `mcp` command) so a new command surfaces here once it is registered.
952
+ * @returns The menu as a single printable string.
953
+ */
954
+ function renderMenu() {
955
+ const names = /* @__PURE__ */ new Set([...commands.map((command) => command.name), "mcp"]);
956
+ const ordered = [...ORDER.filter((name) => names.has(name)), ...[...names].filter((name) => !ORDER.includes(name))];
957
+ const width = Math.max(...ordered.map((name) => name.length));
958
+ const rows = ordered.map((name) => {
959
+ return ` ${pc.green(name.padEnd(width))} ${pc.dim(SUMMARIES[name] ?? "")}`;
960
+ });
961
+ return [
962
+ "",
963
+ ` ${pc.bold(pc.magenta("ubd"))} ${pc.dim("· the Unbound addon toolkit")}`,
964
+ "",
965
+ ` ${pc.bold("Usage")}`,
966
+ ` ${pc.cyan("ubd")} ${pc.yellow("<command>")} ${pc.dim("[flags]")}`,
967
+ "",
968
+ ` ${pc.bold("Commands")}`,
969
+ ...rows,
970
+ "",
971
+ ` ${pc.dim("Run")} ${pc.cyan("ubd <command> --help")} ${pc.dim("for details.")}`,
972
+ ` ${pc.dim("New here? Start with")} ${pc.cyan("ubd create")}${pc.dim(".")}`,
973
+ ""
974
+ ].join("\n");
975
+ }
976
+ //#endregion
977
+ //#region src/index.ts
978
+ const argv = cli({
979
+ name: "ubd",
980
+ commands: [...commands.map(toCleyeCommand), mcpCommand]
981
+ });
982
+ if (!argv.command && argv._.length === 0 && !argv.flags.help && !argv.flags.version) process.stdout.write(`${renderMenu()}\n`);
983
+ //#endregion
984
+ export {};
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@unbound-app/cli",
3
+ "version": "0.1.0",
4
+ "description": "The `ubd` command line for the Unbound client: evaluate on-device code and serve the debugger as an MCP.",
5
+ "author": {
6
+ "name": "Mario P.",
7
+ "email": "contact@marioparaschiv.com",
8
+ "url": "https://github.com/marioparaschiv"
9
+ },
10
+ "bin": {
11
+ "ubd": "./dist/index.mjs"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "templates"
16
+ ],
17
+ "type": "module",
18
+ "module": "./dist/index.mjs",
19
+ "scripts": {
20
+ "build": "tsdown"
21
+ },
22
+ "dependencies": {
23
+ "@clack/prompts": "^1.7.0",
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "@unbound-app/debugger-protocol": "0.1.0",
26
+ "cleye": "^1.3.4",
27
+ "picocolors": "^1.1.1",
28
+ "zod": "^4.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/bun": "latest",
32
+ "tsdown": "^0.22.9"
33
+ },
34
+ "peerDependencies": {
35
+ "typescript": "^5.9.3"
36
+ }
37
+ }
@@ -0,0 +1,21 @@
1
+ import { metro, patcher } from '@unbound-app/api';
2
+
3
+ const unpatches: (() => void)[] = [];
4
+
5
+ export default {
6
+ start() {
7
+ const UserStore = metro.findByProps('getCurrentUser', 'getUser');
8
+
9
+ unpatches.push(
10
+ patcher.after(UserStore, 'getCurrentUser', ({ result }) => {
11
+ console.log('[{{displayName}}] getCurrentUser returned', result?.username);
12
+ }),
13
+ );
14
+ },
15
+
16
+ stop() {
17
+ for (const unpatch of unpatches) unpatch();
18
+
19
+ unpatches.length = 0;
20
+ },
21
+ };
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "{{id}}",
3
+ "name": "{{displayName}}",
4
+ "description": "{{description}}",
5
+ "authors": [
6
+ {
7
+ "name": "{{authorName}}",
8
+ "id": "{{authorId}}"
9
+ }
10
+ ],
11
+ "version": "1.0.0",
12
+ "main": "index.tsx"
13
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "1.0.0",
4
+ "description": "{{description}}",
5
+ "private": true,
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "rollup -c"
9
+ },
10
+ "devDependencies": {
11
+ "@unbound-app/api": "^1.0.0",
12
+ "@rollup/plugin-json": "^6.1.0",
13
+ "@rollup/plugin-node-resolve": "^16.0.3",
14
+ "@types/react": "^19.2.17",
15
+ "react-native": "^0.86.0",
16
+ "rollup": "^4.62.2",
17
+ "rollup-plugin-iife": "^0.8.0",
18
+ "rollup-plugin-swc3": "^0.12.1"
19
+ }
20
+ }
@@ -0,0 +1,27 @@
1
+ import { nodeResolve } from '@rollup/plugin-node-resolve';
2
+ import { swc } from 'rollup-plugin-swc3';
3
+ import json from '@rollup/plugin-json';
4
+ import iife from 'rollup-plugin-iife';
5
+
6
+ const globals = {
7
+ '@unbound-app/api': 'window.unbound',
8
+ 'react': 'window.React',
9
+ 'react-native': 'window.ReactNative',
10
+ 'react-native-reanimated': 'window.unbound.metro.common.Reanimated',
11
+ '@react-native-clipboard/clipboard': 'window.unbound.metro.common.Clipboard',
12
+ 'moment': 'window.unbound.metro.common.Moment',
13
+ };
14
+
15
+ /** @type {import('rollup').RollupOptions} */
16
+ export default {
17
+ input: 'index.tsx',
18
+ external: Object.keys(globals),
19
+ plugins: [nodeResolve(), json(), swc({ tsconfig: false }), iife()],
20
+ output: {
21
+ dir: 'dist',
22
+ format: 'es',
23
+ compact: true,
24
+ exports: 'named',
25
+ globals,
26
+ },
27
+ };
@@ -0,0 +1,15 @@
1
+ name: Build
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: oven-sh/setup-bun@v2
14
+ - run: bun install
15
+ - run: bunx ubd build
@@ -0,0 +1,28 @@
1
+ # {{displayName}}
2
+
3
+ {{description}}
4
+
5
+ An [Unbound](https://unbound.rip) addon workspace scaffolded with `ubd create`.
6
+
7
+ ## Development
8
+
9
+ ```sh
10
+ bun install
11
+ bun run dev
12
+ ```
13
+
14
+ `ubd dev` watches every addon under `plugins/` and `themes/`, rebuilding the one you change.
15
+
16
+ ## Building
17
+
18
+ ```sh
19
+ bun run build
20
+ ```
21
+
22
+ Builds every addon once into its `dist/` directory.
23
+
24
+ ## Learn more
25
+
26
+ - Plugins: https://docs.unbound.rip/plugins/introduction
27
+ - Themes: https://docs.unbound.rip/themes/introduction
28
+ - Manifest reference: https://docs.unbound.rip/addons/manifest
@@ -0,0 +1,4 @@
1
+ node_modules
2
+ dist
3
+ *.log
4
+ .DS_Store
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "workspaces": [
7
+ "plugins/*",
8
+ "themes/*"
9
+ ],
10
+ "scripts": {
11
+ "dev": "ubd dev",
12
+ "build": "ubd build"
13
+ },
14
+ "devDependencies": {
15
+ "@unbound-app/cli": "^1.0.0"
16
+ }
17
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ESNext"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true,
11
+ "resolveJsonModule": true,
12
+ "noEmit": true
13
+ },
14
+ "include": ["plugins", "themes"]
15
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://docs.unbound.rip/schemas/config.json",
3
+ "addons": ["plugins/*", "themes/*"],
4
+ "build": "bun run build",
5
+ "output": "dist"
6
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "{{id}}",
3
+ "name": "{{displayName}}",
4
+ "description": "{{description}}",
5
+ "authors": [
6
+ {
7
+ "name": "{{authorName}}",
8
+ "id": "{{authorId}}"
9
+ }
10
+ ],
11
+ "version": "1.0.0",
12
+ "main": "{{main}}.json"
13
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "type": "darker",
3
+ "semantic": {
4
+ "BACKGROUND_PRIMARY": {
5
+ "type": "color",
6
+ "value": "#1a1a2e"
7
+ }
8
+ },
9
+ "raw": {}
10
+ }