@gntik-ai/cli 0.0.0-stage → 0.5.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.
Files changed (3) hide show
  1. package/README.md +140 -2
  2. package/dist/index.js +1712 -0
  3. package/package.json +33 -3
package/dist/index.js ADDED
@@ -0,0 +1,1712 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
7
+ import { spawnSync } from "node:child_process";
8
+ import { pathToFileURL } from "node:url";
9
+ //#region package.json
10
+ var version = "0.5.0";
11
+ //#endregion
12
+ //#region src/errors.ts
13
+ /** Machine-readable error codes and the exit code each one maps to. */
14
+ const EXIT_CODES = {
15
+ OK: 0,
16
+ ERROR: 1,
17
+ REGISTRY: 1,
18
+ INSTALL: 1,
19
+ CODEMODS: 1,
20
+ USAGE: 2,
21
+ NOT_FOUND: 3,
22
+ CONFLICT: 4
23
+ };
24
+ var CliError = class extends Error {
25
+ code;
26
+ constructor(code, message) {
27
+ super(message);
28
+ this.code = code;
29
+ }
30
+ get exitCode() {
31
+ return EXIT_CODES[this.code];
32
+ }
33
+ };
34
+ //#endregion
35
+ //#region src/args.ts
36
+ const COMMANDS = [
37
+ "init",
38
+ "add",
39
+ "eject",
40
+ "list",
41
+ "search",
42
+ "docs",
43
+ "upgrade"
44
+ ];
45
+ const OPTIONS = {
46
+ json: { type: "boolean" },
47
+ cwd: { type: "string" },
48
+ registry: {
49
+ type: "string",
50
+ short: "r"
51
+ },
52
+ "dry-run": { type: "boolean" },
53
+ yes: {
54
+ type: "boolean",
55
+ short: "y"
56
+ },
57
+ overwrite: { type: "boolean" },
58
+ "no-install": { type: "boolean" },
59
+ "no-deps": { type: "boolean" },
60
+ kind: {
61
+ type: "string",
62
+ short: "k"
63
+ },
64
+ group: {
65
+ type: "string",
66
+ short: "g"
67
+ },
68
+ brand: { type: "string" },
69
+ limit: { type: "string" },
70
+ from: { type: "string" },
71
+ to: { type: "string" },
72
+ codemod: {
73
+ type: "string",
74
+ multiple: true
75
+ },
76
+ "skip-codemod": {
77
+ type: "string",
78
+ multiple: true
79
+ },
80
+ list: { type: "boolean" },
81
+ apply: { type: "boolean" },
82
+ help: {
83
+ type: "boolean",
84
+ short: "h"
85
+ },
86
+ version: {
87
+ type: "boolean",
88
+ short: "v"
89
+ }
90
+ };
91
+ /** Flags each command accepts on top of the global ones (--json --cwd --registry --help --yes). */
92
+ const COMMAND_FLAGS = {
93
+ init: [
94
+ "dry-run",
95
+ "overwrite",
96
+ "no-install",
97
+ "brand"
98
+ ],
99
+ add: [
100
+ "dry-run",
101
+ "overwrite",
102
+ "no-install"
103
+ ],
104
+ eject: [
105
+ "dry-run",
106
+ "overwrite",
107
+ "no-install",
108
+ "no-deps"
109
+ ],
110
+ list: ["kind", "group"],
111
+ search: ["kind", "limit"],
112
+ docs: [],
113
+ upgrade: [
114
+ "dry-run",
115
+ "from",
116
+ "to",
117
+ "codemod",
118
+ "skip-codemod",
119
+ "list",
120
+ "apply"
121
+ ]
122
+ };
123
+ const GLOBAL_FLAGS = [
124
+ "json",
125
+ "cwd",
126
+ "registry",
127
+ "help",
128
+ "version",
129
+ "yes"
130
+ ];
131
+ function parseCliArgs(argv) {
132
+ let parsed;
133
+ try {
134
+ parsed = parseArgs({
135
+ args: argv,
136
+ options: OPTIONS,
137
+ allowPositionals: true,
138
+ strict: true
139
+ });
140
+ } catch (e) {
141
+ throw new CliError("USAGE", e.message);
142
+ }
143
+ const { values, positionals } = parsed;
144
+ const [first, ...rest] = positionals;
145
+ let command;
146
+ if (first !== void 0) {
147
+ if (!COMMANDS.includes(first)) throw new CliError("USAGE", `Unknown command "${first}". Run gntik-ui --help.`);
148
+ command = first;
149
+ const allowed = /* @__PURE__ */ new Set([...GLOBAL_FLAGS, ...COMMAND_FLAGS[command]]);
150
+ for (const key of Object.keys(values)) if (!allowed.has(key)) throw new CliError("USAGE", `Option --${key} is not valid for "${command}".`);
151
+ }
152
+ let limit;
153
+ if (values.limit !== void 0) {
154
+ limit = Number(values.limit);
155
+ if (!Number.isInteger(limit) || limit < 1) throw new CliError("USAGE", "--limit must be a positive integer");
156
+ }
157
+ if (values.apply && values["dry-run"]) throw new CliError("USAGE", "--apply and --dry-run cannot be combined");
158
+ return {
159
+ command,
160
+ positionals: command ? rest : positionals,
161
+ flags: {
162
+ json: values.json ?? false,
163
+ cwd: values.cwd,
164
+ registry: values.registry,
165
+ dryRun: values["dry-run"] ?? false,
166
+ yes: values.yes ?? false,
167
+ overwrite: values.overwrite ?? false,
168
+ install: !values["no-install"],
169
+ deps: !values["no-deps"],
170
+ kind: values.kind,
171
+ group: values.group,
172
+ brand: values.brand,
173
+ limit,
174
+ from: values.from,
175
+ to: values.to,
176
+ codemods: (values.codemod ?? []).flatMap(splitList),
177
+ skipCodemods: (values["skip-codemod"] ?? []).flatMap(splitList),
178
+ list: values.list ?? false,
179
+ apply: values.apply ?? false,
180
+ help: values.help ?? false,
181
+ version: values.version ?? false
182
+ }
183
+ };
184
+ }
185
+ /** `--codemod a,b --codemod c` → ['a', 'b', 'c']. */
186
+ function splitList(value) {
187
+ return value.split(",").map((v) => v.trim()).filter(Boolean);
188
+ }
189
+ //#endregion
190
+ //#region src/project.ts
191
+ const CONFIG_FILE = "gntik-ui.json";
192
+ const VITE_CONFIGS = [
193
+ "ts",
194
+ "mts",
195
+ "js",
196
+ "mjs",
197
+ "cjs",
198
+ "cts"
199
+ ].map((e) => `vite.config.${e}`);
200
+ const NEXT_CONFIGS = [
201
+ "ts",
202
+ "mjs",
203
+ "js",
204
+ "cjs"
205
+ ].map((e) => `next.config.${e}`);
206
+ const CSS_CANDIDATES = [
207
+ "src/index.css",
208
+ "src/styles.css",
209
+ "src/globals.css",
210
+ "src/app.css",
211
+ "src/main.css",
212
+ "src/styles/globals.css",
213
+ "src/app/globals.css",
214
+ "app/globals.css",
215
+ "styles/globals.css",
216
+ "styles.css"
217
+ ];
218
+ const LOCKFILES = [
219
+ ["pnpm-lock.yaml", "pnpm"],
220
+ ["bun.lock", "bun"],
221
+ ["bun.lockb", "bun"],
222
+ ["yarn.lock", "yarn"],
223
+ ["package-lock.json", "npm"]
224
+ ];
225
+ function readJson(file) {
226
+ if (!existsSync(file)) return null;
227
+ try {
228
+ return JSON.parse(readFileSync(file, "utf8"));
229
+ } catch {
230
+ throw new CliError("ERROR", `${file} is not valid JSON`);
231
+ }
232
+ }
233
+ function detectPackageManager(cwd, pkg) {
234
+ let dir = cwd;
235
+ for (;;) {
236
+ for (const [file, pm] of LOCKFILES) if (existsSync(path.join(dir, file))) return pm;
237
+ const parent = path.dirname(dir);
238
+ if (parent === dir) break;
239
+ dir = parent;
240
+ }
241
+ const declared = pkg?.packageManager?.split("@")[0];
242
+ if (declared === "pnpm" || declared === "yarn" || declared === "bun" || declared === "npm") return declared;
243
+ return "npm";
244
+ }
245
+ function detectProject(cwd) {
246
+ const has = (f) => existsSync(path.join(cwd, f));
247
+ const packageJson = readJson(path.join(cwd, "package.json"));
248
+ const deps = {
249
+ ...packageJson?.dependencies,
250
+ ...packageJson?.devDependencies
251
+ };
252
+ let framework = "generic";
253
+ if (NEXT_CONFIGS.some(has) || "next" in deps) framework = "next";
254
+ else if (VITE_CONFIGS.some(has) || "vite" in deps) framework = "vite";
255
+ const src = has("src");
256
+ const css = CSS_CANDIDATES.find(has) ?? null;
257
+ let cssDefault = "src/index.css";
258
+ let entry = [
259
+ "src/main.tsx",
260
+ "src/main.jsx",
261
+ "src/main.ts",
262
+ "src/main.js",
263
+ "src/index.tsx"
264
+ ].find(has) ?? "src/main.tsx";
265
+ if (framework === "next") {
266
+ const appDir = has("src/app") || src && !has("app") ? "src/app" : "app";
267
+ cssDefault = `${appDir}/globals.css`;
268
+ entry = [
269
+ "tsx",
270
+ "jsx",
271
+ "js"
272
+ ].map((e) => `${appDir}/layout.${e}`).find(has) ?? `${appDir}/layout.tsx`;
273
+ } else if (!src) cssDefault = "index.css";
274
+ return {
275
+ cwd,
276
+ framework,
277
+ packageManager: detectPackageManager(cwd, packageJson),
278
+ css,
279
+ cssDefault,
280
+ src,
281
+ entry,
282
+ packageJson
283
+ };
284
+ }
285
+ function defaultConfig(info, brand = "gntik") {
286
+ const root = info.src ? "src/" : "";
287
+ return {
288
+ version: 1,
289
+ framework: info.framework,
290
+ packageManager: info.packageManager,
291
+ css: info.css ?? info.cssDefault,
292
+ brand,
293
+ aliases: {
294
+ components: `${root}components`,
295
+ blocks: `${root}components/blocks`,
296
+ templates: `${root}components/templates`
297
+ }
298
+ };
299
+ }
300
+ function loadConfig(cwd) {
301
+ const cfg = readJson(path.join(cwd, CONFIG_FILE));
302
+ if (!cfg) return null;
303
+ if (!cfg.aliases?.components) throw new CliError("ERROR", `${CONFIG_FILE} is missing aliases.components`);
304
+ return cfg;
305
+ }
306
+ /** Maps a registry target into the project via the config aliases (project-relative, posix). */
307
+ function mapTarget(target, kind, config) {
308
+ const { aliases } = config;
309
+ const join = (dir, rest) => path.posix.join(dir || ".", rest);
310
+ if (target.startsWith("components/")) return join(aliases.components, target.slice(11));
311
+ if (target.startsWith("blocks/")) return join(aliases.blocks, target.slice(7));
312
+ if (target.startsWith("templates/")) return join(aliases.templates, target.slice(10));
313
+ return join(kind === "block" ? aliases.blocks : kind === "template" ? aliases.templates : aliases.components, target);
314
+ }
315
+ function installedPackages(pkg) {
316
+ return /* @__PURE__ */ new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
317
+ }
318
+ function installCommand(pm, packages, dev = false) {
319
+ return [
320
+ pm,
321
+ pm === "npm" ? "install" : "add",
322
+ ...dev ? ["-D"] : [],
323
+ ...packages
324
+ ];
325
+ }
326
+ //#endregion
327
+ //#region src/copy.ts
328
+ const posix = path.posix;
329
+ const CODE_EXT = /\.(tsx?|jsx?|mts|cts|mjs|cjs)$/;
330
+ /** Packages a React project always has; never auto-installed. */
331
+ const IMPLICIT = /* @__PURE__ */ new Set(["react", "react-dom"]);
332
+ const stripExt = (p) => p.replace(CODE_EXT, "");
333
+ /** Bare specifier → package name ('@scope/pkg/sub' → '@scope/pkg'). */
334
+ function packageName(spec) {
335
+ if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:") || spec.startsWith("#")) return null;
336
+ const parts = spec.split("/");
337
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0] ?? null;
338
+ }
339
+ /** Every module specifier in a source file, with its position. */
340
+ const SPEC_RE = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)(['"])([^'"\n]+)\2/g;
341
+ function rewriteSpecifiers(code, fn) {
342
+ return code.replace(SPEC_RE, (m, lead, q, spec) => {
343
+ const next = fn(spec);
344
+ return next === spec ? m : `${lead}${q}${next}${q}`;
345
+ });
346
+ }
347
+ function buildIndex(registry) {
348
+ const byKey = /* @__PURE__ */ new Map();
349
+ const pkgByDir = /* @__PURE__ */ new Map();
350
+ for (const item of registry.items) for (const f of [...item.files.map((x) => x.path), ...item.docs ? [item.docs] : []]) {
351
+ const key = stripExt(f);
352
+ if (!byKey.has(key)) byKey.set(key, {
353
+ item,
354
+ path: f,
355
+ viaDir: false
356
+ });
357
+ if (posix.basename(key) === "index") {
358
+ const dir = posix.dirname(key);
359
+ if (!byKey.has(dir)) byKey.set(dir, {
360
+ item,
361
+ path: f,
362
+ viaDir: true
363
+ });
364
+ }
365
+ const m = /^packages\/([^/]+)\//.exec(f);
366
+ if (m?.[1] && !pkgByDir.has(m[1])) pkgByDir.set(m[1], item.package);
367
+ }
368
+ return {
369
+ byKey,
370
+ pkgByDir
371
+ };
372
+ }
373
+ /** Target for an item's doc file: next to the item's file that shares its source folder. */
374
+ function docsTarget(item, config) {
375
+ if (!item.docs) return null;
376
+ const dir = posix.dirname(item.docs);
377
+ const sibling = item.files.find((f) => posix.dirname(f.path) === dir) ?? item.files[0];
378
+ if (!sibling) return null;
379
+ return posix.join(posix.dirname(mapTarget(sibling.target, item.kind, config)), posix.basename(item.docs));
380
+ }
381
+ function relSpec(fromFile, toFile, keepExt, asDir) {
382
+ let to = asDir ? posix.dirname(toFile) : stripExt(toFile);
383
+ if (keepExt && !asDir) to += keepExt;
384
+ let rel = posix.relative(posix.dirname(fromFile), to);
385
+ if (!rel.startsWith(".")) rel = `./${rel}`;
386
+ return rel;
387
+ }
388
+ /**
389
+ * Reads the items' files from the registry, maps them to project targets and rewrites imports:
390
+ * relative imports between copied files are re-pointed at the new locations; relative imports
391
+ * into kit code that is not copied become package imports (e.g. '../../components/Button' →
392
+ * '@gntik-ai/ui'). Nothing is written here.
393
+ */
394
+ async function planCopy(registry, items, config, cwd, opts = {}) {
395
+ const { byKey, pkgByDir } = buildIndex(registry);
396
+ const copyMap = /* @__PURE__ */ new Map();
397
+ const sources = [];
398
+ for (const item of items) {
399
+ for (const f of item.files) {
400
+ copyMap.set(f.path, mapTarget(f.target, item.kind, config));
401
+ sources.push({
402
+ item,
403
+ source: f.path
404
+ });
405
+ }
406
+ const dt = opts.includeDocs ? docsTarget(item, config) : null;
407
+ if (dt && item.docs) {
408
+ copyMap.set(item.docs, dt);
409
+ sources.push({
410
+ item,
411
+ source: item.docs
412
+ });
413
+ }
414
+ }
415
+ const rewrites = [];
416
+ const warnings = [];
417
+ const packages = /* @__PURE__ */ new Set();
418
+ const files = [];
419
+ for (const { item, source } of sources) {
420
+ const target = copyMap.get(source);
421
+ let content = await registry.read(source);
422
+ if (CODE_EXT.test(source)) content = rewriteSpecifiers(content, (spec) => {
423
+ const bare = packageName(spec);
424
+ if (bare) {
425
+ if (!IMPLICIT.has(bare)) packages.add(bare);
426
+ return spec;
427
+ }
428
+ if (!spec.startsWith(".")) return spec;
429
+ const extMatch = CODE_EXT.exec(spec);
430
+ const resolved = posix.normalize(posix.join(posix.dirname(source), stripExt(spec)));
431
+ const hit = byKey.get(resolved);
432
+ let next = spec;
433
+ const copiedTarget = hit ? copyMap.get(hit.path) : void 0;
434
+ if (hit && copiedTarget) next = relSpec(target, copiedTarget, extMatch?.[0] ?? null, hit.viaDir);
435
+ else if (hit) next = hit.item.package;
436
+ else {
437
+ const dir = /^packages\/([^/]+)\//.exec(resolved)?.[1];
438
+ if (dir) {
439
+ next = pkgByDir.get(dir) ?? `@gntik-ai/${dir}`;
440
+ if (source !== item.docs) warnings.push(`${target}: '${spec}' is kit-internal code outside the registry; mapped to '${next}' — check that it is exported there.`);
441
+ } else warnings.push(`${target}: could not resolve '${spec}'; left unchanged.`);
442
+ }
443
+ if (next !== spec) {
444
+ rewrites.push({
445
+ file: target,
446
+ from: spec,
447
+ to: next
448
+ });
449
+ const pkg = packageName(next);
450
+ if (pkg) packages.add(pkg);
451
+ }
452
+ return next;
453
+ });
454
+ const abs = path.join(cwd, target);
455
+ let status = "create";
456
+ if (existsSync(abs)) status = await readFile(abs, "utf8") === content ? "identical" : "conflict";
457
+ files.push({
458
+ id: item.id,
459
+ source,
460
+ target,
461
+ status,
462
+ content
463
+ });
464
+ }
465
+ return {
466
+ files,
467
+ rewrites,
468
+ warnings,
469
+ packages: [...packages].sort()
470
+ };
471
+ }
472
+ /** Writes the plan unless it has conflicts (then nothing is written) or this is a dry run. */
473
+ async function applyCopy(plan, cwd, opts) {
474
+ if (opts.overwrite) {
475
+ for (const f of plan.files) if (f.status === "conflict") f.status = "overwrite";
476
+ }
477
+ const conflicts = plan.files.filter((f) => f.status === "conflict").map((f) => f.target);
478
+ const written = [];
479
+ if (conflicts.length || opts.dryRun) return {
480
+ conflicts,
481
+ written
482
+ };
483
+ for (const f of plan.files) {
484
+ if (f.status === "identical") continue;
485
+ const abs = path.join(cwd, f.target);
486
+ await mkdir(path.dirname(abs), { recursive: true });
487
+ await writeFile(abs, f.content);
488
+ written.push(f.target);
489
+ }
490
+ return {
491
+ conflicts,
492
+ written
493
+ };
494
+ }
495
+ const fileSummary = (f) => ({
496
+ id: f.id,
497
+ source: f.source,
498
+ target: f.target,
499
+ status: f.status
500
+ });
501
+ const REGISTRY_FILE = "kit-registry.json";
502
+ const ITEM_KINDS = [
503
+ "component",
504
+ "layout",
505
+ "block",
506
+ "template"
507
+ ];
508
+ /** Copy-in kinds: their source is copied into the project. Components and layouts install from npm. */
509
+ const isCopyIn = (kind) => kind === "block" || kind === "template";
510
+ var Registry = class {
511
+ data;
512
+ source;
513
+ byId = /* @__PURE__ */ new Map();
514
+ constructor(data, source) {
515
+ this.data = data;
516
+ this.source = source;
517
+ for (const item of data.items) this.byId.set(item.id, item);
518
+ }
519
+ get items() {
520
+ return this.data.items;
521
+ }
522
+ get(id) {
523
+ return this.byId.get(id);
524
+ }
525
+ /** Reads a repo-relative file from the registry source. */
526
+ async read(file) {
527
+ if (this.source.type === "local") try {
528
+ return await readFile(path.join(this.source.base, file), "utf8");
529
+ } catch {
530
+ throw new CliError("REGISTRY", `Cannot read ${file} from ${this.source.base}`);
531
+ }
532
+ return fetchText(new URL(file, this.source.base).href);
533
+ }
534
+ /**
535
+ * Requested ids plus their registryDependencies, recursively; dependencies come before
536
+ * the items that need them. Unknown ids throw NOT_FOUND.
537
+ */
538
+ closure(ids) {
539
+ const out = [];
540
+ const seen = /* @__PURE__ */ new Set();
541
+ const visit = (id, from) => {
542
+ if (seen.has(id)) return;
543
+ seen.add(id);
544
+ const item = this.get(id);
545
+ if (!item) throw new CliError("NOT_FOUND", from ? `Unknown registry dependency "${id}" (needed by ${from})` : `Unknown item "${id}"`);
546
+ for (const dep of item.registryDependencies) visit(dep, id);
547
+ out.push(item);
548
+ };
549
+ for (const id of ids) visit(id);
550
+ return out;
551
+ }
552
+ };
553
+ async function fetchText(url) {
554
+ let res;
555
+ try {
556
+ res = await fetch(url);
557
+ } catch (e) {
558
+ throw new CliError("REGISTRY", `Cannot fetch ${url}: ${e.message}`);
559
+ }
560
+ if (!res.ok) throw new CliError("REGISTRY", `Cannot fetch ${url}: HTTP ${res.status}`);
561
+ return res.text();
562
+ }
563
+ const isUrl = (s) => /^https?:\/\//i.test(s);
564
+ /** Resolves `--registry` (a checkout dir, a kit-registry.json path or a base URL) to a source. */
565
+ async function resolveSource(spec, cwd) {
566
+ if (isUrl(spec)) {
567
+ if (spec.endsWith(".json")) {
568
+ const u = new URL(spec);
569
+ return {
570
+ source: {
571
+ type: "url",
572
+ base: new URL("./", u).href
573
+ },
574
+ file: u.href
575
+ };
576
+ }
577
+ const base = spec.endsWith("/") ? spec : `${spec}/`;
578
+ return {
579
+ source: {
580
+ type: "url",
581
+ base
582
+ },
583
+ file: new URL(REGISTRY_FILE, base).href
584
+ };
585
+ }
586
+ const abs = path.resolve(cwd, spec);
587
+ const st = await stat(abs).catch(() => null);
588
+ if (!st) throw new CliError("REGISTRY", `Registry path not found: ${abs}`);
589
+ if (st.isFile()) return {
590
+ source: {
591
+ type: "local",
592
+ base: path.dirname(abs)
593
+ },
594
+ file: abs
595
+ };
596
+ return {
597
+ source: {
598
+ type: "local",
599
+ base: abs
600
+ },
601
+ file: path.join(abs, REGISTRY_FILE)
602
+ };
603
+ }
604
+ function validateRegistry(data, where) {
605
+ const fail = (msg) => {
606
+ throw new CliError("REGISTRY", `Invalid registry ${where}: ${msg}`);
607
+ };
608
+ if (!data || typeof data !== "object") fail("not an object");
609
+ const d = data;
610
+ if (d.version !== 1) fail(`unsupported version ${String(d.version)}`);
611
+ if (!Array.isArray(d.items)) fail("\"items\" must be an array");
612
+ for (const [i, item] of d.items.entries()) {
613
+ if (!item || typeof item.id !== "string") fail(`items[${i}].id missing`);
614
+ if (!ITEM_KINDS.includes(item.kind)) fail(`items[${i}] (${item.id}) has unknown kind "${String(item.kind)}"`);
615
+ if (!Array.isArray(item.files)) fail(`items[${i}] (${item.id}) has no files array`);
616
+ item.dependencies ??= [];
617
+ item.registryDependencies ??= [];
618
+ item.group ??= "";
619
+ item.status ??= "stable";
620
+ item.description ??= "";
621
+ }
622
+ return d;
623
+ }
624
+ async function loadRegistry(spec, cwd) {
625
+ const { source, file } = await resolveSource(spec, cwd);
626
+ let text;
627
+ if (source.type === "local") text = await readFile(file, "utf8").catch(() => {
628
+ throw new CliError("REGISTRY", `No ${REGISTRY_FILE} at ${file}`);
629
+ });
630
+ else text = await fetchText(file);
631
+ let json;
632
+ try {
633
+ json = JSON.parse(text);
634
+ } catch {
635
+ throw new CliError("REGISTRY", `${file} is not valid JSON`);
636
+ }
637
+ return new Registry(validateRegistry(json, file), source);
638
+ }
639
+ /** Short, stable item summary used by list/search JSON. */
640
+ function summary(item) {
641
+ return {
642
+ id: item.id,
643
+ kind: item.kind,
644
+ name: item.name,
645
+ package: item.package,
646
+ group: item.group,
647
+ status: item.status,
648
+ description: item.description
649
+ };
650
+ }
651
+ //#endregion
652
+ //#region src/context.ts
653
+ const defaultExec = (cmd, args, { cwd, quiet }) => {
654
+ const r = spawnSync(cmd, args, {
655
+ cwd,
656
+ stdio: quiet ? "pipe" : "inherit",
657
+ shell: process.platform === "win32"
658
+ });
659
+ if (r.error) return 127;
660
+ return r.status ?? 1;
661
+ };
662
+ const ok = (data, text) => ({
663
+ code: 0,
664
+ data,
665
+ text
666
+ });
667
+ function registrySpec(ctx, configured) {
668
+ return ctx.flags.registry ?? ctx.env.GNTIK_UI_REGISTRY ?? configured ?? "https://raw.githubusercontent.com/gntik-ai/gntik-ui/main/";
669
+ }
670
+ async function openRegistry(ctx, configured) {
671
+ return loadRegistry(registrySpec(ctx, configured), ctx.invocationCwd);
672
+ }
673
+ /**
674
+ * Installs the packages the project does not declare yet. Runs only when installing is allowed
675
+ * (no --dry-run, no --no-install); otherwise reports the commands.
676
+ */
677
+ function runInstall(ctx, pm, pkg, wanted, wantedDev = []) {
678
+ const have = installedPackages(pkg);
679
+ const packages = [...new Set(wanted)].filter((p) => !have.has(p)).sort();
680
+ const dev = [...new Set(wantedDev)].filter((p) => !have.has(p) && !packages.includes(p)).sort();
681
+ const cmds = [];
682
+ if (packages.length) cmds.push(installCommand(pm, packages));
683
+ if (dev.length) cmds.push(installCommand(pm, dev, true));
684
+ const report = {
685
+ packages,
686
+ dev,
687
+ commands: cmds.map((c) => c.join(" ")),
688
+ ran: false,
689
+ exitCode: null
690
+ };
691
+ if (!cmds.length || ctx.flags.dryRun || !ctx.flags.install) return report;
692
+ report.ran = true;
693
+ for (const [cmd, ...args] of cmds) {
694
+ const code = ctx.exec(cmd, args, {
695
+ cwd: ctx.cwd,
696
+ quiet: ctx.flags.json
697
+ });
698
+ report.exitCode = code;
699
+ if (code !== 0) break;
700
+ }
701
+ return report;
702
+ }
703
+ function installLines(report, ctx) {
704
+ if (!report.commands.length) return ["Packages: already installed."];
705
+ const verb = report.ran ? "Ran" : ctx.flags.dryRun ? "Would run" : "Run";
706
+ const lines = report.commands.map((c) => `${verb}: ${c}`);
707
+ if (report.ran && report.exitCode !== 0) lines.push(`Install failed (exit ${report.exitCode}).`);
708
+ return lines;
709
+ }
710
+ /** Turns a failed install into a non-zero result that still carries the data. */
711
+ function withInstallStatus(result, report) {
712
+ if (report.ran && report.exitCode !== 0) return {
713
+ ...result,
714
+ code: 1,
715
+ error: new CliError("INSTALL", `Install command failed with exit code ${report.exitCode}`)
716
+ };
717
+ return result;
718
+ }
719
+ //#endregion
720
+ //#region src/commands/add.ts
721
+ function projectContext(ctx) {
722
+ const info = detectProject(ctx.cwd);
723
+ const loaded = loadConfig(ctx.cwd);
724
+ return {
725
+ info,
726
+ config: loaded ?? defaultConfig(info),
727
+ configured: loaded !== null
728
+ };
729
+ }
730
+ /** Public export names of a component: from its index file, else its name. */
731
+ function exportNames(item, indexSource) {
732
+ const names = /* @__PURE__ */ new Set();
733
+ if (indexSource) for (const m of indexSource.matchAll(/export\s*\{([^}]*)\}/g)) for (const part of (m[1] ?? "").split(",")) {
734
+ const p = part.trim();
735
+ if (!p || p.startsWith("type ")) continue;
736
+ const name = (p.split(/\s+as\s+/)[1] ?? p).trim();
737
+ if (/^[A-Z]/.test(name)) names.add(name);
738
+ }
739
+ if (!names.size) names.add(item.name.replace(/[^A-Za-z0-9_$]/g, ""));
740
+ return [...names];
741
+ }
742
+ function importLine(names, from) {
743
+ return `import { ${names.join(", ")} } from '${from}';`;
744
+ }
745
+ async function packageImport(ctx, item, read) {
746
+ const index = item.files.find((f) => /\/index\.[jt]sx?$/.test(f.path));
747
+ return importLine(exportNames(item, index ? await read(index.path).catch(() => void 0) : void 0), item.package);
748
+ }
749
+ function copySection(plan, conflicts, ctx) {
750
+ const label = {
751
+ create: ctx.flags.dryRun ? "would create" : "created",
752
+ identical: "unchanged",
753
+ conflict: "CONFLICT",
754
+ overwrite: ctx.flags.dryRun ? "would overwrite" : "overwritten"
755
+ };
756
+ const lines = plan.files.map((f) => ` ${label[f.status].padEnd(15)} ${f.target}`);
757
+ if (plan.rewrites.length) lines.push("Rewritten imports:", ...plan.rewrites.map((r) => ` ${r.file}: '${r.from}' → '${r.to}'`));
758
+ if (plan.warnings.length) lines.push("Warnings:", ...plan.warnings.map((w) => ` ${w}`));
759
+ if (conflicts.length) lines.push(`${conflicts.length} file(s) already exist and differ — nothing was written. Re-run with --overwrite to replace them.`);
760
+ return lines;
761
+ }
762
+ async function add(ctx, positionals) {
763
+ if (!positionals.length) throw new CliError("USAGE", "Usage: gntik-ui add <id...>");
764
+ const project = projectContext(ctx);
765
+ const { config, info } = project;
766
+ const registry = await openRegistry(ctx, config.registry);
767
+ const closure = registry.closure(positionals);
768
+ const requested = new Set(positionals);
769
+ const toCopy = closure.filter((i) => isCopyIn(i.kind));
770
+ const packageItems = closure.filter((i) => !isCopyIn(i.kind) && (requested.has(i.id) || toCopy.length > 0));
771
+ const viaPackage = packageItems.filter((i) => requested.has(i.id));
772
+ const plan = await planCopy(registry, toCopy, config, ctx.cwd);
773
+ const { conflicts, written } = await applyCopy(plan, ctx.cwd, {
774
+ overwrite: ctx.flags.overwrite,
775
+ dryRun: ctx.flags.dryRun
776
+ });
777
+ const imports = [];
778
+ for (const item of viaPackage) imports.push(await packageImport(ctx, item, (p) => registry.read(p)));
779
+ const wanted = [
780
+ ...packageItems.map((i) => i.package),
781
+ ...toCopy.flatMap((i) => i.dependencies),
782
+ ...plan.packages
783
+ ];
784
+ const install = conflicts.length ? {
785
+ packages: [],
786
+ dev: [],
787
+ commands: [],
788
+ ran: false,
789
+ exitCode: null
790
+ } : runInstall(ctx, config.packageManager, info.packageJson, wanted);
791
+ const data = {
792
+ dryRun: ctx.flags.dryRun,
793
+ configured: project.configured,
794
+ items: closure.map((i) => ({
795
+ id: i.id,
796
+ kind: i.kind,
797
+ package: i.package,
798
+ mode: isCopyIn(i.kind) ? "copy" : "package",
799
+ requested: requested.has(i.id)
800
+ })),
801
+ files: plan.files.map(fileSummary),
802
+ written,
803
+ conflicts,
804
+ rewrites: plan.rewrites,
805
+ warnings: plan.warnings,
806
+ imports,
807
+ install
808
+ };
809
+ const text = [];
810
+ if (!project.configured) text.push(`No ${CONFIG_FILE} found — using defaults (run gntik-ui init).`);
811
+ if (plan.files.length) text.push("Files:", ...copySection(plan, conflicts, ctx));
812
+ if (imports.length) text.push("Import:", ...imports.map((l) => ` ${l}`));
813
+ text.push(...installLines(install, ctx));
814
+ if (conflicts.length) return {
815
+ code: 4,
816
+ data,
817
+ text,
818
+ error: new CliError("CONFLICT", `${conflicts.length} file(s) already exist: ${conflicts.join(", ")}`)
819
+ };
820
+ return withInstallStatus({
821
+ code: 0,
822
+ data,
823
+ text
824
+ }, install);
825
+ }
826
+ //#endregion
827
+ //#region src/commands/eject.ts
828
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
829
+ "node_modules",
830
+ ".git",
831
+ "dist",
832
+ "build",
833
+ ".next",
834
+ "out",
835
+ "coverage",
836
+ ".turbo",
837
+ ".vercel"
838
+ ]);
839
+ const CODE = /\.(tsx?|jsx?|mts|mjs)$/;
840
+ async function walk(dir, out = []) {
841
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
842
+ for (const e of entries) if (e.isDirectory()) {
843
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) await walk(path.join(dir, e.name), out);
844
+ } else if (CODE.test(e.name)) out.push(path.join(dir, e.name));
845
+ return out;
846
+ }
847
+ /** Finds `import { … } from '<package>'` lines that use the ejected component's names. */
848
+ async function findUsages(cwd, pkg, names, localEntry, skip) {
849
+ const usages = [];
850
+ const re = new RegExp(String.raw`import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]${pkg.replace(/[/.]/g, "\\$&")}['"]`, "g");
851
+ for (const abs of await walk(cwd)) {
852
+ const rel = path.relative(cwd, abs).split(path.sep).join("/");
853
+ if (skip.has(rel)) continue;
854
+ const text = await readFile(abs, "utf8");
855
+ for (const m of text.matchAll(re)) {
856
+ const hit = (m[1] ?? "").split(",").map((s) => s.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim() ?? "").filter((n) => names.includes(n));
857
+ if (!hit.length) continue;
858
+ let spec = path.posix.relative(path.posix.dirname(rel), localEntry);
859
+ if (!spec.startsWith(".")) spec = `./${spec}`;
860
+ const line = text.slice(0, m.index).split("\n").length;
861
+ usages.push({
862
+ file: rel,
863
+ line,
864
+ names: hit,
865
+ replacement: `import { ${hit.join(", ")} } from '${spec}';`
866
+ });
867
+ }
868
+ }
869
+ return usages;
870
+ }
871
+ async function eject(ctx, positionals) {
872
+ const [id] = positionals;
873
+ if (!id || positionals.length > 1) throw new CliError("USAGE", "Usage: gntik-ui eject <id>");
874
+ const project = projectContext(ctx);
875
+ const { config, info } = project;
876
+ const registry = await openRegistry(ctx, config.registry);
877
+ const item = registry.get(id);
878
+ if (!item) throw new CliError("NOT_FOUND", `Unknown item "${id}"`);
879
+ const items = ctx.flags.deps ? registry.closure([id]) : [item];
880
+ const plan = await planCopy(registry, items, config, ctx.cwd, { includeDocs: true });
881
+ const { conflicts, written } = await applyCopy(plan, ctx.cwd, {
882
+ overwrite: ctx.flags.overwrite,
883
+ dryRun: ctx.flags.dryRun
884
+ });
885
+ const indexFile = item.files.find((f) => /\/index\.[jt]sx?$/.test(f.path)) ?? item.files[0];
886
+ const localEntry = indexFile ? (() => {
887
+ const t = mapTarget(indexFile.target, item.kind, config);
888
+ return /\/index\.[jt]sx?$/.test(t) ? path.posix.dirname(t) : t.replace(/\.[jt]sx?$/, "");
889
+ })() : "";
890
+ const indexContent = plan.files.find((f) => f.source === indexFile?.path)?.content;
891
+ const names = exportNames(item, indexContent);
892
+ const usages = await findUsages(ctx.cwd, item.package, names, localEntry, new Set(plan.files.map((f) => f.target)));
893
+ const install = conflicts.length ? {
894
+ packages: [],
895
+ dev: [],
896
+ commands: [],
897
+ ran: false,
898
+ exitCode: null
899
+ } : runInstall(ctx, config.packageManager, info.packageJson, [...items.flatMap((i) => i.dependencies), ...plan.packages]);
900
+ const nextSteps = [
901
+ `Import ${names.join(", ")} from the local copy (${localEntry}) instead of '${item.package}'.`,
902
+ ...usages.length ? [`Update the ${usages.length} import(s) listed under usages.`] : [],
903
+ "The copy no longer receives kit updates; diff against the registry when upgrading."
904
+ ];
905
+ const data = {
906
+ dryRun: ctx.flags.dryRun,
907
+ configured: project.configured,
908
+ id,
909
+ items: items.map((i) => i.id),
910
+ files: plan.files.map(fileSummary),
911
+ written,
912
+ conflicts,
913
+ rewrites: plan.rewrites,
914
+ warnings: plan.warnings,
915
+ localImport: localEntry,
916
+ exports: names,
917
+ usages,
918
+ nextSteps,
919
+ install
920
+ };
921
+ const text = [];
922
+ if (!project.configured) text.push(`No ${CONFIG_FILE} found — using defaults (run gntik-ui init).`);
923
+ text.push(`Eject ${item.name} (${items.map((i) => i.id).join(", ")}):`, ...copySection(plan, conflicts, ctx));
924
+ if (conflicts.length) {
925
+ if (ctx.flags.deps) text.push("Tip: --no-deps ejects only this item and keeps its dependencies on the package.");
926
+ return {
927
+ code: 4,
928
+ data,
929
+ text,
930
+ error: new CliError("CONFLICT", `${conflicts.length} file(s) already exist: ${conflicts.join(", ")}`)
931
+ };
932
+ }
933
+ text.push("Next:", ...nextSteps.map((s) => ` - ${s}`));
934
+ for (const u of usages) text.push(` ${u.file}:${u.line} ${u.replacement}`);
935
+ text.push(...installLines(install, ctx));
936
+ return withInstallStatus({
937
+ code: 0,
938
+ data,
939
+ text
940
+ }, install);
941
+ }
942
+ //#endregion
943
+ //#region src/commands/init.ts
944
+ const BRANDS = [
945
+ "gntik",
946
+ "musematic",
947
+ "falcone"
948
+ ];
949
+ const CSS_IMPORTS = [
950
+ "@import \"tailwindcss\";",
951
+ "@import \"@fontsource/geist\";",
952
+ "@import \"@fontsource/geist-mono\";",
953
+ "@import \"@gntik-ai/ui/styles.css\";"
954
+ ];
955
+ const IMPORT_TARGETS = [
956
+ "tailwindcss",
957
+ "@fontsource/geist",
958
+ "@fontsource/geist-mono",
959
+ "@gntik-ai/ui/styles.css"
960
+ ];
961
+ /**
962
+ * Puts Tailwind, the Geist fonts and the kit styles at the top of the CSS entry, in that order,
963
+ * keeping everything else. Existing copies of those imports are moved, not duplicated.
964
+ */
965
+ function patchCss(text) {
966
+ const lines = text === null ? [] : text.split(/\r?\n/);
967
+ const isOurs = (l) => {
968
+ const m = /^\s*@import\s+(?:url\()?\s*["']([^"']+)["']/.exec(l);
969
+ return !!m && IMPORT_TARGETS.includes(m[1] ?? "");
970
+ };
971
+ const head = lines.slice(0, CSS_IMPORTS.length).map((l) => l.trim());
972
+ if (CSS_IMPORTS.every((l, i) => head[i] === l) && lines.slice(CSS_IMPORTS.length).every((l) => !isOurs(l))) return text ?? "";
973
+ const rest = lines.filter((l) => !isOurs(l));
974
+ while (rest.length && rest[0]?.trim() === "") rest.shift();
975
+ return [
976
+ ...CSS_IMPORTS,
977
+ "",
978
+ ...rest
979
+ ].join("\n").replace(/\n*$/, "\n");
980
+ }
981
+ const presetName = (brand) => `${brand}Preset`;
982
+ function themeSnippet(framework, entry, css, brand) {
983
+ const preset = presetName(brand);
984
+ const cssImport = (from) => {
985
+ let rel = path.posix.relative(path.posix.dirname(from), css);
986
+ if (!rel.startsWith(".")) rel = `./${rel}`;
987
+ return rel;
988
+ };
989
+ if (framework === "next") return [{
990
+ file: `${path.posix.dirname(entry)}/providers.tsx`,
991
+ code: `'use client';
992
+ import type { ReactNode } from 'react';
993
+ import { ThemeProvider, ${preset}, themeScript } from '@gntik-ai/ui';
994
+
995
+ export function Providers({ children }: { children: ReactNode }) {
996
+ return (
997
+ <>
998
+ {/* Applies the stored theme before first paint (no flash). */}
999
+ <script dangerouslySetInnerHTML={{ __html: themeScript() }} />
1000
+ <ThemeProvider brand={${preset}}>{children}</ThemeProvider>
1001
+ </>
1002
+ );
1003
+ }
1004
+ `
1005
+ }, {
1006
+ file: entry,
1007
+ code: `import type { ReactNode } from 'react';
1008
+ import { Providers } from './providers';
1009
+ import '${cssImport(entry)}';
1010
+
1011
+ export default function RootLayout({ children }: { children: ReactNode }) {
1012
+ return (
1013
+ // The theme script sets the class before React hydrates.
1014
+ <html lang="en" className="dark" suppressHydrationWarning>
1015
+ <body className="bg-background text-foreground antialiased">
1016
+ <Providers>{children}</Providers>
1017
+ </body>
1018
+ </html>
1019
+ );
1020
+ }
1021
+ `
1022
+ }];
1023
+ const files = [{
1024
+ file: entry,
1025
+ code: `import { StrictMode } from 'react';
1026
+ import { createRoot } from 'react-dom/client';
1027
+ import { ThemeProvider, ${preset} } from '@gntik-ai/ui';
1028
+ import App from './App';
1029
+ import '${cssImport(entry)}';
1030
+
1031
+ createRoot(document.getElementById('root')!).render(
1032
+ <StrictMode>
1033
+ <ThemeProvider brand={${preset}}>
1034
+ <App />
1035
+ </ThemeProvider>
1036
+ </StrictMode>,
1037
+ );
1038
+ `
1039
+ }];
1040
+ if (framework === "vite") files.push({
1041
+ file: "vite.config.ts",
1042
+ code: `import { defineConfig } from 'vite';
1043
+ import react from '@vitejs/plugin-react';
1044
+ import tailwindcss from '@tailwindcss/vite';
1045
+ import { themeScript } from '@gntik-ai/ui';
1046
+
1047
+ export default defineConfig({
1048
+ plugins: [
1049
+ react(),
1050
+ tailwindcss(),
1051
+ // Inlines the theme script in <head> so the stored theme applies before first paint.
1052
+ { name: 'gntik-theme', transformIndexHtml: () => [{ tag: 'script', children: themeScript(), injectTo: 'head-prepend' }] },
1053
+ ],
1054
+ });
1055
+ `
1056
+ });
1057
+ else files.push({
1058
+ file: "index.html",
1059
+ code: `<!-- In <head>: inline the output of themeScript() from @gntik-ai/ui to avoid a theme flash. -->
1060
+ <script>/* themeScript() output */<\/script>
1061
+ `
1062
+ });
1063
+ return files;
1064
+ }
1065
+ async function readOrNull(file) {
1066
+ return existsSync(file) ? readFile(file, "utf8") : null;
1067
+ }
1068
+ function change(file, before, after) {
1069
+ return {
1070
+ file,
1071
+ action: before === null ? "create" : before === after ? "unchanged" : "patch",
1072
+ content: after
1073
+ };
1074
+ }
1075
+ async function init(ctx) {
1076
+ const brand = ctx.flags.brand ?? "gntik";
1077
+ if (!BRANDS.includes(brand)) throw new CliError("USAGE", `--brand must be one of ${BRANDS.join(", ")}`);
1078
+ if (!existsSync(ctx.cwd)) throw new CliError("USAGE", `Directory not found: ${ctx.cwd}`);
1079
+ const info = detectProject(ctx.cwd);
1080
+ const existing = loadConfig(ctx.cwd);
1081
+ const config = existing && !ctx.flags.overwrite ? existing : {
1082
+ ...defaultConfig(info, brand),
1083
+ ...existing?.registry ? { registry: existing.registry } : {}
1084
+ };
1085
+ const cssFile = config.css;
1086
+ const cssPath = path.join(ctx.cwd, cssFile);
1087
+ const cfgPath = path.join(ctx.cwd, CONFIG_FILE);
1088
+ const cssBefore = await readOrNull(cssPath);
1089
+ const cfgBefore = await readOrNull(cfgPath);
1090
+ const cfgText = `${JSON.stringify(config, null, 2)}\n`;
1091
+ const changes = [change(cssFile, cssBefore, patchCss(cssBefore)), change(CONFIG_FILE, cfgBefore, cfgBefore !== null && !ctx.flags.overwrite ? cfgBefore : cfgText)];
1092
+ if (!ctx.flags.dryRun) for (const c of changes) {
1093
+ if (c.action === "unchanged") continue;
1094
+ const abs = path.join(ctx.cwd, c.file);
1095
+ await mkdir(path.dirname(abs), { recursive: true });
1096
+ await writeFile(abs, c.content);
1097
+ }
1098
+ const warnings = [];
1099
+ if ((await readOrNull(path.join(ctx.cwd, ".npmrc")))?.split(/\r?\n/).some((line) => line.trim().startsWith(`@gntik-ai:registry`))) warnings.push("New @gntik-ai versions are on npmjs.org; remove the @gntik-ai:registry line from .npmrc.");
1100
+ if (cssBefore && /@tailwind\s+(base|components|utilities)/.test(cssBefore)) warnings.push(`${cssFile} has Tailwind v3 directives (@tailwind …); the kit needs Tailwind v4 — remove them.`);
1101
+ if (!info.packageJson) warnings.push("No package.json found in this directory.");
1102
+ const have = installedPackages(info.packageJson);
1103
+ const dev = [];
1104
+ if (!have.has("tailwindcss")) {
1105
+ dev.push("tailwindcss", info.framework === "next" ? "@tailwindcss/postcss" : "@tailwindcss/vite");
1106
+ if (info.framework === "next" && !existsSync(path.join(ctx.cwd, "postcss.config.mjs"))) warnings.push("Add postcss.config.mjs: export default { plugins: { \"@tailwindcss/postcss\": {} } };");
1107
+ }
1108
+ const install = runInstall(ctx, config.packageManager, info.packageJson, [
1109
+ "@gntik-ai/ui",
1110
+ "@gntik-ai/tokens",
1111
+ "@fontsource/geist",
1112
+ "@fontsource/geist-mono"
1113
+ ], dev);
1114
+ const snippet = themeSnippet(info.framework, info.entry, cssFile, config.brand);
1115
+ const label = (a) => (ctx.flags.dryRun ? {
1116
+ create: "would create",
1117
+ patch: "would patch",
1118
+ unchanged: "unchanged"
1119
+ } : {
1120
+ create: "created",
1121
+ patch: "patched",
1122
+ unchanged: "unchanged"
1123
+ })[a];
1124
+ const text = [
1125
+ `Project: ${info.framework} · ${config.packageManager} · CSS ${cssFile}${info.css ? "" : " (new)"}`,
1126
+ ...changes.map((c) => ` ${label(c.action).padEnd(13)} ${c.file}`),
1127
+ ...warnings.map((w) => `Warning: ${w}`),
1128
+ "",
1129
+ "Wire the theme (dark by default; light and high_contrast via useTheme().setMode):",
1130
+ ...snippet.flatMap((s) => [
1131
+ "",
1132
+ `// ${s.file}`,
1133
+ ...s.code.trimEnd().split("\n")
1134
+ ]),
1135
+ "",
1136
+ ...installLines(install, ctx)
1137
+ ];
1138
+ return withInstallStatus({
1139
+ code: 0,
1140
+ data: {
1141
+ dryRun: ctx.flags.dryRun,
1142
+ project: {
1143
+ framework: info.framework,
1144
+ packageManager: config.packageManager,
1145
+ css: cssFile,
1146
+ cssExisted: info.css !== null,
1147
+ entry: info.entry,
1148
+ src: info.src
1149
+ },
1150
+ config,
1151
+ changes: changes.map(({ file, action }) => ({
1152
+ file,
1153
+ action
1154
+ })),
1155
+ snippet,
1156
+ warnings,
1157
+ install
1158
+ },
1159
+ text
1160
+ }, install);
1161
+ }
1162
+ //#endregion
1163
+ //#region src/commands/query.ts
1164
+ async function registryFor(ctx) {
1165
+ let configured;
1166
+ try {
1167
+ configured = loadConfig(ctx.cwd)?.registry;
1168
+ } catch {
1169
+ configured = void 0;
1170
+ }
1171
+ return openRegistry(ctx, configured);
1172
+ }
1173
+ function checkKind(kind) {
1174
+ if (kind && !ITEM_KINDS.includes(kind)) throw new CliError("USAGE", `--kind must be one of ${ITEM_KINDS.join(", ")}`);
1175
+ }
1176
+ /** Fixed-width table for humans. */
1177
+ function table(rows) {
1178
+ const widths = rows[0]?.map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length))) ?? [];
1179
+ return rows.map((r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ").trimEnd());
1180
+ }
1181
+ const truncate = (s, n) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
1182
+ async function list(ctx) {
1183
+ const { kind, group } = ctx.flags;
1184
+ checkKind(kind);
1185
+ const registry = await registryFor(ctx);
1186
+ const items = registry.items.filter((i) => (!kind || i.kind === kind) && (!group || i.group.toLowerCase() === group.toLowerCase()));
1187
+ const rows = [[
1188
+ "ID",
1189
+ "KIND",
1190
+ "GROUP",
1191
+ "STATUS",
1192
+ "PACKAGE"
1193
+ ], ...items.map((i) => [
1194
+ i.id,
1195
+ i.kind,
1196
+ i.group,
1197
+ i.status,
1198
+ i.package
1199
+ ])];
1200
+ return ok({
1201
+ registry: registry.source.base,
1202
+ count: items.length,
1203
+ items: items.map(summary)
1204
+ }, items.length ? [
1205
+ ...table(rows),
1206
+ "",
1207
+ `${items.length} item(s)`
1208
+ ] : ["No items match."]);
1209
+ }
1210
+ /** Subsequence match: every query char appears in order. Returns a 0..1 closeness. */
1211
+ function subsequence(q, s) {
1212
+ let i = 0;
1213
+ let gaps = 0;
1214
+ let last = -1;
1215
+ for (let j = 0; j < s.length && i < q.length; j++) if (s[j] === q[i]) {
1216
+ if (last >= 0) gaps += j - last - 1;
1217
+ last = j;
1218
+ i++;
1219
+ }
1220
+ return i === q.length ? 1 / (1 + gaps) : 0;
1221
+ }
1222
+ /** Scores one item against one query term; 0 means no match. */
1223
+ function termScore(term, item) {
1224
+ const id = item.id.toLowerCase();
1225
+ const name = item.name.toLowerCase();
1226
+ const group = item.group.toLowerCase();
1227
+ const desc = item.description.toLowerCase();
1228
+ if (id === term || name === term) return 100;
1229
+ if (id.startsWith(term) || name.startsWith(term)) return 80;
1230
+ if (id.includes(term) || name.includes(term)) return 60;
1231
+ if (group === term || group.includes(term)) return 40;
1232
+ if (new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(desc)) return 30;
1233
+ const fuzzy = Math.max(subsequence(term, id), subsequence(term, name));
1234
+ if (fuzzy > 0 && term.length >= 2) return Math.round(10 + 20 * fuzzy);
1235
+ if (desc.includes(term)) return 10;
1236
+ return 0;
1237
+ }
1238
+ function searchItems(items, query) {
1239
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
1240
+ return items.map((item) => {
1241
+ const scores = terms.map((t) => termScore(t, item));
1242
+ return {
1243
+ item,
1244
+ score: scores.every((s) => s > 0) ? scores.reduce((a, b) => a + b, 0) : 0
1245
+ };
1246
+ }).filter((r) => r.score > 0).sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id));
1247
+ }
1248
+ async function search(ctx, positionals) {
1249
+ const query = positionals.join(" ").trim();
1250
+ if (!query) throw new CliError("USAGE", "Usage: gntik-ui search <query>");
1251
+ checkKind(ctx.flags.kind);
1252
+ let results = searchItems((await registryFor(ctx)).items.filter((i) => !ctx.flags.kind || i.kind === ctx.flags.kind), query);
1253
+ if (ctx.flags.limit) results = results.slice(0, ctx.flags.limit);
1254
+ const rows = [[
1255
+ "ID",
1256
+ "KIND",
1257
+ "SCORE",
1258
+ "DESCRIPTION"
1259
+ ], ...results.map((r) => [
1260
+ r.item.id,
1261
+ r.item.kind,
1262
+ String(r.score),
1263
+ truncate(r.item.description, 60)
1264
+ ])];
1265
+ return ok({
1266
+ query,
1267
+ count: results.length,
1268
+ items: results.map((r) => ({
1269
+ ...summary(r.item),
1270
+ score: r.score
1271
+ }))
1272
+ }, results.length ? table(rows) : [`No items match "${query}".`]);
1273
+ }
1274
+ const STR = String.raw`(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"|\x60((?:[^\x60\\]|\\.)*)\x60)`;
1275
+ const unescape = (s) => s.replace(/\\(.)/g, "$1");
1276
+ /** Reads primitive, pattern and the keyboard table from a *.doc.ts file without executing it. */
1277
+ function parseDocFile(text) {
1278
+ const field = (name) => {
1279
+ const m = new RegExp(`\\b${name}\\s*:\\s*${STR}`).exec(text);
1280
+ return m ? unescape(m[1] ?? m[2] ?? m[3] ?? "") : null;
1281
+ };
1282
+ const keyboard = [];
1283
+ const start = /\bkeyboard\s*:\s*\[/.exec(text);
1284
+ if (start) {
1285
+ let depth = 1;
1286
+ let i = start.index + start[0].length;
1287
+ for (; i < text.length && depth > 0; i++) {
1288
+ const c = text[i];
1289
+ if (c === "[") depth++;
1290
+ else if (c === "]") depth--;
1291
+ else if (c === "'" || c === "\"" || c === "`") {
1292
+ for (i++; i < text.length && text[i] !== c; i++) if (text[i] === "\\") i++;
1293
+ }
1294
+ }
1295
+ const body = text.slice(start.index + start[0].length, i - 1);
1296
+ const row = new RegExp(String.raw`\[\s*${STR}\s*,\s*${STR}\s*,?\s*\]`, "g");
1297
+ for (const m of body.matchAll(row)) keyboard.push({
1298
+ key: unescape(m[1] ?? m[2] ?? m[3] ?? ""),
1299
+ behaviour: unescape(m[4] ?? m[5] ?? m[6] ?? "")
1300
+ });
1301
+ }
1302
+ return {
1303
+ primitive: field("primitive"),
1304
+ pattern: field("pattern"),
1305
+ keyboard
1306
+ };
1307
+ }
1308
+ async function docs(ctx, positionals) {
1309
+ const [id] = positionals;
1310
+ if (!id || positionals.length > 1) throw new CliError("USAGE", "Usage: gntik-ui docs <id>");
1311
+ const registry = await registryFor(ctx);
1312
+ const item = registry.get(id);
1313
+ if (!item) throw new CliError("NOT_FOUND", `Unknown item "${id}". Try: gntik-ui search ${id}`);
1314
+ let info = {
1315
+ primitive: null,
1316
+ pattern: null,
1317
+ keyboard: []
1318
+ };
1319
+ if (item.docs) info = parseDocFile(await registry.read(item.docs));
1320
+ const text = [
1321
+ `${item.name} (${item.id}) — ${item.kind} · ${item.group} · ${item.status}`,
1322
+ "",
1323
+ item.description,
1324
+ "",
1325
+ `Package: ${item.package}`,
1326
+ ...info.primitive ? [`Primitive: ${info.primitive}`] : [],
1327
+ ...info.pattern ? [`ARIA pattern: ${info.pattern}`] : [],
1328
+ `Dependencies: ${item.dependencies.join(", ") || "—"}`,
1329
+ `Registry deps: ${item.registryDependencies.join(", ") || "—"}`,
1330
+ "Files:",
1331
+ ...item.files.map((f) => ` ${f.path} → ${f.target}`)
1332
+ ];
1333
+ if (info.keyboard.length) text.push("", "Keyboard:", ...table([["KEY", "BEHAVIOUR"], ...info.keyboard.map((k) => [k.key, k.behaviour])]).map((l) => ` ${l}`));
1334
+ return ok({
1335
+ item: {
1336
+ ...summary(item),
1337
+ files: item.files,
1338
+ dependencies: item.dependencies,
1339
+ registryDependencies: item.registryDependencies,
1340
+ docs: item.docs ?? null
1341
+ },
1342
+ primitive: info.primitive,
1343
+ pattern: info.pattern,
1344
+ keyboard: info.keyboard
1345
+ }, text);
1346
+ }
1347
+ //#endregion
1348
+ //#region src/commands/upgrade.ts
1349
+ const CODEMODS_PACKAGE = "@gntik-ai/codemods";
1350
+ const VERSION_RE = /^v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/;
1351
+ /**
1352
+ * Finds @gntik-ai/codemods in the project's node_modules (walking up from cwd, like Node — but not
1353
+ * NODE_PATH or global folders, so only a package the project installed counts).
1354
+ */
1355
+ function findCodemods(cwd) {
1356
+ let dir = path.resolve(cwd);
1357
+ for (;;) {
1358
+ const pkgDir = path.join(dir, "node_modules", ...CODEMODS_PACKAGE.split("/"));
1359
+ if (existsSync(path.join(pkgDir, "package.json"))) try {
1360
+ return createRequire(path.join(pkgDir, "package.json")).resolve(CODEMODS_PACKAGE);
1361
+ } catch {
1362
+ return null;
1363
+ }
1364
+ const parent = path.dirname(dir);
1365
+ if (parent === dir) return null;
1366
+ dir = parent;
1367
+ }
1368
+ }
1369
+ /** Imports @gntik-ai/codemods from the project; null when it is not installed. */
1370
+ async function loadCodemods(cwd) {
1371
+ const resolved = findCodemods(cwd);
1372
+ if (!resolved) return null;
1373
+ const mod = await import(pathToFileURL(resolved).href);
1374
+ if (!Array.isArray(mod.transforms) || typeof mod.selectTransforms !== "function" || typeof mod.runCodemods !== "function") throw new CliError("ERROR", `${resolved} does not look like ${CODEMODS_PACKAGE} (missing transforms/selectTransforms/runCodemods)`);
1375
+ return {
1376
+ module: mod,
1377
+ path: resolved
1378
+ };
1379
+ }
1380
+ /** Declared @gntik-ai/* versions in the project's package.json (ranges stripped; workspace:/file: specs skipped). */
1381
+ function declaredVersions(pkg) {
1382
+ const out = {};
1383
+ for (const [name, spec] of Object.entries({
1384
+ ...pkg?.devDependencies,
1385
+ ...pkg?.dependencies
1386
+ })) {
1387
+ if (!name.startsWith(`@gntik-ai/`) || name === "@gntik-ai/codemods") continue;
1388
+ const m = /\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?/.exec(spec);
1389
+ if (m && !/^(workspace|file|link|git|github|https?):/.test(spec)) out[name] = m[0];
1390
+ }
1391
+ return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
1392
+ }
1393
+ const metaOut = (t) => ({
1394
+ id: t.id,
1395
+ package: t.package,
1396
+ fromVersion: t.fromVersion,
1397
+ toVersion: t.toVersion,
1398
+ reportOnly: t.reportOnly,
1399
+ description: t.description
1400
+ });
1401
+ async function upgrade(ctx, positionals) {
1402
+ const { flags } = ctx;
1403
+ for (const [name, v] of [["--from", flags.from], ["--to", flags.to]]) if (v !== void 0 && !(name === "--to" && v === "latest") && !VERSION_RE.test(v)) throw new CliError("USAGE", `${name} must be a version like 0.2.0, got "${v}"`);
1404
+ const dryRun = !flags.apply;
1405
+ const pkg = readJson(path.join(ctx.cwd, "package.json"));
1406
+ const detected = declaredVersions(pkg);
1407
+ const from = flags.from ?? (Object.keys(detected).length ? detected : null);
1408
+ const to = flags.to ?? "latest";
1409
+ const loaded = await loadCodemods(ctx.cwd);
1410
+ if (!loaded) {
1411
+ const install = installCommand(detectPackageManager(ctx.cwd, pkg), [CODEMODS_PACKAGE], true).join(" ");
1412
+ return {
1413
+ code: 1,
1414
+ data: {
1415
+ dryRun,
1416
+ from,
1417
+ to,
1418
+ codemods: {
1419
+ installed: false,
1420
+ version: null,
1421
+ install
1422
+ }
1423
+ },
1424
+ text: [`${CODEMODS_PACKAGE} is not installed in this project. Install it, then run upgrade again:`, ` ${install}`],
1425
+ error: new CliError("CODEMODS", `${CODEMODS_PACKAGE} not found from ${ctx.cwd}`)
1426
+ };
1427
+ }
1428
+ const { module: mods } = loaded;
1429
+ const codemods = {
1430
+ installed: true,
1431
+ version: mods.VERSION ?? null,
1432
+ install: null
1433
+ };
1434
+ const known = new Set(mods.transforms.map((t) => t.id));
1435
+ const unknown = [...flags.codemods, ...flags.skipCodemods].filter((id) => !known.has(id));
1436
+ if (unknown.length) throw new CliError("NOT_FOUND", `Unknown codemod(s): ${unknown.join(", ")}. Run gntik-ui upgrade --list.`);
1437
+ const selected = mods.selectTransforms({
1438
+ from: from ?? void 0,
1439
+ to,
1440
+ only: flags.codemods.length ? flags.codemods : void 0,
1441
+ skip: flags.skipCodemods,
1442
+ force: flags.codemods.length > 0
1443
+ });
1444
+ const selectedIds = new Set(selected.map((t) => t.id));
1445
+ const fromLabel = from === null ? "any version" : typeof from === "string" ? from : Object.entries(from).map(([n, v]) => `${n}@${v}`).join(", ");
1446
+ const header = `${CODEMODS_PACKAGE} ${codemods.version ?? "?"} · from ${fromLabel} → ${to}`;
1447
+ if (flags.list) {
1448
+ const rows = [[
1449
+ "ID",
1450
+ "PACKAGE",
1451
+ "VERSIONS",
1452
+ "SELECTED",
1453
+ "KIND"
1454
+ ], ...mods.transforms.map((t) => [
1455
+ t.id,
1456
+ t.package,
1457
+ `${t.fromVersion} → ${t.toVersion}`,
1458
+ selectedIds.has(t.id) ? "yes" : "no",
1459
+ t.reportOnly ? "report" : "rewrite"
1460
+ ])];
1461
+ return ok({
1462
+ dryRun,
1463
+ from,
1464
+ to,
1465
+ codemods,
1466
+ transforms: mods.transforms.map((t) => ({
1467
+ ...metaOut(t),
1468
+ selected: selectedIds.has(t.id)
1469
+ }))
1470
+ }, [
1471
+ header,
1472
+ "",
1473
+ ...table(rows),
1474
+ "",
1475
+ ...mods.transforms.map((t) => `${t.id}: ${t.description}`)
1476
+ ]);
1477
+ }
1478
+ const base = {
1479
+ dryRun,
1480
+ from,
1481
+ to,
1482
+ codemods,
1483
+ transforms: selected.map(metaOut)
1484
+ };
1485
+ if (!selected.length) return ok({
1486
+ ...base,
1487
+ scanned: 0,
1488
+ files: [],
1489
+ changed: [],
1490
+ reports: [],
1491
+ errors: []
1492
+ }, [header, "No codemods apply to this upgrade."]);
1493
+ const result = await mods.runCodemods({
1494
+ cwd: ctx.cwd,
1495
+ transforms: selected.map((t) => t.id),
1496
+ dry: dryRun,
1497
+ files: positionals.length ? positionals : void 0
1498
+ });
1499
+ const files = [...result.files].sort((a, b) => a.file.localeCompare(b.file));
1500
+ const changed = files.filter((f) => f.status === "modified").map((f) => f.file);
1501
+ const reports = files.flatMap((f) => f.reports.map((r) => ({
1502
+ file: f.file,
1503
+ line: r.line,
1504
+ transform: r.transform,
1505
+ message: r.message
1506
+ })));
1507
+ const errors = files.filter((f) => f.status === "error").map((f) => ({
1508
+ file: f.file,
1509
+ message: f.error ?? "failed"
1510
+ }));
1511
+ const data = {
1512
+ ...base,
1513
+ scanned: result.scanned,
1514
+ files,
1515
+ changed,
1516
+ reports,
1517
+ errors
1518
+ };
1519
+ const text = [
1520
+ header,
1521
+ `Codemods: ${selected.map((t) => t.id).join(", ")}`,
1522
+ `Scanned ${result.scanned} file(s).`
1523
+ ];
1524
+ const verb = dryRun ? "Would modify" : "Modified";
1525
+ if (changed.length) text.push("", `${verb} ${changed.length} file(s):`, ...files.filter((f) => f.status === "modified").map((f) => ` ${f.file} (${f.transforms.join(", ")})`));
1526
+ else text.push("No files to change.");
1527
+ if (reports.length) text.push("", `Warnings (${reports.length}):`, ...reports.map((r) => ` ${r.file}${r.line ? `:${r.line}` : ""} [${r.transform}] ${r.message}`));
1528
+ if (errors.length) text.push("", `Errors (${errors.length}):`, ...errors.map((e) => ` ${e.file}: ${e.message}`));
1529
+ if (dryRun && changed.length) text.push("", "Dry run: nothing was written. Re-run with --apply to write the changes.");
1530
+ if (errors.length) return {
1531
+ code: 1,
1532
+ data,
1533
+ text,
1534
+ error: new CliError("ERROR", `${errors.length} file(s) could not be transformed`)
1535
+ };
1536
+ return ok(data, text);
1537
+ }
1538
+ //#endregion
1539
+ //#region src/help.ts
1540
+ const GLOBAL = `Global options:
1541
+ --json Machine-readable output on stdout (stable shape, see README)
1542
+ --cwd <dir> Project directory (default: current directory)
1543
+ -r, --registry <p> Registry: checkout dir, kit-registry.json path or base URL
1544
+ (default: $GNTIK_UI_REGISTRY, gntik-ui.json "registry", then GitHub main)
1545
+ -y, --yes Accepted for scripts; the CLI never prompts
1546
+ -h, --help Help for a command
1547
+ -v, --version Print the version
1548
+
1549
+ Exit codes: 0 ok · 1 error (registry, install, codemods) · 2 usage · 3 unknown item · 4 file conflicts`;
1550
+ const HELP = {
1551
+ main: `gntik-ui — add the gntik-ui design system to a product.
1552
+
1553
+ Usage: gntik-ui <command> [options]
1554
+
1555
+ Commands:
1556
+ init Set up a project (CSS imports, gntik-ui.json, packages)
1557
+ add <id...> Add components/layouts (npm) or copy blocks/templates into the project
1558
+ eject <id> Copy a component's source into the project so the team owns it
1559
+ list List registry items (--kind, --group)
1560
+ search <query> Fuzzy search on id, name, description and group
1561
+ docs <id> Show an item's description, files, dependencies and keyboard contract
1562
+ upgrade [paths...] Run the codemods for @gntik-ai/* breaking changes (dry run unless --apply)
1563
+
1564
+ ${GLOBAL}`,
1565
+ init: `Usage: gntik-ui init [--brand gntik|musematic|falcone] [--dry-run] [--no-install] [--overwrite]
1566
+
1567
+ Detects the framework (Vite, Next, generic), package manager and CSS entry, then:
1568
+ - puts @import "tailwindcss", the Geist fonts and "@gntik-ai/ui/styles.css" at the top of the CSS entry
1569
+ - writes gntik-ui.json (aliases for components, blocks, templates; brand preset); kept if present
1570
+ unless --overwrite
1571
+ - prints the ThemeProvider + themeScript wiring for the framework
1572
+ - installs @gntik-ai/ui @gntik-ai/tokens and the fonts (skipped with --no-install or --dry-run)
1573
+
1574
+ ${GLOBAL}`,
1575
+ add: `Usage: gntik-ui add <id...> [--dry-run] [--overwrite] [--no-install]
1576
+
1577
+ Components and layouts install from their npm package; the import line is printed.
1578
+ Blocks and templates are copied into the configured directories with their registry
1579
+ dependencies; imports are rewritten (relative between copied files, package imports for kit
1580
+ code). Existing files that differ are reported as conflicts and nothing is written unless
1581
+ --overwrite is given.
1582
+
1583
+ ${GLOBAL}`,
1584
+ eject: `Usage: gntik-ui eject <id> [--no-deps] [--dry-run] [--overwrite] [--no-install]
1585
+
1586
+ Copies the item's source (and its registry dependencies, unless --no-deps) into the components
1587
+ directory, rewrites imports, and lists the imports in the project to switch to the local copy.
1588
+
1589
+ ${GLOBAL}`,
1590
+ list: `Usage: gntik-ui list [--kind component|layout|block|template] [--group <group>]
1591
+
1592
+ ${GLOBAL}`,
1593
+ search: `Usage: gntik-ui search <query> [--kind <kind>] [--limit <n>]
1594
+
1595
+ All terms must match (id, name, group or description); results are ranked.
1596
+
1597
+ ${GLOBAL}`,
1598
+ docs: `Usage: gntik-ui docs <id>
1599
+
1600
+ Prints description, status, package, files, dependencies and the keyboard table from the
1601
+ item's *.doc.ts file.
1602
+
1603
+ ${GLOBAL}`,
1604
+ upgrade: `Usage: gntik-ui upgrade [paths...] [--from <v>] [--to <v>] [--codemod <id>...] [--skip-codemod <id>...]
1605
+ [--list] [--apply]
1606
+
1607
+ Migrates the project's code across @gntik-ai/* breaking changes with the transforms in
1608
+ @gntik-ai/codemods, loaded from the project (install it with: pnpm add -D @gntik-ai/codemods).
1609
+
1610
+ --from <v> Version upgrading from (default: the @gntik-ai/* versions in package.json;
1611
+ every codemod when none are declared)
1612
+ --to <v> Version upgrading to (default: latest)
1613
+ --codemod <id> Run only these codemods, whatever the versions (repeatable or comma-separated)
1614
+ --skip-codemod <id> Leave these out (repeatable or comma-separated)
1615
+ --list List the codemods and which ones this upgrade selects; changes nothing
1616
+ --apply Write the changes. Without it the run is dry: it reports the files it would
1617
+ change and the warnings (report-only codemods never write)
1618
+ paths... Files or directories to process (default: the project, without node_modules,
1619
+ dist, build, .next, …)
1620
+
1621
+ Exit code 1 when @gntik-ai/codemods is missing (the install command is printed) or a file fails
1622
+ to parse.
1623
+
1624
+ ${GLOBAL}`
1625
+ };
1626
+ //#endregion
1627
+ //#region src/cli.ts
1628
+ const VERSION = version;
1629
+ const HANDLERS = {
1630
+ init: (ctx) => init(ctx),
1631
+ add,
1632
+ eject,
1633
+ list: (ctx) => list(ctx),
1634
+ search,
1635
+ docs,
1636
+ upgrade
1637
+ };
1638
+ /** Runs the CLI and returns the exit code. Never calls process.exit. */
1639
+ async function run(argv, opts = {}) {
1640
+ const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
1641
+ const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
1642
+ const invocationCwd = opts.cwd ?? process.cwd();
1643
+ const wantsJson = argv.includes("--json");
1644
+ let command = null;
1645
+ const fail = (err) => {
1646
+ if (wantsJson) stdout(`${JSON.stringify({
1647
+ ok: false,
1648
+ command,
1649
+ error: {
1650
+ code: err.code,
1651
+ message: err.message
1652
+ }
1653
+ }, null, 2)}\n`);
1654
+ else stderr(`gntik-ui: ${err.message}\n`);
1655
+ return err.exitCode;
1656
+ };
1657
+ try {
1658
+ const args = parseCliArgs(argv);
1659
+ command = args.command ?? null;
1660
+ const { flags } = args;
1661
+ if (flags.version) {
1662
+ stdout(flags.json ? `${JSON.stringify({
1663
+ ok: true,
1664
+ command: "version",
1665
+ version: VERSION
1666
+ })}\n` : `${VERSION}\n`);
1667
+ return 0;
1668
+ }
1669
+ if (flags.help || !args.command) {
1670
+ if (!flags.help && args.positionals.length) throw new CliError("USAGE", `Unknown command "${args.positionals[0]}"`);
1671
+ const help = HELP[args.command ?? "main"];
1672
+ stdout(flags.json ? `${JSON.stringify({
1673
+ ok: true,
1674
+ command: command ?? "help",
1675
+ help
1676
+ })}\n` : `${help}\n`);
1677
+ return flags.help ? 0 : 2;
1678
+ }
1679
+ const ctx = {
1680
+ cwd: path.resolve(invocationCwd, flags.cwd ?? "."),
1681
+ invocationCwd,
1682
+ flags,
1683
+ exec: opts.exec ?? defaultExec,
1684
+ env: opts.env ?? process.env
1685
+ };
1686
+ const result = await HANDLERS[args.command](ctx, args.positionals);
1687
+ if (flags.json) {
1688
+ const out = {
1689
+ ok: result.code === 0,
1690
+ command,
1691
+ ...result.data
1692
+ };
1693
+ if (result.error) out.error = {
1694
+ code: result.error.code,
1695
+ message: result.error.message
1696
+ };
1697
+ stdout(`${JSON.stringify(out, null, 2)}\n`);
1698
+ } else {
1699
+ if (result.text.length) stdout(`${result.text.join("\n")}\n`);
1700
+ if (result.error) stderr(`gntik-ui: ${result.error.message}\n`);
1701
+ }
1702
+ return result.code;
1703
+ } catch (e) {
1704
+ if (e instanceof CliError) return fail(e);
1705
+ return fail(new CliError("ERROR", e.message ?? String(e)));
1706
+ }
1707
+ }
1708
+ //#endregion
1709
+ //#region src/index.ts
1710
+ process.exitCode = await run(process.argv.slice(2));
1711
+ //#endregion
1712
+ export {};