argsbarg 4.1.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/README.md +91 -85
  3. package/docs/README.md +8 -8
  4. package/docs/ai-skills.md +9 -9
  5. package/docs/bundled-docs.md +5 -5
  6. package/docs/cli-program.md +13 -11
  7. package/docs/config-schema.md +36 -9
  8. package/docs/configure.md +177 -0
  9. package/docs/developing.md +7 -7
  10. package/docs/distribution-homebrew.md +104 -0
  11. package/docs/mcp.md +11 -12
  12. package/docs/output-schema.md +1 -1
  13. package/examples/full-example/Formula/.gitkeep +0 -0
  14. package/examples/full-example/README.md +98 -0
  15. package/examples/full-example/biome.json +22 -0
  16. package/examples/{consumer-app → full-example}/bun.lock +2 -0
  17. package/examples/full-example/justfile +134 -0
  18. package/examples/{consumer-app → full-example}/package.json +10 -3
  19. package/examples/{consumer-app → full-example}/schemas/generated/app-config.json +1 -1
  20. package/examples/{consumer-app → full-example}/schemas/generated/status.json +1 -1
  21. package/examples/full-example/scripts/create-identity.ts +11 -0
  22. package/examples/full-example/scripts/formula-shared.ts +73 -0
  23. package/examples/full-example/scripts/gen-dev-formula.ts +26 -0
  24. package/examples/full-example/scripts/print-identity.ts +27 -0
  25. package/examples/full-example/src/commands/echo/command.ts +21 -0
  26. package/examples/full-example/src/commands/status/command.test.ts +10 -0
  27. package/examples/full-example/src/commands/status/command.ts +36 -0
  28. package/examples/{consumer-app → full-example}/src/commands/status/types.ts +1 -1
  29. package/examples/full-example/src/index.ts +10 -0
  30. package/examples/full-example/src/program.ts +57 -0
  31. package/examples/{consumer-app → full-example}/src/types.ts +1 -1
  32. package/examples/nested.ts +1 -3
  33. package/index.d.ts +49 -81
  34. package/package.json +2 -2
  35. package/src/builtins/builtins.test.ts +84 -64
  36. package/src/builtins/completion-group.ts +17 -17
  37. package/src/builtins/configure-copy.ts +86 -0
  38. package/src/builtins/configure.ts +70 -0
  39. package/src/builtins/dispatch.ts +13 -9
  40. package/src/builtins/index.ts +1 -1
  41. package/src/builtins/mcp.ts +2 -2
  42. package/src/builtins/registry.ts +6 -4
  43. package/src/capabilities.ts +22 -15
  44. package/src/cli-tool/cli-smoke.test.ts +29 -0
  45. package/src/cli-tool/create.test.ts +141 -0
  46. package/src/cli-tool/create.ts +402 -0
  47. package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +16 -15
  48. package/src/cli-tool/main.ts +8 -0
  49. package/src/cli-tool/post-create.ts +111 -0
  50. package/src/cli-tool/program.ts +97 -0
  51. package/src/cli-tool/prompt.ts +28 -0
  52. package/src/cli-tool/run-create.ts +138 -0
  53. package/src/cli.ts +0 -2
  54. package/src/config/bootstrap.ts +27 -18
  55. package/src/config/file.test.ts +1 -1
  56. package/src/config/resolve.test.ts +167 -0
  57. package/src/config/resolve.ts +52 -8
  58. package/src/configure/configure.test.ts +148 -0
  59. package/src/configure/index.ts +284 -0
  60. package/src/configure/prompt.ts +40 -0
  61. package/src/docs/api-guide.test.ts +4 -5
  62. package/src/docs/builtin.ts +3 -5
  63. package/src/docs/docs.test.ts +6 -5
  64. package/src/docs/mcp-guide.ts +11 -12
  65. package/src/index.ts +5 -12
  66. package/src/install/binary-placement.test.ts +101 -0
  67. package/src/install/binary-placement.ts +47 -0
  68. package/src/install/install-validate.test.ts +5 -5
  69. package/src/install/normalize-uninstall.ts +11 -0
  70. package/src/install/normalize.ts +4 -19
  71. package/src/install/opts.ts +17 -0
  72. package/src/install/paths.ts +0 -22
  73. package/src/install/plan.ts +14 -6
  74. package/src/install/shell.ts +0 -14
  75. package/src/install/status.test.ts +6 -6
  76. package/src/install/status.ts +0 -6
  77. package/src/install/target-effective.ts +8 -10
  78. package/src/install/target-scope.ts +26 -36
  79. package/src/install/target-types.ts +0 -16
  80. package/src/install/targets/app.ts +19 -28
  81. package/src/install/targets/configure.ts +6 -2
  82. package/src/install/targets/index.ts +0 -3
  83. package/src/install/targets.test.ts +26 -44
  84. package/src/invoke.test.ts +1 -1
  85. package/src/mcp/env.test.ts +92 -0
  86. package/src/mcp/env.ts +15 -14
  87. package/src/mcp/tools.ts +1 -1
  88. package/src/mcp.integration.test.ts +4 -4
  89. package/src/parse.test.ts +13 -14
  90. package/src/prompt.ts +10 -0
  91. package/src/schema.ts +1 -1
  92. package/src/skill/hint.ts +2 -2
  93. package/src/types.ts +48 -22
  94. package/src/validate.ts +22 -28
  95. package/docs/install.md +0 -290
  96. package/docs/templates/cursor/rules/cli-program.mdc +0 -31
  97. package/examples/config-app/main.ts +0 -20
  98. package/examples/config-app/program.ts +0 -78
  99. package/examples/config-app/schema.ts +0 -37
  100. package/examples/config-app/types.ts +0 -19
  101. package/examples/consumer-app/README.md +0 -56
  102. package/examples/consumer-app/src/main.ts +0 -15
  103. package/examples/consumer-app/src/program.ts +0 -108
  104. package/src/builtins/install.ts +0 -136
  105. package/src/install/app.ts +0 -94
  106. package/src/install/bootstrap.ts +0 -22
  107. package/src/install/completions.ts +0 -56
  108. package/src/install/index.ts +0 -415
  109. package/src/install/install.test.ts +0 -333
  110. package/src/install/targets/completions.ts +0 -133
  111. package/src/install/update.test.ts +0 -123
  112. package/src/install/update.ts +0 -54
  113. /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
  114. /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
  115. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
  116. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
  117. /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
  118. /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
  119. /package/examples/{consumer-app → full-example}/tsconfig.json +0 -0
@@ -0,0 +1,402 @@
1
+ /** `argsbarg create` — copy npm-shipped examples/full-example with substitutions. */
2
+
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import pkg from "../../package.json" with { type: "json" };
7
+
8
+ export interface CreateOptions {
9
+ key: string;
10
+ className: string;
11
+ tap: string;
12
+ homepage: string;
13
+ releaseRepo: string;
14
+ desc: string;
15
+ force: boolean;
16
+ dryRun: boolean;
17
+ check: boolean;
18
+ diff: boolean;
19
+ yes: boolean;
20
+ /** Keep file:../.. dep (in-repo template). */
21
+ devTemplate: boolean;
22
+ }
23
+
24
+ const CREATE_IDENTITY_REL = "scripts/create-identity.ts";
25
+
26
+ const EXCLUDE_REL = new Set(["bun.lock", "HOMEBREW-SCAFFOLD.md", "node_modules", "dist"]);
27
+
28
+ export function packageRoot(): string {
29
+ return resolve(dirname(fileURLToPath(import.meta.url)), "../..");
30
+ }
31
+
32
+ export function templateDir(): string {
33
+ return join(packageRoot(), "examples/full-example");
34
+ }
35
+
36
+ export function isDevTemplateDir(baseDir: string): boolean {
37
+ return resolve(baseDir) === resolve(templateDir());
38
+ }
39
+
40
+ export function keyToEnvPrefix(key: string): string {
41
+ return key
42
+ .replace(/[^a-zA-Z0-9]+/g, "_")
43
+ .replace(/^_+|_+$/g, "")
44
+ .toUpperCase();
45
+ }
46
+
47
+ /** PascalCase Homebrew formula class from CLI key; prefix `App` when Ruby constant rules require it. */
48
+ export function classNameFromKey(key: string): string {
49
+ const name = key
50
+ .split(/[-_]/)
51
+ .filter((s) => s.length > 0)
52
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
53
+ .join("");
54
+ if (name.length === 0) {
55
+ throw new Error(`Invalid CLI key: ${key}`);
56
+ }
57
+ if (!/^[A-Z]/.test(name)) {
58
+ return `App${name}`;
59
+ }
60
+ return name;
61
+ }
62
+
63
+ function tapLibraryParts(tap: string): { org: string; repo: string } {
64
+ const slash = tap.indexOf("/");
65
+ if (slash === -1) throw new Error(`Invalid tap (expected org/repo): ${tap}`);
66
+ return {
67
+ org: tap.slice(0, slash),
68
+ repo: tap.slice(slash + 1),
69
+ };
70
+ }
71
+
72
+ /** Parse `scripts/create-identity.ts` for inference and template defaults. */
73
+ export function parseCreateIdentityFile(
74
+ path: string,
75
+ ): Partial<CreateOptions> & { envPrefix?: string } {
76
+ if (!existsSync(path)) return {};
77
+ const text = readFileSync(path, "utf8");
78
+ const pick = (field: string) => text.match(new RegExp(`${field}:\\s*"([^"]*)"`))?.[1];
79
+ return {
80
+ key: pick("key"),
81
+ className: pick("className"),
82
+ tap: pick("tap"),
83
+ homepage: pick("homepage"),
84
+ releaseRepo: pick("releaseRepo"),
85
+ desc: pick("desc"),
86
+ envPrefix: pick("envPrefix"),
87
+ };
88
+ }
89
+
90
+ export function templateIdentity(): {
91
+ key: string;
92
+ className: string;
93
+ tap: string;
94
+ homepage: string;
95
+ releaseRepo: string;
96
+ desc: string;
97
+ envPrefix: string;
98
+ } {
99
+ const parsed = parseCreateIdentityFile(join(templateDir(), CREATE_IDENTITY_REL));
100
+ const key = parsed.key ?? "full-example";
101
+ return {
102
+ key,
103
+ className: parsed.className ?? classNameFromKey(key),
104
+ tap: parsed.tap ?? `local/${key}`,
105
+ homepage: parsed.homepage ?? "https://github.com/bdombro/bun-argsbarg",
106
+ releaseRepo: parsed.releaseRepo ?? "bdombro/bun-argsbarg",
107
+ desc: parsed.desc ?? "Argsbarg full example reference app",
108
+ envPrefix: parsed.envPrefix ?? keyToEnvPrefix(key),
109
+ };
110
+ }
111
+
112
+ export function inferCreateOptions(
113
+ baseDir: string,
114
+ partial: Partial<CreateOptions>,
115
+ ): Partial<CreateOptions> {
116
+ if (
117
+ partial.key &&
118
+ partial.className &&
119
+ partial.homepage &&
120
+ partial.releaseRepo &&
121
+ partial.desc &&
122
+ partial.tap
123
+ ) {
124
+ return partial;
125
+ }
126
+ const fromIdentity = parseCreateIdentityFile(join(baseDir, CREATE_IDENTITY_REL));
127
+ return {
128
+ key: partial.key ?? fromIdentity.key,
129
+ className: partial.className ?? fromIdentity.className,
130
+ desc: partial.desc ?? fromIdentity.desc,
131
+ homepage: partial.homepage ?? fromIdentity.homepage,
132
+ releaseRepo: partial.releaseRepo ?? fromIdentity.releaseRepo,
133
+ tap: partial.tap ?? fromIdentity.tap,
134
+ force: partial.force,
135
+ dryRun: partial.dryRun,
136
+ check: partial.check,
137
+ diff: partial.diff,
138
+ yes: partial.yes,
139
+ devTemplate: partial.devTemplate,
140
+ };
141
+ }
142
+
143
+ function assertReleaseRepoFormat(releaseRepo: string): void {
144
+ if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(releaseRepo)) {
145
+ throw new Error(`Invalid release repo (expected org/repo): ${releaseRepo}`);
146
+ }
147
+ }
148
+
149
+ export function resolveCreateOptions(
150
+ partial: Partial<CreateOptions>,
151
+ baseDir?: string,
152
+ ): CreateOptions {
153
+ const merged = baseDir ? inferCreateOptions(baseDir, partial) : partial;
154
+ const tmpl = templateIdentity();
155
+ const key = merged.key ?? tmpl.key ?? "full-example";
156
+ const className = merged.className ?? classNameFromKey(key);
157
+ const releaseRepo = merged.releaseRepo;
158
+ if (!releaseRepo) {
159
+ throw new Error(
160
+ "GitHub release repo (org/repo) is required. Pass --release-repo or use the interactive wizard.",
161
+ );
162
+ }
163
+ assertReleaseRepoFormat(releaseRepo);
164
+ const devTemplate = merged.devTemplate ?? (baseDir ? isDevTemplateDir(baseDir) : false);
165
+ return {
166
+ key,
167
+ className,
168
+ tap: merged.tap ?? releaseRepo,
169
+ homepage: merged.homepage ?? `https://github.com/${releaseRepo}`,
170
+ releaseRepo,
171
+ desc: merged.desc ?? `${className} CLI`,
172
+ force: merged.force ?? false,
173
+ dryRun: merged.dryRun ?? false,
174
+ check: merged.check ?? false,
175
+ diff: merged.diff ?? false,
176
+ yes: merged.yes ?? false,
177
+ devTemplate,
178
+ };
179
+ }
180
+
181
+ export function renderCreateIdentitySource(opts: CreateOptions): string {
182
+ const envPrefix = keyToEnvPrefix(opts.key);
183
+ return `/** CLI identity — substituted by \`argsbarg create\`. */
184
+
185
+ export const createIdentity = {
186
+ key: "${opts.key}",
187
+ className: "${opts.className}",
188
+ tap: "${opts.tap}",
189
+ homepage: "${opts.homepage}",
190
+ releaseRepo: "${opts.releaseRepo}",
191
+ desc: "${opts.desc}",
192
+ envPrefix: "${envPrefix}",
193
+ } as const;
194
+ `;
195
+ }
196
+
197
+ function tokenMap(opts: CreateOptions): Record<string, string> {
198
+ const { org: tapOrg, repo: tapRepo } = tapLibraryParts(opts.tap);
199
+ const envPrefix = keyToEnvPrefix(opts.key);
200
+ return {
201
+ key: opts.key,
202
+ className: opts.className,
203
+ envPrefix,
204
+ tap: opts.tap,
205
+ tapOrg,
206
+ tapRepo,
207
+ homepage: opts.homepage,
208
+ releaseRepo: opts.releaseRepo,
209
+ desc: opts.desc,
210
+ };
211
+ }
212
+
213
+ /** Substitute \`{key}\`-style placeholders; also replace template identity literals. */
214
+ export function substituteTemplateContent(content: string, opts: CreateOptions): string {
215
+ const tmpl = templateIdentity();
216
+ const tokens = tokenMap(opts);
217
+ const argsbargVersion = pkg.version;
218
+
219
+ const protectedSpans: string[] = [];
220
+ let out = content.replace(/\$\{[^}]+\}/g, (span) => {
221
+ const idx = protectedSpans.length;
222
+ protectedSpans.push(span);
223
+ return `@@PROTECT${idx}@@`;
224
+ });
225
+
226
+ out = out.replace(
227
+ /(?<!\{)\{(key|className|envPrefix|tap|tapOrg|tapRepo|homepage|releaseRepo|desc)\}(?!\})/g,
228
+ (_, name: string) => tokens[name] ?? `{${name}}`,
229
+ );
230
+
231
+ const literalPairs: [string, string][] = [
232
+ [tmpl.envPrefix, tokens.envPrefix],
233
+ [tmpl.tap, opts.tap],
234
+ [tmpl.releaseRepo, opts.releaseRepo],
235
+ [tmpl.homepage, opts.homepage],
236
+ [tmpl.desc, opts.desc],
237
+ [tmpl.className, opts.className],
238
+ [tmpl.key, opts.key],
239
+ [`**${tmpl.key} conventions:**`, `**${opts.key} conventions:**`],
240
+ ["**App-specific conventions:**", `**${opts.key} conventions:**`],
241
+ ];
242
+ for (const [from, to] of literalPairs) {
243
+ if (from && from !== to) {
244
+ out = out.split(from).join(to);
245
+ }
246
+ }
247
+
248
+ out = out.replace(/@@PROTECT(\d+)@@/g, (_, idx: string) => protectedSpans[Number(idx)] ?? "");
249
+
250
+ if (!opts.devTemplate) {
251
+ out = out.replace(/"argsbarg":\s*"file:\.\.\/\.\."/, `"argsbarg": "^${argsbargVersion}"`);
252
+ }
253
+
254
+ return out;
255
+ }
256
+
257
+ function shouldExcludeRel(rel: string): boolean {
258
+ const parts = rel.split("/");
259
+ for (const part of parts) {
260
+ if (EXCLUDE_REL.has(part)) return true;
261
+ }
262
+ return EXCLUDE_REL.has(rel);
263
+ }
264
+
265
+ function listTemplateFiles(): string[] {
266
+ const root = templateDir();
267
+ if (!existsSync(root)) return [];
268
+ const out: string[] = [];
269
+ const walk = (dir: string, prefix: string) => {
270
+ for (const name of readdirSync(dir)) {
271
+ const full = join(dir, name);
272
+ const rel = prefix ? `${prefix}/${name}` : name;
273
+ if (statSync(full).isDirectory()) {
274
+ if (!shouldExcludeRel(rel)) walk(full, rel);
275
+ } else if (!shouldExcludeRel(rel)) {
276
+ out.push(rel);
277
+ }
278
+ }
279
+ };
280
+ walk(root, "");
281
+ return out.sort();
282
+ }
283
+
284
+ export function renderCreateTree(opts: CreateOptions): Map<string, string> {
285
+ const files = new Map<string, string>();
286
+ for (const rel of listTemplateFiles()) {
287
+ if (rel === CREATE_IDENTITY_REL) {
288
+ files.set(rel, renderCreateIdentitySource(opts));
289
+ continue;
290
+ }
291
+ const src = join(templateDir(), rel);
292
+ const raw = readFileSync(src, "utf8");
293
+ files.set(rel, substituteTemplateContent(raw, opts));
294
+ }
295
+ return files;
296
+ }
297
+
298
+ export function applyCreate(baseDir: string, opts: CreateOptions): string[] {
299
+ const written: string[] = [];
300
+ const tree = renderCreateTree(opts);
301
+ for (const [rel, content] of tree) {
302
+ const dest = join(baseDir, rel);
303
+ if (existsSync(dest) && !opts.force && !opts.check && !opts.dryRun) {
304
+ continue;
305
+ }
306
+ if (opts.dryRun || opts.check) {
307
+ written.push(dest);
308
+ continue;
309
+ }
310
+ mkdirSync(dirname(dest), { recursive: true });
311
+ writeFileSync(dest, content, "utf8");
312
+ written.push(dest);
313
+ }
314
+ return written;
315
+ }
316
+
317
+ export interface CreateDrift {
318
+ rel: string;
319
+ expected: string;
320
+ actual?: string;
321
+ }
322
+
323
+ export function diffCreateDetails(baseDir: string, partial: Partial<CreateOptions>): CreateDrift[] {
324
+ const opts = resolveCreateOptions(partial, baseDir);
325
+ const drifts: CreateDrift[] = [];
326
+ const tree = renderCreateTree(opts);
327
+ for (const [rel, expected] of tree) {
328
+ const dest = join(baseDir, rel);
329
+ if (!existsSync(dest)) {
330
+ drifts.push({ rel: `${rel} (missing)`, expected, actual: undefined });
331
+ continue;
332
+ }
333
+ const actual = readFileSync(dest, "utf8");
334
+ if (actual !== expected) {
335
+ drifts.push({ rel, expected, actual });
336
+ }
337
+ }
338
+ return drifts;
339
+ }
340
+
341
+ export function diffCreate(baseDir: string, partial: Partial<CreateOptions>): string[] {
342
+ return diffCreateDetails(baseDir, partial).map((d) => d.rel);
343
+ }
344
+
345
+ /** Print a short unified diff for drifted files. */
346
+ export function printCreateDiffs(drifts: CreateDrift[], baseDir: string): void {
347
+ for (const drift of drifts) {
348
+ if (drift.actual === undefined) {
349
+ process.stderr.write(`--- missing ${drift.rel}\n`);
350
+ continue;
351
+ }
352
+ const relPath = drift.rel;
353
+ process.stderr.write(`--- ${relPath}\n`);
354
+ const expectedLines = drift.expected.split("\n");
355
+ const actualLines = drift.actual.split("\n");
356
+ const max = Math.max(expectedLines.length, actualLines.length);
357
+ let shown = 0;
358
+ for (let i = 0; i < max && shown < 12; i++) {
359
+ const exp = expectedLines[i];
360
+ const act = actualLines[i];
361
+ if (exp !== act) {
362
+ if (act !== undefined) process.stderr.write(`+ ${act}\n`);
363
+ if (exp !== undefined) process.stderr.write(`- ${exp}\n`);
364
+ shown++;
365
+ }
366
+ }
367
+ if (max > 12) {
368
+ process.stderr.write(` … (${relative(baseDir, join(baseDir, relPath))})\n`);
369
+ }
370
+ }
371
+ }
372
+
373
+ export interface ParsedCreateArgv {
374
+ dir: string;
375
+ opts: Partial<CreateOptions>;
376
+ }
377
+
378
+ export function parseCreateArgv(rest: string[]): ParsedCreateArgv {
379
+ const opts: Partial<CreateOptions> = {};
380
+ const positional: string[] = [];
381
+ for (let i = 0; i < rest.length; i++) {
382
+ const a = rest[i];
383
+ if (a === "--check") opts.check = true;
384
+ else if (a === "--diff") opts.diff = true;
385
+ else if (a === "--force") opts.force = true;
386
+ else if (a === "--dry-run") opts.dryRun = true;
387
+ else if (a === "--yes") opts.yes = true;
388
+ else if (a === "--key" && rest[i + 1]) opts.key = rest[++i];
389
+ else if (a === "--class-name" && rest[i + 1]) opts.className = rest[++i];
390
+ else if (a === "--tap" && rest[i + 1]) opts.tap = rest[++i];
391
+ else if (a === "--homepage" && rest[i + 1]) opts.homepage = rest[++i];
392
+ else if (a === "--release-repo" && rest[i + 1]) opts.releaseRepo = rest[++i];
393
+ else if (a === "--desc" && rest[i + 1]) opts.desc = rest[++i];
394
+ else if (a?.startsWith("--")) {
395
+ throw new Error(`Unknown option: ${a}`);
396
+ } else if (a) {
397
+ positional.push(a);
398
+ }
399
+ }
400
+ const dir = positional[0] ?? ".";
401
+ return { dir, opts };
402
+ }
@@ -1,19 +1,20 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { resolveCapabilities } from "../../src/capabilities.ts";
5
- import { CliOptionKind, type CliProgram } from "../../src/types.ts";
4
+ import { resolveCapabilities } from "../capabilities.ts";
5
+ import { CliOptionKind, type CliProgram } from "../types.ts";
6
6
 
7
- const programSource = readFileSync(join(import.meta.dir, "src/program.ts"), "utf8");
7
+ const exampleRoot = join(import.meta.dir, "../../examples/full-example");
8
+ const programSource = readFileSync(join(exampleRoot, "src/program.ts"), "utf8");
8
9
 
9
- /** Mirror of consumer-app/src/program.ts capability flags (in-repo types only). */
10
+ /** Mirror of full-example/src/program.ts capability flags (in-repo types only). */
10
11
  const sinkProgram = {
11
- key: "consumer-app",
12
+ key: "full-example",
12
13
  version: "1.0.0",
13
- description: "Sink reference.",
14
+ description: "Full example reference.",
14
15
  appConfig: {
15
16
  entries: {
16
- apiToken: { description: "Token.", env: "CONSUMER_APP_API_TOKEN" },
17
+ apiToken: { description: "Token.", env: "FULL_EXAMPLE_API_TOKEN" },
17
18
  },
18
19
  },
19
20
  docs: {
@@ -21,9 +22,7 @@ const sinkProgram = {
21
22
  topics: { readme: { text: "# readme\n" } },
22
23
  },
23
24
  mcpServer: { enabled: true },
24
- install: {
25
- updateGetLatest: async () => ({ path: "/bin/app", version: "1.0.0" }),
26
- },
25
+ configure: {},
27
26
  commands: [
28
27
  {
29
28
  key: "status",
@@ -46,23 +45,25 @@ const sinkProgram = {
46
45
  ],
47
46
  } satisfies CliProgram;
48
47
 
49
- describe("consumer-app sink", () => {
48
+ describe("full-example template", () => {
50
49
  test("program source enables every builtin flag", () => {
51
50
  expect(programSource).toContain("mcpServer: {");
52
51
  expect(programSource).toContain("enabled: true");
53
52
  expect(programSource).toContain("docs:");
54
- expect(programSource).toContain("updateGetLatest");
55
53
  expect(programSource).toContain("appConfig:");
56
- expect(programSource).toContain("outputSchema:");
54
+ });
55
+
56
+ test("status command defines outputSchema", () => {
57
+ const statusSource = readFileSync(join(exampleRoot, "src/commands/status/command.ts"), "utf8");
58
+ expect(statusSource).toContain("outputSchema:");
57
59
  });
58
60
 
59
61
  test("resolveCapabilities matches full sink shape", () => {
60
62
  expect(resolveCapabilities(sinkProgram)).toEqual({
61
63
  completion: true,
62
64
  mcp: true,
63
- install: true,
65
+ configure: true,
64
66
  docs: true,
65
- update: true,
66
67
  configCommands: true,
67
68
  });
68
69
  });
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+ /** Argsbarg package CLI (`bunx argsbarg`) — bootstrap and tooling; library API is `import from "argsbarg"`. */
3
+
4
+ import { Cli } from "../index.ts";
5
+ import { program } from "./program.ts";
6
+
7
+ const cli = new Cli(program);
8
+ await cli.run();
@@ -0,0 +1,111 @@
1
+ /** Post-create steps and git bootstrap for `argsbarg create`. */
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+
6
+ export function isInsideGitWorkTree(dir: string): boolean {
7
+ try {
8
+ const proc = Bun.spawnSync(["git", "-C", dir, "rev-parse", "--show-toplevel"], {
9
+ stdout: "pipe",
10
+ stderr: "pipe",
11
+ });
12
+ return proc.exitCode === 0;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function shouldSkipGitBootstrap(targetDir: string): boolean {
19
+ if (existsSync(join(targetDir, ".git"))) return true;
20
+ const parent = resolve(targetDir, "..");
21
+ if (parent === targetDir) return false;
22
+ if (isInsideGitWorkTree(parent) && !isInsideGitWorkTree(targetDir)) {
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ export async function runPostCreate(targetDir: string, dryRun: boolean): Promise<void> {
29
+ const abs = resolve(targetDir);
30
+ const steps: Array<{ label: string; run: () => Promise<void> | void }> = [
31
+ {
32
+ label: "bun install",
33
+ run: () => {
34
+ if (dryRun) return;
35
+ const proc = Bun.spawnSync(["bun", "install"], {
36
+ cwd: abs,
37
+ stdout: "inherit",
38
+ stderr: "inherit",
39
+ });
40
+ if (proc.exitCode !== 0) throw new Error("bun install failed");
41
+ },
42
+ },
43
+ {
44
+ label: "bun scripts/schemagen.ts",
45
+ run: () => {
46
+ if (dryRun) return;
47
+ const proc = Bun.spawnSync(["bun", "scripts/schemagen.ts"], {
48
+ cwd: abs,
49
+ stdout: "inherit",
50
+ stderr: "inherit",
51
+ });
52
+ if (proc.exitCode !== 0) throw new Error("schemagen failed");
53
+ },
54
+ },
55
+ {
56
+ label: "bun test",
57
+ run: () => {
58
+ if (dryRun) return;
59
+ const proc = Bun.spawnSync(["bun", "test"], {
60
+ cwd: abs,
61
+ stdout: "inherit",
62
+ stderr: "inherit",
63
+ });
64
+ if (proc.exitCode !== 0) throw new Error("bun test failed");
65
+ },
66
+ },
67
+ {
68
+ label: "git init + Initial commit",
69
+ run: () => {
70
+ if (dryRun) return;
71
+ if (shouldSkipGitBootstrap(abs)) {
72
+ process.stderr.write(
73
+ "Skipping git bootstrap (existing repo or nested in git work tree).\n",
74
+ );
75
+ return;
76
+ }
77
+ let proc = Bun.spawnSync(["git", "init"], {
78
+ cwd: abs,
79
+ stdout: "inherit",
80
+ stderr: "inherit",
81
+ });
82
+ if (proc.exitCode !== 0) throw new Error("git init failed");
83
+ proc = Bun.spawnSync(["git", "add", "-A"], {
84
+ cwd: abs,
85
+ stdout: "inherit",
86
+ stderr: "inherit",
87
+ });
88
+ if (proc.exitCode !== 0) throw new Error("git add failed");
89
+ proc = Bun.spawnSync(["git", "commit", "-m", "Initial commit"], {
90
+ cwd: abs,
91
+ stdout: "inherit",
92
+ stderr: "inherit",
93
+ });
94
+ if (proc.exitCode !== 0) throw new Error("git commit failed");
95
+ },
96
+ },
97
+ ];
98
+
99
+ for (const step of steps) {
100
+ process.stderr.write(`→ ${step.label}\n`);
101
+ await step.run();
102
+ }
103
+ }
104
+
105
+ export function printPostCreatePlan(): void {
106
+ process.stderr.write("Post-create steps:\n");
107
+ process.stderr.write(" 1. bun install\n");
108
+ process.stderr.write(" 2. bun scripts/schemagen.ts\n");
109
+ process.stderr.write(" 3. bun test\n");
110
+ process.stderr.write(" 4. git init + Initial commit (skipped inside existing git work tree)\n");
111
+ }
@@ -0,0 +1,97 @@
1
+ /*
2
+ Argsbarg developer tools — bootstrap consumer CLIs via `create`.
3
+ */
4
+
5
+ import pkg from "../../package.json" with { type: "json" };
6
+ import { CliOptionKind, type CliProgram } from "../index.ts";
7
+ import { runCreate } from "./run-create.ts";
8
+
9
+ export const program = {
10
+ key: "argsbarg",
11
+ version: pkg.version,
12
+ description: "Argsbarg developer tools — bootstrap CLIs from the full-example template.",
13
+ completion: { enabled: false },
14
+ configure: { enabled: false },
15
+ commands: [
16
+ {
17
+ key: "create",
18
+ description: "Copy the full-example template into a directory with substitutions.",
19
+ options: [
20
+ { name: "key", description: "CLI binary name.", kind: CliOptionKind.String },
21
+ {
22
+ name: "class-name",
23
+ description: "Homebrew formula Ruby class (default: derived from --key).",
24
+ kind: CliOptionKind.String,
25
+ },
26
+ {
27
+ name: "tap",
28
+ description: "Homebrew tap (default: same as --release-repo).",
29
+ kind: CliOptionKind.String,
30
+ },
31
+ {
32
+ name: "homepage",
33
+ description: "Formula homepage (default: https://github.com/<release-repo>).",
34
+ kind: CliOptionKind.String,
35
+ },
36
+ {
37
+ name: "release-repo",
38
+ description: "GitHub org/repo for releases (required in non-interactive mode).",
39
+ kind: CliOptionKind.String,
40
+ },
41
+ {
42
+ name: "desc",
43
+ description:
44
+ "App + formula description (default: <ClassName> CLI; stored in create-identity.ts).",
45
+ kind: CliOptionKind.String,
46
+ },
47
+ { name: "force", description: "Overwrite existing files.", kind: CliOptionKind.Presence },
48
+ {
49
+ name: "dry-run",
50
+ description: "Print planned writes without changing disk.",
51
+ kind: CliOptionKind.Presence,
52
+ },
53
+ {
54
+ name: "check",
55
+ description: "Fail if directory drifts from template output.",
56
+ kind: CliOptionKind.Presence,
57
+ },
58
+ {
59
+ name: "diff",
60
+ description: "With --check, print a short diff for drifted files.",
61
+ kind: CliOptionKind.Presence,
62
+ },
63
+ {
64
+ name: "yes",
65
+ description: "Skip confirmation (required when stdin is not a TTY).",
66
+ kind: CliOptionKind.Presence,
67
+ },
68
+ ],
69
+ positionals: [
70
+ {
71
+ name: "dir",
72
+ description: "Target directory (default: current directory).",
73
+ kind: CliOptionKind.String,
74
+ argMin: 0,
75
+ argMax: 1,
76
+ },
77
+ ],
78
+ handler: async (ctx) => {
79
+ const code = await runCreate({
80
+ dir: ctx.args[0],
81
+ key: ctx.stringOpt("key"),
82
+ className: ctx.stringOpt("class-name"),
83
+ tap: ctx.stringOpt("tap"),
84
+ homepage: ctx.stringOpt("homepage"),
85
+ releaseRepo: ctx.stringOpt("release-repo"),
86
+ desc: ctx.stringOpt("desc"),
87
+ force: ctx.hasFlag("force"),
88
+ dryRun: ctx.hasFlag("dry-run"),
89
+ check: ctx.hasFlag("check"),
90
+ diff: ctx.hasFlag("diff"),
91
+ yes: ctx.hasFlag("yes"),
92
+ });
93
+ process.exit(code);
94
+ },
95
+ },
96
+ ],
97
+ } satisfies CliProgram;