@theaiteam/promptdiff 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +697 -0
- package/SPEC.md +314 -0
- package/package.json +54 -0
- package/promptdiff +4 -0
- package/src/args.ts +83 -0
- package/src/cli.ts +685 -0
- package/src/engine/cache.ts +150 -0
- package/src/engine/compare.ts +563 -0
- package/src/engine/config.ts +502 -0
- package/src/engine/grader.ts +149 -0
- package/src/engine/json-assert.ts +277 -0
- package/src/engine/judge.ts +388 -0
- package/src/engine/receipt.ts +150 -0
- package/src/engine/render.ts +59 -0
- package/src/engine/report.ts +49 -0
- package/src/engine/sandbox.ts +97 -0
- package/src/engine/skill-install.ts +112 -0
- package/src/engine/stats.ts +41 -0
- package/src/prompt.ts +16 -0
- package/src/runner/claude-p.ts +156 -0
- package/src/runner/index.ts +31 -0
- package/src/runner/openai-compat.ts +228 -0
- package/src/types.ts +65 -0
package/SPEC.md
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# promptdiff - Design Spec
|
|
2
|
+
|
|
3
|
+
Status: early implementation. The original spike proved the `claude -p`
|
|
4
|
+
mechanism; the current repo now contains a real `run` command, a scenario-driven
|
|
5
|
+
`compare` command, deterministic graders, sandbox setup, timeout and budget
|
|
6
|
+
bounds, pluggable runners (headless Claude Code and OpenAI-compatible
|
|
7
|
+
endpoints), and tests for the core local behavior.
|
|
8
|
+
|
|
9
|
+
## 1. Problem
|
|
10
|
+
|
|
11
|
+
AI agent pipelines generate proposed improvements to prompts, skills, and
|
|
12
|
+
enforcement hooks. The chronic failure mode is that proposed edits are shipped
|
|
13
|
+
on faith or never shipped at all because there is no cheap proof that the edit
|
|
14
|
+
changes behavior without damaging scenarios that already worked.
|
|
15
|
+
|
|
16
|
+
`promptdiff` is the proof step between "we think this skill edit helps" and
|
|
17
|
+
"ship it." It does not collect findings, rank recurrence, or manage tuning
|
|
18
|
+
rounds. Those can live in a larger system. This repo focuses on the eval harness.
|
|
19
|
+
|
|
20
|
+
## 2. Eval Altitudes
|
|
21
|
+
|
|
22
|
+
The eval method should match the change being tested:
|
|
23
|
+
|
|
24
|
+
| Change altitude | Eval method | Cost |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| Enforcement hook | Deterministic unit test | 1 run, near-free |
|
|
27
|
+
| Skill or agent prompt text | N-run stochastic A/B over pass rates | N x arms x per-run cost |
|
|
28
|
+
|
|
29
|
+
`promptdiff` targets the second row. It runs one agent with baseline skill text
|
|
30
|
+
and one agent with proposed skill text against the same scenario set.
|
|
31
|
+
|
|
32
|
+
## 3. Runners
|
|
33
|
+
|
|
34
|
+
Model access goes through a small `Runner` interface: system prompt + user
|
|
35
|
+
prompt + bounds in, `RunResult` (output text, cost, turns, duration, models)
|
|
36
|
+
out. Each runner declares capabilities:
|
|
37
|
+
|
|
38
|
+
- `sandboxTools`: the runner executes tools inside the sandbox cwd (needed for
|
|
39
|
+
artifact mode, command graders against agent-written files, and any
|
|
40
|
+
non-empty tools list)
|
|
41
|
+
- `skillRegistry`: the runner has a harness-managed skill registry and an
|
|
42
|
+
appendable default system prompt (needed for install delivery)
|
|
43
|
+
- `images`: the runner can attach image files to the user message (needed for
|
|
44
|
+
scenarios with `images` / `run --image`)
|
|
45
|
+
|
|
46
|
+
The engine validates a scenario's demands against the selected runner's
|
|
47
|
+
capabilities before any paid run, so unsupported combinations fail loudly at
|
|
48
|
+
startup instead of producing a silently tool-less arm. Runners are selected
|
|
49
|
+
with `--runner <name>` or a top-level `"runner"` scenario field.
|
|
50
|
+
|
|
51
|
+
### 3.1 `claude-p` (default)
|
|
52
|
+
|
|
53
|
+
Shells out to headless Claude Code:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
claude -p "<fixture work item>" \
|
|
57
|
+
--system-prompt-file <assembled prompt file> \
|
|
58
|
+
--model <model> \
|
|
59
|
+
--output-format json \
|
|
60
|
+
--tools <tools> \
|
|
61
|
+
--max-budget-usd <amount>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The spawned process runs with the per-run sandbox as its actual working
|
|
65
|
+
directory. Extra directories can be granted with `--add-dir`, but they are not
|
|
66
|
+
used as a substitute for sandboxing.
|
|
67
|
+
|
|
68
|
+
The runner captures final output text, `total_cost_usd`, turn count, duration,
|
|
69
|
+
and model usage keys.
|
|
70
|
+
|
|
71
|
+
### 3.2 `openai`
|
|
72
|
+
|
|
73
|
+
Sends one `POST <baseUrl>/chat/completions` request to any OpenAI-compatible
|
|
74
|
+
endpoint (OpenAI, ollama, vLLM, llama.cpp, OpenRouter, ...). The assembled
|
|
75
|
+
system prompt and the scenario prompt become the `system` and `user` messages.
|
|
76
|
+
`--base-url`/`$OPENAI_BASE_URL` select the server; `$OPENAI_API_KEY` is sent as
|
|
77
|
+
a bearer token when set (local servers need none).
|
|
78
|
+
|
|
79
|
+
Text mode only — no tools, no sandbox execution, no skill registry, so it pairs
|
|
80
|
+
with text graders. These endpoints report tokens, not USD, so pricing is
|
|
81
|
+
user-declared: scenario `pricing` (per-model USD per million input/output
|
|
82
|
+
tokens) or `run --price in,out`. Priced runs compute real cost from response
|
|
83
|
+
`usage`, enforce `maxBudgetUsd` post-hoc (a completed completion cannot be
|
|
84
|
+
aborted mid-request; over-budget runs error rather than retry), and fail
|
|
85
|
+
loudly when a priced endpoint returns no usage. Unpriced runs report 0 —
|
|
86
|
+
true for local servers — and are bounded only by being single completions.
|
|
87
|
+
Raw usage is always preserved in the raw result.
|
|
88
|
+
|
|
89
|
+
Vision: this is the only runner declaring the `images` capability. Scenario
|
|
90
|
+
`images` (or `run --image`) are embedded as base64 data-URI `image_url`
|
|
91
|
+
content parts ahead of the prompt text — jpg/jpeg/png/webp/gif, validated for
|
|
92
|
+
existence at load time. Two scenario fields tune the endpoint: `requestParams`
|
|
93
|
+
merges extra fields into the request body (spread first, so it can never
|
|
94
|
+
clobber `model` or `messages` — pin `temperature` here to keep pass-rate
|
|
95
|
+
deltas about the prompt), and `retries` (default 2) re-attempts transient
|
|
96
|
+
failures — timeouts, connection errors, HTTP 429/5xx — with exponential
|
|
97
|
+
backoff and a per-attempt timeout, so one overloaded-endpoint response cannot
|
|
98
|
+
abort a whole compare. Deterministic failures (other 4xx, malformed
|
|
99
|
+
responses) fail immediately without burning retries. claude-p is excluded on
|
|
100
|
+
purpose: the CLI manages its own transport, and retrying non-zero exits
|
|
101
|
+
would re-run budget aborts.
|
|
102
|
+
|
|
103
|
+
## 4. A/B Variable
|
|
104
|
+
|
|
105
|
+
Two delivery modes, matching two distinct questions about a skill edit:
|
|
106
|
+
|
|
107
|
+
**`inline` (default) — compliance.** Skill variants are inlined into the system
|
|
108
|
+
prompt instead of being loaded by the Skill tool at runtime:
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
system prompt = agent body with frontmatter stripped
|
|
112
|
+
+ baseline or proposed skill body with frontmatter stripped
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
That keeps the comparison controlled. The baseline and proposed arms differ by
|
|
116
|
+
the skill text under test, not by whichever skill version happens to be
|
|
117
|
+
installed on disk. The limitation is the flip side of the control: an inlined
|
|
118
|
+
skill is always in context, so inline evals cannot measure whether the skill
|
|
119
|
+
would have been *invoked* — and frontmatter (including the `description` that
|
|
120
|
+
drives model-invoked triggering) is stripped entirely.
|
|
121
|
+
|
|
122
|
+
**`install` — invocation.** The skill directory is copied, frontmatter intact,
|
|
123
|
+
into `<sandbox>/.claude/skills/<name>`, where headless Claude's project-skill
|
|
124
|
+
discovery registers it. The agent body is delivered with
|
|
125
|
+
`--append-system-prompt` rather than `--system-prompt-file`, because replacing
|
|
126
|
+
the system prompt would strip the harness machinery (skill registry, Skill
|
|
127
|
+
tool) that this mode exists to exercise. Arms differ only by which variant
|
|
128
|
+
directory lands in the per-run sandbox registry. This is the mode for A/B
|
|
129
|
+
testing description/trigger wording — the "does it fire at all" question. It
|
|
130
|
+
requires tools and is incompatible with `--tools ""`.
|
|
131
|
+
|
|
132
|
+
Known contamination source in install mode: same-named user-level skills load
|
|
133
|
+
in both arms; the installer warns when it detects one.
|
|
134
|
+
|
|
135
|
+
**Model as the A/B variable.** The arms can also differ by model, runner, or
|
|
136
|
+
endpoint instead of (or in addition to) skill text. A shared top-level
|
|
137
|
+
`"skills"` array holds the instruction set constant, and the record form of
|
|
138
|
+
`"baseline"`/`"proposed"` carries per-arm `"model"`, `"runner"`, and
|
|
139
|
+
`"baseUrl"` (each falling back to the shared top-level value). Each arm is
|
|
140
|
+
validated against its own runner's capabilities before any paid run, and the
|
|
141
|
+
summary labels each arm with its model (and runner, when they differ).
|
|
142
|
+
|
|
143
|
+
Cross-runner confound: a claude-p arm is agentic — tools, multiple turns —
|
|
144
|
+
while an openai arm is a single completion. Pass rates on text-graded
|
|
145
|
+
scenarios compare meaningfully, but the mechanics differ and cost does not
|
|
146
|
+
compare at all (the openai runner reports $0; the summary notes this when
|
|
147
|
+
exactly one arm is openai).
|
|
148
|
+
|
|
149
|
+
## 5. Scenario Format
|
|
150
|
+
|
|
151
|
+
A compare file defines:
|
|
152
|
+
|
|
153
|
+
- agent file
|
|
154
|
+
- baseline skill files (or a shared skill set both arms inherit)
|
|
155
|
+
- proposed skill files
|
|
156
|
+
- model (shared, or per arm for model comparisons)
|
|
157
|
+
- run count
|
|
158
|
+
- sandbox root and optional seed workspace
|
|
159
|
+
- target and regression scenarios
|
|
160
|
+
- deterministic graders
|
|
161
|
+
|
|
162
|
+
The first scenario defaults to `target`; later scenarios default to
|
|
163
|
+
`regression`. Explicit `kind` is preferred.
|
|
164
|
+
|
|
165
|
+
Target assertions:
|
|
166
|
+
|
|
167
|
+
1. baseline must not fully pass, otherwise the gap was not reproduced
|
|
168
|
+
2. proposed must improve the target pass rate
|
|
169
|
+
|
|
170
|
+
Regression assertions:
|
|
171
|
+
|
|
172
|
+
1. proposed must not fall below baseline pass rate
|
|
173
|
+
|
|
174
|
+
Every case also carries a two-tailed Fisher exact p for its pass/fail table;
|
|
175
|
+
the summary labels deltas with p > 0.05 as explainable by sampling noise.
|
|
176
|
+
This is a label, not a gate — assertions and exit codes are unchanged, but a
|
|
177
|
+
receipt now says how thin its evidence is. Scenarios may declare
|
|
178
|
+
`productionModel`; arms testing a different model are flagged in the summary.
|
|
179
|
+
`--report ndjson --report-out <file>` appends per-scenario records (arms,
|
|
180
|
+
rates, cost, sampling p, rendered-prompt sha256 hashes) as append-only run
|
|
181
|
+
history. `--receipts <dir>` writes per-scenario receipt files (overwritten
|
|
182
|
+
each run) recording the content hash of every prompt file tested plus the
|
|
183
|
+
verdict — content addressing instead of hand-maintained prompt_version
|
|
184
|
+
strings, so a consuming repo's CI can require a passing receipt for each
|
|
185
|
+
shipped prompt's current hash. Reports are history; receipts are current
|
|
186
|
+
state.
|
|
187
|
+
|
|
188
|
+
`compare` scenarios assert nothing: they exist to report both arms' pass
|
|
189
|
+
rates and the delta, for comparisons (typically model-vs-model) where neither
|
|
190
|
+
direction is claimed in advance.
|
|
191
|
+
|
|
192
|
+
**Measure mode.** `promptdiff measure` runs a single instruction set through
|
|
193
|
+
the same scenario machinery and reports per-case pass rates with no delta
|
|
194
|
+
and no assertions — the "characterize before you change" half of prompt
|
|
195
|
+
testing. It loads the compare format with a single-arm allowance (`skills`
|
|
196
|
+
or `baselineSkills` alone suffices) and always exits 0 on completion.
|
|
197
|
+
Identical-arm compares are not a substitute: at small n they emit
|
|
198
|
+
directional verdicts from pure sampling noise.
|
|
199
|
+
|
|
200
|
+
**Template rendering.** `render.vars` — top-level and/or per scenario, with
|
|
201
|
+
the scenario winning per var — binds `{{name}}` placeholders across the agent
|
|
202
|
+
body, inlined skill text, and scenario prompts. This is what lets scenarios
|
|
203
|
+
point at production prompt files (which are full of pipeline placeholders)
|
|
204
|
+
instead of hand-rendered copies that drift. Values resolve file-first
|
|
205
|
+
relative to the scenario file and are read at load time; anything that isn't
|
|
206
|
+
an existing file is a literal. Rendering is opt-in and strict: with any
|
|
207
|
+
`render` block present, unbound placeholders abort before any paid run;
|
|
208
|
+
without one, braces pass through untouched. Substituted content is never
|
|
209
|
+
re-scanned, so fixtures containing braces neither expand recursively nor
|
|
210
|
+
false-positive the unbound check. Incompatible with install delivery, which
|
|
211
|
+
copies skill files verbatim.
|
|
212
|
+
|
|
213
|
+
## 6. Grading
|
|
214
|
+
|
|
215
|
+
Prefer deterministic graders over LLM judges.
|
|
216
|
+
|
|
217
|
+
Text graders inspect the run's final output. JSON graders parse that output —
|
|
218
|
+
the last balanced JSON value when prose surrounds it, since reasoning models
|
|
219
|
+
narrate around their answer — and check `assert` path assertions of the form
|
|
220
|
+
`<path> <op> <literal>` (ops `==` `!=` `>` `>=` `<` `<=` `contains`; `[*]` is
|
|
221
|
+
existential: any element may satisfy the assertion, including for `!=`).
|
|
222
|
+
Assertion grammar is validated at config load, before any paid run; missing
|
|
223
|
+
paths and type mismatches at grade time fail the assertion with a message,
|
|
224
|
+
never throw. Like text graders, json graders demand no sandbox tools and work
|
|
225
|
+
on every runner. Command graders run inside the
|
|
226
|
+
sandbox after the model invocation and check the exit code of a local command
|
|
227
|
+
such as `bun test`, `go test ./...`, or a fixture-specific script. The final
|
|
228
|
+
output is also written into the sandbox and exposed to command graders as
|
|
229
|
+
`$PROMPTDIFF_OUTPUT_FILE`, so completion-only runs can be command-graded
|
|
230
|
+
(scenario `mode: "text"` keeps the tools demand at zero).
|
|
231
|
+
|
|
232
|
+
LLM judges are implemented, WITH mandatory calibration, for judgments that
|
|
233
|
+
cannot be expressed as local checks (semantic and style rubrics that regex
|
|
234
|
+
both under- and over-catches). The design premise is that an uncalibrated
|
|
235
|
+
judge is worse than the regex it replaces — same wrongness, more confidence,
|
|
236
|
+
higher cost — so judge graders refuse to grade until proven:
|
|
237
|
+
|
|
238
|
+
- A judge grader is `{ "type": "judge", "rubric": <markdown file>, "model":
|
|
239
|
+
<judge model>, "runner": <claude-p|openai>, "baseUrl"?, "minAccuracy"?
|
|
240
|
+
(default 0.9) }`. The rubric becomes the judge's system prompt verbatim,
|
|
241
|
+
plus a fixed harness instruction demanding one JSON verdict object; the
|
|
242
|
+
graded output is the user prompt. The judge model is explicit on purpose
|
|
243
|
+
and never defaults to the arm's model (self-grading bias). The last
|
|
244
|
+
balanced JSON object in the reply wins (reasoning models add prose); no
|
|
245
|
+
valid verdict fails the graded run, never passes it.
|
|
246
|
+
- Labeled fixtures live in a sibling directory,
|
|
247
|
+
`<rubric-stem>.fixtures/pass/*.md` (outputs the judge must call clean) and
|
|
248
|
+
`.../fail/*.md` (outputs it must flag). `promptdiff calibrate` runs the
|
|
249
|
+
judge over every fixture and writes `<rubric>.calibration.json` next to
|
|
250
|
+
the rubric — committable, keyed by rubric content hash.
|
|
251
|
+
- The gate: at compare/measure startup, before any paid run, every judge
|
|
252
|
+
grader must have a calibration record that exists, matches the current
|
|
253
|
+
rubric sha256 and the spec's model/runner, and clears `minAccuracy` on
|
|
254
|
+
BOTH classes. Per-class bars on purpose: a judge that passes everything is
|
|
255
|
+
100% on the pass class and 0% on the fail class; overall accuracy hides it.
|
|
256
|
+
- v1 judges are absolute (grade one output against the rubric); pairwise
|
|
257
|
+
comparison is deferred. The calibration gate is the escalation signal: a
|
|
258
|
+
rubric whose absolute judge cannot clear the bar is the case for pairwise.
|
|
259
|
+
|
|
260
|
+
A judge adds one billed model call per graded run (its cost is added to the
|
|
261
|
+
run's cost), so deterministic graders remain the default choice.
|
|
262
|
+
|
|
263
|
+
## 7. Architecture
|
|
264
|
+
|
|
265
|
+
```text
|
|
266
|
+
promptdiff # Bun executable shim
|
|
267
|
+
src/cli.ts # command parsing and user-facing orchestration
|
|
268
|
+
src/args.ts # strict local flag parser
|
|
269
|
+
src/prompt.ts # frontmatter stripping and prompt assembly
|
|
270
|
+
src/runner/ # provider-coupled runner code
|
|
271
|
+
src/engine/ # compare loop, config loading, graders, sandbox lifecycle
|
|
272
|
+
test/ # Bun tests
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The compare engine depends only on the runner interface and its declared
|
|
276
|
+
capabilities; it never imports provider SDKs or provider-specific code.
|
|
277
|
+
`src/runner/index.ts` is the registry that maps runner names to
|
|
278
|
+
implementations.
|
|
279
|
+
|
|
280
|
+
Baseline arms are usually frozen by construction, so `compare --cache`
|
|
281
|
+
(opt-in; results under `--cache-dir`, default `.promptdiff/cache`) reuses
|
|
282
|
+
recorded baseline-arm results instead of re-paying for them on every
|
|
283
|
+
iteration of the proposed prompt. `src/engine/cache.ts` keys each case on a
|
|
284
|
+
sha256 over everything that could change the outcome — rendered baseline
|
|
285
|
+
system prompt, scenario prompt, baseline model/runner/baseUrl, run count,
|
|
286
|
+
tools/mode/delivery, grader spec, image contents, and a deterministic tree
|
|
287
|
+
hash of the sandbox seed (plus the baseline skill trees under install
|
|
288
|
+
delivery, where skill text never enters the system prompt). Hits are real
|
|
289
|
+
recorded ArmSummaries, marked `(cached)` on the summary; the proposed arm
|
|
290
|
+
always runs fresh. Deleting the cache directory busts it.
|
|
291
|
+
|
|
292
|
+
## 8. Current Limitations
|
|
293
|
+
|
|
294
|
+
- Pass-rate assertions are intentionally simple. They are good enough to catch
|
|
295
|
+
decisive effects at small N, but there is no statistical test yet.
|
|
296
|
+
- Scenario authoring is manual JSON. A future tuning loop should generate these
|
|
297
|
+
files from accepted findings.
|
|
298
|
+
- Command graders run trusted local commands from scenario files. Do not run
|
|
299
|
+
untrusted scenario files.
|
|
300
|
+
- The openai runner is a single completion per run: no tool use, so it can only
|
|
301
|
+
answer text-graded questions. Tool-using evals on non-Claude models would
|
|
302
|
+
need an agentic runner (e.g. wrapping another agent CLI).
|
|
303
|
+
- Fixture coverage remains the bottleneck. Missing fixtures mean missing
|
|
304
|
+
regression protection.
|
|
305
|
+
|
|
306
|
+
## 9. Decisions
|
|
307
|
+
|
|
308
|
+
- Standalone Bun repo rather than embedding in a larger mission runner.
|
|
309
|
+
- Claude Code `-p` runner first; OpenAI-compatible endpoints second. Runner
|
|
310
|
+
capabilities are validated up front rather than degraded silently.
|
|
311
|
+
- Inline skills for variant control.
|
|
312
|
+
- Deterministic graders first; LLM judges only behind a mandatory calibration
|
|
313
|
+
gate (absolute mode in v1, pairwise deferred).
|
|
314
|
+
- Per-run sandbox cwd, timeout, and budget bounds are mandatory for paid runs.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theaiteam/promptdiff",
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
|
+
"description": "A/B test harness for LLM prompt and skill changes — headless Claude Code or any OpenAI-compatible endpoint.",
|
|
5
|
+
"author": "Josh Owens <josh@theaiteam.dev>",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/queso/promptdiff.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/queso/promptdiff#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/queso/promptdiff/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"packageManager": "bun@1.3.11",
|
|
16
|
+
"bin": {
|
|
17
|
+
"promptdiff": "./promptdiff"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"promptdiff",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md",
|
|
23
|
+
"SPEC.md",
|
|
24
|
+
"CHANGELOG.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"eval": "./promptdiff",
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"check": "bun test && tsc --noEmit",
|
|
31
|
+
"prepublishOnly": "bun run check"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"bun": ">=1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"ai",
|
|
38
|
+
"agents",
|
|
39
|
+
"evals",
|
|
40
|
+
"prompts",
|
|
41
|
+
"llm",
|
|
42
|
+
"claude",
|
|
43
|
+
"openai",
|
|
44
|
+
"bun"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/bun": "latest",
|
|
51
|
+
"typescript": "latest"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT"
|
|
54
|
+
}
|
package/promptdiff
ADDED
package/src/args.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
readonly exitCode = 2;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export type FlagSpec = {
|
|
6
|
+
arity: "none" | "one";
|
|
7
|
+
repeat?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type FlagSpecs = Record<string, FlagSpec>;
|
|
11
|
+
|
|
12
|
+
export interface ParsedArgs {
|
|
13
|
+
one(name: string): string | undefined;
|
|
14
|
+
many(name: string): string[];
|
|
15
|
+
has(name: string): boolean;
|
|
16
|
+
number(name: string, defaultValue: number): number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseArgs(argv: string[], specs: FlagSpecs): ParsedArgs {
|
|
20
|
+
const out: Record<string, string[]> = {};
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
23
|
+
const token = argv[i];
|
|
24
|
+
if (!token.startsWith("--") || token === "--") {
|
|
25
|
+
throw new CliError(`unexpected positional argument: ${token}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const raw = token.slice(2);
|
|
29
|
+
const eq = raw.indexOf("=");
|
|
30
|
+
const name = eq === -1 ? raw : raw.slice(0, eq);
|
|
31
|
+
const inlineValue = eq === -1 ? undefined : raw.slice(eq + 1);
|
|
32
|
+
const spec = specs[name];
|
|
33
|
+
if (!spec) {
|
|
34
|
+
throw new CliError(`unknown flag: --${name}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!spec.repeat && out[name]?.length) {
|
|
38
|
+
throw new CliError(`flag cannot be repeated: --${name}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (spec.arity === "none") {
|
|
42
|
+
if (inlineValue !== undefined) {
|
|
43
|
+
throw new CliError(`flag does not take a value: --${name}`);
|
|
44
|
+
}
|
|
45
|
+
out[name] ??= [];
|
|
46
|
+
out[name].push("true");
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let value = inlineValue;
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
i += 1;
|
|
53
|
+
value = argv[i];
|
|
54
|
+
}
|
|
55
|
+
if (value === undefined || value.startsWith("--")) {
|
|
56
|
+
throw new CliError(`missing value for --${name}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
out[name] ??= [];
|
|
60
|
+
out[name].push(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
one(name: string) {
|
|
65
|
+
return out[name]?.[0];
|
|
66
|
+
},
|
|
67
|
+
many(name: string) {
|
|
68
|
+
return out[name] ?? [];
|
|
69
|
+
},
|
|
70
|
+
has(name: string) {
|
|
71
|
+
return Object.hasOwn(out, name);
|
|
72
|
+
},
|
|
73
|
+
number(name: string, defaultValue: number) {
|
|
74
|
+
const raw = out[name]?.[0];
|
|
75
|
+
if (raw === undefined) return defaultValue;
|
|
76
|
+
const parsed = Number(raw);
|
|
77
|
+
if (!Number.isFinite(parsed)) {
|
|
78
|
+
throw new CliError(`--${name} must be a number`);
|
|
79
|
+
}
|
|
80
|
+
return parsed;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|