@awilum/scope 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/bin/scope.js +612 -0
  4. package/package.json +59 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Sergey Romanenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,381 @@
1
+ # Scope
2
+
3
+ Scope is a minimal, dependency-free command-line utility for exploring software projects and building a clean, structured project context as plain text.
4
+
5
+ Scope runs locally, reads your project according to explicit rules, and produces deterministic text that can be copied into a chat, issue, review, documentation tool, or another text-based development environment.
6
+
7
+ ## Why Scope?
8
+
9
+ When working on an unfamiliar codebase, developers often need a quick answer to: _What is in this project, and what does the relevant source actually contain?_ Scope turns that task into one command.
10
+
11
+ ```bash
12
+ scope
13
+ ```
14
+
15
+ The default command builds `scope-context.txt`, containing project metadata, statistics, a complete readable tree, and the contents of recognized text files.
16
+
17
+ ## Requirements
18
+
19
+ - Node.js 18 or newer
20
+ - npm
21
+ - No runtime dependencies
22
+ - No network connection
23
+
24
+ ## Installation
25
+
26
+ Global installation from npm:
27
+
28
+ ```bash
29
+ npm install -g @awilum/scope
30
+ ```
31
+
32
+ Or from a local clone:
33
+
34
+ ```bash
35
+ git clone <repository>
36
+ cd scope
37
+ npm install -g .
38
+ ```
39
+
40
+ For local development, `npm link` is also supported:
41
+
42
+ ```bash
43
+ npm link
44
+ ```
45
+
46
+ Then verify:
47
+
48
+ ```bash
49
+ scope --version
50
+ ```
51
+
52
+ ## Quick start
53
+
54
+ From any project directory:
55
+
56
+ ```bash
57
+ cd my-project
58
+ scope init
59
+ scope tree
60
+ scope stats
61
+ scope inspect src/index.js
62
+ scope build
63
+ ```
64
+
65
+ The shortest workflow is simply:
66
+
67
+ ```bash
68
+ scope
69
+ ```
70
+
71
+ which is equivalent to `scope build`.
72
+
73
+ ## Commands
74
+
75
+ ### `scope`
76
+
77
+ Builds project context using the current directory and `.scope.json` if present.
78
+
79
+ ### `scope init`
80
+
81
+ Creates `.scope.json` with sensible defaults. Existing configuration is never overwritten.
82
+
83
+ ### `scope build`
84
+
85
+ Generates `scope-context.txt` by default.
86
+
87
+ The output contains:
88
+
89
+ 1. project metadata;
90
+ 2. statistics;
91
+ 3. project tree;
92
+ 4. recognized text-file contents.
93
+
94
+ File boundaries are explicit:
95
+
96
+ ```text
97
+ FILE: src/app.js
98
+ ------------------
99
+ import ...
100
+ ```
101
+
102
+ Generated context contains no ANSI terminal escape sequences and uses relative paths for file content.
103
+
104
+ ### `scope tree`
105
+
106
+ Displays a readable project tree.
107
+
108
+ ```bash
109
+ scope tree
110
+ scope tree 3
111
+ scope tree --depth 2
112
+ ```
113
+
114
+ ### `scope stats`
115
+
116
+ Displays file, directory, text/binary, size, line, skipped-file, and extension statistics.
117
+
118
+ ### `scope inspect <file>`
119
+
120
+ Displays metadata and the content of one recognized text file.
121
+
122
+ ```bash
123
+ scope inspect package.json
124
+ scope inspect src/index.js
125
+ ```
126
+
127
+ The path is resolved inside the current project root. Traversal outside the root is rejected.
128
+
129
+ ### `scope doctor`
130
+
131
+ Checks Node.js, the project root, configuration, include paths, and output path.
132
+
133
+ A missing `.scope.json` is normal: Scope simply uses defaults.
134
+
135
+ ### `scope help`
136
+
137
+ Prints command help.
138
+
139
+ ### `scope --version`
140
+
141
+ Prints the installed version.
142
+
143
+ ## JSON mode
144
+
145
+ The tree, statistics, inspect, and doctor commands support machine-readable output:
146
+
147
+ ```bash
148
+ scope tree --json
149
+ scope stats --json
150
+ scope inspect package.json --json
151
+ scope doctor --json
152
+ ```
153
+
154
+ JSON output contains no ANSI styling or progress messages and is suitable for scripts.
155
+
156
+ ## Configuration
157
+
158
+ Scope looks for `.scope.json` in the current project root.
159
+
160
+ Example:
161
+
162
+ ```json
163
+ {
164
+ "output": "scope-context.txt",
165
+ "include": ["."],
166
+ "exclude": ["node_modules", ".git", "dist", "build", "coverage"],
167
+ "excludeFiles": ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"],
168
+ "excludePaths": [],
169
+ "extensions": [".js", ".ts", ".tsx", ".json", ".md"],
170
+ "maxFileSize": 1048576
171
+ }
172
+ ```
173
+
174
+ ### `output`
175
+
176
+ Output filename for `scope build`. The output must remain inside the project root.
177
+
178
+ ### `include`
179
+
180
+ Relative files or directories to process. The default is `["."]`.
181
+
182
+ ### `exclude`
183
+
184
+ Directory names that are not traversed. Common generated and dependency directories are excluded by default.
185
+
186
+ ### `excludeFiles`
187
+
188
+ Exact filenames that are skipped.
189
+
190
+ ### `excludePaths`
191
+
192
+ Relative path prefixes that are skipped.
193
+
194
+ ### `extensions`
195
+
196
+ Recognized textual file extensions. Files without extensions such as `Dockerfile`, `Makefile`, `Procfile`, `LICENSE`, and `README` are recognized as well.
197
+
198
+ ### `maxFileSize`
199
+
200
+ Maximum file size read into context. The default is 1 MiB.
201
+
202
+ ## Security and privacy
203
+
204
+ Scope is local-only. It does not upload, transmit, or call an external service.
205
+
206
+ Sensitive files are skipped by default, including environment files and common private credentials such as `.pem`, `.key`, `.p12`, `.pfx`, `id_rsa`, `id_ed25519`, `credentials.json`, and `service-account.json`.
207
+
208
+ Binary files are never copied into generated context. Files larger than `maxFileSize` are skipped from content generation.
209
+
210
+ Configuration is an explicit trust boundary: if you intentionally add a sensitive file to an allowed configuration, you are responsible for that choice.
211
+
212
+ ## Generated context format
213
+
214
+ A typical context looks like:
215
+
216
+ ```text
217
+ PROJECT CONTEXT
218
+ ===============
219
+ Project: my-project
220
+ Root: /path/to/my-project
221
+ Generated: 2026-09-27
222
+
223
+ PROJECT STATISTICS
224
+ ==================
225
+ Files: 42
226
+ Directories: 9
227
+ Text files: 37
228
+ Binary files: 5
229
+ Total size: 482.0 KB
230
+ Context size: 310.2 KB
231
+ Lines: 8421
232
+
233
+ PROJECT TREE
234
+ ============
235
+ src/
236
+ config.js
237
+ index.js
238
+ package.json
239
+ README.md
240
+
241
+ FILES
242
+ =====
243
+ FILE: package.json
244
+ ------------------
245
+ { ... }
246
+
247
+ FILE: src/index.js
248
+ ------------------
249
+ import ...
250
+ ```
251
+
252
+ The representation is deliberately plain text, stable, and easy to copy.
253
+
254
+ ## Supported text formats
255
+
256
+ The default configuration recognizes common JavaScript, TypeScript, Python, Go, Rust, Java, Kotlin, Swift, C/C++, CSS, HTML, Vue, Svelte, Astro, JSON, YAML, XML, TOML, Markdown, shell, PHP, Ruby, SQL, GraphQL, SVG, and related formats.
257
+
258
+ The extension list is configurable.
259
+
260
+ ## Development
261
+
262
+ Run the CLI directly without installing it globally:
263
+
264
+ ```bash
265
+ node bin/scope.js --version
266
+ node bin/scope.js help
267
+ ```
268
+
269
+ Check syntax:
270
+
271
+ ```bash
272
+ npm run check
273
+ ```
274
+
275
+ Test the CLI in the repository:
276
+
277
+ ```bash
278
+ node bin/scope.js doctor
279
+ node bin/scope.js tree
280
+ node bin/scope.js stats
281
+ node bin/scope.js inspect package.json
282
+ node bin/scope.js build
283
+ ```
284
+
285
+ Then test the global command:
286
+
287
+ ```bash
288
+ npm install -g .
289
+ scope --version
290
+ ```
291
+
292
+ Or:
293
+
294
+ ```bash
295
+ npm link
296
+ scope --version
297
+ ```
298
+
299
+ ## Publishing to npm
300
+
301
+ After choosing and confirming an available package name and authenticating with npm:
302
+
303
+ ```bash
304
+ npm publish --access public
305
+ ```
306
+
307
+ For the scoped package name used by this repository:
308
+
309
+ ```bash
310
+ npm install -g @awilum/scope
311
+ ```
312
+
313
+ ## GitHub usage
314
+
315
+ The repository is intentionally small and can be inspected without learning a framework. A typical first commit is:
316
+
317
+ ```bash
318
+ git init
319
+ git add .
320
+ git commit -m "Initial release"
321
+ git remote add origin <repository>
322
+ git push -u origin main
323
+ ```
324
+
325
+ The repository intentionally excludes `node_modules`, generated context files, logs, and macOS metadata.
326
+
327
+ ## Troubleshooting
328
+
329
+ ### `scope: command not found`
330
+
331
+ Confirm that the global npm bin directory is on your `PATH`, then reinstall:
332
+
333
+ ```bash
334
+ npm install -g .
335
+ ```
336
+
337
+ ### Configuration errors
338
+
339
+ Run:
340
+
341
+ ```bash
342
+ scope doctor
343
+ ```
344
+
345
+ Fix the reported `.scope.json` values and run the command again.
346
+
347
+ ### A file is missing from context
348
+
349
+ Check whether it is:
350
+
351
+ - excluded by directory name;
352
+ - listed in `excludeFiles` or `excludePaths`;
353
+ - a recognized text extension;
354
+ - larger than `maxFileSize`;
355
+ - recognized as a sensitive file.
356
+
357
+ Use `scope stats` to see skipped counts.
358
+
359
+ ### A file is outside the configured root
360
+
361
+ Scope intentionally refuses `inspect` paths that resolve outside the current project root.
362
+
363
+ ## Project structure
364
+
365
+ ```text
366
+ scope/
367
+ ├── bin/
368
+ │ └── scope.js
369
+ ├── examples/
370
+ ├── .gitignore
371
+ ├── .scope.json.example
372
+ ├── LICENSE
373
+ ├── package.json
374
+ └── README.md
375
+ ```
376
+
377
+ The implementation intentionally stays concentrated in one CLI file. Scope is a utility, not a framework.
378
+
379
+ ## License
380
+
381
+ MIT. See `LICENSE`.
package/bin/scope.js ADDED
@@ -0,0 +1,612 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import process from "node:process";
6
+
7
+ const VERSION = "1.0.0";
8
+ const DEFAULT_CONFIG = {
9
+ output: "scope-context.txt",
10
+ include: ["."],
11
+ exclude: [
12
+ "node_modules",
13
+ ".git",
14
+ "dist",
15
+ "build",
16
+ "coverage",
17
+ ".cache",
18
+ ".next",
19
+ ".vite",
20
+ ".venv",
21
+ "__pycache__",
22
+ ".idea",
23
+ ".vscode",
24
+ ".DS_Store",
25
+ ],
26
+ excludeFiles: ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"],
27
+ excludePaths: [],
28
+ extensions: [
29
+ ".js",
30
+ ".jsx",
31
+ ".mjs",
32
+ ".cjs",
33
+ ".ts",
34
+ ".tsx",
35
+ ".py",
36
+ ".go",
37
+ ".rs",
38
+ ".java",
39
+ ".kt",
40
+ ".swift",
41
+ ".c",
42
+ ".cpp",
43
+ ".h",
44
+ ".hpp",
45
+ ".css",
46
+ ".scss",
47
+ ".sass",
48
+ ".less",
49
+ ".html",
50
+ ".htm",
51
+ ".vue",
52
+ ".svelte",
53
+ ".astro",
54
+ ".json",
55
+ ".jsonc",
56
+ ".yaml",
57
+ ".yml",
58
+ ".xml",
59
+ ".toml",
60
+ ".ini",
61
+ ".env.example",
62
+ ".md",
63
+ ".mdx",
64
+ ".txt",
65
+ ".sh",
66
+ ".bash",
67
+ ".zsh",
68
+ ".php",
69
+ ".rb",
70
+ ".sql",
71
+ ".graphql",
72
+ ".gql",
73
+ ".svg",
74
+ ],
75
+ maxFileSize: 1048576,
76
+ };
77
+ const SPECIAL = new Set([
78
+ "Dockerfile",
79
+ "Makefile",
80
+ "Procfile",
81
+ "LICENSE",
82
+ "README",
83
+ "README.md",
84
+ "README.txt",
85
+ ".scope.json",
86
+ ".gitignore",
87
+ ]);
88
+ const SECRET_NAMES = new Set([
89
+ ".env",
90
+ ".env.local",
91
+ ".env.production",
92
+ ".env.development",
93
+ "credentials.json",
94
+ "service-account.json",
95
+ "id_rsa",
96
+ "id_ed25519",
97
+ ]);
98
+ const SECRET_EXTENSIONS = new Set([".pem", ".key", ".p12", ".pfx"]);
99
+
100
+ const ansi = (code, s) =>
101
+ process.stdout.isTTY ? `\x1b[${code}m${s}\x1b[0m` : s;
102
+ const bold = (s) => ansi(1, s);
103
+ const dim = (s) => ansi(2, s);
104
+ const green = (s) => ansi(32, s);
105
+ const red = (s) => ansi(31, s);
106
+ const yellow = (s) => ansi(33, s);
107
+ const cyan = (s) => ansi(36, s);
108
+ const fail = (message, code = 1) => {
109
+ console.error(`${red("✗")} ${message}`);
110
+ process.exitCode = code;
111
+ };
112
+ const humanSize = (bytes) => {
113
+ const units = ["B", "KB", "MB", "GB"];
114
+ let n = bytes,
115
+ i = 0;
116
+ while (n >= 1024 && i < units.length - 1) {
117
+ n /= 1024;
118
+ i++;
119
+ }
120
+ return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
121
+ };
122
+ const rel = (root, p) => path.relative(root, p) || ".";
123
+
124
+ function parseArgs(argv) {
125
+ const args = { _: [], json: false, depth: null };
126
+ for (let i = 0; i < argv.length; i++) {
127
+ const a = argv[i];
128
+ if (a === "--json") args.json = true;
129
+ else if (a === "--help" || a === "-h") args.help = true;
130
+ else if (a === "--version" || a === "-v") args.version = true;
131
+ else if (a === "--depth") {
132
+ const v = argv[++i];
133
+ args.depth = Number(v);
134
+ } else if (a.startsWith("--depth=")) args.depth = Number(a.slice(8));
135
+ else args._.push(a);
136
+ }
137
+ return args;
138
+ }
139
+
140
+ function loadConfig(root) {
141
+ const file = path.join(root, ".scope.json");
142
+ if (!fs.existsSync(file))
143
+ return { config: { ...DEFAULT_CONFIG }, exists: false, error: null };
144
+ try {
145
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
146
+ const c = { ...DEFAULT_CONFIG, ...raw };
147
+ if (
148
+ !Array.isArray(c.include) ||
149
+ !Array.isArray(c.exclude) ||
150
+ !Array.isArray(c.excludeFiles) ||
151
+ !Array.isArray(c.excludePaths) ||
152
+ !Array.isArray(c.extensions)
153
+ )
154
+ throw new Error(
155
+ "include, exclude, excludeFiles, excludePaths and extensions must be arrays",
156
+ );
157
+ if (typeof c.output !== "string" || !c.output.trim())
158
+ throw new Error("output must be a non-empty string");
159
+ if (!Number.isInteger(c.maxFileSize) || c.maxFileSize <= 0)
160
+ throw new Error("maxFileSize must be a positive integer");
161
+ return { config: c, exists: true, error: null };
162
+ } catch (e) {
163
+ return { config: { ...DEFAULT_CONFIG }, exists: true, error: e.message };
164
+ }
165
+ }
166
+
167
+ function isSecret(relPath) {
168
+ const normalized = relPath.split(path.sep).join("/");
169
+ const base = path.basename(normalized);
170
+ if (SECRET_NAMES.has(base)) return true;
171
+ if ([...SECRET_EXTENSIONS].some((ext) => base.endsWith(ext))) return true;
172
+ if (base.startsWith(".env.") && base !== ".env.example") return true;
173
+ return false;
174
+ }
175
+ function isAllowedFile(name, config) {
176
+ if (SPECIAL.has(name)) return true;
177
+ const lower = name.toLowerCase();
178
+ return config.extensions.some((ext) => lower.endsWith(ext.toLowerCase()));
179
+ }
180
+ function shouldExclude(relPath, config) {
181
+ const parts = relPath.split(path.sep).filter(Boolean);
182
+ if (parts.some((p) => config.exclude.includes(p))) return true;
183
+ if (config.excludeFiles.includes(path.basename(relPath))) return true;
184
+ const normalized = relPath.split(path.sep).join("/");
185
+ return config.excludePaths.some(
186
+ (p) =>
187
+ normalized === p || normalized.startsWith(p.replace(/\/$/, "") + "/"),
188
+ );
189
+ }
190
+ function inside(root, target) {
191
+ const r = path.resolve(root),
192
+ t = path.resolve(target);
193
+ return t === r || t.startsWith(r + path.sep);
194
+ }
195
+ function includedByConfig(relPath, config) {
196
+ const clean = relPath.split(path.sep).join("/");
197
+ return config.include.some(
198
+ (item) =>
199
+ item === "." ||
200
+ clean === item ||
201
+ clean.startsWith(item.replace(/\/$/, "") + "/"),
202
+ );
203
+ }
204
+ function walk(root, config) {
205
+ const entries = [];
206
+ const skipped = [];
207
+ const seen = new Set();
208
+ const visit = (dir) => {
209
+ let names;
210
+ try {
211
+ names = fs
212
+ .readdirSync(dir, { withFileTypes: true })
213
+ .sort((a, b) => a.name.localeCompare(b.name));
214
+ } catch (e) {
215
+ skipped.push({ path: rel(root, dir), reason: e.code || e.message });
216
+ return;
217
+ }
218
+ for (const ent of names) {
219
+ const full = path.join(dir, ent.name),
220
+ rp = rel(root, full);
221
+ if (shouldExclude(rp, config) || !includedByConfig(rp, config)) continue;
222
+ let st;
223
+ try {
224
+ st = fs.lstatSync(full);
225
+ } catch (e) {
226
+ skipped.push({ path: rp, reason: e.code || e.message });
227
+ continue;
228
+ }
229
+ if (st.isSymbolicLink()) {
230
+ let target;
231
+ try {
232
+ target = fs.realpathSync(full);
233
+ if (!inside(root, target))
234
+ skipped.push({ path: rp, reason: "symlink outside project root" });
235
+ else {
236
+ const key = fs.realpathSync(full);
237
+ if (!seen.has(key)) {
238
+ seen.add(key);
239
+ const ts = fs.statSync(full);
240
+ if (ts.isDirectory()) visit(full);
241
+ else
242
+ entries.push({
243
+ path: rp,
244
+ type: "file",
245
+ size: ts.size,
246
+ symlink: true,
247
+ });
248
+ }
249
+ }
250
+ } catch (e) {
251
+ skipped.push({ path: rp, reason: "broken symbolic link" });
252
+ }
253
+ continue;
254
+ }
255
+ if (st.isDirectory()) {
256
+ entries.push({ path: rp, type: "directory" });
257
+ visit(full);
258
+ continue;
259
+ }
260
+ if (st.isFile()) {
261
+ if (isSecret(rp)) {
262
+ skipped.push({ path: rp, reason: "sensitive file" });
263
+ continue;
264
+ }
265
+ const textual = isAllowedFile(ent.name, config);
266
+ entries.push({
267
+ path: rp,
268
+ type: "file",
269
+ size: st.size,
270
+ textual,
271
+ tooLarge: st.size > config.maxFileSize,
272
+ });
273
+ }
274
+ }
275
+ };
276
+ visit(root);
277
+ return { entries, skipped };
278
+ }
279
+ function inspectFile(root, config, file) {
280
+ const target = path.resolve(root, file);
281
+ if (!inside(root, target))
282
+ throw new Error("Path is outside the project root");
283
+ if (!fs.existsSync(target)) throw new Error(`File does not exist: ${file}`);
284
+ const st = fs.lstatSync(target);
285
+ if (!st.isFile()) throw new Error("Path is not a regular file");
286
+ const rp = rel(root, target);
287
+ if (isSecret(rp)) throw new Error("Refusing to inspect a sensitive file");
288
+ if (!isAllowedFile(path.basename(target), config))
289
+ throw new Error("File type is not recognized as text");
290
+ if (st.size > config.maxFileSize)
291
+ throw new Error(
292
+ `File exceeds maxFileSize (${humanSize(config.maxFileSize)})`,
293
+ );
294
+ const content = fs.readFileSync(target, "utf8");
295
+ return {
296
+ path: rp,
297
+ size: st.size,
298
+ lines: content === "" ? 0 : content.split(/\r?\n/).length,
299
+ encoding: "UTF-8",
300
+ content,
301
+ };
302
+ }
303
+ function projectData(root, config) {
304
+ const { entries, skipped } = walk(root, config);
305
+ const files = entries.filter((e) => e.type === "file");
306
+ const dirs = entries.filter((e) => e.type === "directory");
307
+ const text = files.filter((e) => e.textual && !e.tooLarge);
308
+ const binary = files.filter((e) => !e.textual);
309
+ const tooLarge = files.filter((e) => e.tooLarge);
310
+ const ext = {};
311
+ let contextSize = 0,
312
+ totalLines = 0;
313
+ for (const f of text) {
314
+ const extn = path.extname(f.path).toLowerCase() || "[no extension]";
315
+ ext[extn] = (ext[extn] || 0) + 1;
316
+ try {
317
+ const c = fs.readFileSync(path.join(root, f.path), "utf8");
318
+ contextSize += Buffer.byteLength(c);
319
+ totalLines += c === "" ? 0 : c.split(/\r?\n/).length;
320
+ } catch {}
321
+ }
322
+ return {
323
+ entries,
324
+ skipped,
325
+ files,
326
+ dirs,
327
+ text,
328
+ binary,
329
+ tooLarge,
330
+ stats: {
331
+ files: files.length,
332
+ directories: dirs.length,
333
+ textFiles: text.length,
334
+ binaryFiles: binary.length,
335
+ totalSize: files.reduce((n, f) => n + f.size, 0),
336
+ contextSize,
337
+ lines: totalLines,
338
+ extensions: ext,
339
+ excluded: skipped.length,
340
+ },
341
+ };
342
+ }
343
+ function treeText(data, depth = Infinity) {
344
+ const nodes = new Map([[".", []]]);
345
+ for (const e of data.entries) {
346
+ const parts = e.path.split(path.sep);
347
+ if (parts.length > depth + 1) continue;
348
+ let cur = ".";
349
+ for (let i = 0; i < parts.length; i++) {
350
+ const p = parts[i],
351
+ key = cur + "/" + p;
352
+ if (!nodes.has(cur)) nodes.set(cur, []);
353
+ if (!nodes.get(cur).some((x) => x.name === p))
354
+ nodes
355
+ .get(cur)
356
+ .push({
357
+ name: p,
358
+ type: i === parts.length - 1 ? e.type : "directory",
359
+ });
360
+ cur = key;
361
+ if (!nodes.has(cur)) nodes.set(cur, []);
362
+ }
363
+ }
364
+ const lines = [];
365
+ const render = (parent, prefix) => {
366
+ const kids = (nodes.get(parent) || []).sort((a, b) =>
367
+ a.type === b.type
368
+ ? a.name.localeCompare(b.name)
369
+ : a.type === "directory"
370
+ ? -1
371
+ : 1,
372
+ );
373
+ for (const k of kids) {
374
+ lines.push(prefix + k.name + (k.type === "directory" ? "/" : ""));
375
+ if (k.type === "directory") render(parent + "/" + k.name, prefix + " ");
376
+ }
377
+ };
378
+ render(".", "");
379
+ return lines.join("\n");
380
+ }
381
+ function contextText(root, data, config) {
382
+ const name = path.basename(root);
383
+ const s = data.stats;
384
+ const lines = [];
385
+ lines.push(
386
+ "PROJECT CONTEXT",
387
+ "===============",
388
+ `Project: ${name}`,
389
+ `Root: ${root}`,
390
+ `Generated: ${new Date().toISOString().slice(0, 10)}`,
391
+ "",
392
+ "PROJECT STATISTICS",
393
+ "==================",
394
+ `Files: ${s.files}`,
395
+ `Directories: ${s.directories}`,
396
+ `Text files: ${s.textFiles}`,
397
+ `Binary files: ${s.binaryFiles}`,
398
+ `Total size: ${humanSize(s.totalSize)}`,
399
+ `Context size: ${humanSize(s.contextSize)}`,
400
+ `Lines: ${s.lines}`,
401
+ "",
402
+ "PROJECT TREE",
403
+ "============",
404
+ treeText(data),
405
+ "",
406
+ "FILES",
407
+ "=====",
408
+ );
409
+ for (const f of data.text.sort((a, b) => a.path.localeCompare(b.path))) {
410
+ let content;
411
+ try {
412
+ content = fs.readFileSync(path.join(root, f.path), "utf8");
413
+ } catch {
414
+ continue;
415
+ }
416
+ lines.push(
417
+ `FILE: ${f.path}`,
418
+ "------------------",
419
+ content.replace(/\r\n/g, "\n").replace(/\u0000/g, ""),
420
+ );
421
+ }
422
+ return lines.join("\n") + "\n";
423
+ }
424
+ function printHelp() {
425
+ console.log(
426
+ `${bold("Scope")} — minimal project exploration and context builder\n\nUsage:\n scope [command] [options]\n\nCommands:\n scope Build project context (same as scope build)\n scope init Create .scope.json\n scope build Generate scope-context.txt\n scope tree Show project tree\n scope stats Show project statistics\n scope inspect <file> Show file metadata and content\n scope doctor Check project/configuration health\n scope help Show this help\n\nOptions:\n -h, --help Show help\n -v, --version Show version\n --json Machine-readable JSON output\n --depth <n> Limit tree depth\n\nExamples:\n scope\n scope init\n scope tree 3\n scope tree --depth 2\n scope stats --json\n scope inspect src/index.js\n scope build`,
427
+ );
428
+ }
429
+ function init(root) {
430
+ const file = path.join(root, ".scope.json");
431
+ if (fs.existsSync(file)) {
432
+ console.log(
433
+ `${yellow("!")} .scope.json already exists. Nothing was overwritten.`,
434
+ );
435
+ return;
436
+ }
437
+ fs.writeFileSync(file, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
438
+ console.log(`${green("✓")} Created .scope.json`);
439
+ }
440
+ function doctor(root, configInfo) {
441
+ const checks = [];
442
+ checks.push({
443
+ name: "Node.js",
444
+ ok: Number(process.versions.node.split(".")[0]) >= 18,
445
+ value: process.version,
446
+ });
447
+ checks.push({ name: "Project root", ok: fs.existsSync(root), value: root });
448
+ checks.push({
449
+ name: "Configuration",
450
+ ok: !configInfo.error,
451
+ value: configInfo.exists ? ".scope.json" : "defaults",
452
+ });
453
+ if (!configInfo.error) {
454
+ for (const p of configInfo.config.include) {
455
+ const t = path.resolve(root, p);
456
+ checks.push({
457
+ name: `Include: ${p}`,
458
+ ok: inside(root, t) && fs.existsSync(t),
459
+ value: inside(root, t) ? t : "outside root",
460
+ });
461
+ }
462
+ const out = path.resolve(root, configInfo.config.output);
463
+ checks.push({ name: "Output path", ok: inside(root, out), value: out });
464
+ }
465
+ return checks;
466
+ }
467
+
468
+ function main() {
469
+ const root = process.cwd(),
470
+ args = parseArgs(process.argv.slice(2));
471
+ if (args.version) {
472
+ console.log(VERSION);
473
+ return;
474
+ }
475
+ if (args.help || args._[0] === "help") {
476
+ printHelp();
477
+ return;
478
+ }
479
+ const command = args._[0] || "build";
480
+ const configInfo = loadConfig(root);
481
+ if (configInfo.error && command !== "doctor") {
482
+ fail(`Invalid .scope.json: ${configInfo.error}`);
483
+ return;
484
+ }
485
+ const config = configInfo.config;
486
+ try {
487
+ if (command === "init") {
488
+ init(root);
489
+ return;
490
+ }
491
+ if (command === "doctor") {
492
+ const checks = doctor(root, configInfo);
493
+ if (args.json) {
494
+ console.log(
495
+ JSON.stringify({ ok: checks.every((c) => c.ok), checks }, null, 2),
496
+ );
497
+ return;
498
+ }
499
+ console.log(
500
+ bold("SCOPE DOCTOR"),
501
+ "\n────────────\n" +
502
+ checks
503
+ .map(
504
+ (c) =>
505
+ ` ${c.ok ? green("✓") : red("✗")} ${c.name.padEnd(18)} ${c.value}`,
506
+ )
507
+ .join("\n") +
508
+ "\n" +
509
+ (checks.every((c) => c.ok)
510
+ ? "No problems found."
511
+ : "Problems found."),
512
+ );
513
+ if (!checks.every((c) => c.ok)) process.exitCode = 1;
514
+ return;
515
+ }
516
+ if (!["build", "tree", "stats", "inspect"].includes(command)) {
517
+ fail(`Unknown command: ${command}\nRun: scope help`);
518
+ return;
519
+ }
520
+ const data = projectData(root, config);
521
+ if (command === "tree") {
522
+ let depth = args.depth;
523
+ if (depth === null && args._[1]) depth = Number(args._[1]);
524
+ if (depth !== null && (!Number.isInteger(depth) || depth < 0)) {
525
+ fail("Depth must be a non-negative integer");
526
+ return;
527
+ }
528
+ const out = {
529
+ root,
530
+ tree: treeText(data, depth === null ? Infinity : depth),
531
+ entries: data.entries,
532
+ };
533
+ if (args.json) {
534
+ console.log(JSON.stringify(out, null, 2));
535
+ return;
536
+ }
537
+ console.log(
538
+ bold("PROJECT TREE"),
539
+ "\n─────────────\n" + treeText(data, depth === null ? Infinity : depth),
540
+ );
541
+ return;
542
+ }
543
+ if (command === "stats") {
544
+ const out = { root, stats: data.stats };
545
+ if (args.json) {
546
+ console.log(JSON.stringify(out, null, 2));
547
+ return;
548
+ }
549
+ const ex = Object.entries(data.stats.extensions).sort(
550
+ (a, b) => b[1] - a[1],
551
+ );
552
+ console.log(
553
+ bold("PROJECT STATISTICS"),
554
+ "\n──────────────────\n" +
555
+ ` Files ${data.stats.files}\n Directories ${data.stats.directories}\n Text files ${data.stats.textFiles}\n Binary files ${data.stats.binaryFiles}\n Total size ${humanSize(data.stats.totalSize)}\n Context size ${humanSize(data.stats.contextSize)}\n Lines ${data.stats.lines}\n Skipped ${data.stats.excluded}\n Extensions\n` +
556
+ ex.map(([k, v]) => ` ${k.padEnd(14)} ${v}`).join("\n"),
557
+ );
558
+ return;
559
+ }
560
+ if (command === "inspect") {
561
+ if (!args._[1]) {
562
+ fail("Missing file path. Example: scope inspect src/index.js");
563
+ return;
564
+ }
565
+ const info = inspectFile(root, config, args._[1]);
566
+ if (args.json) {
567
+ console.log(JSON.stringify(info, null, 2));
568
+ return;
569
+ }
570
+ console.log(
571
+ bold("FILE"),
572
+ "\n────\n" +
573
+ ` Path ${info.path}\n Size ${humanSize(info.size)}\n Lines ${info.lines}\n Encoding ${info.encoding}\n\n` +
574
+ bold("CONTENT"),
575
+ "\n───────\n" + info.content,
576
+ );
577
+ return;
578
+ }
579
+ const output = path.resolve(root, config.output);
580
+ if (!inside(root, output))
581
+ throw new Error(
582
+ "Configured output path must stay inside the project root",
583
+ );
584
+ const context = contextText(root, data, config);
585
+ fs.writeFileSync(output, context, "utf8");
586
+ if (args.json) {
587
+ console.log(
588
+ JSON.stringify(
589
+ {
590
+ ok: true,
591
+ root,
592
+ output,
593
+ files: data.text.length,
594
+ contextSize: Buffer.byteLength(context),
595
+ durationMs: 0,
596
+ },
597
+ null,
598
+ 2,
599
+ ),
600
+ );
601
+ return;
602
+ }
603
+ console.log(
604
+ bold("PROJECT CONTEXT BUILDER"),
605
+ "\n────────────────────────\n" +
606
+ `Project\n───────\n Root ${root}\n Output ${rel(root, output)}\n\nScanning\n────────\n ${green("✓")} Found ${data.text.length} text files\n\nBuilding Context\n────────────────\n ${green("✓")} ${data.text.length} files included\n\nComplete\n────────\n ${green("✓")} Context generated successfully\n Files ${data.text.length}\n Context size ${humanSize(Buffer.byteLength(context))}\n Output ${output}\nContext build finished successfully.`,
607
+ );
608
+ } catch (e) {
609
+ fail(e.message || String(e));
610
+ }
611
+ }
612
+ main();
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@awilum/scope",
3
+ "version": "1.0.0",
4
+ "description": "A minimal dependency-free CLI for exploring projects and building structured project context.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Sergey Romanenko",
8
+ "email": "awilum@msn.com",
9
+ "url": "https://github.com/Awilum"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/Awilum/scope.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/Awilum/scope/issues"
17
+ },
18
+ "homepage": "https://github.com/Awilum/scope",
19
+ "type": "module",
20
+ "bin": {
21
+ "scope": "./bin/scope.js"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "README.md",
29
+ "LICENSE",
30
+ ".scope.json.example"
31
+ ],
32
+ "keywords": [
33
+ "cli",
34
+ "developer-tools",
35
+ "project-context",
36
+ "codebase",
37
+ "source-code",
38
+ "project-explorer",
39
+ "code-explorer",
40
+ "context",
41
+ "unix",
42
+ "nodejs"
43
+ ],
44
+ "scripts": {
45
+ "check": "node --check bin/scope.js",
46
+ "test": "node --test",
47
+ "test:cli": "node bin/scope.js doctor --json",
48
+ "format": "prettier --write .",
49
+ "format:check": "prettier --check .",
50
+ "pack:check": "npm pack --dry-run"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "devDependencies": {
56
+ "esbuild": "^0.28.0",
57
+ "prettier": "^3.9.9"
58
+ }
59
+ }