@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.2

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 (68) hide show
  1. package/README.md +33 -0
  2. package/assets/pugi-mascot.ansi +41 -0
  3. package/dist/commands/deploy.js +439 -0
  4. package/dist/core/agents/loader.js +104 -0
  5. package/dist/core/agents/registry.js +1 -1
  6. package/dist/core/consensus/anvil-fanout.js +276 -0
  7. package/dist/core/consensus/diff-capture.js +382 -0
  8. package/dist/core/consensus/rubric.js +233 -0
  9. package/dist/core/context/index.js +21 -0
  10. package/dist/core/context/pugiignore.js +316 -0
  11. package/dist/core/context/repo-skeleton.js +533 -0
  12. package/dist/core/context/watcher.js +342 -0
  13. package/dist/core/context/working-set.js +165 -0
  14. package/dist/core/edits/dispatch.js +185 -0
  15. package/dist/core/edits/index.js +15 -0
  16. package/dist/core/edits/layer-a-apply.js +217 -0
  17. package/dist/core/edits/layer-b-apply.js +211 -0
  18. package/dist/core/edits/layer-c-apply.js +160 -0
  19. package/dist/core/edits/layer-d-ast.js +29 -0
  20. package/dist/core/edits/marker-parser.js +401 -0
  21. package/dist/core/edits/security-gate.js +223 -0
  22. package/dist/core/edits/worktree.js +229 -0
  23. package/dist/core/engine/native-pugi.js +6 -1
  24. package/dist/core/engine/prompts.js +4 -1
  25. package/dist/core/engine/tool-bridge.js +33 -1
  26. package/dist/core/lsp/client.js +631 -0
  27. package/dist/core/repl/ask.js +512 -0
  28. package/dist/core/repl/cancellation.js +98 -0
  29. package/dist/core/repl/dispatch-fsm.js +220 -0
  30. package/dist/core/repl/privacy-banner.js +71 -0
  31. package/dist/core/repl/session.js +1896 -13
  32. package/dist/core/repl/slash-commands.js +59 -32
  33. package/dist/core/repl/store/index.js +12 -0
  34. package/dist/core/repl/store/jsonl-log.js +321 -0
  35. package/dist/core/repl/store/lockfile.js +155 -0
  36. package/dist/core/repl/store/session-store.js +792 -0
  37. package/dist/core/repl/store/types.js +44 -0
  38. package/dist/core/repl/store/uuid-v7.js +68 -0
  39. package/dist/core/repl/workspace-context.js +72 -1
  40. package/dist/core/skills/loader.js +454 -0
  41. package/dist/core/skills/sources.js +480 -0
  42. package/dist/core/skills/trust.js +172 -0
  43. package/dist/runtime/cli.js +767 -10
  44. package/dist/runtime/commands/agents.js +385 -0
  45. package/dist/runtime/commands/config.js +338 -8
  46. package/dist/runtime/commands/lsp.js +184 -0
  47. package/dist/runtime/commands/patch.js +111 -0
  48. package/dist/runtime/commands/review-consensus.js +399 -0
  49. package/dist/runtime/commands/skills.js +401 -0
  50. package/dist/runtime/commands/worktree.js +133 -0
  51. package/dist/tools/apply-patch.js +314 -0
  52. package/dist/tools/file-tools.js +90 -0
  53. package/dist/tools/lsp-tools.js +189 -0
  54. package/dist/tools/registry.js +18 -0
  55. package/dist/tools/web-fetch.js +1 -1
  56. package/dist/tui/agent-tree-pane.js +9 -0
  57. package/dist/tui/ask-cli.js +52 -0
  58. package/dist/tui/ask-modal.js +211 -0
  59. package/dist/tui/conversation-pane.js +48 -3
  60. package/dist/tui/input-box.js +48 -5
  61. package/dist/tui/markdown-render.js +266 -0
  62. package/dist/tui/repl-render.js +185 -0
  63. package/dist/tui/repl-splash-mascot.js +130 -0
  64. package/dist/tui/repl-splash.js +7 -1
  65. package/dist/tui/repl.js +82 -11
  66. package/dist/tui/status-bar.js +63 -3
  67. package/dist/tui/tool-stream-pane.js +91 -0
  68. package/package.json +11 -5
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Tier 0 repo skeleton builder - α6.5 Phase 1 (three-tier context).
3
+ *
4
+ * The skeleton is the ~5KB "always loaded" tier in the three-tier
5
+ * model. Goal: give the agent enough structural awareness to navigate
6
+ * an unfamiliar repo on the first turn without uploading the whole
7
+ * tree. We capture:
8
+ *
9
+ * - cwd + current branch (best-effort, no exec)
10
+ * - detected package manager (lockfile heuristic)
11
+ * - primary languages (file-extension histogram, top 5)
12
+ * - ASCII directory tree (depth <= MAX_TREE_DEPTH, collapses busy
13
+ * dirs to a "(N dirs, M files)" line)
14
+ * - package.json projection (name/version/scripts/deps)
15
+ * - first MAX_README_LINES of README.md
16
+ *
17
+ * Hard constraints:
18
+ *
19
+ * 1. **5KB cap on the rendered string**. We render in priority
20
+ * order (headers + meta first, then tree, then package.json, then
21
+ * README) and truncate the tail with a "..." marker rather than
22
+ * omitting fields. The walker's bound is independent: it stops
23
+ * visiting dirs after a hard `MAX_WALK_NODES` even when the
24
+ * skeleton would still fit.
25
+ *
26
+ * 2. **Ignore-aware**: every fs read is filtered through `PugiIgnore`
27
+ * so `.env`, `*.pem`, `node_modules/`, etc. stay out. The walker
28
+ * uses the same matcher so `node_modules/` is never expanded.
29
+ *
30
+ * 3. **No exec**: we read `.git/HEAD` for the branch, never spawn
31
+ * `git`. The skeleton is built on session bootstrap; spawning
32
+ * git on every launch is slow + makes the CLI flaky on hosts
33
+ * without git installed.
34
+ *
35
+ * 4. **Best-effort**: every FS read is wrapped in try/catch.
36
+ * Missing README / missing package.json / unreadable file -> the
37
+ * field is omitted but the skeleton still builds.
38
+ */
39
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
40
+ import { basename, join, resolve } from 'node:path';
41
+ /** Hard cap on the rendered skeleton string. Spec: ~5KB. */
42
+ export const MAX_SKELETON_BYTES = 5_000;
43
+ /** Maximum depth the ASCII tree descends. */
44
+ export const MAX_TREE_DEPTH = 3;
45
+ /** Maximum nodes the walker visits before giving up. Guards giant repos. */
46
+ export const MAX_WALK_NODES = 5_000;
47
+ /** Maximum README lines copied into the skeleton. Spec: ~200. */
48
+ export const MAX_README_LINES = 200;
49
+ /** When a single dir holds more than this many entries we collapse to a count. */
50
+ export const COLLAPSE_DIR_ENTRIES = 20;
51
+ /** Top-N languages reported. */
52
+ export const TOP_LANGUAGES = 5;
53
+ /**
54
+ * Walk the workspace, collect signals, render the skeleton. Returns a
55
+ * structured `RepoSkeleton` whose `dirTree` is already trimmed to fit
56
+ * `MAX_SKELETON_BYTES` once rendered.
57
+ */
58
+ export function buildRepoSkeleton(cwd, options) {
59
+ const normalised = resolve(cwd);
60
+ const ignore = options.ignore;
61
+ const branch = readGitBranch(normalised);
62
+ // Pass the ignore matcher through to both readers so an operator who
63
+ // explicitly added README.md or package.json to .pugiignore (private
64
+ // contracts, customer-specific data, NDA'd boilerplate) keeps those
65
+ // files out of the agent context. Without this gate, the direct
66
+ // skeleton reads would BYPASS the matcher even though the walker
67
+ // honours it. triple-review P1 (PR #380).
68
+ const pkg = readPackageJson(normalised, ignore);
69
+ const packageManager = detectPackageManager(normalised, ignore);
70
+ const walk = walkTree(normalised, ignore);
71
+ const primaryLanguages = topLanguages(walk.extensionHistogram, TOP_LANGUAGES);
72
+ const dirTree = renderTree(walk.root);
73
+ const readmeExcerpt = readReadme(normalised, options.readmePath, ignore);
74
+ const skeleton = {
75
+ cwd: normalised,
76
+ branch,
77
+ packageManager,
78
+ primaryLanguages,
79
+ dirTree,
80
+ packageJson: pkg,
81
+ readmeExcerpt,
82
+ totalSize: 0,
83
+ };
84
+ const rendered = renderSkeleton(skeleton);
85
+ return Object.freeze({ ...skeleton, totalSize: Buffer.byteLength(rendered, 'utf8') });
86
+ }
87
+ /**
88
+ * Render the skeleton as a single string for injection into the agent
89
+ * system prompt. Sections are emitted in priority order; once the
90
+ * cumulative byte count would exceed `MAX_SKELETON_BYTES` we drop the
91
+ * remaining sections in reverse priority and emit a `...truncated...`
92
+ * marker so the model knows the skeleton was clipped.
93
+ *
94
+ * Section order (highest priority first):
95
+ *
96
+ * 1. Meta header (cwd / branch / package manager / languages)
97
+ * 2. Directory tree
98
+ * 3. package.json projection (name / version / scripts / deps count)
99
+ * 4. README excerpt
100
+ *
101
+ * Each lower-priority section is appended only when there is room for
102
+ * its FULL body. Partial sections would mislead the model (e.g. a
103
+ * half-README scriptkiddies a wrong project description).
104
+ */
105
+ export function renderSkeleton(skeleton) {
106
+ const header = renderHeader(skeleton);
107
+ const tree = renderTreeSection(skeleton);
108
+ const pkg = renderPackageJsonSection(skeleton);
109
+ const readme = renderReadmeSection(skeleton);
110
+ // Always include the header + tree even when they push past cap;
111
+ // those are the load-bearing signals. The package.json + readme are
112
+ // dropped from the tail if there is no room.
113
+ const required = [header, tree].join('\n');
114
+ const optional = [pkg, readme].filter((s) => s.length > 0);
115
+ let out = required;
116
+ for (const section of optional) {
117
+ const candidate = `${out}\n${section}`;
118
+ if (Buffer.byteLength(candidate, 'utf8') > MAX_SKELETON_BYTES) {
119
+ out = `${out}\n...truncated to fit ${MAX_SKELETON_BYTES} byte cap...`;
120
+ return out;
121
+ }
122
+ out = candidate;
123
+ }
124
+ // Final tail-trim: if even the required sections breached the cap
125
+ // (huge repo with a wide top-level tree), clip the rendered string
126
+ // and stamp a truncation marker.
127
+ if (Buffer.byteLength(out, 'utf8') > MAX_SKELETON_BYTES) {
128
+ const buf = Buffer.from(out, 'utf8');
129
+ const slice = buf.subarray(0, MAX_SKELETON_BYTES - 64).toString('utf8');
130
+ out = `${slice}\n...truncated to fit ${MAX_SKELETON_BYTES} byte cap...`;
131
+ }
132
+ return out;
133
+ }
134
+ /* ------------------------------------------------------------------ */
135
+ /* Section renderers */
136
+ /* ------------------------------------------------------------------ */
137
+ function renderHeader(skeleton) {
138
+ const lines = [];
139
+ lines.push('# Repo skeleton (Tier 0)');
140
+ lines.push(`cwd: ${skeleton.cwd}`);
141
+ if (skeleton.branch)
142
+ lines.push(`branch: ${skeleton.branch}`);
143
+ if (skeleton.packageManager)
144
+ lines.push(`packageManager: ${skeleton.packageManager}`);
145
+ if (skeleton.primaryLanguages.length > 0) {
146
+ lines.push(`languages: ${skeleton.primaryLanguages.join(', ')}`);
147
+ }
148
+ return lines.join('\n');
149
+ }
150
+ function renderTreeSection(skeleton) {
151
+ if (!skeleton.dirTree || skeleton.dirTree.length === 0)
152
+ return '';
153
+ return `\n## tree\n${skeleton.dirTree}`;
154
+ }
155
+ function renderPackageJsonSection(skeleton) {
156
+ const pkg = skeleton.packageJson;
157
+ if (!pkg)
158
+ return '';
159
+ const lines = ['\n## package.json'];
160
+ if (pkg.name)
161
+ lines.push(`name: ${pkg.name}`);
162
+ if (pkg.version)
163
+ lines.push(`version: ${pkg.version}`);
164
+ if (pkg.scripts) {
165
+ const names = Object.keys(pkg.scripts).slice(0, 12);
166
+ if (names.length > 0)
167
+ lines.push(`scripts: ${names.join(', ')}`);
168
+ }
169
+ if (pkg.dependencies) {
170
+ const depCount = Object.keys(pkg.dependencies).length;
171
+ if (depCount > 0)
172
+ lines.push(`dependencies: ${depCount}`);
173
+ }
174
+ if (pkg.devDependencies) {
175
+ const depCount = Object.keys(pkg.devDependencies).length;
176
+ if (depCount > 0)
177
+ lines.push(`devDependencies: ${depCount}`);
178
+ }
179
+ return lines.join('\n');
180
+ }
181
+ function renderReadmeSection(skeleton) {
182
+ if (!skeleton.readmeExcerpt)
183
+ return '';
184
+ return `\n## README (first ${MAX_README_LINES} lines)\n${skeleton.readmeExcerpt}`;
185
+ }
186
+ /* ------------------------------------------------------------------ */
187
+ /* Signal readers */
188
+ /* ------------------------------------------------------------------ */
189
+ /**
190
+ * Read the current branch from `.git/HEAD` WITHOUT spawning git. The
191
+ * file is either a ref pointer (`ref: refs/heads/main`) or a detached
192
+ * SHA. We surface the branch name when the pointer parses, otherwise
193
+ * the 8-char SHA prefix. Returns undefined on any error (not a git
194
+ * repo, permissions issue, malformed HEAD).
195
+ */
196
+ export function readGitBranch(cwd) {
197
+ try {
198
+ const headPath = join(cwd, '.git', 'HEAD');
199
+ if (!existsSync(headPath))
200
+ return undefined;
201
+ const raw = readFileSync(headPath, 'utf8').trim();
202
+ const refMatch = /^ref:\s+refs\/heads\/(.+)$/.exec(raw);
203
+ if (refMatch)
204
+ return refMatch[1];
205
+ // Detached HEAD - raw is a 40-char SHA.
206
+ if (/^[0-9a-f]{7,40}$/i.test(raw))
207
+ return raw.slice(0, 8);
208
+ return undefined;
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ }
214
+ /**
215
+ * Detect the package manager by looking for canonical lockfiles.
216
+ * Preference order matches the npm ecosystem convention (pnpm > yarn
217
+ * > bun > npm when multiple lockfiles co-exist, which is itself a
218
+ * smell but we pick the most-likely-correct one).
219
+ */
220
+ export function detectPackageManager(cwd, ignore) {
221
+ try {
222
+ if (existsSync(join(cwd, 'pnpm-lock.yaml')))
223
+ return 'pnpm';
224
+ if (existsSync(join(cwd, 'yarn.lock')))
225
+ return 'yarn';
226
+ if (existsSync(join(cwd, 'bun.lockb')))
227
+ return 'bun';
228
+ if (existsSync(join(cwd, 'package-lock.json')))
229
+ return 'npm';
230
+ // Fall back to packageManager field in package.json when no
231
+ // lockfile is committed (e.g. a fresh monorepo skeleton).
232
+ // Codex R2 P2: honour .pugiignore here too — without this, an
233
+ // ignored package.json still leaks its packageManager value into
234
+ // the skeleton via this fallback path.
235
+ const pkgPath = join(cwd, 'package.json');
236
+ if (ignore?.isIgnored(pkgPath))
237
+ return undefined;
238
+ const pkgRaw = safeRead(pkgPath);
239
+ if (!pkgRaw)
240
+ return undefined;
241
+ const parsed = JSON.parse(pkgRaw);
242
+ if (typeof parsed.packageManager === 'string') {
243
+ const head = parsed.packageManager.split('@')[0];
244
+ if (head === 'npm' || head === 'yarn' || head === 'pnpm' || head === 'bun') {
245
+ return head;
246
+ }
247
+ }
248
+ return undefined;
249
+ }
250
+ catch {
251
+ return undefined;
252
+ }
253
+ }
254
+ /**
255
+ * Read + project the `package.json`. We strip everything except the
256
+ * fields the agent benefits from: name / version / scripts /
257
+ * dependencies / devDependencies. The full file may be megabytes (deep
258
+ * dependency trees, embedded changelogs); the projection caps the
259
+ * payload at a few hundred bytes.
260
+ */
261
+ export function readPackageJson(cwd, ignore) {
262
+ const pkgPath = join(cwd, 'package.json');
263
+ // Honour the ignore matcher when supplied. Operators who add
264
+ // package.json to .pugiignore (private template projects, customer
265
+ // boilerplate under NDA) must not have it read into the agent
266
+ // context even though the walker would have skipped it.
267
+ // triple-review P1 (PR #380). The matcher is optional so direct callers
268
+ // (e.g. unit tests of readPackageJson in isolation) keep working.
269
+ if (ignore?.isIgnored(pkgPath))
270
+ return undefined;
271
+ const raw = safeRead(pkgPath);
272
+ if (!raw)
273
+ return undefined;
274
+ try {
275
+ const parsed = JSON.parse(raw);
276
+ const projection = {
277
+ name: typeof parsed.name === 'string' ? parsed.name : undefined,
278
+ version: typeof parsed.version === 'string' ? parsed.version : undefined,
279
+ scripts: pickStringMap(parsed.scripts),
280
+ dependencies: pickStringMap(parsed.dependencies),
281
+ devDependencies: pickStringMap(parsed.devDependencies),
282
+ };
283
+ return projection;
284
+ }
285
+ catch {
286
+ return undefined;
287
+ }
288
+ }
289
+ /**
290
+ * Read the first `MAX_README_LINES` of README.md (case-insensitive
291
+ * match, since macOS / Windows are case-insensitive but POSIX is
292
+ * case-sensitive). Returns undefined when no README is present.
293
+ */
294
+ export function readReadme(cwd, overridePath, ignore) {
295
+ const candidates = overridePath
296
+ ? [overridePath]
297
+ : ['README.md', 'readme.md', 'README', 'README.markdown'].map((n) => join(cwd, n));
298
+ for (const candidate of candidates) {
299
+ // Skip candidates the operator has explicitly ignored. A workspace
300
+ // README that documents private contracts / customer data must
301
+ // never land in the rendered skeleton. triple-review P1 (PR #380).
302
+ if (ignore?.isIgnored(candidate))
303
+ continue;
304
+ const raw = safeRead(candidate);
305
+ if (!raw)
306
+ continue;
307
+ const lines = raw.split(/\r?\n/).slice(0, MAX_README_LINES);
308
+ return lines.join('\n').trim();
309
+ }
310
+ return undefined;
311
+ }
312
+ /**
313
+ * Walk the workspace once. Returns a depth-bounded tree, a histogram
314
+ * of file extensions (drives `primaryLanguages`), and the total
315
+ * number of nodes visited (used to short-circuit on huge repos).
316
+ *
317
+ * The walker honours `ignore.isIgnored` at EVERY entry so even
318
+ * non-default ignores from `.gitignore` / `.pugiignore` stay out of
319
+ * the tree AND out of the extension histogram. This matters because
320
+ * a huge `node_modules/` would otherwise dominate the language signal
321
+ * with `.json` / `.md` / `.d.ts`.
322
+ */
323
+ function walkTree(cwd, ignore) {
324
+ const extensionHistogram = new Map();
325
+ let visited = 0;
326
+ function walk(absPath, depth, name) {
327
+ visited += 1;
328
+ if (visited > MAX_WALK_NODES) {
329
+ return Object.freeze({
330
+ name,
331
+ children: [],
332
+ fileCount: 0,
333
+ dirCount: 0,
334
+ collapsed: true,
335
+ });
336
+ }
337
+ let entries;
338
+ try {
339
+ entries = readdirSync(absPath, { withFileTypes: true });
340
+ }
341
+ catch {
342
+ return Object.freeze({ name, children: [], fileCount: 0, dirCount: 0, collapsed: false });
343
+ }
344
+ // Sort alphabetically so the rendered tree is stable.
345
+ entries.sort((a, b) => a.name.localeCompare(b.name));
346
+ let fileCount = 0;
347
+ let dirCount = 0;
348
+ const dirs = [];
349
+ for (const entry of entries) {
350
+ const childAbs = join(absPath, entry.name);
351
+ // Pass the isDir hint so gitignore-style dir patterns
352
+ // (`node_modules/`, `dist/`) drop the dir itself, not just its
353
+ // children. Without this, the walker descends into node_modules
354
+ // and the tree leaks paths the agent should never see.
355
+ if (ignore.isIgnored(childAbs, entry.isDirectory()))
356
+ continue;
357
+ if (entry.isFile()) {
358
+ fileCount += 1;
359
+ const ext = languageForExtension(entry.name);
360
+ if (ext)
361
+ extensionHistogram.set(ext, (extensionHistogram.get(ext) ?? 0) + 1);
362
+ }
363
+ else if (entry.isDirectory()) {
364
+ dirCount += 1;
365
+ dirs.push(entry);
366
+ }
367
+ }
368
+ if (depth >= MAX_TREE_DEPTH) {
369
+ return Object.freeze({ name, children: [], fileCount, dirCount, collapsed: dirCount > 0 });
370
+ }
371
+ // Collapse very busy dirs to keep the rendered tree under cap.
372
+ if (dirs.length > COLLAPSE_DIR_ENTRIES) {
373
+ return Object.freeze({ name, children: [], fileCount, dirCount, collapsed: true });
374
+ }
375
+ const children = [];
376
+ for (const dir of dirs) {
377
+ children.push(walk(join(absPath, dir.name), depth + 1, dir.name));
378
+ }
379
+ return Object.freeze({ name, children, fileCount, dirCount, collapsed: false });
380
+ }
381
+ const root = walk(cwd, 0, basename(cwd) || cwd);
382
+ return { root, extensionHistogram, visitedCount: visited };
383
+ }
384
+ /**
385
+ * Render a `DirNode` as a UTF-8 ASCII-tree string. Lines use the
386
+ * Claude Code convention: `├──` / `└──` glyphs, two-space indents per
387
+ * level. Collapsed dirs land as `name/ (N dirs, M files)`.
388
+ */
389
+ function renderTree(root) {
390
+ const lines = [];
391
+ lines.push(`${root.name}/${counts(root)}`);
392
+ appendChildren(root, '', lines);
393
+ return lines.join('\n');
394
+ }
395
+ function counts(node) {
396
+ if (!node.collapsed && node.children.length > 0)
397
+ return '';
398
+ // Surface counts on either a collapsed dir or a leaf-depth dir with
399
+ // nested content we did not descend into.
400
+ if (node.dirCount === 0 && node.fileCount === 0)
401
+ return '';
402
+ return ` (${node.dirCount} dirs, ${node.fileCount} files)`;
403
+ }
404
+ function appendChildren(node, prefix, lines) {
405
+ const children = node.children;
406
+ for (let i = 0; i < children.length; i += 1) {
407
+ const child = children[i];
408
+ const isLast = i === children.length - 1;
409
+ const glyph = isLast ? '└── ' : '├── ';
410
+ lines.push(`${prefix}${glyph}${child.name}/${counts(child)}`);
411
+ const nextPrefix = `${prefix}${isLast ? ' ' : '│ '}`;
412
+ appendChildren(child, nextPrefix, lines);
413
+ }
414
+ }
415
+ /* ------------------------------------------------------------------ */
416
+ /* Language histogram */
417
+ /* ------------------------------------------------------------------ */
418
+ /**
419
+ * Map a filename to a human-readable language label. Returns null for
420
+ * extensions we do not care to surface (e.g. `.json` is interesting
421
+ * but contributes to the histogram as `json`; `.txt` is dropped).
422
+ *
423
+ * The mapping is conservative; we only label extensions the agent's
424
+ * top-of-stack persona is likely to act on. Unknown extensions return
425
+ * null so the histogram is dominated by signal rather than noise.
426
+ */
427
+ export function languageForExtension(filename) {
428
+ const dotIndex = filename.lastIndexOf('.');
429
+ if (dotIndex <= 0)
430
+ return null;
431
+ const ext = filename.slice(dotIndex + 1).toLowerCase();
432
+ const known = {
433
+ ts: 'TypeScript',
434
+ tsx: 'TypeScript',
435
+ mts: 'TypeScript',
436
+ cts: 'TypeScript',
437
+ js: 'JavaScript',
438
+ jsx: 'JavaScript',
439
+ mjs: 'JavaScript',
440
+ cjs: 'JavaScript',
441
+ py: 'Python',
442
+ rb: 'Ruby',
443
+ go: 'Go',
444
+ rs: 'Rust',
445
+ java: 'Java',
446
+ kt: 'Kotlin',
447
+ swift: 'Swift',
448
+ c: 'C',
449
+ h: 'C',
450
+ cpp: 'C++',
451
+ hpp: 'C++',
452
+ cc: 'C++',
453
+ cs: 'C#',
454
+ php: 'PHP',
455
+ scala: 'Scala',
456
+ ex: 'Elixir',
457
+ exs: 'Elixir',
458
+ erl: 'Erlang',
459
+ clj: 'Clojure',
460
+ sh: 'Shell',
461
+ bash: 'Shell',
462
+ zsh: 'Shell',
463
+ html: 'HTML',
464
+ css: 'CSS',
465
+ scss: 'CSS',
466
+ sass: 'CSS',
467
+ less: 'CSS',
468
+ vue: 'Vue',
469
+ svelte: 'Svelte',
470
+ md: 'Markdown',
471
+ mdx: 'Markdown',
472
+ json: 'JSON',
473
+ yaml: 'YAML',
474
+ yml: 'YAML',
475
+ toml: 'TOML',
476
+ sql: 'SQL',
477
+ graphql: 'GraphQL',
478
+ gql: 'GraphQL',
479
+ proto: 'Protobuf',
480
+ dockerfile: 'Dockerfile',
481
+ };
482
+ return known[ext] ?? null;
483
+ }
484
+ /**
485
+ * Return the top-N languages by file count. Ties are broken
486
+ * alphabetically so the output is stable across runs.
487
+ */
488
+ export function topLanguages(histogram, topN) {
489
+ const entries = Array.from(histogram.entries());
490
+ entries.sort((a, b) => {
491
+ if (b[1] !== a[1])
492
+ return b[1] - a[1];
493
+ return a[0].localeCompare(b[0]);
494
+ });
495
+ return entries.slice(0, topN).map(([lang]) => lang);
496
+ }
497
+ /* ------------------------------------------------------------------ */
498
+ /* Small helpers */
499
+ /* ------------------------------------------------------------------ */
500
+ function safeRead(path) {
501
+ // Stat first to keep the regular-file guard (Codex R2 P2): a FIFO or
502
+ // socket named like README.md would otherwise block readFileSync()
503
+ // indefinitely and hang the REPL bootstrap. The stat itself is in a
504
+ // try/catch so missing-file / EACCES still fail soft. The catch on
505
+ // readFileSync still handles the race-deleted-between-syscalls case
506
+ // (TOCTOU is acceptable here - worst case we return null instead of
507
+ // partial bytes, never partial bytes themselves).
508
+ try {
509
+ const st = statSync(path);
510
+ if (!st.isFile())
511
+ return null;
512
+ }
513
+ catch {
514
+ return null;
515
+ }
516
+ try {
517
+ return readFileSync(path, 'utf8');
518
+ }
519
+ catch {
520
+ return null;
521
+ }
522
+ }
523
+ function pickStringMap(value) {
524
+ if (!value || typeof value !== 'object')
525
+ return undefined;
526
+ const out = {};
527
+ for (const [k, v] of Object.entries(value)) {
528
+ if (typeof v === 'string')
529
+ out[k] = v;
530
+ }
531
+ return Object.keys(out).length > 0 ? out : undefined;
532
+ }
533
+ //# sourceMappingURL=repo-skeleton.js.map