@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.
- package/README.md +33 -0
- package/assets/pugi-mascot.ansi +41 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -0
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/edits/worktree.js +229 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/prompts.js +4 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/lsp/client.js +631 -0
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1896 -13
- package/dist/core/repl/slash-commands.js +59 -32
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/runtime/cli.js +767 -10
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/lsp.js +184 -0
- package/dist/runtime/commands/patch.js +111 -0
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/worktree.js +133 -0
- package/dist/tools/apply-patch.js +314 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/web-fetch.js +1 -1
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +185 -0
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +82 -11
- package/dist/tui/status-bar.js +63 -3
- package/dist/tui/tool-stream-pane.js +91 -0
- 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
|