@chappibunny/repolens 1.14.3 → 1.15.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.
@@ -0,0 +1,268 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Language profiles used by `init --quick` and config-less `demo`.
6
+ * Globs are repository-wide so a Python package that lives next to setup.py
7
+ * is scanned even when it is not under src/.
8
+ */
9
+ export const LANGUAGE_PROFILES = [
10
+ {
11
+ id: "python",
12
+ label: "Python",
13
+ markers: ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "poetry.lock"],
14
+ extensions: [".py"],
15
+ include: ["**/*.py"],
16
+ candidateRoots: ["src", "app", "lib", "api", "core", "services"],
17
+ },
18
+ {
19
+ id: "go",
20
+ label: "Go",
21
+ markers: ["go.mod"],
22
+ extensions: [".go"],
23
+ include: ["**/*.go"],
24
+ candidateRoots: ["cmd", "internal", "pkg", "api"],
25
+ },
26
+ {
27
+ id: "rust",
28
+ label: "Rust",
29
+ markers: ["Cargo.toml"],
30
+ extensions: [".rs"],
31
+ include: ["**/*.rs"],
32
+ candidateRoots: ["src", "crates"],
33
+ },
34
+ {
35
+ id: "java",
36
+ label: "Java",
37
+ markers: ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"],
38
+ extensions: [".java", ".kt", ".kts", ".scala"],
39
+ include: ["**/*.{java,kt,kts,scala}"],
40
+ candidateRoots: ["src/main/java", "src/main/kotlin", "src/main/scala", "src", "app"],
41
+ },
42
+ {
43
+ id: "javascript",
44
+ label: "JavaScript",
45
+ markers: ["package.json", "tsconfig.json", "jsconfig.json"],
46
+ extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".vue", ".svelte"],
47
+ include: ["**/*.{js,jsx,mjs,cjs,ts,tsx,vue,svelte}"],
48
+ candidateRoots: ["src", "app", "lib", "pages", "components", "packages"],
49
+ },
50
+ {
51
+ id: "ruby",
52
+ label: "Ruby",
53
+ markers: ["Gemfile"],
54
+ extensions: [".rb"],
55
+ include: ["**/*.rb"],
56
+ candidateRoots: ["app", "lib"],
57
+ },
58
+ {
59
+ id: "php",
60
+ label: "PHP",
61
+ markers: ["composer.json"],
62
+ extensions: [".php"],
63
+ include: ["**/*.php"],
64
+ candidateRoots: ["src", "app", "lib"],
65
+ },
66
+ {
67
+ id: "csharp",
68
+ label: "C#",
69
+ markers: ["*.csproj", "*.sln"],
70
+ extensions: [".cs"],
71
+ include: ["**/*.cs"],
72
+ candidateRoots: ["src"],
73
+ },
74
+ {
75
+ id: "swift",
76
+ label: "Swift",
77
+ markers: ["Package.swift"],
78
+ extensions: [".swift"],
79
+ include: ["**/*.swift"],
80
+ candidateRoots: ["Sources", "src"],
81
+ },
82
+ {
83
+ id: "cpp",
84
+ label: "C",
85
+ markers: ["CMakeLists.txt"],
86
+ extensions: [".c", ".cc", ".cpp", ".h", ".hpp"],
87
+ include: ["**/*.{c,cc,cpp,h,hpp}"],
88
+ candidateRoots: ["src", "include"],
89
+ },
90
+ ];
91
+
92
+ const EXT_TO_LANGUAGE = new Map();
93
+ for (const profile of LANGUAGE_PROFILES) {
94
+ for (const ext of profile.extensions) {
95
+ if (!EXT_TO_LANGUAGE.has(ext)) EXT_TO_LANGUAGE.set(ext, profile.id);
96
+ }
97
+ }
98
+
99
+ const SKIP_DIRS = new Set([
100
+ ".git",
101
+ ".hg",
102
+ ".svn",
103
+ ".repolens",
104
+ ".next",
105
+ ".venv",
106
+ "venv",
107
+ "node_modules",
108
+ "vendor",
109
+ "target",
110
+ "dist",
111
+ "build",
112
+ "out",
113
+ "coverage",
114
+ "__pycache__",
115
+ ".tox",
116
+ ".mypy_cache",
117
+ ]);
118
+
119
+ const UNIVERSAL_CANDIDATE_ROOTS = ["src", "app", "lib", "cmd", "internal", "pkg", "packages"];
120
+
121
+ export const UNIVERSAL_INCLUDE = [
122
+ "**/*.{js,jsx,mjs,cjs,ts,tsx,vue,svelte,py,go,rs,java,kt,kts,scala,rb,php,cs,swift,c,cc,cpp,h,hpp}",
123
+ ];
124
+
125
+ const MAX_WALK_ENTRIES = 8000;
126
+ const MAX_WALK_DEPTH = 4;
127
+
128
+ function markerMatches(marker, rootNames) {
129
+ if (marker.startsWith("*.")) {
130
+ const suffix = marker.slice(1);
131
+ return rootNames.some((name) => name.endsWith(suffix));
132
+ }
133
+ return rootNames.includes(marker);
134
+ }
135
+
136
+ function rootRank(root) {
137
+ const base = root.split("/").pop() || root;
138
+ if (/^(tests?|specs?|__tests__|testdata)$/i.test(base)) return 2;
139
+ if (/^(docs|examples?|benchmarks?)$/i.test(base)) return 1;
140
+ return 0;
141
+ }
142
+
143
+ function compareRoots(a, b) {
144
+ const rank = rootRank(a) - rootRank(b);
145
+ if (rank !== 0) return rank;
146
+ return a.localeCompare(b);
147
+ }
148
+
149
+ async function dirExists(dirPath) {
150
+ try {
151
+ const stat = await fs.stat(dirPath);
152
+ return stat.isDirectory();
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ async function walkSourceTree(repoRoot) {
159
+ const extCounts = new Map();
160
+ const topLevelExts = new Map();
161
+ const rootNames = [];
162
+ let seen = 0;
163
+
164
+ async function walk(dir, depth) {
165
+ if (depth > MAX_WALK_DEPTH || seen >= MAX_WALK_ENTRIES) return;
166
+ let entries;
167
+ try {
168
+ entries = await fs.readdir(dir, { withFileTypes: true });
169
+ } catch {
170
+ return;
171
+ }
172
+
173
+ for (const entry of entries) {
174
+ if (seen >= MAX_WALK_ENTRIES) return;
175
+ seen += 1;
176
+ if (entry.isSymbolicLink()) continue;
177
+
178
+ if (entry.isDirectory()) {
179
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
180
+ await walk(path.join(dir, entry.name), depth + 1);
181
+ continue;
182
+ }
183
+
184
+ if (!entry.isFile()) continue;
185
+ const ext = path.extname(entry.name).toLowerCase();
186
+ if (EXT_TO_LANGUAGE.has(ext)) {
187
+ extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
188
+ }
189
+
190
+ const relDir = path.relative(repoRoot, dir);
191
+ if (!relDir) {
192
+ rootNames.push(entry.name);
193
+ continue;
194
+ }
195
+
196
+ const top = relDir.split(path.sep)[0];
197
+ if (!topLevelExts.has(top)) topLevelExts.set(top, new Set());
198
+ if (ext) topLevelExts.get(top).add(ext);
199
+ }
200
+ }
201
+
202
+ await walk(repoRoot, 0);
203
+ return { extCounts, topLevelExts, rootNames };
204
+ }
205
+
206
+ /**
207
+ * Detect languages and the scan globs / module roots that fit this repository.
208
+ * Always returns at least one include pattern and one module root.
209
+ */
210
+ export async function detectRepoLanguages(repoRoot) {
211
+ const root = path.resolve(repoRoot);
212
+ const { extCounts, topLevelExts, rootNames } = await walkSourceTree(root);
213
+
214
+ const presentIds = new Set();
215
+ for (const ext of extCounts.keys()) {
216
+ const id = EXT_TO_LANGUAGE.get(ext);
217
+ if (id) presentIds.add(id);
218
+ }
219
+
220
+ const detected = LANGUAGE_PROFILES.filter((profile) => {
221
+ if (presentIds.has(profile.id)) return true;
222
+ return profile.markers.some((marker) => markerMatches(marker, rootNames));
223
+ });
224
+
225
+ const include = [];
226
+ const seenInclude = new Set();
227
+ const source = detected.length > 0 ? detected.flatMap((profile) => profile.include) : UNIVERSAL_INCLUDE;
228
+ for (const pattern of source) {
229
+ if (seenInclude.has(pattern)) continue;
230
+ seenInclude.add(pattern);
231
+ include.push(pattern);
232
+ }
233
+
234
+ const sourceExts = new Set();
235
+ const profilesForRoots = detected.length > 0 ? detected : LANGUAGE_PROFILES;
236
+ for (const profile of profilesForRoots) {
237
+ if (detected.length > 0 || presentIds.has(profile.id)) {
238
+ for (const ext of profile.extensions) sourceExts.add(ext);
239
+ }
240
+ }
241
+ if (detected.length === 0) {
242
+ for (const ext of EXT_TO_LANGUAGE.keys()) sourceExts.add(ext);
243
+ }
244
+
245
+ const moduleRoots = [];
246
+ for (const [dir, exts] of topLevelExts) {
247
+ if ([...exts].some((ext) => sourceExts.has(ext))) moduleRoots.push(dir);
248
+ }
249
+
250
+ if (moduleRoots.length === 0) {
251
+ const candidates = detected.length > 0
252
+ ? [...new Set(detected.flatMap((profile) => profile.candidateRoots))]
253
+ : UNIVERSAL_CANDIDATE_ROOTS;
254
+ for (const candidate of candidates) {
255
+ if (await dirExists(path.join(root, candidate))) moduleRoots.push(candidate);
256
+ }
257
+ }
258
+
259
+ if (moduleRoots.length === 0) moduleRoots.push(".");
260
+
261
+ moduleRoots.sort(compareRoots);
262
+
263
+ return {
264
+ languages: detected.map((profile) => ({ id: profile.id, label: profile.label })),
265
+ include,
266
+ moduleRoots,
267
+ };
268
+ }
package/src/core/scan.js CHANGED
@@ -476,20 +476,25 @@ function isValidRoutePath(p) {
476
476
  return p.startsWith("/") || /^[a-zA-Z0-9]/.test(p);
477
477
  }
478
478
 
479
- export async function scanRepo(cfg) {
479
+ export async function listMatchedFiles(cfg) {
480
480
  const repoRoot = cfg.__repoRoot;
481
-
482
481
  const rootResolved = path.resolve(repoRoot);
483
- const files = (await fg(cfg.scan.include, {
482
+ const files = await fg(cfg.scan.include, {
484
483
  cwd: rootResolved,
485
- ignore: cfg.scan.ignore,
484
+ ignore: cfg.scan?.ignore || [],
486
485
  onlyFiles: true,
487
486
  followSymbolicLinks: false,
488
487
  absolute: false
489
- })).filter((file) => isInsideRoot(rootResolved, path.resolve(rootResolved, file)));
488
+ });
489
+ return files.filter((file) => isInsideRoot(rootResolved, path.resolve(rootResolved, file)));
490
+ }
491
+
492
+ export async function scanRepo(cfg) {
493
+ const repoRoot = cfg.__repoRoot;
494
+ const files = await listMatchedFiles(cfg);
490
495
 
491
496
  if (files.length === 0) {
492
- warn("No files matched the scan patterns. Documentation will be mostly empty. Check scan.include in .repolens.yml.");
497
+ warn("No files matched the scan patterns. Check scan.include in .repolens.yml.");
493
498
  }
494
499
 
495
500
  // Performance guardrails
package/src/doctor.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { loadYaml } from "./utils/yaml.js";
4
4
  import { loadConfig } from "./core/config.js";
5
+ import { listMatchedFiles } from "./core/scan.js";
5
6
  import { detectConfigTypos } from "./core/config-schema.js";
6
7
  import { info } from "./utils/logger.js";
7
8
  import { forceCheckForUpdates } from "./utils/update-check.js";
@@ -56,8 +57,11 @@ function ok(message) {
56
57
  info(`✅ ${message}`);
57
58
  }
58
59
 
60
+ let activeFailures = null;
61
+
59
62
  function fail(message) {
60
63
  info(`❌ ${message}`);
64
+ if (activeFailures) activeFailures.push(message);
61
65
  }
62
66
 
63
67
  function warn(message) {
@@ -66,6 +70,8 @@ function warn(message) {
66
70
 
67
71
  export async function runDoctor(targetDir = process.cwd()) {
68
72
  const repoRoot = path.resolve(targetDir);
73
+ const failures = [];
74
+ activeFailures = failures;
69
75
 
70
76
  const repolensConfigPath = path.join(repoRoot, ".repolens.yml");
71
77
  const workflowPath = path.join(repoRoot, ".github", "workflows", "repolens.yml");
@@ -172,6 +178,20 @@ export async function runDoctor(targetDir = process.cwd()) {
172
178
  fail("outputs.pages is missing or empty");
173
179
  hasFailures = true;
174
180
  }
181
+
182
+ try {
183
+ const matched = await listMatchedFiles(cfg);
184
+ if (matched.length === 0) {
185
+ const patterns = (cfg.scan?.include || []).join(", ") || "(none)";
186
+ fail(`0 files matched scan.include (${patterns}). publish and demo would write empty documentation. Update scan.include, or run \`repolens init --quick\` so the globs match this repository's language.`);
187
+ hasFailures = true;
188
+ } else {
189
+ ok(`scan.include matches ${matched.length} file(s)`);
190
+ }
191
+ } catch (error) {
192
+ fail(`Could not scan repository: ${error.message}`);
193
+ hasFailures = true;
194
+ }
175
195
  }
176
196
 
177
197
  // Validate environment variables for configured publishers
@@ -259,10 +279,11 @@ export async function runDoctor(targetDir = process.cwd()) {
259
279
 
260
280
  if (hasFailures) {
261
281
  fail("RepoLens doctor found blocking issues.");
262
- process.exitCode = 1;
263
- return { ok: false };
282
+ activeFailures = null;
283
+ return { ok: false, failures };
264
284
  }
265
285
 
266
286
  ok("RepoLens doctor passed.");
267
- return { ok: true };
287
+ activeFailures = null;
288
+ return { ok: true, failures };
268
289
  }
package/src/help.js CHANGED
@@ -1,10 +1,237 @@
1
- export function printHelp() {
2
- console.log(`
1
+ const EXIT_CODES = `Exit codes:
2
+ 0 success (including a cancelled wizard, or help with no other work)
3
+ 1 runtime error, or an unknown command
4
+ 2 invalid flags, missing config, target directory not found,
5
+ a failed doctor check (including 0 files matched),
6
+ demo or publish scanned 0 files, watch has nothing to watch,
7
+ publish --strict is missing publisher credentials,
8
+ or feedback has no terminal`;
9
+
10
+ const COMMAND_HELP = {
11
+ init: `
12
+ RepoLens init — scaffold RepoLens files
13
+
14
+ Usage:
15
+ repolens init [--target <path>] [--quick]
16
+
17
+ Options:
18
+ --target <path> Repository to scaffold (default: current directory)
19
+ --quick Skip the wizard. Markdown publisher only.
20
+ Detects the repo language and writes matching globs.
21
+ --non-interactive Same as --quick
22
+ --verbose Print debug logs
23
+ --help, -h Show this help
24
+
25
+ \`repolens init\` opens the wizard, which can add Notion, Confluence, GitHub
26
+ Wiki, and Obsidian. \`repolens init --quick\` writes a Markdown-only config.
27
+ It detects Python, Go, Rust, Java/Kotlin, JavaScript/TypeScript, Ruby, PHP,
28
+ C#, Swift, and C/C++ (and combinations) and sets scan.include plus
29
+ module_roots from the files it finds. A repository with no recognized
30
+ language gets multi-language globs, not JavaScript-only defaults.
31
+
32
+ The target directory must already exist.
33
+
34
+ ${EXIT_CODES}
35
+
36
+ Examples:
37
+ repolens init
38
+ repolens init --quick
39
+ repolens init --target /path/to/repo --quick
40
+ `,
41
+
42
+ doctor: `
43
+ RepoLens doctor — validate a RepoLens setup
44
+
45
+ Usage:
46
+ repolens doctor [--target <path>]
47
+
48
+ Options:
49
+ --target <path> Repository to validate (default: current directory)
50
+ --verbose Print debug logs
51
+ --help, -h Show this help
52
+
53
+ Checks .repolens.yml, the GitHub workflow, publishers, scan.include,
54
+ module_roots, and outputs.pages. A scan that matches 0 files is a blocking
55
+ failure: publish and demo would otherwise write empty docs.
56
+
57
+ The target directory must already exist.
58
+
59
+ ${EXIT_CODES}
60
+
61
+ Examples:
62
+ repolens doctor
63
+ repolens doctor --target /path/to/repo
64
+ `,
65
+
66
+ migrate: `
67
+ RepoLens migrate — upgrade workflow files
68
+
69
+ Usage:
70
+ repolens migrate [--target <path>] [--dry-run] [--force]
71
+
72
+ Options:
73
+ --target <path> Repository to migrate (default: current directory)
74
+ --dry-run Preview changes without writing them
75
+ --force Apply changes without a confirmation prompt
76
+ --verbose Print debug logs
77
+ --help, -h Show this help
78
+
79
+ The target directory must already exist.
80
+
81
+ ${EXIT_CODES}
82
+
83
+ Examples:
84
+ repolens migrate --dry-run
85
+ repolens migrate --force
86
+ repolens migrate --target /path/to/repo
87
+ `,
88
+
89
+ publish: `
90
+ RepoLens publish — scan, render, and publish documentation
91
+
92
+ Usage:
93
+ repolens publish [--config <path>] [--strict]
94
+
95
+ Options:
96
+ --config <path> Path to .repolens.yml (auto-discovered if omitted)
97
+ --strict Exit 2 when a configured publisher is missing
98
+ credentials (Notion, Confluence, GitHub Wiki) or
99
+ obsidian.vault. Markdown needs no credentials.
100
+ --verbose Print debug logs
101
+ --help, -h Show this help
102
+
103
+ Without --strict, a publisher with missing credentials is skipped.
104
+ A scan that matches 0 files exits 2 and does not write empty docs.
105
+ \`publish\` does not accept --dry-run.
106
+
107
+ ${EXIT_CODES}
108
+
109
+ Examples:
110
+ repolens publish
111
+ repolens publish --config /path/.repolens.yml
112
+ repolens publish --strict
113
+ `,
114
+
115
+ demo: `
116
+ RepoLens demo — local preview without API keys
117
+
118
+ Usage:
119
+ repolens demo [--target <path>] [--config <path>]
120
+
121
+ Options:
122
+ --target <path> Repository to preview (default: current directory)
123
+ --config <path> Path to .repolens.yml (discovered from --target)
124
+ --verbose Print debug logs
125
+ --help, -h Show this help
126
+
127
+ With no config, demo detects the repository language and scans matching
128
+ files (Python, Go, Rust, Java, JavaScript, and others). It does not publish
129
+ to Notion or Confluence. A scan that matches 0 files exits 2 and does not
130
+ write empty docs.
131
+
132
+ The target directory must already exist.
133
+
134
+ ${EXIT_CODES}
135
+
136
+ Examples:
137
+ repolens demo
138
+ repolens demo --target /path/to/repo
139
+ `,
140
+
141
+ watch: `
142
+ RepoLens watch — regenerate Markdown when files change
143
+
144
+ Usage:
145
+ repolens watch [--config <path>]
146
+
147
+ Options:
148
+ --config <path> Path to .repolens.yml (auto-discovered if omitted)
149
+ --verbose Print debug logs
150
+ --help, -h Show this help
151
+
152
+ Watches directories listed in module_roots. If none of those directories
153
+ exist, watch exits 2 and tells you to fix module_roots. Press Ctrl+C to stop
154
+ when a watch is running.
155
+
156
+ ${EXIT_CODES}
157
+
158
+ Examples:
159
+ repolens watch
160
+ repolens watch --config /path/.repolens.yml
161
+ `,
162
+
163
+ uninstall: `
164
+ RepoLens uninstall — remove RepoLens-generated files
165
+
166
+ Usage:
167
+ repolens uninstall [--target <path>] [--force]
168
+
169
+ Options:
170
+ --target <path> Repository to clean (default: current directory)
171
+ --force Remove files without a confirmation prompt
172
+ --verbose Print debug logs
173
+ --help, -h Show this help
174
+
175
+ Removes .repolens/, .repolens.yml, .github/workflows/repolens.yml,
176
+ .env.example, and README.repolens.md when those files are present.
177
+ Outside CI, a closed terminal defaults to keeping the files.
178
+ The target directory must already exist.
179
+
180
+ ${EXIT_CODES}
181
+
182
+ Examples:
183
+ repolens uninstall --target /path/to/repo
184
+ repolens uninstall --force
185
+ `,
186
+
187
+ feedback: `
188
+ RepoLens feedback — send feedback to the RepoLens team
189
+
190
+ Usage:
191
+ repolens feedback
192
+
193
+ Options:
194
+ --verbose Print debug logs
195
+ --help, -h Show this help
196
+
197
+ Needs an interactive terminal. CI, pipes, and REPOLENS_NON_INTERACTIVE
198
+ exit 2.
199
+
200
+ ${EXIT_CODES}
201
+
202
+ Examples:
203
+ repolens feedback
204
+ `,
205
+
206
+ mcp: `
207
+ RepoLens mcp — stdio MCP server
208
+
209
+ Usage:
210
+ repolens mcp
211
+
212
+ Options:
213
+ --help, -h Show this help
214
+
215
+ Starts the Model Context Protocol server on stdio. \`repolens-mcp\` is the
216
+ same server. Logs go to stderr. The workspace is REPOLENS_WORKSPACE or the
217
+ current directory. Paths cannot leave that directory.
218
+
219
+ ${EXIT_CODES}
220
+
221
+ Examples:
222
+ repolens mcp
223
+ `,
224
+ };
225
+
226
+ const GLOBAL_HELP = `
3
227
  RepoLens — Repository Intelligence CLI
4
228
 
5
229
  Usage:
6
230
  repolens <command> [options]
7
231
 
232
+ Run \`repolens <command> --help\` for that command's flags.
233
+ With no command, RepoLens prints this help and does not publish.
234
+
8
235
  Commands:
9
236
  init Scaffold RepoLens files in your repository
10
237
  doctor Validate your RepoLens setup
@@ -20,37 +247,54 @@ Commands:
20
247
 
21
248
  Options:
22
249
  --config <path> Path to .repolens.yml (auto-discovered if omitted)
23
- --target <path> Repository path for init, doctor, migrate, demo, uninstall
24
- --quick Scaffold without the wizard (also --non-interactive)
250
+ --target <path> Repository path for init, doctor, migrate, demo, uninstall.
251
+ Must already exist.
252
+ --quick Scaffold without the wizard (also --non-interactive).
253
+ Markdown only; globs follow the detected language.
254
+ --strict publish: exit 2 when publisher credentials are missing
25
255
  --dry-run Preview migration changes without writing them
26
256
  --force Apply migrate or uninstall without a confirmation prompt
27
257
  --verbose Print debug logs and full stacks on unexpected errors
28
258
  --version, -v Print version and exit
29
- --help, -h Show this help message
259
+ --help, -h Show help. After a command, show that command's help.
30
260
 
31
261
  Non-interactive sessions (CI, pipes, or REPOLENS_NON_INTERACTIVE):
32
262
  init falls back to --quick instead of waiting on the wizard
33
263
  yes/no prompts use their default and do not wait (uninstall defaults to no
34
264
  on a closed terminal outside CI; CI still removes files and tells you so)
35
265
  feedback exits with code 2 because it needs a terminal
266
+ the ASCII banner is hidden when stdout is not a terminal or CI is set
36
267
 
37
- Exit codes:
38
- 0 success (including a cancelled wizard)
39
- 1 runtime error, or an unknown command
40
- 2 invalid flags, missing config, or a failed doctor check
268
+ ${EXIT_CODES}
41
269
 
42
270
  Examples:
271
+ repolens
43
272
  repolens init
44
273
  repolens init --quick
274
+ repolens init --help
45
275
  repolens init --target /tmp/my-repo
46
276
  repolens doctor --target /tmp/my-repo
47
277
  repolens migrate --dry-run
48
278
  repolens publish
279
+ repolens publish --strict
49
280
  repolens publish --config /path/.repolens.yml
50
281
  repolens demo
51
282
  repolens watch
52
283
  repolens uninstall --target /tmp/my-repo
53
284
  repolens mcp
54
285
  repolens --version
55
- `);
286
+ `;
287
+
288
+ const KNOWN_COMMANDS = new Set([...Object.keys(COMMAND_HELP), "version", "help"]);
289
+
290
+ export function printHelp(command) {
291
+ if (command && COMMAND_HELP[command]) {
292
+ console.log(COMMAND_HELP[command]);
293
+ return;
294
+ }
295
+ if (command && !KNOWN_COMMANDS.has(command)) {
296
+ console.log(`Unknown command: ${command}`);
297
+ console.log("Run 'repolens help' for the command list.\n");
298
+ }
299
+ console.log(GLOBAL_HELP);
56
300
  }