@stupify/cli 0.0.2 → 0.0.4

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 (66) hide show
  1. package/README.md +55 -0
  2. package/dist/analysis.d.ts +16 -0
  3. package/dist/analysis.js +133 -0
  4. package/dist/cache.d.ts +2 -0
  5. package/dist/cache.js +59 -0
  6. package/dist/checks.d.ts +4 -0
  7. package/dist/checks.js +218 -0
  8. package/dist/command.d.ts +2 -0
  9. package/dist/command.js +147 -0
  10. package/dist/constants.d.ts +4 -0
  11. package/dist/constants.js +53 -0
  12. package/dist/counter-scout.d.ts +14 -0
  13. package/dist/counter-scout.js +159 -0
  14. package/dist/diff.d.ts +1 -0
  15. package/dist/diff.js +10 -0
  16. package/dist/doctor.d.ts +4 -0
  17. package/dist/doctor.js +131 -0
  18. package/dist/git.d.ts +11 -0
  19. package/dist/git.js +253 -0
  20. package/dist/hooks.d.ts +3 -0
  21. package/dist/hooks.js +117 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/model.d.ts +10 -0
  25. package/dist/model.js +297 -0
  26. package/dist/prompts.d.ts +8 -0
  27. package/dist/prompts.js +87 -0
  28. package/dist/render.d.ts +3 -0
  29. package/dist/render.js +93 -0
  30. package/dist/repomix-provider.d.ts +12 -0
  31. package/dist/repomix-provider.js +196 -0
  32. package/dist/search-bench.d.ts +1 -0
  33. package/dist/search-bench.js +675 -0
  34. package/dist/search-profile.d.ts +6 -0
  35. package/dist/search-profile.js +73 -0
  36. package/dist/sem-provider.d.ts +2 -0
  37. package/dist/sem-provider.js +247 -0
  38. package/dist/stupify.d.ts +4 -0
  39. package/dist/stupify.js +237 -0
  40. package/dist/trace.d.ts +29 -0
  41. package/dist/trace.js +64 -0
  42. package/dist/types.d.ts +320 -0
  43. package/dist/types.js +6 -0
  44. package/package.json +42 -5
  45. package/src/analysis.ts +188 -0
  46. package/src/cache.ts +65 -0
  47. package/src/checks.ts +221 -0
  48. package/src/command.ts +173 -0
  49. package/src/constants.ts +56 -0
  50. package/src/counter-scout.ts +175 -0
  51. package/src/diff.ts +9 -0
  52. package/src/doctor.ts +140 -0
  53. package/src/git.ts +262 -0
  54. package/src/hooks.ts +134 -0
  55. package/src/index.ts +1 -0
  56. package/src/model.ts +373 -0
  57. package/src/prompts.ts +98 -0
  58. package/src/render.ts +96 -0
  59. package/src/repomix-provider.ts +219 -0
  60. package/src/search-bench.ts +783 -0
  61. package/src/search-profile.ts +89 -0
  62. package/src/sem-provider.ts +282 -0
  63. package/src/stupify.ts +285 -0
  64. package/src/trace.ts +103 -0
  65. package/src/types.ts +340 -0
  66. package/bin/stupify.mjs +0 -3
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @stupify/cli
2
+
3
+ Local-only diagnostic CLI for checking whether AI is making you dumber.
4
+
5
+ Stupify has one analysis path:
6
+
7
+ ```text
8
+ sem diff -> counter scout -> Repomix context -> local search model
9
+ ```
10
+
11
+ It emits search `matches`, not audit findings.
12
+
13
+ ```sh
14
+ npx @stupify/cli --staged
15
+ npx @stupify/cli --since "2 weeks ago"
16
+ npx @stupify/cli --commit HEAD
17
+ npx @stupify/cli --commits 20
18
+ git diff HEAD~1..HEAD | npx @stupify/cli --stdin
19
+ ```
20
+
21
+ Install the warn-only pre-commit hook:
22
+
23
+ ```sh
24
+ stupify hook install
25
+ ```
26
+
27
+ The hook runs `stupify --staged` and exits 0.
28
+
29
+ Check local setup:
30
+
31
+ ```sh
32
+ stupify doctor
33
+ ```
34
+
35
+ Default search enables the checks that currently pass the local hook-safety
36
+ bench: `duplicated_schema`, `unnecessary_complexity`, `over_commenting`,
37
+ `lint_bypass`, and `reinvented_utils`. Other registry patterns can be opted in
38
+ with `--checks`.
39
+
40
+ ```sh
41
+ stupify --staged --checks over_commenting
42
+ ```
43
+
44
+ Large search inputs are skipped rather than truncated:
45
+
46
+ ```sh
47
+ stupify --staged --max-search-input-tokens 24000
48
+ ```
49
+
50
+ The package is prepared for the public `@stupify` npm scope. Publishing should
51
+ run the TypeScript build first so the executable points at `dist/stupify.js`.
52
+
53
+ This iteration intentionally does not run findings audit, validators, judges,
54
+ baselines, hosted LLM APIs, GitHub integration, dashboards, or repo-wide
55
+ crawling.
@@ -0,0 +1,16 @@
1
+ import type { LocalModel } from "./model.ts";
2
+ import type { SearchMatch, SemChangeSet, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
3
+ export declare function runSearch(model: LocalModel, request: SearchRequest): Promise<readonly SearchMatch[]>;
4
+ export type SearchRequest = Readonly<{
5
+ prompt: string;
6
+ schema: unknown;
7
+ contexts: readonly SemContext[];
8
+ }>;
9
+ export declare function searchRequest(input: Readonly<{
10
+ changeSet: SemChangeSet;
11
+ contexts: readonly SemContext[];
12
+ pack: SemContextPack;
13
+ patterns: readonly StupifyCheck[];
14
+ includeCounterReasonInPrompt?: boolean;
15
+ }>): SearchRequest;
16
+ export declare function countPromptTokens(model: LocalModel, prompt: string): Promise<number>;
@@ -0,0 +1,133 @@
1
+ import { cachedJson, fingerprint } from "./cache.js";
2
+ import { searchPrompt } from "./prompts.js";
3
+ export async function runSearch(model, request) {
4
+ const raw = await runJsonPrompt(model, request.prompt, request.schema, 0);
5
+ return uncheckedSearchMatches(raw, request.contexts);
6
+ }
7
+ export function searchRequest(input) {
8
+ return {
9
+ prompt: searchPrompt({
10
+ ...input,
11
+ includeCounterReason: input.includeCounterReasonInPrompt ?? false,
12
+ }),
13
+ schema: searchSchema(input.contexts),
14
+ contexts: input.contexts,
15
+ };
16
+ }
17
+ export async function countPromptTokens(model, prompt) {
18
+ const cached = await cachedJson("prompt-tokens", fingerprint({
19
+ version: 1,
20
+ modelId: model.id,
21
+ profile: model.profile,
22
+ prompt,
23
+ }), async () => {
24
+ const response = await fetch(`${model.baseUrl}/tokenize`, {
25
+ method: "POST",
26
+ headers: { "content-type": "application/json" },
27
+ body: JSON.stringify({ content: prompt }),
28
+ });
29
+ if (!response.ok) {
30
+ throw new Error(`llama-server tokenize failed: HTTP ${response.status} ${await response.text()}`);
31
+ }
32
+ const body = await response.json();
33
+ if (!Array.isArray(body.tokens))
34
+ throw new Error("llama-server tokenize returned no tokens.");
35
+ return { count: body.tokens.length };
36
+ });
37
+ return cached.count;
38
+ }
39
+ function searchSchema(contexts) {
40
+ return {
41
+ type: "object",
42
+ properties: {
43
+ matches: {
44
+ type: "array",
45
+ maxItems: 5,
46
+ items: {
47
+ type: "object",
48
+ properties: {
49
+ targetId: { type: "string", enum: contexts.map((context) => context.targetId) },
50
+ reason: { type: "string" },
51
+ proof: { type: "string" },
52
+ },
53
+ required: ["targetId", "reason", "proof"],
54
+ additionalProperties: false,
55
+ },
56
+ },
57
+ },
58
+ required: ["matches"],
59
+ additionalProperties: false,
60
+ };
61
+ }
62
+ function uncheckedSearchMatches(value, contexts) {
63
+ const output = value;
64
+ const contextsByTargetId = new Map(contexts.map((context) => [context.targetId, context]));
65
+ return (output.matches ?? []).flatMap((match) => {
66
+ const targetId = match.targetId ?? "";
67
+ const context = contextsByTargetId.get(targetId);
68
+ if (!context)
69
+ return [];
70
+ return [{
71
+ targetId,
72
+ patternId: context.checkId,
73
+ reason: match.reason ?? "",
74
+ proof: match.proof ?? "",
75
+ }];
76
+ });
77
+ }
78
+ async function runJsonPrompt(model, prompt, schema, temperature) {
79
+ return cachedJson("model-json", fingerprint({
80
+ version: 1,
81
+ modelId: model.id,
82
+ profile: model.profile,
83
+ prompt,
84
+ schema,
85
+ temperature,
86
+ }), () => runJsonPromptUncached(model, prompt, schema, temperature));
87
+ }
88
+ async function runJsonPromptUncached(model, prompt, schema, temperature) {
89
+ const first = await complete(model, prompt, schema, temperature);
90
+ const parsed = parseJson(first);
91
+ if (parsed.ok)
92
+ return parsed.value;
93
+ const retry = await complete(model, `${prompt}
94
+
95
+ Your previous response was not valid JSON. Return the requested JSON object only.`, schema, temperature);
96
+ const retryParsed = parseJson(retry);
97
+ if (retryParsed.ok)
98
+ return retryParsed.value;
99
+ console.error("Raw model output:");
100
+ console.error(retry);
101
+ throw new Error("Model returned invalid JSON.");
102
+ }
103
+ async function complete(model, prompt, schema, temperature) {
104
+ const response = await fetch(`${model.baseUrl}/v1/chat/completions`, {
105
+ method: "POST",
106
+ headers: { "content-type": "application/json" },
107
+ body: JSON.stringify({
108
+ model: model.id,
109
+ messages: [{ role: "user", content: prompt }],
110
+ temperature,
111
+ response_format: {
112
+ type: "json_object",
113
+ schema,
114
+ },
115
+ }),
116
+ });
117
+ if (!response.ok)
118
+ throw new Error(`llama-server request failed: HTTP ${response.status} ${await response.text()}`);
119
+ const body = await response.json();
120
+ const content = body.choices?.[0]?.message?.content;
121
+ if (typeof content !== "string")
122
+ throw new Error("llama-server returned no message content.");
123
+ return content;
124
+ }
125
+ function parseJson(raw) {
126
+ try {
127
+ const value = JSON.parse(raw);
128
+ return { ok: true, value };
129
+ }
130
+ catch {
131
+ return { ok: false };
132
+ }
133
+ }
@@ -0,0 +1,2 @@
1
+ export declare function fingerprint(value: unknown): string;
2
+ export declare function cachedJson<T>(namespace: string, key: string, compute: () => Promise<T>): Promise<T>;
package/dist/cache.js ADDED
@@ -0,0 +1,59 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, platform } from "node:os";
4
+ import path from "node:path";
5
+ export function fingerprint(value) {
6
+ const text = typeof value === "string" ? value : stableStringify(value);
7
+ return createHash("sha256").update(text).digest("hex");
8
+ }
9
+ export async function cachedJson(namespace, key, compute) {
10
+ const filePath = cachePath(namespace, key);
11
+ try {
12
+ const value = JSON.parse(await readFile(filePath, "utf8"));
13
+ console.error(`cache hit ${namespace} ${key.slice(0, 12)}`);
14
+ return value;
15
+ }
16
+ catch {
17
+ console.error(`cache miss ${namespace} ${key.slice(0, 12)}`);
18
+ }
19
+ const value = await compute();
20
+ await writeCache(filePath, value).catch(() => undefined);
21
+ return value;
22
+ }
23
+ function cachePath(namespace, key) {
24
+ return path.join(cacheRoot(), "intermediate-v1", safeNamespace(namespace), `${key}.json`);
25
+ }
26
+ async function writeCache(filePath, value) {
27
+ await mkdir(path.dirname(filePath), { recursive: true });
28
+ const tempPath = `${filePath}.${process.pid}.tmp`;
29
+ try {
30
+ await writeFile(tempPath, JSON.stringify(value), "utf8");
31
+ await rename(tempPath, filePath);
32
+ }
33
+ catch (error) {
34
+ await rm(tempPath, { force: true });
35
+ throw error;
36
+ }
37
+ }
38
+ function cacheRoot() {
39
+ if (process.env.STUPIFY_CACHE_DIR)
40
+ return process.env.STUPIFY_CACHE_DIR;
41
+ if (process.env.XDG_CACHE_HOME)
42
+ return path.join(process.env.XDG_CACHE_HOME, "stupify");
43
+ if (platform() === "darwin")
44
+ return path.join(homedir(), "Library", "Caches", "stupify");
45
+ if (platform() === "win32" && process.env.LOCALAPPDATA)
46
+ return path.join(process.env.LOCALAPPDATA, "stupify", "Cache");
47
+ return path.join(homedir(), ".cache", "stupify");
48
+ }
49
+ function safeNamespace(value) {
50
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_");
51
+ }
52
+ function stableStringify(value) {
53
+ if (value === null || typeof value !== "object")
54
+ return JSON.stringify(value);
55
+ if (Array.isArray(value))
56
+ return `[${value.map(stableStringify).join(",")}]`;
57
+ const record = value;
58
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
59
+ }
@@ -0,0 +1,4 @@
1
+ import { type StupifyCheck } from "./types.ts";
2
+ export declare const defaultChecks: readonly StupifyCheck[];
3
+ export declare function enabledChecks(checkIds: readonly string[] | null): readonly StupifyCheck[];
4
+ export declare function searchChecks(checkIds: readonly string[] | null): readonly StupifyCheck[];
package/dist/checks.js ADDED
@@ -0,0 +1,218 @@
1
+ import { checkId } from "./types.js";
2
+ export const defaultChecks = [
3
+ {
4
+ id: checkId("duplicated_schema"),
5
+ name: "Duplicated schema",
6
+ question: "Did the change duplicate an existing type, schema, payload, or DTO shape?",
7
+ lookFor: [
8
+ "local shape mirrors existing fields and maps them one-for-one",
9
+ "new response, payload, schema, or DTO adds no filtering, renaming, validation, or versioning",
10
+ ],
11
+ ignoreWhen: [
12
+ "test fixture, mock, or intentional external contract",
13
+ "public API DTO filters, omits, protects, renames, or versions fields",
14
+ ],
15
+ hookMode: "warn",
16
+ searchPrompt: "Find only local/private payload or schema shapes that clearly copy another local shape one-for-one without creating a boundary. Do not match ordinary Input/Output/Request/Response types by name alone, public DTOs, external contracts, client types, or types that omit/protect private fields.",
17
+ searchExamples: {
18
+ match: [
19
+ "LocalUserPayload repeats User fields and maps id/email/displayName one-for-one.",
20
+ ],
21
+ nonMatch: [
22
+ "PublicWebhookDto omits privateNotes from InternalJob.",
23
+ "A client type describes an external dependency boundary.",
24
+ ],
25
+ },
26
+ },
27
+ {
28
+ id: checkId("unnecessary_complexity"),
29
+ name: "Unnecessary complexity",
30
+ question: "Did the change add structure without buying clarity?",
31
+ lookFor: [
32
+ "helper, wrapper, service, layer, or extra file around simple logic without reuse",
33
+ ],
34
+ ignoreWhen: [
35
+ "isolates dependency, removes duplication, or improves testability",
36
+ ],
37
+ hookMode: "warn",
38
+ searchPrompt: `Find staged changes where a locally simple decision is made harder to understand by new indirection.
39
+ Only match when the staged diff clearly shows:
40
+ - a new named helper, wrapper, service, adapter, boundary, or abstraction
41
+ - and the surrounding change still appears locally simple
42
+ - and the new structure makes the decision harder to see
43
+ Do not match:
44
+ - plain conditionals, guard clauses, skip paths, or error handling
45
+ - normal feature structure
46
+ - exported utilities that are part of a real feature
47
+ - command plumbing
48
+ - prompt/instruction files
49
+ - domain configuration
50
+ - refactors that make ownership clearer
51
+ - changes where the payoff is unclear from the diff
52
+ Prefer no match over a weak match.`,
53
+ searchExamples: {
54
+ match: [
55
+ "A small inline operation becomes a helper/service/wrapper with one obvious caller.",
56
+ "A straightforward flow is split across files in a way that hides the decision.",
57
+ "A new abstraction appears before there is evidence it buys clarity, correctness, reuse, or isolation.",
58
+ ],
59
+ nonMatch: [
60
+ "A real external dependency boundary is isolated.",
61
+ "A security/auth boundary becomes clearer.",
62
+ "A refactor removes larger complexity elsewhere.",
63
+ "Framework-required structure is added.",
64
+ ],
65
+ },
66
+ },
67
+ {
68
+ id: checkId("fake_precision_windowing"),
69
+ name: "Fake precision windowing",
70
+ question: "Did the change add fake precision around model context?",
71
+ lookFor: [
72
+ "precise-looking counts, budgets, ratios, reports, or batching fields without useful behavior",
73
+ ],
74
+ ignoreWhen: [
75
+ "simple fixed cap or chunking",
76
+ "external API requirement",
77
+ ],
78
+ },
79
+ {
80
+ id: checkId("coauthored_slop"),
81
+ name: "Coauthored slop",
82
+ question: "Does author metadata contain co-author text?",
83
+ lookFor: [
84
+ "author signal contains coauhtoried, coauthored, or co-authored text",
85
+ ],
86
+ ignoreWhen: [
87
+ "normal Co-authored-by trailer in the commit body",
88
+ ],
89
+ },
90
+ {
91
+ id: checkId("mega_file"),
92
+ name: "Mega file",
93
+ question: "Is a touched non-config file over 1000 LOC?",
94
+ lookFor: [
95
+ "touched non-config source file over 1000 LOC",
96
+ ],
97
+ ignoreWhen: [
98
+ "config, lock, generated, fixture, or vendored file",
99
+ ],
100
+ },
101
+ {
102
+ id: checkId("over_commenting"),
103
+ name: "Over commenting",
104
+ question: "Did the change add noisy comments?",
105
+ lookFor: [
106
+ "comments restate obvious code or narrate simple logic",
107
+ ],
108
+ ignoreWhen: [
109
+ "comment explains intent, constraint, workaround, or public API behavior",
110
+ ],
111
+ hookMode: "warn",
112
+ searchPrompt: "Find staged changes where comments appear to substitute for judgment rather than clarify it.",
113
+ searchExamples: {
114
+ match: [
115
+ "New comments narrate obvious code instead of explaining tradeoffs.",
116
+ "A simple change gains multiple generic comments that restate control flow.",
117
+ "Comments make the code look more deliberate without adding useful reasoning.",
118
+ ],
119
+ nonMatch: [
120
+ "Comments explain a real domain constraint.",
121
+ "Comments document an external API quirk.",
122
+ "Comments clarify a surprising edge case.",
123
+ "Comments are sparse and specific.",
124
+ "Comments explain provider, finance, reconciliation, timezone, or ledger behavior.",
125
+ ],
126
+ },
127
+ },
128
+ {
129
+ id: checkId("lint_bypass"),
130
+ name: "Lint bypass",
131
+ question: "Did the change bypass lint or type rules?",
132
+ lookFor: [
133
+ "adds suppressions, any, broad casts, or weakens lint/typecheck config",
134
+ ],
135
+ ignoreWhen: [
136
+ "narrow suppression with a reason",
137
+ "type-level test",
138
+ "generated file convention",
139
+ ],
140
+ hookMode: "warn",
141
+ searchPrompt: "Find only broad lint/type bypasses that hide useful feedback. Match bare @ts-ignore, bare @ts-expect-error, broad casts, any, or eslint/biome suppressions without a concrete inline reason. Do not match targeted suppressions that include a reason for a known framework, test, mock, or external-library limitation.",
142
+ searchExamples: {
143
+ match: [
144
+ "A bare // @ts-ignore hides property access on unknown input.",
145
+ ],
146
+ nonMatch: [
147
+ "// @ts-expect-error explains a known external library typing gap.",
148
+ ],
149
+ },
150
+ },
151
+ {
152
+ id: checkId("inconsistent_patterns"),
153
+ name: "Inconsistent patterns",
154
+ question: "Does the change clash with nearby patterns?",
155
+ lookFor: [
156
+ "same job uses different naming, errors, state, imports, or layout than nearby files",
157
+ ],
158
+ ignoreWhen: [
159
+ "external API requires it",
160
+ "change follows a newer local convention",
161
+ ],
162
+ },
163
+ {
164
+ id: checkId("reinvented_utils"),
165
+ name: "Reinvented utils",
166
+ question: "Did the change recreate an existing utility?",
167
+ lookFor: [
168
+ "new helper duplicates local utility or standard library behavior",
169
+ ],
170
+ ignoreWhen: [
171
+ "existing utility has wrong contract",
172
+ "new helper is clearer as a tiny private expression",
173
+ "helper is domain-specific or used by multiple local call sites",
174
+ ],
175
+ hookMode: "warn",
176
+ searchPrompt: "Find only tiny generic utility functions that recreate common helpers such as clamp, debounce, throttle, slugify, group, sort, pick, omit, uniq, or shuffle without domain-specific behavior. Do not match resolve/parse/format helpers, domain formatting, feature constants, or helpers with multiple obvious call sites.",
177
+ searchExamples: {
178
+ match: [
179
+ "clampValue returns min, max, or value.",
180
+ ],
181
+ nonMatch: [
182
+ "formatCurrencyHelper is used by invoice and refund labels.",
183
+ "Subscription tier constants encode domain configuration.",
184
+ ],
185
+ },
186
+ },
187
+ {
188
+ id: checkId("operator_style_mismatch"),
189
+ name: "Operator style mismatch",
190
+ question: "Does the change read unlike the surrounding code?",
191
+ lookFor: [
192
+ "generic or template-like names, abstractions, comments, or control flow clash with local style",
193
+ ],
194
+ ignoreWhen: [
195
+ "generated, vendored, framework-required, or newer established local style",
196
+ ],
197
+ enabledByDefault: false,
198
+ },
199
+ ];
200
+ export function enabledChecks(checkIds) {
201
+ if (!checkIds)
202
+ return defaultChecks.filter((check) => check.enabledByDefault !== false);
203
+ return checksById(checkIds);
204
+ }
205
+ export function searchChecks(checkIds) {
206
+ if (!checkIds)
207
+ return defaultChecks.filter((check) => check.hookMode === "warn");
208
+ return checksById(checkIds);
209
+ }
210
+ function checksById(checkIds) {
211
+ const checksById = new Map(defaultChecks.map((check) => [check.id, check]));
212
+ return checkIds.map((id) => {
213
+ const check = checksById.get(id);
214
+ if (!check)
215
+ throw new Error(`Unknown check: ${id}`);
216
+ return check;
217
+ });
218
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "./types.ts";
2
+ export declare function parseCommand(argv: readonly string[]): Command;
@@ -0,0 +1,147 @@
1
+ import { DEFAULT_MODEL_ID, MODEL_REGISTRY } from "./constants.js";
2
+ const DEFAULT_SINCE = "2 weeks ago";
3
+ const DEFAULT_MAX_CANDIDATES = 10;
4
+ const DEFAULT_MAX_SEARCH_INPUT_TOKENS = 12_000;
5
+ export function parseCommand(argv) {
6
+ if (argv.length === 1 && isHelp(argv[0]))
7
+ return { kind: "help" };
8
+ if (argv[0] === "bench") {
9
+ if (argv[1] !== "search" || !argv[2] || argv.length > 3) {
10
+ throw new Error("Usage: stupify bench search <config.json>");
11
+ }
12
+ return { kind: "bench-search", configPath: argv[2] };
13
+ }
14
+ if (argv[0] === "hook") {
15
+ const action = argv[1];
16
+ if (!action || !isHookAction(action) || argv.length > 2) {
17
+ throw new Error("Usage: stupify hook install|uninstall|status");
18
+ }
19
+ return { kind: "hook", action };
20
+ }
21
+ if (argv[0] === "doctor") {
22
+ if (argv.length > 1)
23
+ throw new Error("Usage: stupify doctor");
24
+ return { kind: "doctor" };
25
+ }
26
+ const initialState = {
27
+ inputMode: { kind: "since", since: DEFAULT_SINCE, source: "since" },
28
+ explicitInputMode: false,
29
+ checkIds: null,
30
+ json: false,
31
+ model: DEFAULT_MODEL_ID,
32
+ debugSem: false,
33
+ maxCandidates: DEFAULT_MAX_CANDIDATES,
34
+ maxSearchInputTokens: DEFAULT_MAX_SEARCH_INPUT_TOKENS,
35
+ searchProfilePath: null,
36
+ includeCounterReasonInPrompt: false,
37
+ };
38
+ const finalState = parseFrom(0, initialState);
39
+ return {
40
+ ...finalState.inputMode,
41
+ mode: "search",
42
+ checkIds: finalState.checkIds,
43
+ json: finalState.json,
44
+ model: finalState.model,
45
+ debugSem: finalState.debugSem,
46
+ maxCandidates: finalState.maxCandidates,
47
+ maxSearchInputTokens: finalState.maxSearchInputTokens,
48
+ searchProfilePath: finalState.searchProfilePath,
49
+ includeCounterReasonInPrompt: finalState.includeCounterReasonInPrompt,
50
+ };
51
+ function parseFrom(index, state) {
52
+ if (index >= argv.length)
53
+ return state;
54
+ const arg = argv[index];
55
+ if (arg === "--mode") {
56
+ const value = argv[index + 1];
57
+ if (value !== "search")
58
+ throw new Error("--mode only supports search.");
59
+ return parseFrom(index + 2, state);
60
+ }
61
+ if (arg === "--staged")
62
+ return parseFrom(index + 1, setInputMode(state, { kind: "staged", source: "staged" }));
63
+ if (arg === "--stdin")
64
+ return parseFrom(index + 1, setInputMode(state, { kind: "stdin", source: "stdin" }));
65
+ if (arg === "--json")
66
+ return parseFrom(index + 1, { ...state, json: true });
67
+ if (arg === "--debug-sem")
68
+ return parseFrom(index + 1, { ...state, debugSem: true });
69
+ if (arg === "--include-counter-reason-in-prompt") {
70
+ return parseFrom(index + 1, { ...state, includeCounterReasonInPrompt: true });
71
+ }
72
+ if (arg === "--search-profile") {
73
+ const value = argv[index + 1];
74
+ if (!value || value.startsWith("-"))
75
+ throw new Error("--search-profile requires a JSON profile path.");
76
+ return parseFrom(index + 2, { ...state, searchProfilePath: value });
77
+ }
78
+ if (arg === "--since") {
79
+ const value = argv[index + 1];
80
+ if (!value || value.startsWith("-"))
81
+ throw new Error("--since requires a git date, such as \"2 weeks ago\".");
82
+ return parseFrom(index + 2, setInputMode(state, { kind: "since", since: value, source: "since" }));
83
+ }
84
+ if (arg === "--commit") {
85
+ const value = argv[index + 1];
86
+ if (!value || !isSafeCommitArg(value))
87
+ throw new Error("Invalid commit.");
88
+ return parseFrom(index + 2, setInputMode(state, { kind: "commit", commit: value, source: "commit" }));
89
+ }
90
+ if (arg === "--commits") {
91
+ const value = argv[index + 1];
92
+ const count = Number(value);
93
+ if (!Number.isInteger(count) || count < 1)
94
+ throw new Error("--commits requires a positive integer.");
95
+ return parseFrom(index + 2, setInputMode(state, { kind: "commits", count, source: "commits" }));
96
+ }
97
+ if (arg === "--checks") {
98
+ const value = argv[index + 1];
99
+ if (!value || value.startsWith("-"))
100
+ throw new Error("--checks requires a comma-separated list.");
101
+ const checkIds = value.split(",").map((id) => id.trim()).filter(Boolean);
102
+ if (checkIds.length === 0)
103
+ throw new Error("--checks requires at least one check id.");
104
+ return parseFrom(index + 2, { ...state, checkIds });
105
+ }
106
+ if (arg === "--model") {
107
+ const value = argv[index + 1];
108
+ if (!value || !isModelId(value))
109
+ throw new Error(`--model must be one of: ${Object.keys(MODEL_REGISTRY).join(", ")}`);
110
+ return parseFrom(index + 2, { ...state, model: value });
111
+ }
112
+ if (arg === "--max-candidates") {
113
+ const value = argv[index + 1];
114
+ const maxCandidates = Number(value);
115
+ if (!Number.isInteger(maxCandidates) || maxCandidates < 1) {
116
+ throw new Error("--max-candidates requires a positive integer.");
117
+ }
118
+ return parseFrom(index + 2, { ...state, maxCandidates });
119
+ }
120
+ if (arg === "--max-search-input-tokens") {
121
+ const value = argv[index + 1];
122
+ const maxSearchInputTokens = Number(value);
123
+ if (!Number.isInteger(maxSearchInputTokens) || maxSearchInputTokens < 1) {
124
+ throw new Error("--max-search-input-tokens requires a positive integer.");
125
+ }
126
+ return parseFrom(index + 2, { ...state, maxSearchInputTokens });
127
+ }
128
+ throw new Error(`Unknown option: ${arg}`);
129
+ }
130
+ function setInputMode(state, next) {
131
+ if (state.explicitInputMode)
132
+ throw new Error("Choose only one input mode: --since, --stdin, --commit, --commits, or --staged.");
133
+ return { ...state, inputMode: next, explicitInputMode: true };
134
+ }
135
+ }
136
+ function isSafeCommitArg(value) {
137
+ return value.length > 0 && !value.startsWith("-") && /^[A-Za-z0-9._/@~^:+-]+$/.test(value);
138
+ }
139
+ function isHelp(value) {
140
+ return value === "--help" || value === "-h";
141
+ }
142
+ function isModelId(value) {
143
+ return value in MODEL_REGISTRY;
144
+ }
145
+ function isHookAction(value) {
146
+ return value === "install" || value === "uninstall" || value === "status";
147
+ }
@@ -0,0 +1,4 @@
1
+ export declare const VERSION = "0.0.4";
2
+ import type { ModelConfig, ModelId } from "./types.ts";
3
+ export declare const DEFAULT_MODEL_ID: ModelId;
4
+ export declare const MODEL_REGISTRY: Record<ModelId, ModelConfig>;