clank-cli 0.1.72 → 0.1.74

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clank-cli",
3
3
  "description": "Keep AI files in a separate overlay repository",
4
- "version": "0.1.72",
4
+ "version": "0.1.74",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
package/src/AgentFiles.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
 
3
3
  /** Base agent directory names (without leading dot) */
4
- export const agentDirNames = ["claude", "gemini"];
4
+ export const agentDirNames = ["claude", "gemini", "codex"];
5
5
 
6
6
  /** Agent directories as they appear in target (with leading dot) */
7
7
  export const managedAgentDirs = agentDirNames.map((d) => `.${d}`);
package/src/Cli.ts CHANGED
@@ -36,6 +36,8 @@ Clank Overlay Directory Structure
36
36
  │ ├── settings.json # -> .claude/settings.json
37
37
  │ ├── commands/ # -> .claude/commands/
38
38
  │ └── agents/ # -> .claude/agents/
39
+ ├── codex/ # Codex specific
40
+ │ └── config.toml # -> .codex/config.toml
39
41
  ├── <subdir>/clank/ # Subdirectory files (monorepo support)
40
42
  │ └── notes.md # -> <subdir>/clank/notes.md
41
43
  └── worktrees/
@@ -51,6 +53,7 @@ Mapping Rules
51
53
  global/claude/commands/<file> -> .claude/commands/<file>
52
54
  targets/<proj>/clank/<file> -> clank/<file>
53
55
  targets/<proj>/claude/commands/ -> .claude/commands/
56
+ targets/<proj>/codex/config.toml -> .codex/config.toml
54
57
  targets/<proj>/agents.md -> CLAUDE.md, AGENTS.md, GEMINI.md
55
58
  targets/<proj>/<sub>/clank/<file> -> <sub>/clank/<file>
56
59
  targets/<proj>/worktrees/<br>/clank -> clank/
@@ -84,31 +87,9 @@ export async function runCLI(): Promise<void> {
84
87
  }
85
88
 
86
89
  function registerCommands(program: Command): void {
87
- registerCoreCommands(program);
88
- registerHelpCommands(program);
89
- }
90
-
91
- function registerCoreCommands(program: Command): void {
92
90
  registerOverlayCommands(program);
93
91
  registerUtilityCommands(program);
94
- }
95
-
96
- function registerHelpCommands(program: Command): void {
97
- const help = program
98
- .command("help")
99
- .description("Show help information")
100
- .argument("[command]", "Command to show help for")
101
- .action((commandName?: string) => {
102
- if (!commandName) return program.help();
103
- const subcommand = program.commands.find((c) => c.name() === commandName);
104
- if (subcommand) return subcommand.help();
105
- console.error(`Unknown command: ${commandName}`);
106
- process.exit(1);
107
- });
108
- help
109
- .command("structure")
110
- .description("Show overlay directory structure")
111
- .action(() => console.log(structureHelp));
92
+ registerHelpCommands(program);
112
93
  }
113
94
 
114
95
  function registerOverlayCommands(program: Command): void {
@@ -163,6 +144,7 @@ function registerUtilityCommands(program: Command): void {
163
144
  .command("check")
164
145
  .alias("status")
165
146
  .description("Show overlay status and check for issues")
147
+ .option("--prompt", "Print the agent fix prompt for orphaned paths")
166
148
  .action(withErrorHandling(checkCommand));
167
149
 
168
150
  registerFilesCommand(program);
@@ -175,6 +157,24 @@ function registerUtilityCommands(program: Command): void {
175
157
  .action(withErrorHandling(vscodeCommand));
176
158
  }
177
159
 
160
+ function registerHelpCommands(program: Command): void {
161
+ const help = program
162
+ .command("help")
163
+ .description("Show help information")
164
+ .argument("[command]", "Command to show help for")
165
+ .action((commandName?: string) => {
166
+ if (!commandName) return program.help();
167
+ const subcommand = program.commands.find((c) => c.name() === commandName);
168
+ if (subcommand) return subcommand.help();
169
+ console.error(`Unknown command: ${commandName}`);
170
+ process.exit(1);
171
+ });
172
+ help
173
+ .command("structure")
174
+ .description("Show overlay directory structure")
175
+ .action(() => console.log(structureHelp));
176
+ }
177
+
178
178
  function withErrorHandling<T extends unknown[]>(
179
179
  fn: (...args: T) => Promise<void>,
180
180
  ): (...args: T) => Promise<void> {
package/src/FsUtil.ts CHANGED
@@ -178,6 +178,16 @@ export async function isSymlink(filePath: string): Promise<boolean> {
178
178
  }
179
179
  }
180
180
 
181
+ /** Check if a path is a real (non-symlink) file tracked by git */
182
+ export async function isTrackedRealFile(
183
+ filePath: string,
184
+ repoRoot: string,
185
+ ): Promise<boolean> {
186
+ if (!(await fileExists(filePath))) return false;
187
+ if (await isSymlink(filePath)) return false;
188
+ return isTrackedByGit(filePath, repoRoot);
189
+ }
190
+
181
191
  /** Get path relative to cwd, or "." if same directory */
182
192
  export function relativePath(cwd: string, path: string): string {
183
193
  return relative(cwd, path) || ".";
package/src/Mapper.ts CHANGED
@@ -292,14 +292,14 @@ function encodeTargetPath(relPath: string, overlayBase: string): string {
292
292
  if (basename(relPath) === "agents.md") {
293
293
  return join(overlayBase, relPath);
294
294
  }
295
- // .claude/prompts/ and .gemini/prompts/ → prompts/ in overlay (agent-agnostic)
295
+ // <agentDir>/prompts/ (.claude/, .gemini/, .codex/) → prompts/ in overlay (agent-agnostic)
296
296
  for (const agentDir of managedAgentDirs) {
297
297
  const prefix = `${agentDir}/prompts/`;
298
298
  if (relPath.startsWith(prefix)) {
299
299
  return join(overlayBase, "prompts", relPath.slice(prefix.length));
300
300
  }
301
301
  }
302
- // .claude/* and .gemini/* → claude/*, gemini/* in overlay (agent-specific)
302
+ // .claude/*, .gemini/*, .codex/* → claude/*, gemini/*, codex/* in overlay (agent-specific)
303
303
  for (const agentDir of managedAgentDirs) {
304
304
  if (relPath.startsWith(`${agentDir}/`)) {
305
305
  const subPath = relPath.slice(agentDir.length + 1);
@@ -314,12 +314,12 @@ function encodeTargetPath(relPath: string, overlayBase: string): string {
314
314
  return join(overlayBase, "clank", relPath);
315
315
  }
316
316
 
317
- /** Check if path starts with a managed agent dir (.claude/, .gemini/) */
317
+ /** Check if path starts with a managed agent dir (.claude/, .gemini/, .codex/) */
318
318
  function startsWithAgentDir(path: string): boolean {
319
319
  return managedAgentDirs.some((dir) => path.startsWith(`${dir}/`));
320
320
  }
321
321
 
322
- /** Check if path is inside a managed agent dir (.claude/, .gemini/) */
322
+ /** Check if path is inside a managed agent dir (.claude/, .gemini/, .codex/) */
323
323
  function isInsideAgentDir(relPath: string): boolean {
324
324
  return managedAgentDirs.some(
325
325
  (dir) => relPath.startsWith(`${dir}/`) || relPath === dir,
@@ -346,7 +346,7 @@ function decodeOverlayPath(
346
346
  };
347
347
  }
348
348
 
349
- // claude/, gemini/ files → map to .claude/, .gemini/ in target
349
+ // claude/, gemini/, codex/ files → map to .claude/, .gemini/, .codex/ in target
350
350
  for (const agentDir of managedAgentDirs) {
351
351
  const overlayDir = agentDir.slice(1); // "claude" or "gemini"
352
352
  if (relPath.startsWith(`${overlayDir}/`)) {
@@ -59,7 +59,7 @@ export async function verifyManaged(
59
59
  };
60
60
  }
61
61
 
62
- // Prompt files are fanned out to all agent directories (.claude/prompts/, .gemini/prompts/)
62
+ // Prompt files are fanned out to all agent directories (.claude/prompts/, .gemini/prompts/, .codex/prompts/)
63
63
  // Accept any agent's prompts dir as valid if the filename matches
64
64
  if (mapping.targetPath !== linkPath) {
65
65
  if (!isMatchingPromptPath(mapping.targetPath, linkPath)) {
@@ -17,7 +17,7 @@ import {
17
17
  getCwd,
18
18
  getLinkTarget,
19
19
  isSymlink,
20
- isTrackedByGit,
20
+ isTrackedRealFile,
21
21
  walkDirectory,
22
22
  } from "../FsUtil.ts";
23
23
  import { type GitContext, getGitContext } from "../Git.ts";
@@ -48,7 +48,6 @@ interface AddContext {
48
48
  overlayRoot: string;
49
49
  }
50
50
 
51
- /** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
52
51
  interface AgentLinkParams {
53
52
  overlayPath: string;
54
53
  symlinkDir: string;
@@ -72,6 +71,17 @@ type ScopeCounts = {
72
71
  skip: number;
73
72
  };
74
73
 
74
+ interface CreateSymlinksParams {
75
+ filePath: string;
76
+ normalizedPath: string;
77
+ overlayPath: string;
78
+ config: { agents: string[] };
79
+ gitRoot: string;
80
+ overlayRoot: string;
81
+ cwd: string;
82
+ quiet?: boolean;
83
+ }
84
+
75
85
  const scopeLabels: Record<Scope, string> = {
76
86
  global: "global",
77
87
  project: "project",
@@ -92,17 +102,11 @@ export async function addCommand(
92
102
 
93
103
  const ctx = { cwd, gitContext, config, overlayRoot };
94
104
 
95
- if (options.interactive) {
105
+ if (options.interactive || filePaths.length === 0) {
96
106
  await addAllInteractive(ctx);
97
107
  return;
98
108
  }
99
109
 
100
- if (filePaths.length === 0) {
101
- throw new Error(
102
- "No files specified. Use --interactive for interactive mode.",
103
- );
104
- }
105
-
106
110
  for (const filePath of filePaths) {
107
111
  const inputPath = join(cwd, filePath);
108
112
 
@@ -137,18 +141,15 @@ async function validateAddOptions(
137
141
  /** Interactive mode: add all unadded files with per-file scope selection */
138
142
  async function addAllInteractive(ctx: AddContext): Promise<void> {
139
143
  const { cwd, gitContext, overlayRoot } = ctx;
140
- const context: MapperContext = {
141
- overlayRoot,
142
- targetRoot: gitContext.gitRoot,
143
- gitContext,
144
- };
144
+ const { gitRoot: targetRoot } = gitContext;
145
+ const context: MapperContext = { overlayRoot, targetRoot, gitContext };
145
146
 
146
147
  const unadded = await findUnaddedFiles(context);
147
148
  const regularFiles = unadded.filter((f) => f.kind === "unadded");
148
149
 
149
150
  // Also find untracked agent files outside managed dirs (e.g. packages/foo/CLAUDE.md)
150
151
  const agentClassification = await classifyAgentFiles(
151
- gitContext.gitRoot,
152
+ targetRoot,
152
153
  overlayRoot,
153
154
  gitContext,
154
155
  );
@@ -200,7 +201,6 @@ async function addSingleFile(
200
201
  const { quiet } = options;
201
202
 
202
203
  const scope = resolveScopeFromOptions(options);
203
- /** Absolute path where symlink will be created in target repo */
204
204
  const normalizedPath = normalizeAddPath(filePath, cwd, gitRoot);
205
205
  const context: MapperContext = {
206
206
  overlayRoot,
@@ -216,7 +216,6 @@ async function addSingleFile(
216
216
  // Only check barePath for symlink - we want the user's input file, not clank/foo.md
217
217
  const barePath = join(cwd, filePath);
218
218
 
219
- // Check if already in overlay at a different scope
220
219
  await checkScopeConflict(barePath, scope, context, cwd);
221
220
 
222
221
  if (await fileExists(overlayPath)) {
@@ -246,53 +245,6 @@ async function addSingleFile(
246
245
  });
247
246
  }
248
247
 
249
- interface CreateSymlinksParams {
250
- filePath: string;
251
- normalizedPath: string;
252
- overlayPath: string;
253
- config: { agents: string[] };
254
- gitRoot: string;
255
- overlayRoot: string;
256
- cwd: string;
257
- quiet?: boolean;
258
- }
259
-
260
- /** Create symlinks based on file type (agent, prompt, or regular) */
261
- async function createSymlinksForFile(params: CreateSymlinksParams) {
262
- const {
263
- filePath,
264
- normalizedPath,
265
- overlayPath,
266
- config,
267
- gitRoot,
268
- overlayRoot,
269
- cwd,
270
- quiet,
271
- } = params;
272
-
273
- if (isAgentFile(filePath)) {
274
- const symlinkDir = dirname(normalizedPath);
275
- await createAgentLinks({
276
- overlayPath,
277
- symlinkDir,
278
- gitRoot,
279
- overlayRoot,
280
- agents: config.agents,
281
- quiet,
282
- });
283
- } else if (isPromptFile(normalizedPath)) {
284
- await handlePromptFile(normalizedPath, overlayPath, gitRoot, cwd, quiet);
285
- } else {
286
- await handleRegularFile(
287
- normalizedPath,
288
- overlayPath,
289
- overlayRoot,
290
- cwd,
291
- quiet,
292
- );
293
- }
294
- }
295
-
296
248
  /** Prompt for scope and add a single file. Returns the choice or "error". */
297
249
  async function promptAndAddFile(
298
250
  relPath: string,
@@ -332,7 +284,7 @@ async function promptAndAddFile(
332
284
  }
333
285
 
334
286
  /** Print summary of interactive add results */
335
- function printSummary(counts: Record<string, number>): void {
287
+ function printSummary(counts: ScopeCounts): void {
336
288
  const parts: string[] = [];
337
289
  if (counts.project > 0) parts.push(`${counts.project} to project`);
338
290
  if (counts.worktree > 0) parts.push(`${counts.worktree} to worktree`);
@@ -398,6 +350,87 @@ async function addFileToOverlay(
398
350
  }
399
351
  }
400
352
 
353
+ /** Create symlinks based on file type (agent, prompt, or regular) */
354
+ async function createSymlinksForFile(params: CreateSymlinksParams) {
355
+ const { filePath, normalizedPath, overlayPath, config } = params;
356
+ const { gitRoot, overlayRoot, cwd, quiet } = params;
357
+
358
+ if (isAgentFile(filePath)) {
359
+ const symlinkDir = dirname(normalizedPath);
360
+ await createAgentLinks({
361
+ overlayPath,
362
+ symlinkDir,
363
+ gitRoot,
364
+ overlayRoot,
365
+ agents: config.agents,
366
+ quiet,
367
+ });
368
+ } else if (isPromptFile(normalizedPath)) {
369
+ await handlePromptFile(normalizedPath, overlayPath, gitRoot, cwd, quiet);
370
+ } else {
371
+ await handleRegularFile(
372
+ normalizedPath,
373
+ overlayPath,
374
+ overlayRoot,
375
+ cwd,
376
+ quiet,
377
+ );
378
+ }
379
+ }
380
+
381
+ /** Read a single keypress for scope selection */
382
+ async function readScopeChoice(
383
+ isWorktree: boolean,
384
+ ): Promise<InteractiveChoice> {
385
+ const key = await readSingleKey();
386
+
387
+ switch (key.toLowerCase()) {
388
+ case "p":
389
+ case "\r":
390
+ case "\n":
391
+ console.log("project");
392
+ return "project";
393
+ case "w":
394
+ if (!isWorktree) {
395
+ console.log("(not in worktree, using project)");
396
+ return "project";
397
+ }
398
+ console.log("worktree");
399
+ return "worktree";
400
+ case "g":
401
+ console.log("global");
402
+ return "global";
403
+ case "s":
404
+ console.log("skip");
405
+ return "skip";
406
+ case "q":
407
+ case "\x03": // Ctrl+C
408
+ return "quit";
409
+ default:
410
+ console.log("project");
411
+ return "project";
412
+ }
413
+ }
414
+
415
+ /** Find content from normalized path or bare input path */
416
+ async function findSourceContent(
417
+ normalizedPath: string,
418
+ barePath: string,
419
+ ): Promise<string> {
420
+ // Try normalized path first (e.g., cwd/clank/foo.md)
421
+ if (
422
+ (await fileExists(normalizedPath)) &&
423
+ !(await isSymlink(normalizedPath))
424
+ ) {
425
+ return await readFile(normalizedPath, "utf-8");
426
+ }
427
+ // Fall back to bare input path (e.g., cwd/foo.md)
428
+ if ((await fileExists(barePath)) && !(await isSymlink(barePath))) {
429
+ return await readFile(barePath, "utf-8");
430
+ }
431
+ return "";
432
+ }
433
+
401
434
  async function createAgentLinks(p: AgentLinkParams): Promise<void> {
402
435
  const { overlayPath, quiet, ...classifyParams } = p;
403
436
  const { toCreate, existing, skipped } =
@@ -441,9 +474,8 @@ async function handlePromptFile(
441
474
  gitRoot,
442
475
  );
443
476
  if (!quiet && created.length) {
444
- console.log(
445
- `Created symlinks: ${created.map((p) => relative(cwd, p)).join(", ")}`,
446
- );
477
+ const names = created.map((p) => relative(cwd, p)).join(", ");
478
+ console.log(`Created symlinks: ${names}`);
447
479
  }
448
480
  }
449
481
  }
@@ -467,57 +499,27 @@ async function handleRegularFile(
467
499
  }
468
500
  }
469
501
 
470
- /** Read a single keypress for scope selection */
471
- async function readScopeChoice(
472
- isWorktree: boolean,
473
- ): Promise<InteractiveChoice> {
474
- const key = await readSingleKey();
502
+ /** Read a single keypress from stdin (raw mode) */
503
+ function readSingleKey(): Promise<string> {
504
+ return new Promise((resolve) => {
505
+ const wasRaw = process.stdin.isRaw;
506
+ if (process.stdin.isTTY) {
507
+ readline.emitKeypressEvents(process.stdin);
508
+ process.stdin.setRawMode(true);
509
+ }
510
+ process.stdin.resume();
475
511
 
476
- switch (key.toLowerCase()) {
477
- case "p":
478
- case "\r":
479
- case "\n":
480
- console.log("project");
481
- return "project";
482
- case "w":
483
- if (!isWorktree) {
484
- console.log("(not in worktree, using project)");
485
- return "project";
512
+ const onKeypress = (data: Buffer): void => {
513
+ if (process.stdin.isTTY) {
514
+ process.stdin.setRawMode(wasRaw ?? false);
486
515
  }
487
- console.log("worktree");
488
- return "worktree";
489
- case "g":
490
- console.log("global");
491
- return "global";
492
- case "s":
493
- console.log("skip");
494
- return "skip";
495
- case "q":
496
- case "\x03": // Ctrl+C
497
- return "quit";
498
- default:
499
- console.log("project");
500
- return "project";
501
- }
502
- }
516
+ process.stdin.pause();
517
+ process.stdin.removeListener("data", onKeypress);
518
+ resolve(data.toString());
519
+ };
503
520
 
504
- /** Find content from normalized path or bare input path */
505
- async function findSourceContent(
506
- normalizedPath: string,
507
- barePath: string,
508
- ): Promise<string> {
509
- // Try normalized path first (e.g., cwd/clank/foo.md)
510
- if (
511
- (await fileExists(normalizedPath)) &&
512
- !(await isSymlink(normalizedPath))
513
- ) {
514
- return await readFile(normalizedPath, "utf-8");
515
- }
516
- // Fall back to bare input path (e.g., cwd/foo.md)
517
- if ((await fileExists(barePath)) && !(await isSymlink(barePath))) {
518
- return await readFile(barePath, "utf-8");
519
- }
520
- return "";
521
+ process.stdin.once("data", onKeypress);
522
+ });
521
523
  }
522
524
 
523
525
  /** Classify which agent symlinks to create vs skip */
@@ -530,18 +532,12 @@ async function classifyAgentLinks(
530
532
  const toCreate: { targetPath: string; name: string }[] = [];
531
533
 
532
534
  await forEachAgentPath(symlinkDir, agents, async (targetPath) => {
533
- // Check if symlink already points to overlay
534
535
  if (await isSymlinkToOverlay(targetPath, overlayRoot)) {
535
536
  existing.push(basename(targetPath));
536
537
  return;
537
538
  }
538
539
 
539
- const isTrackedFile =
540
- (await fileExists(targetPath)) &&
541
- !(await isSymlink(targetPath)) &&
542
- (await isTrackedByGit(targetPath, gitRoot));
543
-
544
- if (isTrackedFile) {
540
+ if (await isTrackedRealFile(targetPath, gitRoot)) {
545
541
  skipped.push(basename(targetPath));
546
542
  } else {
547
543
  toCreate.push({ targetPath, name: basename(targetPath) });
@@ -550,26 +546,3 @@ async function classifyAgentLinks(
550
546
 
551
547
  return { toCreate, existing, skipped };
552
548
  }
553
-
554
- /** Read a single keypress from stdin (raw mode) */
555
- function readSingleKey(): Promise<string> {
556
- return new Promise((resolve) => {
557
- const wasRaw = process.stdin.isRaw;
558
- if (process.stdin.isTTY) {
559
- readline.emitKeypressEvents(process.stdin);
560
- process.stdin.setRawMode(true);
561
- }
562
- process.stdin.resume();
563
-
564
- const onKeypress = (data: Buffer): void => {
565
- if (process.stdin.isTTY) {
566
- process.stdin.setRawMode(wasRaw ?? false);
567
- }
568
- process.stdin.pause();
569
- process.stdin.removeListener("data", onKeypress);
570
- resolve(data.toString());
571
- };
572
-
573
- process.stdin.once("data", onKeypress);
574
- });
575
- }
@@ -35,11 +35,18 @@ export type UnaddedFile = ManagedFileState & {
35
35
  relativePath: string;
36
36
  };
37
37
 
38
+ export interface CheckOptions {
39
+ prompt?: boolean;
40
+ }
41
+
38
42
  /** Files that should remain local and not be tracked by clank */
39
43
  const localOnlyFiles = ["settings.local.json"];
40
44
 
45
+ /** Switch to a one-line summary when a category has at least this many files */
46
+ const compactThreshold = 5;
47
+
41
48
  /** Check for orphaned overlay paths that don't match target structure */
42
- export async function checkCommand(): Promise<void> {
49
+ export async function checkCommand(options: CheckOptions = {}): Promise<void> {
43
50
  const cwd = await getCwd();
44
51
  const gitContext = await getGitContext(cwd);
45
52
  const config = await loadConfig();
@@ -50,7 +57,7 @@ export async function checkCommand(): Promise<void> {
50
57
 
51
58
  await showOverlayStatus(overlayRoot, ignorePatterns);
52
59
 
53
- const problems = await checkAllProblems(ctx, cwd, ignorePatterns);
60
+ const problems = await checkAllProblems(ctx, cwd, ignorePatterns, options);
54
61
  if (!problems) {
55
62
  console.log("No issues found. Overlay matches target structure.");
56
63
  }
@@ -98,13 +105,8 @@ export async function findOrphans(
98
105
  const isIgnored =
99
106
  ignorePatterns.length > 0 ? picomatch(ignorePatterns) : null;
100
107
 
101
- const skip = (relPath: string): boolean => {
102
- if (isIgnored) {
103
- const pathBasename = basename(relPath);
104
- if (isIgnored(relPath) || isIgnored(pathBasename)) return true;
105
- }
106
- return false;
107
- };
108
+ const skip = (relPath: string): boolean =>
109
+ !!isIgnored && (isIgnored(relPath) || isIgnored(basename(relPath)));
108
110
 
109
111
  for await (const { path, isDirectory } of walkDirectory(projectDir, {
110
112
  skipDirs: [".git", "node_modules", "worktrees"],
@@ -122,7 +124,6 @@ export async function findOrphans(
122
124
  continue;
123
125
  }
124
126
 
125
- // This is a subdirectory file - check if target dir exists
126
127
  // e.g., tools/packages/wesl/clank/notes.md -> check tools/packages/wesl/
127
128
  const targetSubdir = extractTargetSubdir(relPath);
128
129
  if (!targetSubdir) continue;
@@ -173,6 +174,7 @@ async function checkAllProblems(
173
174
  ctx: MapperContext,
174
175
  cwd: string,
175
176
  ignorePatterns: string[] = [],
177
+ options: CheckOptions = {},
176
178
  ): Promise<boolean> {
177
179
  const { overlayRoot, targetRoot, gitContext } = ctx;
178
180
  let hasProblems = false;
@@ -202,7 +204,7 @@ async function checkAllProblems(
202
204
  );
203
205
  if (orphans.length > 0) {
204
206
  hasProblems = true;
205
- showOrphanedPaths(orphans, targetRoot, overlayRoot);
207
+ showOrphanedPaths(orphans, targetRoot, overlayRoot, options);
206
208
  }
207
209
 
208
210
  return hasProblems;
@@ -227,12 +229,10 @@ function isLocalOnlyFile(relPath: string): boolean {
227
229
  * @example "tools/packages/wesl/agents.md" -> "tools/packages/wesl"
228
230
  */
229
231
  function extractTargetSubdir(relPath: string): string | null {
230
- // Check for /clank/ or /claude/ in path
231
232
  for (const dir of managedDirs) {
232
233
  const idx = relPath.indexOf(`/${dir}/`);
233
234
  if (idx !== -1) return relPath.slice(0, idx);
234
235
  }
235
- // Check for agents.md in a subdirectory
236
236
  if (relPath.endsWith("/agents.md")) {
237
237
  return relPath.slice(0, -"/agents.md".length);
238
238
  }
@@ -250,49 +250,27 @@ function showUnaddedFiles(
250
250
  ? `${projectName}/${worktreeName}`
251
251
  : projectName;
252
252
 
253
- const outsideOverlay = unadded.filter((f) => f.kind === "outside-overlay");
254
- const wrongMapping = unadded.filter((f) => f.kind === "wrong-mapping");
253
+ const outsideOverlay = unadded.filter(
254
+ (
255
+ f,
256
+ ): f is UnaddedFile & { kind: "outside-overlay"; currentTarget: string } =>
257
+ f.kind === "outside-overlay",
258
+ );
259
+ const wrongMapping = unadded.filter(
260
+ (
261
+ f,
262
+ ): f is UnaddedFile & {
263
+ kind: "wrong-mapping";
264
+ currentTarget: string;
265
+ expectedTarget: string;
266
+ } => f.kind === "wrong-mapping",
267
+ );
255
268
  const regularFiles = unadded.filter((f) => f.kind === "unadded");
256
269
 
257
- if (outsideOverlay.length > 0) {
258
- console.log(
259
- `Found ${outsideOverlay.length} stale symlink(s) in ${targetName}:\n`,
260
- );
261
- console.log("These symlinks point outside the clank overlay.");
262
- console.log("Remove them manually, then run `clank link` to recreate:\n");
263
- for (const file of outsideOverlay) {
264
- console.log(` rm ${relativePath(cwd, file.targetPath)}`);
265
- }
266
- console.log();
267
- }
268
-
269
- if (wrongMapping.length > 0) {
270
- console.log(
271
- `Found ${wrongMapping.length} mislinked symlink(s) in ${targetName}:\n`,
272
- );
273
- console.log("These symlinks point to the wrong overlay location.");
274
- console.log("Run `clank link` to fix them.\n");
275
- for (const file of wrongMapping) {
276
- console.log(` ${relativePath(cwd, file.targetPath)}`);
277
- if (file.currentTarget) {
278
- console.log(` points to: ${file.currentTarget}`);
279
- }
280
- }
281
- console.log();
282
- }
283
-
284
- if (regularFiles.length > 0) {
285
- console.log(
286
- `Found ${regularFiles.length} unadded file(s) in ${targetName}:\n`,
287
- );
288
- for (const file of regularFiles) {
289
- console.log(` ${relativePath(cwd, file.targetPath)}`);
290
- }
291
- console.log();
292
- console.log(" clank add -i # add interactively");
293
- console.log(" clank add <file> [<file>...] # add specific files");
294
- console.log();
295
- }
270
+ if (outsideOverlay.length > 0)
271
+ showOutsideOverlay(outsideOverlay, cwd, targetName);
272
+ if (wrongMapping.length > 0) showWrongMapping(wrongMapping, cwd, targetName);
273
+ if (regularFiles.length > 0) showUnadded(regularFiles, cwd, targetName);
296
274
  }
297
275
 
298
276
  /** Display orphaned paths and remediation prompt */
@@ -300,12 +278,26 @@ function showOrphanedPaths(
300
278
  orphans: OrphanedPath[],
301
279
  targetRoot: string,
302
280
  overlayRoot: string,
281
+ options: CheckOptions = {},
303
282
  ): void {
304
- console.log(`Found ${orphans.length} orphaned overlay path(s):\n`);
305
- for (const orphan of orphans) {
306
- console.log(` ${orphan.fileName} (${orphan.scope})`);
307
- console.log(` Overlay: ${orphan.overlayPath}`);
308
- console.log(` Expected dir: ${orphan.expectedTargetDir}\n`);
283
+ const compact = orphans.length >= compactThreshold;
284
+
285
+ if (compact) {
286
+ const names = orphans.map((o) => `${o.expectedTargetDir}/${o.fileName}`);
287
+ console.log(`Found ${orphans.length} orphaned overlay path(s):`);
288
+ console.log(` ${formatInlineList(names)}\n`);
289
+ } else {
290
+ console.log(`Found ${orphans.length} orphaned overlay path(s):\n`);
291
+ for (const orphan of orphans) {
292
+ console.log(` ${orphan.fileName} (${orphan.scope})`);
293
+ console.log(` Overlay: ${orphan.overlayPath}`);
294
+ console.log(` Expected dir: ${orphan.expectedTargetDir}\n`);
295
+ }
296
+ }
297
+
298
+ if (compact && !options.prompt) {
299
+ console.log("Run `clank status --prompt` for an agent fix prompt.");
300
+ return;
309
301
  }
310
302
 
311
303
  console.log("Target project:", targetRoot);
@@ -316,6 +308,85 @@ function showOrphanedPaths(
316
308
  console.log("─".repeat(50));
317
309
  }
318
310
 
311
+ function showOutsideOverlay(
312
+ items: (UnaddedFile & { currentTarget: string })[],
313
+ cwd: string,
314
+ targetName: string,
315
+ ): void {
316
+ console.log(`Found ${items.length} stale symlink(s) in ${targetName}:\n`);
317
+ console.log("These symlinks point outside the clank overlay.");
318
+ console.log("Remove them manually, then run `clank link` to recreate:\n");
319
+ for (const file of items) {
320
+ console.log(` rm ${relativePath(cwd, file.targetPath)}`);
321
+ }
322
+ console.log();
323
+ }
324
+
325
+ function showWrongMapping(
326
+ items: (UnaddedFile & { currentTarget: string })[],
327
+ cwd: string,
328
+ targetName: string,
329
+ ): void {
330
+ if (items.length >= compactThreshold) {
331
+ const names = items.map((f) => relativePath(cwd, f.targetPath));
332
+ console.log(
333
+ `Found ${items.length} mislinked symlink(s) in ${targetName} — run \`clank link\` to fix:`,
334
+ );
335
+ console.log(` ${formatInlineList(names)}\n`);
336
+ return;
337
+ }
338
+ console.log(`Found ${items.length} mislinked symlink(s) in ${targetName}:\n`);
339
+ console.log("These symlinks point to the wrong overlay location.");
340
+ console.log("Run `clank link` to fix them.\n");
341
+ for (const file of items) {
342
+ console.log(` ${relativePath(cwd, file.targetPath)}`);
343
+ if (file.currentTarget) {
344
+ console.log(` points to: ${file.currentTarget}`);
345
+ }
346
+ }
347
+ console.log();
348
+ }
349
+
350
+ function showUnadded(
351
+ items: UnaddedFile[],
352
+ cwd: string,
353
+ targetName: string,
354
+ ): void {
355
+ if (items.length >= compactThreshold) {
356
+ const names = items.map((f) => relativePath(cwd, f.targetPath));
357
+ console.log(
358
+ `Found ${items.length} unadded file(s) in ${targetName} — run \`clank add\` to add:`,
359
+ );
360
+ console.log(` ${formatInlineList(names)}\n`);
361
+ return;
362
+ }
363
+ console.log(`Found ${items.length} unadded file(s) in ${targetName}:\n`);
364
+ for (const file of items) {
365
+ console.log(` ${relativePath(cwd, file.targetPath)}`);
366
+ }
367
+ console.log();
368
+ console.log(" clank add # add interactively");
369
+ console.log(" clank add <file> [<file>...] # add specific files");
370
+ console.log();
371
+ }
372
+
373
+ /** Join names with spaces up to maxChars, then append "(+N more)" if truncated */
374
+ function formatInlineList(names: string[], maxChars = 80): string {
375
+ const parts: string[] = [];
376
+ let used = 0;
377
+ for (let i = 0; i < names.length; i++) {
378
+ const next = names[i];
379
+ const sep = parts.length === 0 ? 0 : 1;
380
+ if (parts.length > 0 && used + sep + next.length > maxChars) {
381
+ parts.push(`(+${names.length - i} more)`);
382
+ break;
383
+ }
384
+ parts.push(next);
385
+ used += sep + next.length;
386
+ }
387
+ return parts.join(" ");
388
+ }
389
+
319
390
  /** Generate agent prompt for fixing orphaned paths */
320
391
  function generateAgentPrompt(
321
392
  orphans: OrphanedPath[],
@@ -21,7 +21,7 @@ import {
21
21
  getCwd,
22
22
  getLinkTarget,
23
23
  isSymlink,
24
- isTrackedByGit,
24
+ isTrackedRealFile,
25
25
  toSlash,
26
26
  } from "../FsUtil.ts";
27
27
  import { type GitContext, getGitContext } from "../Git.ts";
@@ -134,27 +134,6 @@ async function cleanStaleAndCheck(
134
134
  }
135
135
  }
136
136
 
137
- /** Consolidate rules into generated AGENTS.md/GEMINI.md if rules exist */
138
- async function maybeConsolidateRules(
139
- overlayRoot: string,
140
- targetRoot: string,
141
- gitContext: GitContext,
142
- config: ClankConfig,
143
- ): Promise<void> {
144
- const consolidated = await consolidateRulesIntoAgentFiles({
145
- overlayRoot,
146
- targetRoot,
147
- gitContext,
148
- agents: config.agents,
149
- });
150
- if (consolidated.length > 0) {
151
- console.log(`\nGenerated consolidated agent files:`);
152
- for (const name of consolidated) {
153
- console.log(` ${name}`);
154
- }
155
- }
156
- }
157
-
158
137
  async function maybeInitWorktree(
159
138
  overlayRoot: string,
160
139
  gitContext: GitContext,
@@ -258,7 +237,7 @@ async function createAgentLinks(
258
237
  }
259
238
  }
260
239
 
261
- /** Create prompt symlinks in all agent directories (.claude/prompts/, .gemini/prompts/) */
240
+ /** Create prompt symlinks in all agent directories (.claude/prompts/, .gemini/prompts/, .codex/prompts/) */
262
241
  async function createPromptLinks(
263
242
  promptsMappings: FileMapping[],
264
243
  targetRoot: string,
@@ -278,6 +257,27 @@ async function createPromptLinks(
278
257
  }
279
258
  }
280
259
 
260
+ /** Consolidate rules into generated AGENTS.md/GEMINI.md if rules exist */
261
+ async function maybeConsolidateRules(
262
+ overlayRoot: string,
263
+ targetRoot: string,
264
+ gitContext: GitContext,
265
+ config: ClankConfig,
266
+ ): Promise<void> {
267
+ const consolidated = await consolidateRulesIntoAgentFiles({
268
+ overlayRoot,
269
+ targetRoot,
270
+ gitContext,
271
+ agents: config.agents,
272
+ });
273
+ if (consolidated.length > 0) {
274
+ console.log(`\nGenerated consolidated agent files:`);
275
+ for (const name of consolidated) {
276
+ console.log(` ${name}`);
277
+ }
278
+ }
279
+ }
280
+
281
281
  /** Setup project settings.json - adopt existing or create new */
282
282
  async function setupProjectSettings(
283
283
  overlayRoot: string,
@@ -432,7 +432,7 @@ async function processAgentMapping(
432
432
  const skipped: string[] = [];
433
433
 
434
434
  await forEachAgentPath(targetDir, agents, async (agentPath) => {
435
- if (await isTrackedFile(agentPath, targetRoot)) {
435
+ if (await isTrackedRealFile(agentPath, targetRoot)) {
436
436
  skipped.push(relative(targetRoot, agentPath));
437
437
  } else {
438
438
  const linkTarget = getLinkTarget(agentPath, overlayPath);
@@ -495,10 +495,3 @@ async function checkMappingParentExists(
495
495
  }
496
496
  return m;
497
497
  }
498
-
499
- /** Check if a file is tracked in git (exists as real file, not symlink, and tracked) */
500
- async function isTrackedFile(path: string, gitRoot: string): Promise<boolean> {
501
- if (!(await fileExists(path))) return false;
502
- if (await isSymlink(path)) return false;
503
- return isTrackedByGit(path, gitRoot);
504
- }
@@ -1,4 +1,5 @@
1
1
  import { join, relative } from "node:path";
2
+ import { managedAgentDirs } from "../../AgentFiles.ts";
2
3
  import { expandPath, loadConfig } from "../../Config.ts";
3
4
  import {
4
5
  getCwd,
@@ -244,7 +245,7 @@ function passesLinkFilter(
244
245
  }
245
246
 
246
247
  function isInDotAgentDir(relPath: string): boolean {
247
- return isInDirectory(relPath, ".claude") || isInDirectory(relPath, ".gemini");
248
+ return managedAgentDirs.some((dir) => isInDirectory(relPath, dir));
248
249
  }
249
250
 
250
251
  /** Enforce a max segment count beneath the nearest `clank/` path component. */