@stupify/cli 0.0.3 → 0.0.5

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 (59) hide show
  1. package/README.md +26 -31
  2. package/dist/analysis.d.ts +11 -9
  3. package/dist/analysis.js +30 -173
  4. package/dist/checks.d.ts +1 -0
  5. package/dist/checks.js +89 -2
  6. package/dist/command.js +55 -91
  7. package/dist/constants.d.ts +1 -1
  8. package/dist/constants.js +1 -1
  9. package/dist/counter-scout.js +70 -8
  10. package/dist/doctor.d.ts +4 -0
  11. package/dist/doctor.js +131 -0
  12. package/dist/git.d.ts +4 -1
  13. package/dist/git.js +34 -0
  14. package/dist/hooks.d.ts +3 -0
  15. package/dist/hooks.js +117 -0
  16. package/dist/model.d.ts +1 -15
  17. package/dist/model.js +37 -21
  18. package/dist/prompts.d.ts +8 -5
  19. package/dist/prompts.js +58 -168
  20. package/dist/render.d.ts +2 -2
  21. package/dist/render.js +70 -78
  22. package/dist/repomix-provider.d.ts +10 -2
  23. package/dist/repomix-provider.js +62 -11
  24. package/dist/search-bench.d.ts +1 -0
  25. package/dist/search-bench.js +675 -0
  26. package/dist/search-profile.d.ts +6 -0
  27. package/dist/search-profile.js +73 -0
  28. package/dist/sem-provider.d.ts +2 -2
  29. package/dist/sem-provider.js +33 -7
  30. package/dist/stupify.d.ts +2 -0
  31. package/dist/stupify.js +185 -334
  32. package/dist/types.d.ts +193 -109
  33. package/package.json +1 -1
  34. package/src/analysis.ts +48 -268
  35. package/src/checks.ts +91 -2
  36. package/src/command.ts +62 -107
  37. package/src/constants.ts +1 -1
  38. package/src/counter-scout.ts +63 -7
  39. package/src/doctor.ts +140 -0
  40. package/src/git.ts +35 -1
  41. package/src/hooks.ts +134 -0
  42. package/src/model.ts +39 -26
  43. package/src/prompts.ts +66 -202
  44. package/src/render.ts +68 -79
  45. package/src/repomix-provider.ts +66 -10
  46. package/src/search-bench.ts +783 -0
  47. package/src/search-profile.ts +89 -0
  48. package/src/sem-provider.ts +36 -9
  49. package/src/stupify.ts +215 -527
  50. package/src/types.ts +195 -119
  51. package/dist/batcher.d.ts +0 -3
  52. package/dist/batcher.js +0 -142
  53. package/dist/candidate-context.d.ts +0 -2
  54. package/dist/candidate-context.js +0 -40
  55. package/dist/experiment.d.ts +0 -1
  56. package/dist/experiment.js +0 -225
  57. package/src/batcher.ts +0 -198
  58. package/src/candidate-context.ts +0 -43
  59. package/src/experiment.ts +0 -317
package/dist/command.js CHANGED
@@ -1,85 +1,98 @@
1
1
  import { DEFAULT_MODEL_ID, MODEL_REGISTRY } from "./constants.js";
2
2
  const DEFAULT_SINCE = "2 weeks ago";
3
3
  const DEFAULT_MAX_CANDIDATES = 10;
4
- const DEFAULT_SCOUT_MODE = "counter";
5
- const DEFAULT_AUDIT_CONTEXT = "repomix";
6
- const DEFAULT_AUDIT_PROMPT = "high_bar";
7
- const DEFAULT_AUDIT_BATCH_SIZE = 25;
8
- const DEFAULT_MAX_AUDIT_INPUT_TOKENS = 20_000;
9
- const DEFAULT_AUDIT_CONCURRENCY = 2;
4
+ const DEFAULT_MAX_SEARCH_INPUT_TOKENS = 12_000;
10
5
  export function parseCommand(argv) {
11
6
  if (argv.length === 1 && isHelp(argv[0]))
12
7
  return { kind: "help" };
13
- if (argv[0] === "experiment") {
14
- const configPath = argv[1];
15
- if (!configPath || argv.length > 2)
16
- throw new Error("Usage: stupify experiment <config.json>");
17
- return { kind: "experiment", configPath };
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" };
18
25
  }
19
26
  const initialState = {
20
- inputMode: { kind: "since", since: DEFAULT_SINCE },
27
+ inputMode: { kind: "since", since: DEFAULT_SINCE, source: "since" },
21
28
  explicitInputMode: false,
22
29
  checkIds: null,
23
30
  json: false,
24
31
  model: DEFAULT_MODEL_ID,
25
- engine: "raw-diff",
26
- scout: DEFAULT_SCOUT_MODE,
27
- auditContext: DEFAULT_AUDIT_CONTEXT,
28
- auditPrompt: DEFAULT_AUDIT_PROMPT,
29
32
  debugSem: false,
30
- debugTargets: false,
31
33
  maxCandidates: DEFAULT_MAX_CANDIDATES,
32
- auditBatchSize: DEFAULT_AUDIT_BATCH_SIZE,
33
- maxAuditInputTokens: DEFAULT_MAX_AUDIT_INPUT_TOKENS,
34
- auditConcurrency: DEFAULT_AUDIT_CONCURRENCY,
34
+ maxSearchInputTokens: DEFAULT_MAX_SEARCH_INPUT_TOKENS,
35
+ searchProfilePath: null,
36
+ includeCounterReasonInPrompt: false,
35
37
  };
36
38
  const finalState = parseFrom(0, initialState);
37
39
  return {
38
40
  ...finalState.inputMode,
41
+ mode: "search",
39
42
  checkIds: finalState.checkIds,
40
43
  json: finalState.json,
41
44
  model: finalState.model,
42
- engine: finalState.engine,
43
- scout: finalState.scout,
44
- auditContext: finalState.auditContext,
45
- auditPrompt: finalState.auditPrompt,
46
45
  debugSem: finalState.debugSem,
47
- debugTargets: finalState.debugTargets,
48
46
  maxCandidates: finalState.maxCandidates,
49
- auditBatchSize: finalState.auditBatchSize,
50
- maxAuditInputTokens: finalState.maxAuditInputTokens,
51
- auditConcurrency: finalState.auditConcurrency,
47
+ maxSearchInputTokens: finalState.maxSearchInputTokens,
48
+ searchProfilePath: finalState.searchProfilePath,
49
+ includeCounterReasonInPrompt: finalState.includeCounterReasonInPrompt,
52
50
  };
53
51
  function parseFrom(index, state) {
54
52
  if (index >= argv.length)
55
53
  return state;
56
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" }));
57
63
  if (arg === "--stdin")
58
- return parseFrom(index + 1, setInputMode(state, { kind: "stdin" }));
64
+ return parseFrom(index + 1, setInputMode(state, { kind: "stdin", source: "stdin" }));
59
65
  if (arg === "--json")
60
66
  return parseFrom(index + 1, { ...state, json: true });
61
67
  if (arg === "--debug-sem")
62
68
  return parseFrom(index + 1, { ...state, debugSem: true });
63
- if (arg === "--debug-targets")
64
- return parseFrom(index + 1, { ...state, debugTargets: 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
+ }
65
78
  if (arg === "--since") {
66
79
  const value = argv[index + 1];
67
80
  if (!value || value.startsWith("-"))
68
81
  throw new Error("--since requires a git date, such as \"2 weeks ago\".");
69
- return parseFrom(index + 2, setInputMode(state, { kind: "since", since: value }));
82
+ return parseFrom(index + 2, setInputMode(state, { kind: "since", since: value, source: "since" }));
70
83
  }
71
84
  if (arg === "--commit") {
72
85
  const value = argv[index + 1];
73
86
  if (!value || !isSafeCommitArg(value))
74
87
  throw new Error("Invalid commit.");
75
- return parseFrom(index + 2, setInputMode(state, { kind: "commit", commit: value }));
88
+ return parseFrom(index + 2, setInputMode(state, { kind: "commit", commit: value, source: "commit" }));
76
89
  }
77
90
  if (arg === "--commits") {
78
91
  const value = argv[index + 1];
79
92
  const count = Number(value);
80
93
  if (!Number.isInteger(count) || count < 1)
81
94
  throw new Error("--commits requires a positive integer.");
82
- return parseFrom(index + 2, setInputMode(state, { kind: "commits", count }));
95
+ return parseFrom(index + 2, setInputMode(state, { kind: "commits", count, source: "commits" }));
83
96
  }
84
97
  if (arg === "--checks") {
85
98
  const value = argv[index + 1];
@@ -96,30 +109,6 @@ export function parseCommand(argv) {
96
109
  throw new Error(`--model must be one of: ${Object.keys(MODEL_REGISTRY).join(", ")}`);
97
110
  return parseFrom(index + 2, { ...state, model: value });
98
111
  }
99
- if (arg === "--engine") {
100
- const value = argv[index + 1];
101
- if (!value || !isEngine(value))
102
- throw new Error("--engine must be raw-diff or sem.");
103
- return parseFrom(index + 2, { ...state, engine: value });
104
- }
105
- if (arg === "--scout") {
106
- const value = argv[index + 1];
107
- if (!value || !isScoutMode(value))
108
- throw new Error("--scout must be counter or llm.");
109
- return parseFrom(index + 2, { ...state, scout: value });
110
- }
111
- if (arg === "--audit-context") {
112
- const value = argv[index + 1];
113
- if (!value || !isAuditContextMode(value))
114
- throw new Error("--audit-context must be none or repomix.");
115
- return parseFrom(index + 2, { ...state, auditContext: value });
116
- }
117
- if (arg === "--audit-prompt") {
118
- const value = argv[index + 1];
119
- if (!value || !isAuditPromptName(value))
120
- throw new Error("--audit-prompt must be strict or high_bar.");
121
- return parseFrom(index + 2, { ...state, auditPrompt: value });
122
- }
123
112
  if (arg === "--max-candidates") {
124
113
  const value = argv[index + 1];
125
114
  const maxCandidates = Number(value);
@@ -128,35 +117,19 @@ export function parseCommand(argv) {
128
117
  }
129
118
  return parseFrom(index + 2, { ...state, maxCandidates });
130
119
  }
131
- if (arg === "--audit-batch-size") {
132
- const value = argv[index + 1];
133
- const auditBatchSize = Number(value);
134
- if (!Number.isInteger(auditBatchSize) || auditBatchSize < 1) {
135
- throw new Error("--audit-batch-size requires a positive integer.");
136
- }
137
- return parseFrom(index + 2, { ...state, auditBatchSize });
138
- }
139
- if (arg === "--max-audit-input-tokens") {
120
+ if (arg === "--max-search-input-tokens") {
140
121
  const value = argv[index + 1];
141
- const maxAuditInputTokens = Number(value);
142
- if (!Number.isInteger(maxAuditInputTokens) || maxAuditInputTokens < 1) {
143
- throw new Error("--max-audit-input-tokens requires a positive integer.");
122
+ const maxSearchInputTokens = Number(value);
123
+ if (!Number.isInteger(maxSearchInputTokens) || maxSearchInputTokens < 1) {
124
+ throw new Error("--max-search-input-tokens requires a positive integer.");
144
125
  }
145
- return parseFrom(index + 2, { ...state, maxAuditInputTokens });
146
- }
147
- if (arg === "--audit-concurrency") {
148
- const value = argv[index + 1];
149
- const auditConcurrency = Number(value);
150
- if (!Number.isInteger(auditConcurrency) || auditConcurrency < 1) {
151
- throw new Error("--audit-concurrency requires a positive integer.");
152
- }
153
- return parseFrom(index + 2, { ...state, auditConcurrency });
126
+ return parseFrom(index + 2, { ...state, maxSearchInputTokens });
154
127
  }
155
128
  throw new Error(`Unknown option: ${arg}`);
156
129
  }
157
130
  function setInputMode(state, next) {
158
131
  if (state.explicitInputMode)
159
- throw new Error("Choose only one input mode: --since, --stdin, --commit, or --commits.");
132
+ throw new Error("Choose only one input mode: --since, --stdin, --commit, --commits, or --staged.");
160
133
  return { ...state, inputMode: next, explicitInputMode: true };
161
134
  }
162
135
  }
@@ -169,15 +142,6 @@ function isHelp(value) {
169
142
  function isModelId(value) {
170
143
  return value in MODEL_REGISTRY;
171
144
  }
172
- function isEngine(value) {
173
- return value === "raw-diff" || value === "sem";
174
- }
175
- function isScoutMode(value) {
176
- return value === "counter" || value === "llm";
177
- }
178
- function isAuditContextMode(value) {
179
- return value === "none" || value === "repomix";
180
- }
181
- function isAuditPromptName(value) {
182
- return value === "strict" || value === "high_bar";
145
+ function isHookAction(value) {
146
+ return value === "install" || value === "uninstall" || value === "status";
183
147
  }
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.0.3";
1
+ export declare const VERSION = "0.0.5";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
  export declare const DEFAULT_MODEL_ID: ModelId;
4
4
  export declare const MODEL_REGISTRY: Record<ModelId, ModelConfig>;
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.3";
1
+ export const VERSION = "0.0.5";
2
2
  export const DEFAULT_MODEL_ID = "gemma-4-e2b";
3
3
  export const MODEL_REGISTRY = {
4
4
  "gemma-4-e2b": {
@@ -38,13 +38,15 @@ export function runSignalCounters(changeSet, checks) {
38
38
  .filter((bucket) => bucket.total > 0);
39
39
  }
40
40
  function reasonForCheck(checkId, change) {
41
+ if (!isSearchableSourceChange(change))
42
+ return null;
41
43
  const haystack = `${change.entityName}\n${change.entityType}\n${change.filePath}\n${change.afterContent ?? ""}`.toLowerCase();
42
44
  const changed = change.changeType === "added" || change.changeType === "modified";
43
45
  if (!changed)
44
46
  return null;
45
47
  switch (checkId) {
46
48
  case "duplicated_schema":
47
- return isSchemaish(change, haystack) ? "schemaish_type_or_payload" : null;
49
+ return isDuplicatedSchemaCandidate(change) ? "local_schemaish_copy" : null;
48
50
  case "unnecessary_complexity":
49
51
  return /\b(helper|wrapper|service|provider|manager|factory|adapter|resolver|coordinator)\b/i.test(change.entityName)
50
52
  ? "new_abstraction_name"
@@ -62,11 +64,11 @@ function reasonForCheck(checkId, change) {
62
64
  ? "large_changed_chunk"
63
65
  : null;
64
66
  case "over_commenting":
65
- return commentLines(change.afterContent) > commentLines(change.beforeContent) + 3
67
+ return overCommentingSignal(change)
66
68
  ? "comment_lines_increased"
67
69
  : null;
68
70
  case "lint_bypass":
69
- return /(eslint-disable|biome-ignore|@ts-ignore|@ts-expect-error|\bas unknown as\b|\bany\b)/i.test(change.afterContent ?? "")
71
+ return lintBypassSignal(change.afterContent ?? "")
70
72
  ? "lint_or_type_bypass_text"
71
73
  : null;
72
74
  case "inconsistent_patterns":
@@ -74,7 +76,7 @@ function reasonForCheck(checkId, change) {
74
76
  ? "pattern_abstraction_name"
75
77
  : null;
76
78
  case "reinvented_utils":
77
- return /^(format|parse|normalize|group|sort|filter|find|has|get|set|is|resolve|clamp|slug)/i.test(change.entityName)
79
+ return reinventedUtilitySignal(change)
78
80
  ? "generic_utility_name"
79
81
  : null;
80
82
  case "operator_style_mismatch":
@@ -85,13 +87,73 @@ function reasonForCheck(checkId, change) {
85
87
  return null;
86
88
  }
87
89
  }
88
- function isSchemaish(change, haystack) {
89
- if (/^(interface|type|class)$/i.test(change.entityType))
90
- return true;
91
- return /\b(payload|dto|schema|response|request|input|output|result|context|asset|job|node|edge|generation)\b/i.test(haystack);
90
+ function isDuplicatedSchemaCandidate(change) {
91
+ if (!/^(interface|type)$/i.test(change.entityType))
92
+ return false;
93
+ if (/^(public|external|internal|payment|.+client$)/i.test(change.entityName))
94
+ return false;
95
+ return /\b(local|payload|schema)\b/i.test(words(change.entityName));
96
+ }
97
+ function overCommentingSignal(change) {
98
+ const before = commentLines(change.beforeContent);
99
+ const after = commentLines(change.afterContent);
100
+ if (after <= before + 3)
101
+ return false;
102
+ const comments = commentText(change.afterContent);
103
+ if (/\b(because|why|constraint|provider|external|api|quirk|edge case|timezone|utc|ledger|finance|reconciliation|rejects|mirrors|keep this)\b/i.test(comments)) {
104
+ return false;
105
+ }
106
+ return true;
107
+ }
108
+ function lintBypassSignal(value) {
109
+ return value.split(/\r?\n/).some((line) => {
110
+ const trimmed = line.trim();
111
+ const comment = /^(\/\/|\/\*|\*)/.test(trimmed);
112
+ if (comment && /@ts-ignore\s*$/i.test(trimmed))
113
+ return true;
114
+ if (comment && /@ts-expect-error\s*$/i.test(trimmed))
115
+ return true;
116
+ if (comment && /(eslint-disable|biome-ignore)/i.test(trimmed) && !/\s--\s*\S/.test(trimmed))
117
+ return true;
118
+ return /\bas unknown as\b|\bas any\b|:\s*any\b/i.test(trimmed);
119
+ });
120
+ }
121
+ function reinventedUtilitySignal(change) {
122
+ const name = change.entityName;
123
+ if (!/^(clamp|debounce|throttle|slug|slugify|group|sort|shuffle|memoize|pick|omit|uniq)/i.test(name))
124
+ return false;
125
+ const content = change.afterContent ?? "";
126
+ if (/currency|invoice|refund|subscription|tier|domain/i.test(`${name}\n${content}`))
127
+ return false;
128
+ return true;
129
+ }
130
+ function isSearchableSourceChange(change) {
131
+ const filePath = change.filePath.toLowerCase();
132
+ if (/(^|\/)(bun|package-lock|pnpm-lock|yarn)\.lock$/.test(filePath))
133
+ return false;
134
+ if (/(^|\/)(dist|build|coverage|generated|vendor|fixtures?|snapshots?)(\/|$)/.test(filePath))
135
+ return false;
136
+ if (/\.(md|mdx|txt|json|jsonc|ya?ml|toml|lock|csv|svg|png|jpe?g|gif|webp)$/i.test(filePath))
137
+ return false;
138
+ if (/\.(test|spec|fixture)\.[cm]?[jt]sx?$/i.test(filePath))
139
+ return false;
140
+ return /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i.test(filePath);
92
141
  }
93
142
  function commentLines(value) {
94
143
  if (!value)
95
144
  return 0;
96
145
  return value.split(/\r?\n/).filter((line) => /^\s*(\/\/|\/\*|\*|#)/.test(line)).length;
97
146
  }
147
+ function commentText(value) {
148
+ if (!value)
149
+ return "";
150
+ return value
151
+ .split(/\r?\n/)
152
+ .filter((line) => /^\s*(\/\/|\/\*|\*|#)/.test(line))
153
+ .join("\n");
154
+ }
155
+ function words(value) {
156
+ return value
157
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
158
+ .replace(/[_-]+/g, " ");
159
+ }
@@ -0,0 +1,4 @@
1
+ export declare function runDoctor(): Promise<Readonly<{
2
+ exitCode: number;
3
+ text: string;
4
+ }>>;
package/dist/doctor.js ADDED
@@ -0,0 +1,131 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { homedir, platform } from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { DEFAULT_MODEL_ID, MODEL_REGISTRY } from "./constants.js";
7
+ import { runHookCommand } from "./hooks.js";
8
+ const execFileAsync = promisify(execFile);
9
+ export async function runDoctor() {
10
+ const checks = await Promise.all([
11
+ gitCheck(),
12
+ hookCheck(),
13
+ semCheck(),
14
+ repomixCheck(),
15
+ llamaServerCheck(),
16
+ modelCacheCheck(),
17
+ ]);
18
+ const requiredMissing = checks.some((check) => check.required && check.status === "missing");
19
+ return {
20
+ exitCode: requiredMissing ? 1 : 0,
21
+ text: renderDoctor(checks),
22
+ };
23
+ }
24
+ async function gitCheck() {
25
+ try {
26
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { maxBuffer: 1024 * 1024 });
27
+ return { label: "git repo", status: "ok", detail: stdout.trim(), required: true };
28
+ }
29
+ catch {
30
+ return { label: "git repo", status: "missing", detail: "not inside a git repository", required: true };
31
+ }
32
+ }
33
+ async function hookCheck() {
34
+ try {
35
+ const status = await runHookCommand("status");
36
+ return { label: "pre-commit hook", status: "info", detail: status.replace(/^Stupify hook:\s*/, "") };
37
+ }
38
+ catch (error) {
39
+ return { label: "pre-commit hook", status: "info", detail: errorMessage(error) };
40
+ }
41
+ }
42
+ async function semCheck() {
43
+ const packageBin = resolvePackage("@ataraxy-labs/sem/bin/sem.js");
44
+ if (packageBin)
45
+ return { label: "sem", status: "ok", detail: "@ataraxy-labs/sem package binary found", required: true };
46
+ if (await commandExists("sem"))
47
+ return { label: "sem", status: "ok", detail: "sem found on PATH", required: true };
48
+ return { label: "sem", status: "missing", detail: "install @ataraxy-labs/sem or put sem on PATH", required: true };
49
+ }
50
+ async function repomixCheck() {
51
+ if (resolvePackage("repomix"))
52
+ return { label: "Repomix", status: "ok", detail: "repomix package found", required: true };
53
+ return { label: "Repomix", status: "missing", detail: "repomix package is not installed", required: true };
54
+ }
55
+ async function llamaServerCheck() {
56
+ if (await commandExists("llama-server"))
57
+ return { label: "llama-server", status: "ok", detail: "llama-server found on PATH", required: true };
58
+ return { label: "llama-server", status: "missing", detail: "install llama.cpp, for example `brew install llama.cpp`", required: true };
59
+ }
60
+ async function modelCacheCheck() {
61
+ const model = MODEL_REGISTRY[DEFAULT_MODEL_ID];
62
+ const modelPath = path.join(cacheDir(), "models", model.file);
63
+ if (await fileExists(modelPath))
64
+ return { label: "default model", status: "ok", detail: `${model.name} cached` };
65
+ return {
66
+ label: "default model",
67
+ status: "info",
68
+ detail: `${model.name} not cached yet; first interactive search can download it locally`,
69
+ };
70
+ }
71
+ function renderDoctor(checks) {
72
+ const lines = [
73
+ "Stupify doctor",
74
+ "",
75
+ ...checks.map((check) => `${icon(check.status)} ${check.label}: ${check.detail}`),
76
+ "",
77
+ "Privacy: local-only. Stupify does not upload source, diffs, filenames, repo URLs, commit messages, author names, or private package names.",
78
+ ];
79
+ return lines.join("\n");
80
+ }
81
+ function icon(status) {
82
+ if (status === "ok")
83
+ return "OK";
84
+ if (status === "missing")
85
+ return "MISSING";
86
+ return "INFO";
87
+ }
88
+ function resolvePackage(specifier) {
89
+ try {
90
+ const require = createRequire(import.meta.url);
91
+ return require.resolve(specifier);
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ async function commandExists(command) {
98
+ try {
99
+ await execFileAsync("sh", ["-c", `command -v ${shellQuote(command)}`], { maxBuffer: 1024 * 1024 });
100
+ return true;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ async function fileExists(filePath) {
107
+ try {
108
+ const { stat } = await import("node:fs/promises");
109
+ return (await stat(filePath)).isFile();
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ function cacheDir() {
116
+ if (process.env.STUPIFY_CACHE_DIR)
117
+ return process.env.STUPIFY_CACHE_DIR;
118
+ if (process.env.XDG_CACHE_HOME)
119
+ return path.join(process.env.XDG_CACHE_HOME, "stupify");
120
+ if (platform() === "darwin")
121
+ return path.join(homedir(), "Library", "Caches", "stupify");
122
+ if (platform() === "win32" && process.env.LOCALAPPDATA)
123
+ return path.join(process.env.LOCALAPPDATA, "stupify", "Cache");
124
+ return path.join(homedir(), ".cache", "stupify");
125
+ }
126
+ function shellQuote(value) {
127
+ return `'${value.replace(/'/g, "'\\''")}'`;
128
+ }
129
+ function errorMessage(error) {
130
+ return error instanceof Error ? error.message : String(error);
131
+ }
package/dist/git.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type NetDiff, type SourceRange } from "./types.ts";
1
+ import { type NetDiff, type SourceRange, type StagedDiff } from "./types.ts";
2
2
  export declare function netDiffSince(since: string): Promise<NetDiff>;
3
3
  export declare function netDiffForCommit(commit: string): Promise<NetDiff>;
4
4
  export declare function netDiffForRecentCommits(count: number): Promise<NetDiff>;
@@ -6,3 +6,6 @@ export declare function sourceRangeSince(since: string): Promise<SourceRange>;
6
6
  export declare function sourceRangeForCommit(commit: string): Promise<SourceRange>;
7
7
  export declare function sourceRangeForRecentCommits(count: number): Promise<SourceRange>;
8
8
  export declare function netDiffFromStdin(text: string): Promise<NetDiff>;
9
+ export declare function stagedDiff(): Promise<StagedDiff>;
10
+ export declare function gitRoot(): Promise<string>;
11
+ export declare function gitPath(pathspec: string): Promise<string>;
package/dist/git.js CHANGED
@@ -53,6 +53,40 @@ export async function netDiffFromStdin(text) {
53
53
  stats: statsFromDiff(text),
54
54
  };
55
55
  }
56
+ export async function stagedDiff() {
57
+ try {
58
+ const { stdout } = await execFileAsync("git", [
59
+ "diff",
60
+ "--cached",
61
+ "--no-ext-diff",
62
+ "--no-color",
63
+ "--unified=3",
64
+ "--",
65
+ ], { maxBuffer: 64 * 1024 * 1024 });
66
+ return { text: stdout, stats: statsFromDiff(stdout) };
67
+ }
68
+ catch {
69
+ throw new Error("Could not read staged changes. Run stupify inside a git repository.");
70
+ }
71
+ }
72
+ export async function gitRoot() {
73
+ try {
74
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"]);
75
+ return stdout.trim();
76
+ }
77
+ catch {
78
+ throw new Error("Could not find a git repository.");
79
+ }
80
+ }
81
+ export async function gitPath(pathspec) {
82
+ try {
83
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", pathspec]);
84
+ return stdout.trim();
85
+ }
86
+ catch {
87
+ throw new Error(`Could not resolve git path: ${pathspec}`);
88
+ }
89
+ }
56
90
  async function netDiff(base, target, label, id) {
57
91
  const [text, stats, shortBase, shortTarget] = await Promise.all([
58
92
  diff(base, target),
@@ -0,0 +1,3 @@
1
+ import type { HookAction } from "./types.ts";
2
+ export declare function runHookCommand(action: HookAction): Promise<string>;
3
+ export declare function hookSnippet(): string;
package/dist/hooks.js ADDED
@@ -0,0 +1,117 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { chmod, readFile, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { gitPath, gitRoot } from "./git.js";
7
+ const execFileAsync = promisify(execFile);
8
+ const START = "# stupify hook start";
9
+ const END = "# stupify hook end";
10
+ export async function runHookCommand(action) {
11
+ if (action === "status")
12
+ return hookStatus();
13
+ if (action === "install")
14
+ return installHook();
15
+ return uninstallHook();
16
+ }
17
+ export function hookSnippet() {
18
+ return managedBlock("stupify --staged");
19
+ }
20
+ async function hookStatus() {
21
+ const hookPath = await preCommitHookPath();
22
+ if (!existsSync(hookPath))
23
+ return "Stupify hook: not installed";
24
+ const content = await readFile(hookPath, "utf8");
25
+ if (hasManagedBlock(content))
26
+ return "Stupify hook: installed";
27
+ return "Stupify hook: existing non-Stupify pre-commit hook found";
28
+ }
29
+ async function installHook() {
30
+ const hookPath = await preCommitHookPath();
31
+ const block = await managedBlockForInstall();
32
+ if (!existsSync(hookPath)) {
33
+ await writeFile(hookPath, `#!/bin/sh\n${block}\n`, "utf8");
34
+ await chmod(hookPath, 0o755);
35
+ return "Stupify hook: installed";
36
+ }
37
+ const content = await readFile(hookPath, "utf8");
38
+ if (hasManagedBlock(content)) {
39
+ await writeFile(hookPath, `${replaceManagedBlock(content, block).trimEnd()}\n`, "utf8");
40
+ await chmod(hookPath, 0o755);
41
+ return "Stupify hook: updated";
42
+ }
43
+ if (isEffectivelyEmptyHook(content)) {
44
+ await writeFile(hookPath, `#!/bin/sh\n${block}\n`, "utf8");
45
+ await chmod(hookPath, 0o755);
46
+ return "Stupify hook: installed";
47
+ }
48
+ return `Stupify hook: existing non-Stupify pre-commit hook found; not modified.
49
+ Add this snippet manually if you want Stupify in that hook:
50
+ ${block}`;
51
+ }
52
+ async function uninstallHook() {
53
+ const hookPath = await preCommitHookPath();
54
+ if (!existsSync(hookPath))
55
+ return "Stupify hook: not installed";
56
+ const content = await readFile(hookPath, "utf8");
57
+ if (!hasManagedBlock(content))
58
+ return "Stupify hook: not installed";
59
+ const next = replaceManagedBlock(content, "").trim();
60
+ if (isEffectivelyEmptyHook(next)) {
61
+ await rm(hookPath, { force: true });
62
+ return "Stupify hook: uninstalled";
63
+ }
64
+ await writeFile(hookPath, `${next}\n`, "utf8");
65
+ await chmod(hookPath, 0o755);
66
+ return "Stupify hook: uninstalled";
67
+ }
68
+ async function preCommitHookPath() {
69
+ const [root, hook] = await Promise.all([gitRoot(), gitPath("hooks/pre-commit")]);
70
+ return path.isAbsolute(hook) ? hook : path.join(root, hook);
71
+ }
72
+ function hasManagedBlock(content) {
73
+ return content.includes(START) && content.includes(END);
74
+ }
75
+ async function managedBlockForInstall() {
76
+ if (await commandExists("stupify"))
77
+ return managedBlock("stupify --staged");
78
+ const root = await gitRoot();
79
+ const localEntrypoint = path.join(root, "packages", "cli", "src", "stupify.ts");
80
+ if (existsSync(localEntrypoint) && await commandExists("bun")) {
81
+ return managedBlock(`bun ${shellQuote(localEntrypoint)} --staged`);
82
+ }
83
+ return managedBlock("stupify --staged");
84
+ }
85
+ function managedBlock(command) {
86
+ return `${START}
87
+ ${command} || true
88
+ ${END}`;
89
+ }
90
+ function replaceManagedBlock(content, replacement) {
91
+ const pattern = new RegExp(`${escapeRegExp(START)}[\\s\\S]*?${escapeRegExp(END)}`);
92
+ return content.replace(pattern, replacement);
93
+ }
94
+ function isEffectivelyEmptyHook(content) {
95
+ return content
96
+ .split(/\r?\n/)
97
+ .map((line) => line.trim())
98
+ .filter((line) => line && line !== "#!/bin/sh" && line !== "#!/usr/bin/env sh")
99
+ .length === 0;
100
+ }
101
+ function escapeRegExp(value) {
102
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
103
+ }
104
+ async function commandExists(command) {
105
+ try {
106
+ await execFileAsync("sh", ["-c", `command -v ${shellQuote(command)}`], {
107
+ maxBuffer: 1024 * 1024,
108
+ });
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ function shellQuote(value) {
116
+ return `'${value.replace(/'/g, "'\\''")}'`;
117
+ }