@smeltjs/core 0.3.0 → 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 (146) hide show
  1. package/dist/agents/guide.d.ts +68 -0
  2. package/dist/agents/guide.d.ts.map +1 -0
  3. package/dist/agents/guide.js +72 -0
  4. package/dist/agents/guide.js.map +1 -0
  5. package/dist/agents/instructions.d.ts +133 -0
  6. package/dist/agents/instructions.d.ts.map +1 -0
  7. package/dist/agents/instructions.js +212 -0
  8. package/dist/agents/instructions.js.map +1 -0
  9. package/dist/agents/lint.d.ts +154 -0
  10. package/dist/agents/lint.d.ts.map +1 -0
  11. package/dist/agents/lint.js +761 -0
  12. package/dist/agents/lint.js.map +1 -0
  13. package/dist/agents/split.d.ts +113 -0
  14. package/dist/agents/split.d.ts.map +1 -0
  15. package/dist/agents/split.js +234 -0
  16. package/dist/agents/split.js.map +1 -0
  17. package/dist/cli/agents.d.ts +39 -0
  18. package/dist/cli/agents.d.ts.map +1 -0
  19. package/dist/cli/agents.js +121 -0
  20. package/dist/cli/agents.js.map +1 -0
  21. package/dist/cli/args.d.ts +1 -0
  22. package/dist/cli/args.d.ts.map +1 -1
  23. package/dist/cli/args.js.map +1 -1
  24. package/dist/cli/bin.d.ts.map +1 -1
  25. package/dist/cli/bin.js +9 -4
  26. package/dist/cli/bin.js.map +1 -1
  27. package/dist/cli/config.d.ts +20 -0
  28. package/dist/cli/config.d.ts.map +1 -1
  29. package/dist/cli/config.js +34 -1
  30. package/dist/cli/config.js.map +1 -1
  31. package/dist/cli/doctor.d.ts +66 -0
  32. package/dist/cli/doctor.d.ts.map +1 -0
  33. package/dist/cli/doctor.js +137 -0
  34. package/dist/cli/doctor.js.map +1 -0
  35. package/dist/cli/hooks.d.ts +61 -18
  36. package/dist/cli/hooks.d.ts.map +1 -1
  37. package/dist/cli/hooks.js +178 -241
  38. package/dist/cli/hooks.js.map +1 -1
  39. package/dist/cli/init.d.ts.map +1 -1
  40. package/dist/cli/init.js +8 -14
  41. package/dist/cli/init.js.map +1 -1
  42. package/dist/cli/installed.d.ts +56 -0
  43. package/dist/cli/installed.d.ts.map +1 -0
  44. package/dist/cli/installed.js +105 -0
  45. package/dist/cli/installed.js.map +1 -0
  46. package/dist/cli/lava.d.ts +31 -0
  47. package/dist/cli/lava.d.ts.map +1 -0
  48. package/dist/cli/lava.js +82 -0
  49. package/dist/cli/lava.js.map +1 -0
  50. package/dist/cli/report.d.ts +23 -0
  51. package/dist/cli/report.d.ts.map +1 -1
  52. package/dist/cli/report.js +140 -2
  53. package/dist/cli/report.js.map +1 -1
  54. package/dist/cli/run.d.ts +5 -3
  55. package/dist/cli/run.d.ts.map +1 -1
  56. package/dist/cli/run.js +2 -1
  57. package/dist/cli/run.js.map +1 -1
  58. package/dist/cli/setup.d.ts +87 -0
  59. package/dist/cli/setup.d.ts.map +1 -0
  60. package/dist/cli/setup.js +513 -0
  61. package/dist/cli/setup.js.map +1 -0
  62. package/dist/cli/shell.d.ts +11 -4
  63. package/dist/cli/shell.d.ts.map +1 -1
  64. package/dist/cli/shell.js.map +1 -1
  65. package/dist/cli/subcommands/agents.d.ts +72 -0
  66. package/dist/cli/subcommands/agents.d.ts.map +1 -0
  67. package/dist/cli/subcommands/agents.js +163 -0
  68. package/dist/cli/subcommands/agents.js.map +1 -0
  69. package/dist/cli/subcommands/doctor.d.ts +15 -0
  70. package/dist/cli/subcommands/doctor.d.ts.map +1 -0
  71. package/dist/cli/subcommands/doctor.js +43 -0
  72. package/dist/cli/subcommands/doctor.js.map +1 -0
  73. package/dist/cli/subcommands/flags.d.ts +16 -0
  74. package/dist/cli/subcommands/flags.d.ts.map +1 -1
  75. package/dist/cli/subcommands/flags.js +40 -2
  76. package/dist/cli/subcommands/flags.js.map +1 -1
  77. package/dist/cli/subcommands/hooks.d.ts.map +1 -1
  78. package/dist/cli/subcommands/hooks.js +29 -5
  79. package/dist/cli/subcommands/hooks.js.map +1 -1
  80. package/dist/cli/subcommands/init.d.ts.map +1 -1
  81. package/dist/cli/subcommands/init.js +2 -1
  82. package/dist/cli/subcommands/init.js.map +1 -1
  83. package/dist/cli/subcommands/registry.d.ts +4 -1
  84. package/dist/cli/subcommands/registry.d.ts.map +1 -1
  85. package/dist/cli/subcommands/registry.js +9 -0
  86. package/dist/cli/subcommands/registry.js.map +1 -1
  87. package/dist/cli/subcommands/setup.d.ts +23 -0
  88. package/dist/cli/subcommands/setup.d.ts.map +1 -0
  89. package/dist/cli/subcommands/setup.js +87 -0
  90. package/dist/cli/subcommands/setup.js.map +1 -0
  91. package/dist/cli/subcommands/subcommand.d.ts +1 -1
  92. package/dist/cli/subcommands/subcommand.d.ts.map +1 -1
  93. package/dist/cli/subcommands/subcommand.js.map +1 -1
  94. package/dist/cli/wizard.d.ts +72 -0
  95. package/dist/cli/wizard.d.ts.map +1 -0
  96. package/dist/cli/wizard.js +86 -0
  97. package/dist/cli/wizard.js.map +1 -0
  98. package/dist/harness/claude-code.d.ts.map +1 -1
  99. package/dist/harness/claude-code.js +10 -0
  100. package/dist/harness/claude-code.js.map +1 -1
  101. package/dist/harness/codex.d.ts.map +1 -1
  102. package/dist/harness/codex.js +2 -0
  103. package/dist/harness/codex.js.map +1 -1
  104. package/dist/harness/gemini.d.ts.map +1 -1
  105. package/dist/harness/gemini.js +1 -0
  106. package/dist/harness/gemini.js.map +1 -1
  107. package/dist/harness/grok.d.ts.map +1 -1
  108. package/dist/harness/grok.js +5 -1
  109. package/dist/harness/grok.js.map +1 -1
  110. package/dist/harness/hermes.d.ts.map +1 -1
  111. package/dist/harness/hermes.js +1 -0
  112. package/dist/harness/hermes.js.map +1 -1
  113. package/dist/harness/kilocode.d.ts.map +1 -1
  114. package/dist/harness/kilocode.js +1 -1
  115. package/dist/harness/kilocode.js.map +1 -1
  116. package/dist/harness/opencode.d.ts.map +1 -1
  117. package/dist/harness/opencode.js +10 -0
  118. package/dist/harness/opencode.js.map +1 -1
  119. package/dist/harness/profile.d.ts +49 -3
  120. package/dist/harness/profile.d.ts.map +1 -1
  121. package/dist/harness/profile.js +15 -0
  122. package/dist/harness/profile.js.map +1 -1
  123. package/dist/harness/registry.d.ts +55 -6
  124. package/dist/harness/registry.d.ts.map +1 -1
  125. package/dist/harness/registry.js +63 -6
  126. package/dist/harness/registry.js.map +1 -1
  127. package/dist/harness/snippet.d.ts +14 -1
  128. package/dist/harness/snippet.d.ts.map +1 -1
  129. package/dist/harness/snippet.js +18 -2
  130. package/dist/harness/snippet.js.map +1 -1
  131. package/dist/index.d.ts +22 -0
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.js +17 -0
  134. package/dist/index.js.map +1 -1
  135. package/dist/setup/recipe.d.ts +73 -0
  136. package/dist/setup/recipe.d.ts.map +1 -0
  137. package/dist/setup/recipe.js +76 -0
  138. package/dist/setup/recipe.js.map +1 -0
  139. package/dist/text/json-edit.d.ts +85 -0
  140. package/dist/text/json-edit.d.ts.map +1 -0
  141. package/dist/text/json-edit.js +290 -0
  142. package/dist/text/json-edit.js.map +1 -0
  143. package/dist/types.d.ts +0 -11
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/types.js.map +1 -1
  146. package/package.json +1 -1
@@ -0,0 +1,121 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { GUIDE_URL } from '../agents/guide.js';
4
+ import { readInstructionSet } from '../agents/instructions.js';
5
+ import { planSplit, refactorPrompt, splitSeamNotice } from '../agents/split.js';
6
+ import { CliUsageError } from '../errors.js';
7
+ import { readTree } from '../ops/inputs.js';
8
+ import { answerReader, CLI_NAME } from './shell.js';
9
+ /**
10
+ * One `smelt agents split` run, start to finish. Returns an exit code, never calls
11
+ * `exit` — the same testability shape as `runInit` and `runHooks`.
12
+ *
13
+ * @throws {CliUsageError} when the directory cannot be read, when there is no
14
+ * instruction file to split, or when input ends mid-wizard. A refusal, never a guess:
15
+ * splitting a file smelt had to invent would be the auto-generation the guide rules
16
+ * out, and a missing directory reported as an internal error would be smelt blaming
17
+ * itself for a typo.
18
+ */
19
+ export async function runAgentsSplit(io) {
20
+ // `resolve`, not `join`: an absolute `dir` is the user's answer, and concatenating
21
+ // the working directory onto it invents a path nobody named. Then the same tree
22
+ // ruling `agents lint` uses — a directory that is not there is a refusal naming it,
23
+ // not an ENOENT escaping as "unexpected internal error" with a stack trace.
24
+ const root = resolve(io.cwd, io.dir);
25
+ const tree = readTree(root, io.dir, { tree: 'agents split', file: `\`${CLI_NAME} <file>\`` });
26
+ if (!tree.ok)
27
+ throw new CliUsageError(`${CLI_NAME}: ${tree.refusal}`);
28
+ const set = readInstructionSet({ root });
29
+ const rootLevel = set.levels.find((level) => level.dir === '');
30
+ if (rootLevel === undefined) {
31
+ throw new CliUsageError(`${CLI_NAME} agents split: no AGENTS.md, CLAUDE.md or GEMINI.md at ${io.dir}. ` +
32
+ `There is nothing to split, and ${CLI_NAME} will not write one for you — ` +
33
+ `the guide it follows says never to auto-generate an AGENTS.md (${GUIDE_URL}).`);
34
+ }
35
+ const file = rootLevel.primary;
36
+ const plan = planSplit(file);
37
+ io.output(`${CLI_NAME} agents split — the mechanical half of the guide's refactor.\n` +
38
+ `Nothing is written until you confirm, and an existing file is never overwritten\n` +
39
+ `without its own yes.\n\n`);
40
+ if (plan.refusal !== undefined) {
41
+ io.output(` ${plan.refusal}\n\n`);
42
+ return 0;
43
+ }
44
+ io.output(`${plan.source}: ${String(plan.beforeBytes)} bytes, ` +
45
+ `${String(plan.sections.length)} \`##\` section${plan.sections.length === 1 ? '' : 's'}.\n` +
46
+ `The root file would become ${String(plan.afterBytes)} bytes — everything above the\n` +
47
+ `first heading, plus a link to each moved section.\n\n`);
48
+ const lines = answerReader(io.input);
49
+ const ask = async (prompt) => {
50
+ io.output(prompt);
51
+ const next = await lines.next();
52
+ if (next === undefined) {
53
+ throw new CliUsageError(`${CLI_NAME} agents split: input ended before the wizard finished. ` +
54
+ `Files already confirmed and written stay; nothing further was written.`);
55
+ }
56
+ return next.trim();
57
+ };
58
+ try {
59
+ await confirmAndWrite(io, ask, root, plan);
60
+ }
61
+ finally {
62
+ await lines.release();
63
+ }
64
+ // Printed whether or not anything was written: the prompt is the half of the
65
+ // refactor smelt refused to do, and refusing the writes does not make it less true.
66
+ io.output(`\n${refactorPrompt(plan)}\n`);
67
+ io.output(`${splitSeamNotice()}\n`);
68
+ return 0;
69
+ }
70
+ function planned(root, name, content) {
71
+ const path = join(root, name);
72
+ const exists = existsSync(path);
73
+ return {
74
+ name,
75
+ path,
76
+ content,
77
+ exists,
78
+ unchanged: exists && readFileSync(path, 'utf8') === content,
79
+ };
80
+ }
81
+ const fileLabel = (file) => {
82
+ if (file.unchanged)
83
+ return 'unchanged — nothing to write';
84
+ return file.exists ? 'exists — will ask before overwriting' : 'new';
85
+ };
86
+ async function confirmAndWrite(io, ask, root, plan) {
87
+ const files = plan.files.map((file) => planned(root, file.path, file.content));
88
+ io.output(`About to write, into ${root}:\n` +
89
+ files.map((file) => ` ${file.name.padEnd(40)} (${fileLabel(file)})\n`).join('') +
90
+ `Nothing has been written yet.\n`);
91
+ for (;;) {
92
+ const answer = await ask(`confirm (yes / no)> `);
93
+ if (answer === 'no') {
94
+ io.output(`Nothing was written.\n`);
95
+ return;
96
+ }
97
+ if (answer === 'yes')
98
+ break;
99
+ io.output(`yes to write, no to leave everything untouched.\n`);
100
+ }
101
+ for (const file of files) {
102
+ if (file.unchanged) {
103
+ io.output(` ${file.name} — unchanged, not rewritten\n`);
104
+ continue;
105
+ }
106
+ if (file.exists) {
107
+ // The one hard rule, same as `smelt init` and `smelt hooks`: an existing file is
108
+ // never touched without an explicit per-file yes — not `y`, not Enter, a literal
109
+ // `yes`. The root instruction file is *always* on this branch, by construction.
110
+ const answer = await ask(` ${file.name} exists — overwrite it? (yes/no)> `);
111
+ if (answer !== 'yes') {
112
+ io.output(` skipped ${file.name} — the existing file was not touched\n`);
113
+ continue;
114
+ }
115
+ }
116
+ mkdirSync(dirname(file.path), { recursive: true });
117
+ writeFileSync(file.path, file.content);
118
+ io.output(` wrote ${file.name}\n`);
119
+ }
120
+ }
121
+ //# sourceMappingURL=agents.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agents.js","sourceRoot":"","sources":["../../src/cli/agents.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\n\nimport { GUIDE_URL } from '../agents/guide.ts';\nimport { readInstructionSet } from '../agents/instructions.ts';\nimport type { InstructionFile } from '../agents/instructions.ts';\nimport { planSplit, refactorPrompt, splitSeamNotice } from '../agents/split.ts';\nimport type { SplitPlan } from '../agents/split.ts';\nimport { CliUsageError } from '../errors.ts';\nimport { readTree } from '../ops/inputs.ts';\n\nimport { answerReader, CLI_NAME } from './shell.ts';\nimport type { AnswerStream } from './shell.ts';\n\n/**\n * `smelt agents split` — the wizard, and nothing else.\n *\n * The partition, the link rewrite and the refactor prompt are `src/agents/split.ts`,\n * which is strings in, strings out and knows nothing about a terminal. What lives here\n * is the half that touches disk, and it is `smelt init`'s discipline verbatim, for the\n * reason stated in `cli/hooks.ts`: this command rewrites a file somebody wrote by hand,\n * and a hand-written AGENTS.md clobbered by a tool is somebody's day gone.\n *\n * - every file listed, with `new` / `exists` / `unchanged`, before anything is written;\n * - one final `yes`;\n * - an existing file overwritten only after its **own** literal `yes` — not `y`, not\n * Enter;\n * - the guide's refactor prompt printed either way, because the judgment half is\n * what the user actually came for and refusing the writes does not change that.\n */\n\n/** Where the split wizard's bytes come from and go. Injected, so it tests in-process. */\nexport interface AgentsSplitIo {\n /** Scripted answers in, one line at a time. See {@link AnswerStream}. */\n readonly input: AnswerStream;\n readonly output: (text: string) => void;\n /** The working directory; every write is relative to `dir` resolved against it. */\n readonly cwd: string;\n /** The directory to split, as typed. `'.'` by default. */\n readonly dir: string;\n}\n\n/**\n * One `smelt agents split` run, start to finish. Returns an exit code, never calls\n * `exit` — the same testability shape as `runInit` and `runHooks`.\n *\n * @throws {CliUsageError} when the directory cannot be read, when there is no\n * instruction file to split, or when input ends mid-wizard. A refusal, never a guess:\n * splitting a file smelt had to invent would be the auto-generation the guide rules\n * out, and a missing directory reported as an internal error would be smelt blaming\n * itself for a typo.\n */\nexport async function runAgentsSplit(io: AgentsSplitIo): Promise<number> {\n // `resolve`, not `join`: an absolute `dir` is the user's answer, and concatenating\n // the working directory onto it invents a path nobody named. Then the same tree\n // ruling `agents lint` uses — a directory that is not there is a refusal naming it,\n // not an ENOENT escaping as \"unexpected internal error\" with a stack trace.\n const root = resolve(io.cwd, io.dir);\n const tree = readTree(root, io.dir, { tree: 'agents split', file: `\\`${CLI_NAME} <file>\\`` });\n if (!tree.ok) throw new CliUsageError(`${CLI_NAME}: ${tree.refusal}`);\n\n const set = readInstructionSet({ root });\n const rootLevel = set.levels.find((level) => level.dir === '');\n\n if (rootLevel === undefined) {\n throw new CliUsageError(\n `${CLI_NAME} agents split: no AGENTS.md, CLAUDE.md or GEMINI.md at ${io.dir}. ` +\n `There is nothing to split, and ${CLI_NAME} will not write one for you — ` +\n `the guide it follows says never to auto-generate an AGENTS.md (${GUIDE_URL}).`,\n );\n }\n\n const file: InstructionFile = rootLevel.primary;\n const plan = planSplit(file);\n\n io.output(\n `${CLI_NAME} agents split — the mechanical half of the guide's refactor.\\n` +\n `Nothing is written until you confirm, and an existing file is never overwritten\\n` +\n `without its own yes.\\n\\n`,\n );\n\n if (plan.refusal !== undefined) {\n io.output(` ${plan.refusal}\\n\\n`);\n return 0;\n }\n\n io.output(\n `${plan.source}: ${String(plan.beforeBytes)} bytes, ` +\n `${String(plan.sections.length)} \\`##\\` section${plan.sections.length === 1 ? '' : 's'}.\\n` +\n `The root file would become ${String(plan.afterBytes)} bytes — everything above the\\n` +\n `first heading, plus a link to each moved section.\\n\\n`,\n );\n\n const lines = answerReader(io.input);\n const ask = async (prompt: string): Promise<string> => {\n io.output(prompt);\n const next = await lines.next();\n if (next === undefined) {\n throw new CliUsageError(\n `${CLI_NAME} agents split: input ended before the wizard finished. ` +\n `Files already confirmed and written stay; nothing further was written.`,\n );\n }\n return next.trim();\n };\n\n try {\n await confirmAndWrite(io, ask, root, plan);\n } finally {\n await lines.release();\n }\n\n // Printed whether or not anything was written: the prompt is the half of the\n // refactor smelt refused to do, and refusing the writes does not make it less true.\n io.output(`\\n${refactorPrompt(plan)}\\n`);\n io.output(`${splitSeamNotice()}\\n`);\n return 0;\n}\n\n/** A file the split would write, checked against what is on disk right now. */\ninterface PlannedSplitFile {\n readonly name: string;\n readonly path: string;\n readonly content: string;\n readonly exists: boolean;\n readonly unchanged: boolean;\n}\n\nfunction planned(root: string, name: string, content: string): PlannedSplitFile {\n const path = join(root, name);\n const exists = existsSync(path);\n return {\n name,\n path,\n content,\n exists,\n unchanged: exists && readFileSync(path, 'utf8') === content,\n };\n}\n\nconst fileLabel = (file: PlannedSplitFile): string => {\n if (file.unchanged) return 'unchanged — nothing to write';\n return file.exists ? 'exists — will ask before overwriting' : 'new';\n};\n\nasync function confirmAndWrite(\n io: AgentsSplitIo,\n ask: (prompt: string) => Promise<string>,\n root: string,\n plan: SplitPlan,\n): Promise<void> {\n const files = plan.files.map((file) => planned(root, file.path, file.content));\n\n io.output(\n `About to write, into ${root}:\\n` +\n files.map((file) => ` ${file.name.padEnd(40)} (${fileLabel(file)})\\n`).join('') +\n `Nothing has been written yet.\\n`,\n );\n\n for (;;) {\n const answer = await ask(`confirm (yes / no)> `);\n if (answer === 'no') {\n io.output(`Nothing was written.\\n`);\n return;\n }\n if (answer === 'yes') break;\n io.output(`yes to write, no to leave everything untouched.\\n`);\n }\n\n for (const file of files) {\n if (file.unchanged) {\n io.output(` ${file.name} — unchanged, not rewritten\\n`);\n continue;\n }\n if (file.exists) {\n // The one hard rule, same as `smelt init` and `smelt hooks`: an existing file is\n // never touched without an explicit per-file yes — not `y`, not Enter, a literal\n // `yes`. The root instruction file is *always* on this branch, by construction.\n const answer = await ask(` ${file.name} exists — overwrite it? (yes/no)> `);\n if (answer !== 'yes') {\n io.output(` skipped ${file.name} — the existing file was not touched\\n`);\n continue;\n }\n }\n mkdirSync(dirname(file.path), { recursive: true });\n writeFileSync(file.path, file.content);\n io.output(` wrote ${file.name}\\n`);\n }\n}\n"]}
@@ -1,6 +1,7 @@
1
1
  export { CLI_NAME } from './shell.ts';
2
2
  export { cliUsage } from './usage.ts';
3
3
  export type { CliInvocation } from './subcommands/registry.ts';
4
+ export type { AgentsInvocation } from './subcommands/agents.ts';
4
5
  export type { HooksInvocation } from './subcommands/hooks.ts';
5
6
  export type { InitInvocation } from './subcommands/init.ts';
6
7
  export type { MapInvocation } from './subcommands/map.ts';
@@ -1 +1 @@
1
- {"version":3,"file":"args.d.ts","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/D,YAAY,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC9D,YAAY,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,YAAY,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC1D,YAAY,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,YAAY,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC9D,YAAY,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE/D;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,aAAa,CA0BrE","sourcesContent":["import { parseArgs } from 'node:util';\n\nimport { CliUsageError } from '../errors.ts';\n\nimport { CLI_NAME } from './shell.ts';\nimport { CLI_FLAGS } from './subcommands/flags.ts';\nimport { refuseForeignFlags, subcommandFor } from './subcommands/registry.ts';\n\nexport { CLI_NAME } from './shell.ts';\nexport { cliUsage } from './usage.ts';\nexport type { CliInvocation } from './subcommands/registry.ts';\nexport type { HooksInvocation } from './subcommands/hooks.ts';\nexport type { InitInvocation } from './subcommands/init.ts';\nexport type { MapInvocation } from './subcommands/map.ts';\nexport type { RetrieveInvocation } from './subcommands/retrieve.ts';\nexport type { SmeltInvocation } from './subcommands/smelt.ts';\nexport type { StatsInvocation } from './subcommands/stats.ts';\n\nimport type { CliInvocation } from './subcommands/registry.ts';\n\n/**\n * Argument parsing on `node:util.parseArgs` — stable since Node 20, which `engines`\n * already requires.\n *\n * The CLI ships as a `bin` on the library rather than as a second package, and this\n * import is the reason that is free: it adds no dependency, so the argument the second\n * package existed to win — keeping the library's dependency tree small — is already won.\n *\n * The shape of this function is the shape of the subcommand seam: split the argv into\n * flags and positionals, answer the two global flags, **look the verb up in\n * `SUBCOMMANDS`**, refuse every flag that verb does not own with one generated\n * message, and let the verb do its own validation. There is no per-verb branching left\n * here — a seventh verb is a seventh file, not a seventh `if`.\n *\n * @throws {CliUsageError} on anything the user got wrong. Never guesses.\n */\nexport function parseSmeltArgs(argv: readonly string[]): CliInvocation {\n let parsed;\n try {\n parsed = parseArgs({\n args: [...argv],\n allowPositionals: true,\n strict: true,\n options: CLI_FLAGS,\n });\n } catch (cause) {\n throw new CliUsageError(\n `${CLI_NAME}: ${cause instanceof Error ? cause.message : String(cause)}\\n` +\n `Run \\`${CLI_NAME} --help\\`.`,\n );\n }\n\n const { values, positionals } = parsed;\n\n // Answered before any verb, so `smelt map --help` prints the help rather than being\n // refused for a flag `map` does not own. No verb may claim these two.\n if (values.help === true) return { mode: 'help', focus: [], json: false };\n if (values.version === true) return { mode: 'version', focus: [], json: false };\n\n const command = subcommandFor(positionals);\n refuseForeignFlags(command, values);\n return command.parse(values, positionals);\n}\n"]}
1
+ {"version":3,"file":"args.d.ts","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/D,YAAY,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,YAAY,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC9D,YAAY,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,YAAY,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC1D,YAAY,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,YAAY,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC9D,YAAY,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE/D;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,aAAa,CA0BrE","sourcesContent":["import { parseArgs } from 'node:util';\n\nimport { CliUsageError } from '../errors.ts';\n\nimport { CLI_NAME } from './shell.ts';\nimport { CLI_FLAGS } from './subcommands/flags.ts';\nimport { refuseForeignFlags, subcommandFor } from './subcommands/registry.ts';\n\nexport { CLI_NAME } from './shell.ts';\nexport { cliUsage } from './usage.ts';\nexport type { CliInvocation } from './subcommands/registry.ts';\nexport type { AgentsInvocation } from './subcommands/agents.ts';\nexport type { HooksInvocation } from './subcommands/hooks.ts';\nexport type { InitInvocation } from './subcommands/init.ts';\nexport type { MapInvocation } from './subcommands/map.ts';\nexport type { RetrieveInvocation } from './subcommands/retrieve.ts';\nexport type { SmeltInvocation } from './subcommands/smelt.ts';\nexport type { StatsInvocation } from './subcommands/stats.ts';\n\nimport type { CliInvocation } from './subcommands/registry.ts';\n\n/**\n * Argument parsing on `node:util.parseArgs` — stable since Node 20, which `engines`\n * already requires.\n *\n * The CLI ships as a `bin` on the library rather than as a second package, and this\n * import is the reason that is free: it adds no dependency, so the argument the second\n * package existed to win — keeping the library's dependency tree small — is already won.\n *\n * The shape of this function is the shape of the subcommand seam: split the argv into\n * flags and positionals, answer the two global flags, **look the verb up in\n * `SUBCOMMANDS`**, refuse every flag that verb does not own with one generated\n * message, and let the verb do its own validation. There is no per-verb branching left\n * here — a seventh verb is a seventh file, not a seventh `if`.\n *\n * @throws {CliUsageError} on anything the user got wrong. Never guesses.\n */\nexport function parseSmeltArgs(argv: readonly string[]): CliInvocation {\n let parsed;\n try {\n parsed = parseArgs({\n args: [...argv],\n allowPositionals: true,\n strict: true,\n options: CLI_FLAGS,\n });\n } catch (cause) {\n throw new CliUsageError(\n `${CLI_NAME}: ${cause instanceof Error ? cause.message : String(cause)}\\n` +\n `Run \\`${CLI_NAME} --help\\`.`,\n );\n }\n\n const { values, positionals } = parsed;\n\n // Answered before any verb, so `smelt map --help` prints the help rather than being\n // refused for a flag `map` does not own. No verb may claim these two.\n if (values.help === true) return { mode: 'help', focus: [], json: false };\n if (values.version === true) return { mode: 'version', focus: [], json: false };\n\n const command = subcommandFor(positionals);\n refuseForeignFlags(command, values);\n return command.parse(values, positionals);\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"args.js","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEtC,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE9E,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAWtC;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,cAAc,CAAC,IAAuB;IACpD,IAAI,MAAM,CAAC;IACX,IAAI,CAAC;QACH,MAAM,GAAG,SAAS,CAAC;YACjB,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC;YACf,gBAAgB,EAAE,IAAI;YACtB,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE,SAAS;SACnB,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,aAAa,CACrB,GAAG,QAAQ,KAAK,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI;YACxE,SAAS,QAAQ,YAAY,CAChC,CAAC;IACJ,CAAC;IAED,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,CAAC;IAEvC,oFAAoF;IACpF,sEAAsE;IACtE,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IAC1E,IAAI,MAAM,CAAC,OAAO,KAAK,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IAEhF,MAAM,OAAO,GAAG,aAAa,CAAC,WAAW,CAAC,CAAC;IAC3C,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACpC,OAAO,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAC5C,CAAC","sourcesContent":["import { parseArgs } from 'node:util';\n\nimport { CliUsageError } from '../errors.ts';\n\nimport { CLI_NAME } from './shell.ts';\nimport { CLI_FLAGS } from './subcommands/flags.ts';\nimport { refuseForeignFlags, subcommandFor } from './subcommands/registry.ts';\n\nexport { CLI_NAME } from './shell.ts';\nexport { cliUsage } from './usage.ts';\nexport type { CliInvocation } from './subcommands/registry.ts';\nexport type { HooksInvocation } from './subcommands/hooks.ts';\nexport type { InitInvocation } from './subcommands/init.ts';\nexport type { MapInvocation } from './subcommands/map.ts';\nexport type { RetrieveInvocation } from './subcommands/retrieve.ts';\nexport type { SmeltInvocation } from './subcommands/smelt.ts';\nexport type { StatsInvocation } from './subcommands/stats.ts';\n\nimport type { CliInvocation } from './subcommands/registry.ts';\n\n/**\n * Argument parsing on `node:util.parseArgs` — stable since Node 20, which `engines`\n * already requires.\n *\n * The CLI ships as a `bin` on the library rather than as a second package, and this\n * import is the reason that is free: it adds no dependency, so the argument the second\n * package existed to win — keeping the library's dependency tree small — is already won.\n *\n * The shape of this function is the shape of the subcommand seam: split the argv into\n * flags and positionals, answer the two global flags, **look the verb up in\n * `SUBCOMMANDS`**, refuse every flag that verb does not own with one generated\n * message, and let the verb do its own validation. There is no per-verb branching left\n * here — a seventh verb is a seventh file, not a seventh `if`.\n *\n * @throws {CliUsageError} on anything the user got wrong. Never guesses.\n */\nexport function parseSmeltArgs(argv: readonly string[]): CliInvocation {\n let parsed;\n try {\n parsed = parseArgs({\n args: [...argv],\n allowPositionals: true,\n strict: true,\n options: CLI_FLAGS,\n });\n } catch (cause) {\n throw new CliUsageError(\n `${CLI_NAME}: ${cause instanceof Error ? cause.message : String(cause)}\\n` +\n `Run \\`${CLI_NAME} --help\\`.`,\n );\n }\n\n const { values, positionals } = parsed;\n\n // Answered before any verb, so `smelt map --help` prints the help rather than being\n // refused for a flag `map` does not own. No verb may claim these two.\n if (values.help === true) return { mode: 'help', focus: [], json: false };\n if (values.version === true) return { mode: 'version', focus: [], json: false };\n\n const command = subcommandFor(positionals);\n refuseForeignFlags(command, values);\n return command.parse(values, positionals);\n}\n"]}
1
+ {"version":3,"file":"args.js","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEtC,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE9E,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAYtC;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,cAAc,CAAC,IAAuB;IACpD,IAAI,MAAM,CAAC;IACX,IAAI,CAAC;QACH,MAAM,GAAG,SAAS,CAAC;YACjB,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC;YACf,gBAAgB,EAAE,IAAI;YACtB,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE,SAAS;SACnB,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,aAAa,CACrB,GAAG,QAAQ,KAAK,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI;YACxE,SAAS,QAAQ,YAAY,CAChC,CAAC;IACJ,CAAC;IAED,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,CAAC;IAEvC,oFAAoF;IACpF,sEAAsE;IACtE,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IAC1E,IAAI,MAAM,CAAC,OAAO,KAAK,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IAEhF,MAAM,OAAO,GAAG,aAAa,CAAC,WAAW,CAAC,CAAC;IAC3C,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACpC,OAAO,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAC5C,CAAC","sourcesContent":["import { parseArgs } from 'node:util';\n\nimport { CliUsageError } from '../errors.ts';\n\nimport { CLI_NAME } from './shell.ts';\nimport { CLI_FLAGS } from './subcommands/flags.ts';\nimport { refuseForeignFlags, subcommandFor } from './subcommands/registry.ts';\n\nexport { CLI_NAME } from './shell.ts';\nexport { cliUsage } from './usage.ts';\nexport type { CliInvocation } from './subcommands/registry.ts';\nexport type { AgentsInvocation } from './subcommands/agents.ts';\nexport type { HooksInvocation } from './subcommands/hooks.ts';\nexport type { InitInvocation } from './subcommands/init.ts';\nexport type { MapInvocation } from './subcommands/map.ts';\nexport type { RetrieveInvocation } from './subcommands/retrieve.ts';\nexport type { SmeltInvocation } from './subcommands/smelt.ts';\nexport type { StatsInvocation } from './subcommands/stats.ts';\n\nimport type { CliInvocation } from './subcommands/registry.ts';\n\n/**\n * Argument parsing on `node:util.parseArgs` — stable since Node 20, which `engines`\n * already requires.\n *\n * The CLI ships as a `bin` on the library rather than as a second package, and this\n * import is the reason that is free: it adds no dependency, so the argument the second\n * package existed to win — keeping the library's dependency tree small — is already won.\n *\n * The shape of this function is the shape of the subcommand seam: split the argv into\n * flags and positionals, answer the two global flags, **look the verb up in\n * `SUBCOMMANDS`**, refuse every flag that verb does not own with one generated\n * message, and let the verb do its own validation. There is no per-verb branching left\n * here — a seventh verb is a seventh file, not a seventh `if`.\n *\n * @throws {CliUsageError} on anything the user got wrong. Never guesses.\n */\nexport function parseSmeltArgs(argv: readonly string[]): CliInvocation {\n let parsed;\n try {\n parsed = parseArgs({\n args: [...argv],\n allowPositionals: true,\n strict: true,\n options: CLI_FLAGS,\n });\n } catch (cause) {\n throw new CliUsageError(\n `${CLI_NAME}: ${cause instanceof Error ? cause.message : String(cause)}\\n` +\n `Run \\`${CLI_NAME} --help\\`.`,\n );\n }\n\n const { values, positionals } = parsed;\n\n // Answered before any verb, so `smelt map --help` prints the help rather than being\n // refused for a flag `map` does not own. No verb may claim these two.\n if (values.help === true) return { mode: 'help', focus: [], json: false };\n if (values.version === true) return { mode: 'version', focus: [], json: false };\n\n const command = subcommandFor(positionals);\n refuseForeignFlags(command, values);\n return command.parse(values, positionals);\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // `smelt init` reads answers line by line, so it gets the stream, not readStdin's\n // one-shot slurp of fd 0. A *getter*, because touching `process.stdin` at all\n // flips fd 0 into non-blocking mode and breaks readStdin's readFileSync(0) for\n // every other mode — only the `init` branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
1
+ {"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // The lava renderer's switch: a real interactive terminal that has not been\n // told to keep its bytes plain. Piped output, agents and NO_COLOR all mean\n // exactly the bytes the wizards have always written.\n color: process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined,\n // The wizard verbs (`init`, `hooks`, `agents split`, `setup`) read answers line by\n // line, so they get the stream, not readStdin's one-shot slurp of fd 0. A\n // *getter*, because touching `process.stdin` at all flips fd 0 into non-blocking\n // mode and breaks readStdin's readFileSync(0) for every other mode — only a\n // wizard branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
package/dist/cli/bin.js CHANGED
@@ -144,10 +144,15 @@ try {
144
144
  stdin: readStdin,
145
145
  version: packageVersion(),
146
146
  cwd: process.cwd(),
147
- // `smelt init` reads answers line by line, so it gets the stream, not readStdin's
148
- // one-shot slurp of fd 0. A *getter*, because touching `process.stdin` at all
149
- // flips fd 0 into non-blocking mode and breaks readStdin's readFileSync(0) for
150
- // every other mode only the `init` branch of runCli ever reads this property.
147
+ // The lava renderer's switch: a real interactive terminal that has not been
148
+ // told to keep its bytes plain. Piped output, agents and NO_COLOR all mean
149
+ // exactly the bytes the wizards have always written.
150
+ color: process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined,
151
+ // The wizard verbs (`init`, `hooks`, `agents split`, `setup`) read answers line by
152
+ // line, so they get the stream, not readStdin's one-shot slurp of fd 0. A
153
+ // *getter*, because touching `process.stdin` at all flips fd 0 into non-blocking
154
+ // mode and breaks readStdin's readFileSync(0) for every other mode — only a
155
+ // wizard branch of runCli ever reads this property.
151
156
  get initInput() {
152
157
  return process.stdin;
153
158
  },
@@ -1 +1 @@
1
- {"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // `smelt init` reads answers line by line, so it gets the stream, not readStdin's\n // one-shot slurp of fd 0. A *getter*, because touching `process.stdin` at all\n // flips fd 0 into non-blocking mode and breaks readStdin's readFileSync(0) for\n // every other mode — only the `init` branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
1
+ {"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // The lava renderer's switch: a real interactive terminal that has not been\n // told to keep its bytes plain. Piped output, agents and NO_COLOR all mean\n // exactly the bytes the wizards have always written.\n color: process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined,\n // The wizard verbs (`init`, `hooks`, `agents split`, `setup`) read answers line by\n // line, so they get the stream, not readStdin's one-shot slurp of fd 0. A\n // *getter*, because touching `process.stdin` at all flips fd 0 into non-blocking\n // mode and breaks readStdin's readFileSync(0) for every other mode — only a\n // wizard branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
@@ -47,6 +47,24 @@ export interface SmeltConfigHooks {
47
47
  */
48
48
  readonly enforcement?: EnforcementMode;
49
49
  }
50
+ /**
51
+ * The `agents` block — settings for `smelt agents lint`.
52
+ *
53
+ * One key, and it is the user's number: how many UTF-8 bytes of instruction file this
54
+ * project is willing to pay on every request. **There is no default and there never
55
+ * will be** — the same ruling as `--budget`, for the same reason. A budget smelt
56
+ * invented would be smelt deciding how much of somebody's context window their own
57
+ * house rules deserve, at a number nobody chose. The guide's cited "~150-200
58
+ * instructions" figure is printed by the lint as a citation and compared to nothing.
59
+ */
60
+ export interface SmeltConfigAgents {
61
+ /**
62
+ * The merged set's byte ceiling — every `AGENTS.md`/`CLAUDE.md`/`GEMINI.md` in the
63
+ * tree, summed. Absent means unbudgeted: the lint measures and reports, and no
64
+ * number can fail.
65
+ */
66
+ readonly budgetBytes?: number;
67
+ }
50
68
  /** The parsed shape of `smelt.config.json`. Every field beyond the version is optional. */
51
69
  export interface SmeltConfig {
52
70
  readonly smeltConfig: typeof CONFIG_VERSION;
@@ -58,6 +76,8 @@ export interface SmeltConfig {
58
76
  readonly store?: SmeltConfigStore;
59
77
  /** Settings for the harness guard. See {@link SmeltConfigHooks}. */
60
78
  readonly hooks?: SmeltConfigHooks;
79
+ /** Settings for `smelt agents lint`. See {@link SmeltConfigAgents}. */
80
+ readonly agents?: SmeltConfigAgents;
61
81
  }
62
82
  /** A config plus where it was found — the path matters for resolving `store.path`. */
63
83
  export interface LoadedConfig {
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/cli/config.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAE9D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAIpD;;;;;;;;;;;;;;;GAeG;AAEH,mEAAmE;AACnE,eAAO,MAAM,gBAAgB,sBAAsB,CAAC;AAEpD,sDAAsD;AACtD,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC,wDAAwD;AACxD,MAAM,MAAM,gBAAgB,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC3B;IACE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,CAAC;AAEN;;;;;;;;GAQG;AACH,MAAM,WAAW,gBAAgB;IAC/B,gFAAgF;IAChF,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,eAAe,CAAC;CACxC;AAED,2FAA2F;AAC3F,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,WAAW,EAAE,OAAO,cAAc,CAAC;IAC5C,sFAAsF;IACtF,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC,2FAA2F;IAC3F,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC7B,kFAAkF;IAClF,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,oEAAoE;IACpE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACnC;AAED,sFAAsF;AACtF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAS9D;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAIvE;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,CA+DnE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAWxD;AAiFD,qFAAqF;AACrF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3E;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtF;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,GAAG,eAAe,CAKjF","sourcesContent":["import { existsSync, readFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\n\nimport { CliUsageError } from '../errors.ts';\nimport { ENFORCEMENT_MODES } from '../hooks/guard-core.ts';\nimport type { EnforcementMode } from '../hooks/guard-core.ts';\nimport { isStrategy, STRATEGIES } from '../plan/planners.ts';\nimport type { Strategy } from '../plan/planners.ts';\n\nimport { CLI_NAME } from './shell.ts';\n\n/**\n * `smelt.config.json` — CLI defaults, and nothing more.\n *\n * This file exists so `smelt` can be run without retyping the same flags, and it is\n * deliberately a *CLI* concern: the programmatic API never reads it. A library that\n * reads config files off the caller's disk is a library whose behaviour depends on\n * where it was invoked from, which is exactly the class of invisible input smelt\n * refuses. `createSmelter()` takes explicit arguments; the CLI translates this file\n * into those arguments, and explicit flags always win over it.\n *\n * The schema is versioned (`\"smeltConfig\": 1`) for the same reason the marker and the\n * `--json` envelope are: files outlive the binaries that wrote them, and a mismatch\n * must be identifiable rather than half-understood. Parsing is strict — an unknown\n * key, a wrong type, or an unknown version is a {@link CliUsageError}, never a shrug.\n * A config the CLI silently ignored would be a budget the user *thought* they set.\n */\n\n/** The file name looked for, from the working directory upward. */\nexport const CONFIG_FILE_NAME = 'smelt.config.json';\n\n/** The schema version this build reads and writes. */\nexport const CONFIG_VERSION = 1;\n\n/** Where the CLI's elision store lives between runs. */\nexport type SmeltConfigStore =\n | { readonly kind: 'memory' }\n | {\n readonly kind: 'directory';\n /** Resolved relative to the directory holding the config file, not the cwd. */\n readonly path: string;\n };\n\n/**\n * The `hooks` block — settings for the harness guard (`smelt hooks install`).\n *\n * Parsed strictly here, like every other key: the CLI refuses a malformed config.\n * The guard core (`src/hooks/guard-core.ts`) reads the same file with its own\n * *tolerant* reader — a guard running inside somebody's session fails open where the\n * CLI correctly refuses — and `test/hooks-guard-core.test.ts` pins the two readers to\n * the same key names and defaults so they cannot drift apart.\n */\nexport interface SmeltConfigHooks {\n /** Reads at or under this many bytes pass the guard untouched. Default 8192. */\n readonly thresholdBytes?: number;\n /**\n * `'deny'` (default): oversized raw reads are refused with a reason naming the\n * exact replacement command. `'rewrite'`: on harnesses whose hooks can modify tool\n * input, the command is substituted instead (never silently — the reason says so).\n */\n readonly enforcement?: EnforcementMode;\n}\n\n/** The parsed shape of `smelt.config.json`. Every field beyond the version is optional. */\nexport interface SmeltConfig {\n readonly smeltConfig: typeof CONFIG_VERSION;\n /** Used when a `smelt` run omits `--budget`. UTF-8 bytes, like every smelt budget. */\n readonly defaultBudgetBytes?: number;\n /** Used when a run omits `--strategy`. Validated against the {@link PLANNERS} registry. */\n readonly strategy?: Strategy;\n /** Used for every run; there is no store flag. Defaults to memory when absent. */\n readonly store?: SmeltConfigStore;\n /** Settings for the harness guard. See {@link SmeltConfigHooks}. */\n readonly hooks?: SmeltConfigHooks;\n}\n\n/** A config plus where it was found — the path matters for resolving `store.path`. */\nexport interface LoadedConfig {\n readonly path: string;\n readonly config: SmeltConfig;\n}\n\n/**\n * The nearest `smelt.config.json`, walking up from `cwd` to the filesystem root —\n * the same discovery shape as `package.json`, so a config at a repo root covers the\n * whole tree. `undefined` when there is none, which is not an error: flags alone are\n * a complete interface.\n */\nexport function findConfigFile(cwd: string): string | undefined {\n let dir = resolve(cwd);\n for (;;) {\n const candidate = join(dir, CONFIG_FILE_NAME);\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n if (parent === dir) return undefined;\n dir = parent;\n }\n}\n\n/**\n * Find and parse the nearest config. `undefined` when no file exists; a file that\n * exists but is malformed **throws** — see {@link parseConfig}. The distinction is the\n * point: \"no config\" is a fine state, \"a config you cannot have meant\" is not.\n *\n * @throws {CliUsageError} when a config file exists and is malformed.\n */\nexport function loadNearestConfig(cwd: string): LoadedConfig | undefined {\n const path = findConfigFile(cwd);\n if (path === undefined) return undefined;\n return { path, config: parseConfig(readFileSync(path, 'utf8'), path) };\n}\n\n/**\n * Parse and validate one config file, strictly.\n *\n * Strict means: unknown keys are refused, not skipped. A typo'd `defaultBudgetByte`\n * that parsed cleanly would leave the user believing a default was set while every\n * run ignored it — a config error whose failure mode is *silence*, which is the one\n * failure shape this project refuses everywhere else too.\n *\n * @throws {CliUsageError} naming the file and the exact problem.\n */\nexport function parseConfig(text: string, path: string): SmeltConfig {\n const bad = (why: string): CliUsageError =>\n new CliUsageError(\n `${CLI_NAME}: malformed ${CONFIG_FILE_NAME} at ${path}: ${why}\\n` +\n `Fix it or delete it — a config smelt cannot read is never silently ignored. ` +\n `\\`${CLI_NAME} init\\` can rewrite it.`,\n );\n\n let value: unknown;\n try {\n value = JSON.parse(text);\n } catch (cause) {\n throw bad(`not JSON (${cause instanceof Error ? cause.message : String(cause)}).`);\n }\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad('expected a JSON object at the top level.');\n }\n const fields = value as Record<string, unknown>;\n\n if (fields['smeltConfig'] !== CONFIG_VERSION) {\n throw bad(\n `\"smeltConfig\" is ${JSON.stringify(fields['smeltConfig'])}; this build reads version ` +\n `${String(CONFIG_VERSION)}. The schema is versioned so a mismatch is visible ` +\n `instead of half-understood.`,\n );\n }\n\n const known = ['smeltConfig', 'defaultBudgetBytes', 'strategy', 'store', 'hooks'];\n const unknown = Object.keys(fields).filter((key) => !known.includes(key));\n if (unknown.length > 0) {\n throw bad(\n `unknown key${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `\"${k}\"`).join(', ')}. ` +\n `Known keys: ${known.join(', ')}. Unknown keys are refused because a typo that ` +\n `parsed cleanly would be a setting you believed was set.`,\n );\n }\n\n const budget = fields['defaultBudgetBytes'];\n if (budget !== undefined && (typeof budget !== 'number' || !Number.isInteger(budget))) {\n throw bad(`\"defaultBudgetBytes\" must be a whole number of UTF-8 bytes.`);\n }\n if (typeof budget === 'number' && budget <= 0) {\n throw bad(`\"defaultBudgetBytes\" must be greater than zero, got ${String(budget)}.`);\n }\n\n const strategy = fields['strategy'];\n if (strategy !== undefined && (typeof strategy !== 'string' || !isStrategy(strategy))) {\n throw bad(\n `\"strategy\" must be ${STRATEGIES.map((s) => `\"${s}\"`).join(' or ')}, ` +\n `got ${JSON.stringify(strategy)}.`,\n );\n }\n\n const store = parseStore(fields['store'], bad);\n const hooks = parseHooks(fields['hooks'], bad);\n\n return {\n smeltConfig: CONFIG_VERSION,\n ...(budget === undefined ? {} : { defaultBudgetBytes: budget }),\n ...(strategy === undefined ? {} : { strategy }),\n ...(store === undefined ? {} : { store }),\n ...(hooks === undefined ? {} : { hooks }),\n };\n}\n\n/**\n * Serialize a config back to the bytes of a `smelt.config.json` — {@link parseConfig}'s\n * inverse, and the **only** writer of this file.\n *\n * The module that owns the schema owns both directions. It used to own only the read:\n * two modules hand-built a config object and stringified it — the `init` wizard and\n * `hooks install` — and they had already drifted, one always emitting `strategy` and\n * the other only when it was carried. A sixth key would have been written by whichever\n * module its author was editing and silently dropped by the other, which is the\n * \"setting the user believed was in force\" failure this file refuses everywhere else.\n *\n * Key order is fixed here — the reader's own key order, top level and inside `store`\n * and `hooks` — so a re-run diffs cleanly no matter which verb wrote the file, and two\n * writers can never disagree about shape. What goes *into* the config stays each\n * verb's policy: the wizard's choices, and `hooks install`'s directory-store injection.\n * Absent keys are omitted rather than written as `null`, so\n * `parseConfig(renderConfig(c))` returns `c` field for field —\n * `test/guards/config-writer.test.ts` pins that round trip.\n */\nexport function renderConfig(config: SmeltConfig): string {\n const ordered: SmeltConfig = {\n smeltConfig: CONFIG_VERSION,\n ...(config.defaultBudgetBytes === undefined\n ? {}\n : { defaultBudgetBytes: config.defaultBudgetBytes }),\n ...(config.strategy === undefined ? {} : { strategy: config.strategy }),\n ...(config.store === undefined ? {} : { store: renderStore(config.store) }),\n ...(config.hooks === undefined ? {} : { hooks: renderHooks(config.hooks) }),\n };\n return `${JSON.stringify(ordered, null, 2)}\\n`;\n}\n\nfunction renderStore(store: SmeltConfigStore): SmeltConfigStore {\n return store.kind === 'memory' ? { kind: 'memory' } : { kind: 'directory', path: store.path };\n}\n\nfunction renderHooks(hooks: SmeltConfigHooks): SmeltConfigHooks {\n return {\n ...(hooks.thresholdBytes === undefined ? {} : { thresholdBytes: hooks.thresholdBytes }),\n ...(hooks.enforcement === undefined ? {} : { enforcement: hooks.enforcement }),\n };\n}\n\nfunction parseStore(\n value: unknown,\n bad: (why: string) => CliUsageError,\n): SmeltConfigStore | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad(`\"store\" must be an object like {\"kind\":\"memory\"} or {\"kind\":\"directory\",\"path\":…}.`);\n }\n const fields = value as Record<string, unknown>;\n const kind = fields['kind'];\n if (kind === 'memory') {\n const extra = Object.keys(fields).filter((key) => key !== 'kind');\n if (extra.length > 0) throw bad(`\"store\" of kind \"memory\" takes no other keys.`);\n return { kind: 'memory' };\n }\n if (kind === 'directory') {\n const path = fields['path'];\n if (typeof path !== 'string' || path === '') {\n throw bad(`\"store\" of kind \"directory\" needs a non-empty \"path\".`);\n }\n const extra = Object.keys(fields).filter((key) => key !== 'kind' && key !== 'path');\n if (extra.length > 0) throw bad(`\"store\" of kind \"directory\" takes only \"kind\" and \"path\".`);\n return { kind: 'directory', path };\n }\n throw bad(`\"store\".kind must be \"memory\" or \"directory\", got ${JSON.stringify(kind)}.`);\n}\n\nfunction parseHooks(\n value: unknown,\n bad: (why: string) => CliUsageError,\n): SmeltConfigHooks | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad(`\"hooks\" must be an object like {\"thresholdBytes\":8192,\"enforcement\":\"deny\"}.`);\n }\n const fields = value as Record<string, unknown>;\n const extra = Object.keys(fields).filter(\n (key) => key !== 'thresholdBytes' && key !== 'enforcement',\n );\n if (extra.length > 0) {\n throw bad(\n `\"hooks\" takes only \"thresholdBytes\" and \"enforcement\", ` +\n `got ${extra.map((k) => `\"${k}\"`).join(', ')}.`,\n );\n }\n const threshold = fields['thresholdBytes'];\n if (\n threshold !== undefined &&\n (typeof threshold !== 'number' || !Number.isInteger(threshold) || threshold <= 0)\n ) {\n throw bad(`\"hooks\".thresholdBytes must be a whole number of bytes greater than zero.`);\n }\n const enforcement = fields['enforcement'];\n if (\n enforcement !== undefined &&\n !(ENFORCEMENT_MODES as readonly unknown[]).includes(enforcement)\n ) {\n throw bad(\n `\"hooks\".enforcement must be ${ENFORCEMENT_MODES.map((m) => `\"${m}\"`).join(' or ')}, ` +\n `got ${JSON.stringify(enforcement)}.`,\n );\n }\n return {\n ...(threshold === undefined ? {} : { thresholdBytes: threshold as number }),\n ...(enforcement === undefined ? {} : { enforcement: enforcement as EnforcementMode }),\n };\n}\n\n/** `store.path` is relative to the config file, so the config works from any cwd. */\nexport function resolveStorePath(loaded: LoadedConfig, path: string): string {\n return resolve(dirname(loaded.path), path);\n}\n\n/**\n * The store decision a config carries, with `path` already resolved against the config\n * file's directory — one config serves every subdirectory it covers without scattering\n * store roots. `'memory'` is the built-in default: a fresh in-memory store per run.\n */\nexport type ConfiguredStore =\n { readonly kind: 'memory' } | { readonly kind: 'directory'; readonly path: string };\n\n/**\n * The store leg of every merge, read once here rather than in each verb that needs it.\n *\n * There is no store flag, so this leg is config-or-built-in only — which is exactly\n * why it belongs to the config module and not to a verb: `smelt` builds a store from\n * it, and `retrieve`/`stats` refuse when it is not a directory (see `resolveStoreRun`\n * in `subcommands/retrieve.ts`). Three verbs, one reading of the same key.\n */\nexport function configuredStore(config: LoadedConfig | undefined): ConfiguredStore {\n if (config?.config.store === undefined || config.config.store.kind === 'memory') {\n return { kind: 'memory' };\n }\n return { kind: 'directory', path: resolveStorePath(config, config.config.store.path) };\n}\n"]}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/cli/config.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAE9D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAIpD;;;;;;;;;;;;;;;GAeG;AAEH,mEAAmE;AACnE,eAAO,MAAM,gBAAgB,sBAAsB,CAAC;AAEpD,sDAAsD;AACtD,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC,wDAAwD;AACxD,MAAM,MAAM,gBAAgB,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC3B;IACE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,CAAC;AAEN;;;;;;;;GAQG;AACH,MAAM,WAAW,gBAAgB;IAC/B,gFAAgF;IAChF,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,eAAe,CAAC;CACxC;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,2FAA2F;AAC3F,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,WAAW,EAAE,OAAO,cAAc,CAAC;IAC5C,sFAAsF;IACtF,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC,2FAA2F;IAC3F,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC7B,kFAAkF;IAClF,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,oEAAoE;IACpE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,CAAC;CACrC;AAED,sFAAsF;AACtF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAS9D;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAIvE;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,CAiEnE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAYxD;AAqHD,qFAAqF;AACrF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3E;AACD;;;;GAIG;AACH,MAAM,MAAM,eAAe,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtF;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,GAAG,eAAe,CAKjF","sourcesContent":["import { existsSync, readFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\n\nimport { CliUsageError } from '../errors.ts';\nimport { ENFORCEMENT_MODES } from '../hooks/guard-core.ts';\nimport type { EnforcementMode } from '../hooks/guard-core.ts';\nimport { isStrategy, STRATEGIES } from '../plan/planners.ts';\nimport type { Strategy } from '../plan/planners.ts';\n\nimport { CLI_NAME } from './shell.ts';\n\n/**\n * `smelt.config.json` — CLI defaults, and nothing more.\n *\n * This file exists so `smelt` can be run without retyping the same flags, and it is\n * deliberately a *CLI* concern: the programmatic API never reads it. A library that\n * reads config files off the caller's disk is a library whose behaviour depends on\n * where it was invoked from, which is exactly the class of invisible input smelt\n * refuses. `createSmelter()` takes explicit arguments; the CLI translates this file\n * into those arguments, and explicit flags always win over it.\n *\n * The schema is versioned (`\"smeltConfig\": 1`) for the same reason the marker and the\n * `--json` envelope are: files outlive the binaries that wrote them, and a mismatch\n * must be identifiable rather than half-understood. Parsing is strict — an unknown\n * key, a wrong type, or an unknown version is a {@link CliUsageError}, never a shrug.\n * A config the CLI silently ignored would be a budget the user *thought* they set.\n */\n\n/** The file name looked for, from the working directory upward. */\nexport const CONFIG_FILE_NAME = 'smelt.config.json';\n\n/** The schema version this build reads and writes. */\nexport const CONFIG_VERSION = 1;\n\n/** Where the CLI's elision store lives between runs. */\nexport type SmeltConfigStore =\n | { readonly kind: 'memory' }\n | {\n readonly kind: 'directory';\n /** Resolved relative to the directory holding the config file, not the cwd. */\n readonly path: string;\n };\n\n/**\n * The `hooks` block — settings for the harness guard (`smelt hooks install`).\n *\n * Parsed strictly here, like every other key: the CLI refuses a malformed config.\n * The guard core (`src/hooks/guard-core.ts`) reads the same file with its own\n * *tolerant* reader — a guard running inside somebody's session fails open where the\n * CLI correctly refuses — and `test/hooks-guard-core.test.ts` pins the two readers to\n * the same key names and defaults so they cannot drift apart.\n */\nexport interface SmeltConfigHooks {\n /** Reads at or under this many bytes pass the guard untouched. Default 8192. */\n readonly thresholdBytes?: number;\n /**\n * `'deny'` (default): oversized raw reads are refused with a reason naming the\n * exact replacement command. `'rewrite'`: on harnesses whose hooks can modify tool\n * input, the command is substituted instead (never silently — the reason says so).\n */\n readonly enforcement?: EnforcementMode;\n}\n\n/**\n * The `agents` block — settings for `smelt agents lint`.\n *\n * One key, and it is the user's number: how many UTF-8 bytes of instruction file this\n * project is willing to pay on every request. **There is no default and there never\n * will be** — the same ruling as `--budget`, for the same reason. A budget smelt\n * invented would be smelt deciding how much of somebody's context window their own\n * house rules deserve, at a number nobody chose. The guide's cited \"~150-200\n * instructions\" figure is printed by the lint as a citation and compared to nothing.\n */\nexport interface SmeltConfigAgents {\n /**\n * The merged set's byte ceiling — every `AGENTS.md`/`CLAUDE.md`/`GEMINI.md` in the\n * tree, summed. Absent means unbudgeted: the lint measures and reports, and no\n * number can fail.\n */\n readonly budgetBytes?: number;\n}\n\n/** The parsed shape of `smelt.config.json`. Every field beyond the version is optional. */\nexport interface SmeltConfig {\n readonly smeltConfig: typeof CONFIG_VERSION;\n /** Used when a `smelt` run omits `--budget`. UTF-8 bytes, like every smelt budget. */\n readonly defaultBudgetBytes?: number;\n /** Used when a run omits `--strategy`. Validated against the {@link PLANNERS} registry. */\n readonly strategy?: Strategy;\n /** Used for every run; there is no store flag. Defaults to memory when absent. */\n readonly store?: SmeltConfigStore;\n /** Settings for the harness guard. See {@link SmeltConfigHooks}. */\n readonly hooks?: SmeltConfigHooks;\n /** Settings for `smelt agents lint`. See {@link SmeltConfigAgents}. */\n readonly agents?: SmeltConfigAgents;\n}\n\n/** A config plus where it was found — the path matters for resolving `store.path`. */\nexport interface LoadedConfig {\n readonly path: string;\n readonly config: SmeltConfig;\n}\n\n/**\n * The nearest `smelt.config.json`, walking up from `cwd` to the filesystem root —\n * the same discovery shape as `package.json`, so a config at a repo root covers the\n * whole tree. `undefined` when there is none, which is not an error: flags alone are\n * a complete interface.\n */\nexport function findConfigFile(cwd: string): string | undefined {\n let dir = resolve(cwd);\n for (;;) {\n const candidate = join(dir, CONFIG_FILE_NAME);\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n if (parent === dir) return undefined;\n dir = parent;\n }\n}\n\n/**\n * Find and parse the nearest config. `undefined` when no file exists; a file that\n * exists but is malformed **throws** — see {@link parseConfig}. The distinction is the\n * point: \"no config\" is a fine state, \"a config you cannot have meant\" is not.\n *\n * @throws {CliUsageError} when a config file exists and is malformed.\n */\nexport function loadNearestConfig(cwd: string): LoadedConfig | undefined {\n const path = findConfigFile(cwd);\n if (path === undefined) return undefined;\n return { path, config: parseConfig(readFileSync(path, 'utf8'), path) };\n}\n\n/**\n * Parse and validate one config file, strictly.\n *\n * Strict means: unknown keys are refused, not skipped. A typo'd `defaultBudgetByte`\n * that parsed cleanly would leave the user believing a default was set while every\n * run ignored it — a config error whose failure mode is *silence*, which is the one\n * failure shape this project refuses everywhere else too.\n *\n * @throws {CliUsageError} naming the file and the exact problem.\n */\nexport function parseConfig(text: string, path: string): SmeltConfig {\n const bad = (why: string): CliUsageError =>\n new CliUsageError(\n `${CLI_NAME}: malformed ${CONFIG_FILE_NAME} at ${path}: ${why}\\n` +\n `Fix it or delete it — a config smelt cannot read is never silently ignored. ` +\n `\\`${CLI_NAME} init\\` can rewrite it.`,\n );\n\n let value: unknown;\n try {\n value = JSON.parse(text);\n } catch (cause) {\n throw bad(`not JSON (${cause instanceof Error ? cause.message : String(cause)}).`);\n }\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad('expected a JSON object at the top level.');\n }\n const fields = value as Record<string, unknown>;\n\n if (fields['smeltConfig'] !== CONFIG_VERSION) {\n throw bad(\n `\"smeltConfig\" is ${JSON.stringify(fields['smeltConfig'])}; this build reads version ` +\n `${String(CONFIG_VERSION)}. The schema is versioned so a mismatch is visible ` +\n `instead of half-understood.`,\n );\n }\n\n const known = ['smeltConfig', 'defaultBudgetBytes', 'strategy', 'store', 'hooks', 'agents'];\n const unknown = Object.keys(fields).filter((key) => !known.includes(key));\n if (unknown.length > 0) {\n throw bad(\n `unknown key${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `\"${k}\"`).join(', ')}. ` +\n `Known keys: ${known.join(', ')}. Unknown keys are refused because a typo that ` +\n `parsed cleanly would be a setting you believed was set.`,\n );\n }\n\n const budget = fields['defaultBudgetBytes'];\n if (budget !== undefined && (typeof budget !== 'number' || !Number.isInteger(budget))) {\n throw bad(`\"defaultBudgetBytes\" must be a whole number of UTF-8 bytes.`);\n }\n if (typeof budget === 'number' && budget <= 0) {\n throw bad(`\"defaultBudgetBytes\" must be greater than zero, got ${String(budget)}.`);\n }\n\n const strategy = fields['strategy'];\n if (strategy !== undefined && (typeof strategy !== 'string' || !isStrategy(strategy))) {\n throw bad(\n `\"strategy\" must be ${STRATEGIES.map((s) => `\"${s}\"`).join(' or ')}, ` +\n `got ${JSON.stringify(strategy)}.`,\n );\n }\n\n const store = parseStore(fields['store'], bad);\n const hooks = parseHooks(fields['hooks'], bad);\n const agents = parseAgents(fields['agents'], bad);\n\n return {\n smeltConfig: CONFIG_VERSION,\n ...(budget === undefined ? {} : { defaultBudgetBytes: budget }),\n ...(strategy === undefined ? {} : { strategy }),\n ...(store === undefined ? {} : { store }),\n ...(hooks === undefined ? {} : { hooks }),\n ...(agents === undefined ? {} : { agents }),\n };\n}\n\n/**\n * Serialize a config back to the bytes of a `smelt.config.json` — {@link parseConfig}'s\n * inverse, and the **only** writer of this file.\n *\n * The module that owns the schema owns both directions. It used to own only the read:\n * two modules hand-built a config object and stringified it — the `init` wizard and\n * `hooks install` — and they had already drifted, one always emitting `strategy` and\n * the other only when it was carried. A sixth key would have been written by whichever\n * module its author was editing and silently dropped by the other, which is the\n * \"setting the user believed was in force\" failure this file refuses everywhere else.\n *\n * Key order is fixed here — the reader's own key order, top level and inside `store`\n * and `hooks` — so a re-run diffs cleanly no matter which verb wrote the file, and two\n * writers can never disagree about shape. What goes *into* the config stays each\n * verb's policy: the wizard's choices, and `hooks install`'s directory-store injection.\n * Absent keys are omitted rather than written as `null`, so\n * `parseConfig(renderConfig(c))` returns `c` field for field —\n * `test/guards/config-writer.test.ts` pins that round trip.\n */\nexport function renderConfig(config: SmeltConfig): string {\n const ordered: SmeltConfig = {\n smeltConfig: CONFIG_VERSION,\n ...(config.defaultBudgetBytes === undefined\n ? {}\n : { defaultBudgetBytes: config.defaultBudgetBytes }),\n ...(config.strategy === undefined ? {} : { strategy: config.strategy }),\n ...(config.store === undefined ? {} : { store: renderStore(config.store) }),\n ...(config.hooks === undefined ? {} : { hooks: renderHooks(config.hooks) }),\n ...(config.agents === undefined ? {} : { agents: renderAgents(config.agents) }),\n };\n return `${JSON.stringify(ordered, null, 2)}\\n`;\n}\n\nfunction renderStore(store: SmeltConfigStore): SmeltConfigStore {\n return store.kind === 'memory' ? { kind: 'memory' } : { kind: 'directory', path: store.path };\n}\n\nfunction renderHooks(hooks: SmeltConfigHooks): SmeltConfigHooks {\n return {\n ...(hooks.thresholdBytes === undefined ? {} : { thresholdBytes: hooks.thresholdBytes }),\n ...(hooks.enforcement === undefined ? {} : { enforcement: hooks.enforcement }),\n };\n}\n\nfunction renderAgents(agents: SmeltConfigAgents): SmeltConfigAgents {\n return agents.budgetBytes === undefined ? {} : { budgetBytes: agents.budgetBytes };\n}\n\nfunction parseStore(\n value: unknown,\n bad: (why: string) => CliUsageError,\n): SmeltConfigStore | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad(`\"store\" must be an object like {\"kind\":\"memory\"} or {\"kind\":\"directory\",\"path\":…}.`);\n }\n const fields = value as Record<string, unknown>;\n const kind = fields['kind'];\n if (kind === 'memory') {\n const extra = Object.keys(fields).filter((key) => key !== 'kind');\n if (extra.length > 0) throw bad(`\"store\" of kind \"memory\" takes no other keys.`);\n return { kind: 'memory' };\n }\n if (kind === 'directory') {\n const path = fields['path'];\n if (typeof path !== 'string' || path === '') {\n throw bad(`\"store\" of kind \"directory\" needs a non-empty \"path\".`);\n }\n const extra = Object.keys(fields).filter((key) => key !== 'kind' && key !== 'path');\n if (extra.length > 0) throw bad(`\"store\" of kind \"directory\" takes only \"kind\" and \"path\".`);\n return { kind: 'directory', path };\n }\n throw bad(`\"store\".kind must be \"memory\" or \"directory\", got ${JSON.stringify(kind)}.`);\n}\n\nfunction parseHooks(\n value: unknown,\n bad: (why: string) => CliUsageError,\n): SmeltConfigHooks | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad(`\"hooks\" must be an object like {\"thresholdBytes\":8192,\"enforcement\":\"deny\"}.`);\n }\n const fields = value as Record<string, unknown>;\n const extra = Object.keys(fields).filter(\n (key) => key !== 'thresholdBytes' && key !== 'enforcement',\n );\n if (extra.length > 0) {\n throw bad(\n `\"hooks\" takes only \"thresholdBytes\" and \"enforcement\", ` +\n `got ${extra.map((k) => `\"${k}\"`).join(', ')}.`,\n );\n }\n const threshold = fields['thresholdBytes'];\n if (\n threshold !== undefined &&\n (typeof threshold !== 'number' || !Number.isInteger(threshold) || threshold <= 0)\n ) {\n throw bad(`\"hooks\".thresholdBytes must be a whole number of bytes greater than zero.`);\n }\n const enforcement = fields['enforcement'];\n if (\n enforcement !== undefined &&\n !(ENFORCEMENT_MODES as readonly unknown[]).includes(enforcement)\n ) {\n throw bad(\n `\"hooks\".enforcement must be ${ENFORCEMENT_MODES.map((m) => `\"${m}\"`).join(' or ')}, ` +\n `got ${JSON.stringify(enforcement)}.`,\n );\n }\n return {\n ...(threshold === undefined ? {} : { thresholdBytes: threshold as number }),\n ...(enforcement === undefined ? {} : { enforcement: enforcement as EnforcementMode }),\n };\n}\n\n/**\n * The `agents` block, parsed as strictly as every other: one key, a whole number of\n * bytes greater than zero.\n *\n * A malformed `agents.budgetBytes` is refused rather than ignored for the reason the\n * whole file is parsed strictly — an ignored budget is a ceiling the user believed was\n * in force. And there is no coercion from a string: `\"budgetBytes\": \"4kb\"` is a\n * mistake with a right answer, and guessing it would be smelt inventing a number.\n */\nfunction parseAgents(\n value: unknown,\n bad: (why: string) => CliUsageError,\n): SmeltConfigAgents | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad(`\"agents\" must be an object like {\"budgetBytes\":2000}.`);\n }\n const fields = value as Record<string, unknown>;\n const extra = Object.keys(fields).filter((key) => key !== 'budgetBytes');\n if (extra.length > 0) {\n throw bad(`\"agents\" takes only \"budgetBytes\", got ${extra.map((k) => `\"${k}\"`).join(', ')}.`);\n }\n const budget = fields['budgetBytes'];\n if (\n budget !== undefined &&\n (typeof budget !== 'number' || !Number.isInteger(budget) || budget <= 0)\n ) {\n throw bad(`\"agents\".budgetBytes must be a whole number of bytes greater than zero.`);\n }\n return budget === undefined ? {} : { budgetBytes: budget as number };\n}\n\n/** `store.path` is relative to the config file, so the config works from any cwd. */\nexport function resolveStorePath(loaded: LoadedConfig, path: string): string {\n return resolve(dirname(loaded.path), path);\n}\n/**\n * The store decision a config carries, with `path` already resolved against the config\n * file's directory — one config serves every subdirectory it covers without scattering\n * store roots. `'memory'` is the built-in default: a fresh in-memory store per run.\n */\nexport type ConfiguredStore =\n { readonly kind: 'memory' } | { readonly kind: 'directory'; readonly path: string };\n\n/**\n * The store leg of every merge, read once here rather than in each verb that needs it.\n *\n * There is no store flag, so this leg is config-or-built-in only — which is exactly\n * why it belongs to the config module and not to a verb: `smelt` builds a store from\n * it, and `retrieve`/`stats` refuse when it is not a directory (see `resolveStoreRun`\n * in `subcommands/retrieve.ts`). Three verbs, one reading of the same key.\n */\nexport function configuredStore(config: LoadedConfig | undefined): ConfiguredStore {\n if (config?.config.store === undefined || config.config.store.kind === 'memory') {\n return { kind: 'memory' };\n }\n return { kind: 'directory', path: resolveStorePath(config, config.config.store.path) };\n}\n"]}
@@ -85,7 +85,7 @@ export function parseConfig(text, path) {
85
85
  `${String(CONFIG_VERSION)}. The schema is versioned so a mismatch is visible ` +
86
86
  `instead of half-understood.`);
87
87
  }
88
- const known = ['smeltConfig', 'defaultBudgetBytes', 'strategy', 'store', 'hooks'];
88
+ const known = ['smeltConfig', 'defaultBudgetBytes', 'strategy', 'store', 'hooks', 'agents'];
89
89
  const unknown = Object.keys(fields).filter((key) => !known.includes(key));
90
90
  if (unknown.length > 0) {
91
91
  throw bad(`unknown key${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')}. ` +
@@ -106,12 +106,14 @@ export function parseConfig(text, path) {
106
106
  }
107
107
  const store = parseStore(fields['store'], bad);
108
108
  const hooks = parseHooks(fields['hooks'], bad);
109
+ const agents = parseAgents(fields['agents'], bad);
109
110
  return {
110
111
  smeltConfig: CONFIG_VERSION,
111
112
  ...(budget === undefined ? {} : { defaultBudgetBytes: budget }),
112
113
  ...(strategy === undefined ? {} : { strategy }),
113
114
  ...(store === undefined ? {} : { store }),
114
115
  ...(hooks === undefined ? {} : { hooks }),
116
+ ...(agents === undefined ? {} : { agents }),
115
117
  };
116
118
  }
117
119
  /**
@@ -142,6 +144,7 @@ export function renderConfig(config) {
142
144
  ...(config.strategy === undefined ? {} : { strategy: config.strategy }),
143
145
  ...(config.store === undefined ? {} : { store: renderStore(config.store) }),
144
146
  ...(config.hooks === undefined ? {} : { hooks: renderHooks(config.hooks) }),
147
+ ...(config.agents === undefined ? {} : { agents: renderAgents(config.agents) }),
145
148
  };
146
149
  return `${JSON.stringify(ordered, null, 2)}\n`;
147
150
  }
@@ -154,6 +157,9 @@ function renderHooks(hooks) {
154
157
  ...(hooks.enforcement === undefined ? {} : { enforcement: hooks.enforcement }),
155
158
  };
156
159
  }
160
+ function renderAgents(agents) {
161
+ return agents.budgetBytes === undefined ? {} : { budgetBytes: agents.budgetBytes };
162
+ }
157
163
  function parseStore(value, bad) {
158
164
  if (value === undefined)
159
165
  return undefined;
@@ -208,6 +214,33 @@ function parseHooks(value, bad) {
208
214
  ...(enforcement === undefined ? {} : { enforcement: enforcement }),
209
215
  };
210
216
  }
217
+ /**
218
+ * The `agents` block, parsed as strictly as every other: one key, a whole number of
219
+ * bytes greater than zero.
220
+ *
221
+ * A malformed `agents.budgetBytes` is refused rather than ignored for the reason the
222
+ * whole file is parsed strictly — an ignored budget is a ceiling the user believed was
223
+ * in force. And there is no coercion from a string: `"budgetBytes": "4kb"` is a
224
+ * mistake with a right answer, and guessing it would be smelt inventing a number.
225
+ */
226
+ function parseAgents(value, bad) {
227
+ if (value === undefined)
228
+ return undefined;
229
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
230
+ throw bad(`"agents" must be an object like {"budgetBytes":2000}.`);
231
+ }
232
+ const fields = value;
233
+ const extra = Object.keys(fields).filter((key) => key !== 'budgetBytes');
234
+ if (extra.length > 0) {
235
+ throw bad(`"agents" takes only "budgetBytes", got ${extra.map((k) => `"${k}"`).join(', ')}.`);
236
+ }
237
+ const budget = fields['budgetBytes'];
238
+ if (budget !== undefined &&
239
+ (typeof budget !== 'number' || !Number.isInteger(budget) || budget <= 0)) {
240
+ throw bad(`"agents".budgetBytes must be a whole number of bytes greater than zero.`);
241
+ }
242
+ return budget === undefined ? {} : { budgetBytes: budget };
243
+ }
211
244
  /** `store.path` is relative to the config file, so the config works from any cwd. */
212
245
  export function resolveStorePath(loaded, path) {
213
246
  return resolve(dirname(loaded.path), path);