whale-igniter 1.1.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -0
  3. package/dist/analyzer/imports.js +88 -0
  4. package/dist/analyzer/insights.js +276 -0
  5. package/dist/commands/add.js +36 -0
  6. package/dist/commands/adopt.js +180 -0
  7. package/dist/commands/adoptReview.js +267 -0
  8. package/dist/commands/component.js +93 -0
  9. package/dist/commands/createComponent.js +207 -0
  10. package/dist/commands/decision.js +98 -0
  11. package/dist/commands/docs.js +34 -0
  12. package/dist/commands/ignite.js +212 -0
  13. package/dist/commands/init.js +66 -0
  14. package/dist/commands/insights.js +123 -0
  15. package/dist/commands/mcp.js +106 -0
  16. package/dist/commands/refine.js +36 -0
  17. package/dist/commands/selene.js +516 -0
  18. package/dist/commands/sync.js +43 -0
  19. package/dist/commands/validate.js +48 -0
  20. package/dist/commands/watch.js +150 -0
  21. package/dist/commands/wiki.js +21 -0
  22. package/dist/generators/markdownGenerator.js +112 -0
  23. package/dist/generators/reportGenerator.js +50 -0
  24. package/dist/generators/wikiGenerator.js +365 -0
  25. package/dist/index.js +213 -0
  26. package/dist/mcp/server.js +404 -0
  27. package/dist/scanner/componentScanner.js +522 -0
  28. package/dist/scanner/foundationInferrer.js +174 -0
  29. package/dist/scanner/tailwindMapper.js +58 -0
  30. package/dist/scanner/tailwindScanner.js +186 -0
  31. package/dist/selene/apiClient.js +168 -0
  32. package/dist/selene/cache.js +68 -0
  33. package/dist/selene/clipboard.js +56 -0
  34. package/dist/selene/promptBuilder.js +229 -0
  35. package/dist/selene/providers.js +67 -0
  36. package/dist/selene/responseParser.js +149 -0
  37. package/dist/ui/atoms.js +30 -0
  38. package/dist/ui/blocks.js +208 -0
  39. package/dist/ui/capabilities.js +64 -0
  40. package/dist/ui/index.js +13 -0
  41. package/dist/ui/symbols.js +41 -0
  42. package/dist/ui/theme.js +78 -0
  43. package/dist/utils/components.js +40 -0
  44. package/dist/utils/config.js +31 -0
  45. package/dist/utils/decisions.js +32 -0
  46. package/dist/utils/paths.js +4 -0
  47. package/dist/utils/proposals.js +61 -0
  48. package/dist/utils/refinements.js +81 -0
  49. package/dist/utils/registry.js +45 -0
  50. package/dist/utils/writeJson.js +6 -0
  51. package/dist/validators/cssValidator.js +204 -0
  52. package/dist/version.js +1 -0
  53. package/docs/ROADMAP.md +206 -0
  54. package/package.json +76 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Pixel → Tailwind class mapper.
3
+ *
4
+ * The scanner reads classes and produces pixel values; this module does
5
+ * the reverse, so the generator can write code that respects the
6
+ * project's foundations using the canonical Tailwind names instead of
7
+ * arbitrary-value escape hatches.
8
+ *
9
+ * When the exact px value doesn't have a Tailwind class (e.g. grid=5px,
10
+ * but Tailwind has no `p-1.25`), we fall back to the arbitrary value
11
+ * syntax (`p-[5px]`). That's worse than a token class but better than
12
+ * silently rounding.
13
+ */
14
+ const SPACING_PX_TO_SUFFIX = {
15
+ 0: "0", 1: "px", 2: "0.5", 4: "1", 6: "1.5", 8: "2", 10: "2.5", 12: "3",
16
+ 14: "3.5", 16: "4", 20: "5", 24: "6", 28: "7", 32: "8", 36: "9", 40: "10",
17
+ 44: "11", 48: "12", 56: "14", 64: "16", 80: "20", 96: "24", 112: "28",
18
+ 128: "32", 144: "36", 160: "40", 176: "44", 192: "48", 208: "52", 224: "56",
19
+ 240: "60", 256: "64", 288: "72", 320: "80", 384: "96"
20
+ };
21
+ const RADIUS_PX_TO_SUFFIX = {
22
+ 0: "none",
23
+ 2: "sm",
24
+ 4: "", // bare `rounded`
25
+ 6: "md",
26
+ 8: "lg",
27
+ 12: "xl",
28
+ 16: "2xl",
29
+ 24: "3xl",
30
+ 9999: "full"
31
+ };
32
+ export function spacingClass(prefix, px) {
33
+ const suffix = SPACING_PX_TO_SUFFIX[px];
34
+ if (suffix !== undefined)
35
+ return `${prefix}-${suffix}`;
36
+ return `${prefix}-[${px}px]`;
37
+ }
38
+ export function radiusClass(prefix, px) {
39
+ const suffix = RADIUS_PX_TO_SUFFIX[px];
40
+ if (suffix !== undefined) {
41
+ return suffix === "" ? prefix : `${prefix}-${suffix}`;
42
+ }
43
+ return `${prefix}-[${px}px]`;
44
+ }
45
+ /**
46
+ * Pick the most common color name to use as the project accent. We
47
+ * fall back to "blue" because that's Tailwind's de facto convention
48
+ * and matches the default `accent` in whale's config.
49
+ */
50
+ export function accentColorFamily(accent) {
51
+ if (!accent)
52
+ return "blue";
53
+ const known = ["slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber", "yellow", "lime", "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia", "pink", "rose"];
54
+ const lower = accent.toLowerCase();
55
+ if (known.includes(lower))
56
+ return lower;
57
+ return "blue";
58
+ }
@@ -0,0 +1,186 @@
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ // Tailwind's default spacing scale, in pixels. We use this when no tailwind.config is found.
4
+ // Keys are the suffix used in classes (e.g. "4" in "p-4"), values are pixel sizes.
5
+ const DEFAULT_SPACING_SCALE = {
6
+ "0": 0, "px": 1, "0.5": 2, "1": 4, "1.5": 6, "2": 8, "2.5": 10, "3": 12,
7
+ "3.5": 14, "4": 16, "5": 20, "6": 24, "7": 28, "8": 32, "9": 36, "10": 40,
8
+ "11": 44, "12": 48, "14": 56, "16": 64, "20": 80, "24": 96, "28": 112,
9
+ "32": 128, "36": 144, "40": 160, "44": 176, "48": 192, "52": 208, "56": 224,
10
+ "60": 240, "64": 256, "72": 288, "80": 320, "96": 384
11
+ };
12
+ // Tailwind's default radius scale.
13
+ const DEFAULT_RADIUS_SCALE = {
14
+ none: 0, sm: 2, "": 4, md: 6, lg: 8, xl: 12, "2xl": 16, "3xl": 24, full: 9999
15
+ };
16
+ // Spacing-related class prefixes. The captured group is the value suffix.
17
+ // Order matters — longer prefixes first to avoid `m-` matching `mt-`.
18
+ const SPACING_PREFIXES = [
19
+ "px-", "py-", "pt-", "pb-", "pl-", "pr-", "ps-", "pe-", "p-",
20
+ "mx-", "my-", "mt-", "mb-", "ml-", "mr-", "ms-", "me-", "m-",
21
+ "gap-x-", "gap-y-", "gap-",
22
+ "space-x-", "space-y-"
23
+ ];
24
+ // Radius prefixes — full first, then sided.
25
+ const RADIUS_PREFIXES = [
26
+ "rounded-t-", "rounded-b-", "rounded-l-", "rounded-r-",
27
+ "rounded-tl-", "rounded-tr-", "rounded-bl-", "rounded-br-",
28
+ "rounded-"
29
+ ];
30
+ // Bare radius class (`rounded`) with no suffix.
31
+ const BARE_RADIUS = /^rounded$/;
32
+ const COLOR_PREFIXES = ["bg-", "text-", "border-", "ring-", "fill-", "stroke-", "from-", "to-", "via-"];
33
+ const BORDER_WIDTH = /^border(?:-[a-z]+)?(?:-(\d+))?$/;
34
+ const RING_WIDTH = /^ring(?:-(\d+))?$/;
35
+ const RING_OFFSET = /^ring-offset(?:-(\d+))?$/;
36
+ const FONT_SIZE = /^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/;
37
+ const SHADOW = /^shadow(-[a-z0-9-]+)?$/;
38
+ function stripVariantPrefixes(cls) {
39
+ // Tailwind variants like `hover:`, `md:`, `dark:`, `focus-visible:`.
40
+ // Multiple variants can stack: `dark:md:hover:p-4`. Take everything
41
+ // after the last `:`.
42
+ const lastColon = cls.lastIndexOf(":");
43
+ return lastColon >= 0 ? cls.slice(lastColon + 1) : cls;
44
+ }
45
+ function isNegative(value) {
46
+ // Tailwind allows negative spacing: `-mt-4`. The negative goes in front of the prefix.
47
+ return value.startsWith("-");
48
+ }
49
+ function parseArbitraryPx(value) {
50
+ // value example: "[10px]" or "[1.5rem]". Returns pixels.
51
+ const m = value.match(/^\[([0-9.]+)(px|rem|em)?\]$/);
52
+ if (!m)
53
+ return null;
54
+ const n = parseFloat(m[1]);
55
+ const unit = m[2] ?? "px";
56
+ if (unit === "px")
57
+ return n;
58
+ if (unit === "rem" || unit === "em")
59
+ return n * 16; // assume default root = 16
60
+ return null;
61
+ }
62
+ function classifyClass(cls) {
63
+ const stripped = stripVariantPrefixes(cls);
64
+ const neg = isNegative(stripped);
65
+ const base = neg ? stripped.slice(1) : stripped;
66
+ // Spacing
67
+ for (const prefix of SPACING_PREFIXES) {
68
+ if (base.startsWith(prefix)) {
69
+ const suffix = base.slice(prefix.length);
70
+ if (suffix.startsWith("[") && suffix.endsWith("]")) {
71
+ const px = parseArbitraryPx(suffix);
72
+ return { raw: cls, kind: "spacing", pxValue: px === null ? null : neg ? -px : px, colorToken: null, isArbitrary: true };
73
+ }
74
+ const scaled = DEFAULT_SPACING_SCALE[suffix];
75
+ return {
76
+ raw: cls,
77
+ kind: "spacing",
78
+ pxValue: scaled === undefined ? null : neg ? -scaled : scaled,
79
+ colorToken: null,
80
+ isArbitrary: false
81
+ };
82
+ }
83
+ }
84
+ // Radius
85
+ for (const prefix of RADIUS_PREFIXES) {
86
+ if (base.startsWith(prefix)) {
87
+ const suffix = base.slice(prefix.length);
88
+ if (suffix.startsWith("[") && suffix.endsWith("]")) {
89
+ return { raw: cls, kind: "radius", pxValue: parseArbitraryPx(suffix), colorToken: null, isArbitrary: true };
90
+ }
91
+ const scaled = DEFAULT_RADIUS_SCALE[suffix];
92
+ return {
93
+ raw: cls,
94
+ kind: "radius",
95
+ pxValue: scaled === undefined ? null : scaled,
96
+ colorToken: null,
97
+ isArbitrary: false
98
+ };
99
+ }
100
+ }
101
+ if (BARE_RADIUS.test(base)) {
102
+ return { raw: cls, kind: "radius", pxValue: DEFAULT_RADIUS_SCALE[""], colorToken: null, isArbitrary: false };
103
+ }
104
+ // Font size — must check before color because `text-sm` would also match the `text-` color prefix.
105
+ if (FONT_SIZE.test(base)) {
106
+ return { raw: cls, kind: "font-size", pxValue: null, colorToken: null, isArbitrary: false };
107
+ }
108
+ // Ring width / offset — match before color because `ring-` is in COLOR_PREFIXES too.
109
+ const ringMatch = base.match(RING_WIDTH);
110
+ if (ringMatch) {
111
+ const px = ringMatch[1] ? parseInt(ringMatch[1], 10) : 3; // tailwind default ring is 3px
112
+ return { raw: cls, kind: "border-width", pxValue: px, colorToken: null, isArbitrary: false };
113
+ }
114
+ const ringOffsetMatch = base.match(RING_OFFSET);
115
+ if (ringOffsetMatch) {
116
+ const px = ringOffsetMatch[1] ? parseInt(ringOffsetMatch[1], 10) : 0;
117
+ return { raw: cls, kind: "border-width", pxValue: px, colorToken: null, isArbitrary: false };
118
+ }
119
+ // Color
120
+ for (const prefix of COLOR_PREFIXES) {
121
+ if (base.startsWith(prefix)) {
122
+ const suffix = base.slice(prefix.length);
123
+ if (suffix.startsWith("[") && suffix.endsWith("]")) {
124
+ return { raw: cls, kind: "color", pxValue: null, colorToken: suffix, isArbitrary: true };
125
+ }
126
+ // bg-blue-500, bg-primary, bg-white, bg-transparent
127
+ return { raw: cls, kind: "color", pxValue: null, colorToken: suffix, isArbitrary: false };
128
+ }
129
+ }
130
+ // Border width
131
+ const borderMatch = base.match(BORDER_WIDTH);
132
+ if (borderMatch && !base.startsWith("border-t-") && !base.startsWith("border-b-")) {
133
+ const px = borderMatch[1] ? parseInt(borderMatch[1], 10) : 1;
134
+ return { raw: cls, kind: "border-width", pxValue: px, colorToken: null, isArbitrary: false };
135
+ }
136
+ // Shadow
137
+ if (SHADOW.test(base)) {
138
+ return { raw: cls, kind: "shadow", pxValue: null, colorToken: null, isArbitrary: false };
139
+ }
140
+ return null;
141
+ }
142
+ /**
143
+ * Aggregate Tailwind observations from a list of raw className strings.
144
+ * Each className string can contain multiple space-separated classes;
145
+ * we tokenize and classify each, then count occurrences.
146
+ */
147
+ export function aggregateTailwind(classNameStrings) {
148
+ const map = new Map();
149
+ for (const str of classNameStrings) {
150
+ if (!str)
151
+ continue;
152
+ const tokens = str.split(/\s+/).filter(Boolean);
153
+ for (const tok of tokens) {
154
+ const obs = classifyClass(tok);
155
+ if (!obs)
156
+ continue;
157
+ const key = obs.raw;
158
+ const existing = map.get(key);
159
+ if (existing) {
160
+ existing.count += 1;
161
+ }
162
+ else {
163
+ map.set(key, { ...obs, count: 1 });
164
+ }
165
+ }
166
+ }
167
+ return Array.from(map.values()).sort((a, b) => b.count - a.count);
168
+ }
169
+ /**
170
+ * Try to read tailwind.config.{js,ts,mjs,cjs} to override default scales.
171
+ * We use a conservative approach: don't execute the file (no eval, no
172
+ * dynamic import — security and reliability), just check existence and
173
+ * report it in the inference.
174
+ *
175
+ * Properly reading the resolved Tailwind config requires running the
176
+ * tailwindcss preset machinery, which is out of scope for v0.8. The
177
+ * default scales catch ~95% of usage in our fixtures.
178
+ */
179
+ export async function detectTailwindConfig(target) {
180
+ const candidates = ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs", "tailwind.config.cjs"];
181
+ for (const c of candidates) {
182
+ if (await fs.pathExists(path.join(target, c)))
183
+ return c;
184
+ }
185
+ return null;
186
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Selene API clients.
3
+ *
4
+ * Two providers, one abstraction. Native fetch — no SDK dependencies.
5
+ * SDKs add weight, version-lock us to one API shape, and pull in
6
+ * tokenizers and types we don't need. Both APIs are extremely simple
7
+ * for the single "send a prompt, get text back" use case, so we hit
8
+ * them directly.
9
+ *
10
+ * Errors are mapped to a normalised shape so the command layer can
11
+ * handle them uniformly. We deliberately surface the provider's own
12
+ * error message verbatim — those are usually accurate ("invalid_api_key",
13
+ * "model_not_found", rate limit, etc.) and editing them obscures the
14
+ * real cause.
15
+ */
16
+ export async function callProvider(resolved, params) {
17
+ if (resolved.provider === "anthropic")
18
+ return callAnthropic(resolved, params);
19
+ if (resolved.provider === "openai")
20
+ return callOpenAI(resolved, params);
21
+ // Compile-time exhaustiveness check — if we add a provider, this errors.
22
+ const _exhaustive = resolved.provider;
23
+ return { ok: false, error: `Unknown provider: ${String(_exhaustive)}`, retryable: false };
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Anthropic Messages API
27
+ // ---------------------------------------------------------------------------
28
+ async function callAnthropic(resolved, params) {
29
+ // We split the prompt: a stable "system" portion (project context +
30
+ // identity) would normally let Anthropic cache it, but we don't have
31
+ // a clean structural split here because the prompt builder produces
32
+ // a single Markdown blob. We send the whole thing as a single user
33
+ // message; that's correct, just not optimal for cache hits.
34
+ //
35
+ // Future: split promptBuilder output into {system, user} pieces and
36
+ // pass them separately to enable prompt caching. Out of scope here.
37
+ const body = {
38
+ model: resolved.model,
39
+ max_tokens: params.maxTokens,
40
+ temperature: params.temperature,
41
+ messages: [{ role: "user", content: params.prompt }]
42
+ };
43
+ try {
44
+ const resp = await fetch("https://api.anthropic.com/v1/messages", {
45
+ method: "POST",
46
+ headers: {
47
+ "content-type": "application/json",
48
+ "x-api-key": resolved.apiKey,
49
+ "anthropic-version": "2023-06-01"
50
+ },
51
+ body: JSON.stringify(body)
52
+ });
53
+ if (!resp.ok) {
54
+ const errText = await resp.text();
55
+ return {
56
+ ok: false,
57
+ status: resp.status,
58
+ error: `Anthropic ${resp.status}: ${errText.slice(0, 500)}`,
59
+ retryable: resp.status === 429 || resp.status >= 500
60
+ };
61
+ }
62
+ const data = (await resp.json());
63
+ // Anthropic returns content blocks; we concatenate the text ones.
64
+ const text = (data.content ?? [])
65
+ .filter((b) => b.type === "text")
66
+ .map((b) => b.text ?? "")
67
+ .join("")
68
+ .trim();
69
+ return {
70
+ ok: true,
71
+ text,
72
+ inputTokens: data.usage?.input_tokens ?? null,
73
+ outputTokens: data.usage?.output_tokens ?? null
74
+ };
75
+ }
76
+ catch (err) {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ return { ok: false, error: `Network error: ${message}`, retryable: true };
79
+ }
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // OpenAI Chat Completions API
83
+ // ---------------------------------------------------------------------------
84
+ async function callOpenAI(resolved, params) {
85
+ const body = {
86
+ model: resolved.model,
87
+ max_tokens: params.maxTokens,
88
+ temperature: params.temperature,
89
+ messages: [{ role: "user", content: params.prompt }]
90
+ };
91
+ try {
92
+ const resp = await fetch("https://api.openai.com/v1/chat/completions", {
93
+ method: "POST",
94
+ headers: {
95
+ "content-type": "application/json",
96
+ authorization: `Bearer ${resolved.apiKey}`
97
+ },
98
+ body: JSON.stringify(body)
99
+ });
100
+ if (!resp.ok) {
101
+ const errText = await resp.text();
102
+ return {
103
+ ok: false,
104
+ status: resp.status,
105
+ error: `OpenAI ${resp.status}: ${errText.slice(0, 500)}`,
106
+ retryable: resp.status === 429 || resp.status >= 500
107
+ };
108
+ }
109
+ const data = (await resp.json());
110
+ const text = (data.choices?.[0]?.message?.content ?? "").trim();
111
+ return {
112
+ ok: true,
113
+ text,
114
+ inputTokens: data.usage?.prompt_tokens ?? null,
115
+ outputTokens: data.usage?.completion_tokens ?? null
116
+ };
117
+ }
118
+ catch (err) {
119
+ const message = err instanceof Error ? err.message : String(err);
120
+ return { ok: false, error: `Network error: ${message}`, retryable: true };
121
+ }
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Cost estimation (very rough, off-by-design)
125
+ // ---------------------------------------------------------------------------
126
+ /**
127
+ * Rough token estimate from a prompt string. We assume ~4 chars/token,
128
+ * which is the rule of thumb for English Latin-script text. Code is
129
+ * denser (~3 chars/token); we err on the high side intentionally so
130
+ * cost estimates skew pessimistic.
131
+ *
132
+ * If you want real numbers, run `whale selene status --tokens` after
133
+ * calls and look at the actual usage; we record it.
134
+ */
135
+ export function estimateTokens(text) {
136
+ return Math.ceil(text.length / 3.5);
137
+ }
138
+ /**
139
+ * Pricing (USD per 1M tokens) for the default models. We deliberately
140
+ * don't hardcode every model — these are approximations for confirmCost
141
+ * messages, not invoices. Users should check the provider's pricing
142
+ * page for accuracy.
143
+ *
144
+ * Source: vendor pricing pages, accessed early 2026. Update opportunistically.
145
+ */
146
+ const APPROX_PRICING = {
147
+ // Anthropic
148
+ "claude-sonnet-4-6": { input: 3.0, output: 15.0 },
149
+ "claude-opus-4-7": { input: 15.0, output: 75.0 },
150
+ "claude-haiku-4-5": { input: 1.0, output: 5.0 },
151
+ // OpenAI
152
+ "gpt-4.1": { input: 2.5, output: 10.0 },
153
+ "gpt-4.1-mini": { input: 0.4, output: 1.6 }
154
+ };
155
+ export function estimateCostUsd(model, inputTokens, expectedOutputTokens) {
156
+ // Try exact model match, then prefix match (handles e.g. "claude-opus-4-7-20260101").
157
+ let pricing = APPROX_PRICING[model];
158
+ if (!pricing) {
159
+ const prefix = Object.keys(APPROX_PRICING).find((k) => model.startsWith(k));
160
+ if (prefix)
161
+ pricing = APPROX_PRICING[prefix];
162
+ }
163
+ if (!pricing)
164
+ return null;
165
+ const inputCost = (inputTokens / 1_000_000) * pricing.input;
166
+ const outputCost = (expectedOutputTokens / 1_000_000) * pricing.output;
167
+ return inputCost + outputCost;
168
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * On-disk cache for Selene API responses.
3
+ *
4
+ * Why: identical prompts run in quick succession (re-running a command,
5
+ * iterating on a workflow) shouldn't pay twice. The cache key is the
6
+ * sha256 of (model + prompt), so any meaningful change to either
7
+ * invalidates. TTL defaults to 7 days; old files are pruned lazily on
8
+ * read.
9
+ *
10
+ * Caveats. We cache the *response text*, not usage tokens or cost — those
11
+ * are zero on a cache hit. We don't cache failures. If the user wants
12
+ * a fresh call, they can pass --no-cache or delete the cache dir.
13
+ */
14
+ import path from "node:path";
15
+ import fs from "fs-extra";
16
+ import { createHash } from "node:crypto";
17
+ const CACHE_DIR_REL = ".whale/selene/cache";
18
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
19
+ function cacheKey(model, prompt) {
20
+ return createHash("sha256").update(`${model}\n${prompt}`).digest("hex").slice(0, 32);
21
+ }
22
+ export async function readCache(target, model, prompt, ttlMs = DEFAULT_TTL_MS) {
23
+ const key = cacheKey(model, prompt);
24
+ const file = path.join(target, CACHE_DIR_REL, `${key}.json`);
25
+ if (!(await fs.pathExists(file)))
26
+ return null;
27
+ try {
28
+ const entry = (await fs.readJson(file));
29
+ const ageMs = Date.now() - new Date(entry.storedAt).getTime();
30
+ if (ageMs > ttlMs) {
31
+ // Expired — clean up lazily so the cache doesn't grow unbounded.
32
+ await fs.remove(file).catch(() => undefined);
33
+ return null;
34
+ }
35
+ return entry.text;
36
+ }
37
+ catch {
38
+ // Corrupt file — remove and treat as miss.
39
+ await fs.remove(file).catch(() => undefined);
40
+ return null;
41
+ }
42
+ }
43
+ export async function writeCache(target, model, prompt, text) {
44
+ const key = cacheKey(model, prompt);
45
+ const dir = path.join(target, CACHE_DIR_REL);
46
+ await fs.ensureDir(dir);
47
+ const entry = {
48
+ key,
49
+ model,
50
+ text,
51
+ storedAt: new Date().toISOString()
52
+ };
53
+ await fs.writeJson(path.join(dir, `${key}.json`), entry, { spaces: 2 });
54
+ }
55
+ export async function clearCache(target) {
56
+ const dir = path.join(target, CACHE_DIR_REL);
57
+ if (!(await fs.pathExists(dir)))
58
+ return 0;
59
+ const files = await fs.readdir(dir);
60
+ let removed = 0;
61
+ for (const f of files) {
62
+ if (f.endsWith(".json")) {
63
+ await fs.remove(path.join(dir, f));
64
+ removed += 1;
65
+ }
66
+ }
67
+ return removed;
68
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Copy text to the system clipboard without an npm dependency.
3
+ *
4
+ * We try in order: pbcopy (macOS), clip.exe (Windows / WSL), wl-copy
5
+ * (Wayland), xclip (X11), xsel (X11). If none works the function
6
+ * returns false; the caller should then print the text so the user can
7
+ * still copy it manually.
8
+ *
9
+ * Reasoning: every clipboard npm package adds a dependency and forces
10
+ * the user to think about install. For a CLI tool whose hot path is
11
+ * "generate a prompt the user pastes elsewhere", a 50-line shell-out
12
+ * approach is more robust and has zero install cost.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ function platformStrategies() {
16
+ const p = process.platform;
17
+ if (p === "darwin")
18
+ return [{ cmd: "pbcopy", args: [] }];
19
+ if (p === "win32")
20
+ return [{ cmd: "clip.exe", args: [] }];
21
+ // Linux / *nix: try Wayland first, then X11.
22
+ return [
23
+ { cmd: "wl-copy", args: [] },
24
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
25
+ { cmd: "xsel", args: ["--clipboard", "--input"] }
26
+ ];
27
+ }
28
+ async function tryCopy(strategy, text) {
29
+ return new Promise((resolve) => {
30
+ let settled = false;
31
+ const finish = (ok) => {
32
+ if (settled)
33
+ return;
34
+ settled = true;
35
+ resolve(ok);
36
+ };
37
+ try {
38
+ const child = spawn(strategy.cmd, strategy.args, { stdio: ["pipe", "ignore", "ignore"] });
39
+ child.on("error", () => finish(false));
40
+ child.on("close", (code) => finish(code === 0));
41
+ child.stdin.write(text);
42
+ child.stdin.end();
43
+ }
44
+ catch {
45
+ finish(false);
46
+ }
47
+ });
48
+ }
49
+ export async function copyToClipboard(text) {
50
+ for (const strat of platformStrategies()) {
51
+ const ok = await tryCopy(strat, text);
52
+ if (ok)
53
+ return true;
54
+ }
55
+ return false;
56
+ }