clank-cli 0.1.59 → 0.1.61

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 CHANGED
@@ -8,6 +8,7 @@ Common commands:
8
8
  - **`clank link`** to connect overlay files to your project.
9
9
  - **`clank commit`** to commit changes in the overlay repository.
10
10
  - **`clank check`** to show overlay status and find misaligned files.
11
+ - **`clank files`** to list clank-managed files for piping into tools like `rg`.
11
12
 
12
13
  ## Why a Separate Repository?
13
14
 
@@ -137,6 +138,29 @@ clank check
137
138
  # The following overlay files no longer match...
138
139
  ```
139
140
 
141
+ ### `clank files [path]`
142
+
143
+ List clank-managed files in the current repo as paths relative to your current directory (useful for `xargs rg` workflows).
144
+
145
+ By default, this includes `clank/` files and agent files (`AGENTS.md`, etc.), but excludes dot-prefixed directories like `.claude/` and `.gemini/`. Use `--hidden` to include those.
146
+
147
+ **Options:**
148
+ - `--hidden` - Include files under dot-prefixed directories (`.claude/`, `.gemini/`)
149
+ - `--depth <n>` - Max depth under `clank/` directories (e.g. `--depth 1` includes `*/clank/*.md` but excludes `*/clank/*/*.md`)
150
+ - `-0, --null` - NUL-separate output paths (recommended when piping to `xargs`)
151
+ - `--no-dedupe` - Disable deduplication of agent files and prompts
152
+ - `--linked-only` - Only include symlinks into the overlay
153
+ - `--unlinked-only` - Only include non-overlay files/symlinks
154
+ - `--global|--project|--worktree` - Only include linked files from that scope (implies `--linked-only`)
155
+
156
+ **Examples:**
157
+ ```bash
158
+ clank files -0 | xargs -0 rg "TODO"
159
+ clank files --depth 1
160
+ clank files --hidden | rg '^\\.claude/'
161
+ clank files . # Only this directory/subtree (relative to cwd)
162
+ ```
163
+
140
164
  ### `clank rm <files...>` (alias: `remove`)
141
165
 
142
166
  Remove file(s) from both the overlay repository and the local project symlinks. Accepts [scope options](#scope-options); if omitted, clank detects the scope from the symlink.
@@ -226,16 +250,18 @@ export default {
226
250
  overlayRepo: "~/clankover",
227
251
  agents: ["agents", "claude", "gemini"],
228
252
  vscodeSettings: "auto", // "auto" | "always" | "never"
229
- vscodeGitignore: true
253
+ vscodeGitignore: true,
254
+ ignore: [".obsidian", "*.bak"]
230
255
  };
231
256
  ```
232
257
 
233
- - `agents` - which symlinks to create for agent files like CLAUDE.md
258
+ - `agents` - which symlinks to create for agent files like CLAUDE.md; also controls which agent file/prompt path is preferred for `clank files` output when deduping.
234
259
  - `vscodeSettings` - when to generate `.vscode/settings.json` to show clank files in VS Code
235
260
  - `"auto"` (default): only if project already has a `.vscode` directory
236
261
  - `"always"`: always generate settings
237
262
  - `"never"`: never auto-generate (you can still run `clank vscode` manually)
238
263
  - `vscodeGitignore` - add `.vscode/settings.json` to `.git/info/exclude` (default: true)
264
+ - `ignore` - glob patterns to skip in the overlay (e.g., `[".obsidian", "*.bak", ".DS_Store"]`).
239
265
 
240
266
  By default, clank creates symlinks for AGENTS.md, CLAUDE.md, and GEMINI.md.
241
267
  Run `clank unlink` then `clank link` to apply config changes.
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.59",
4
+ "version": "0.1.61",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
@@ -14,11 +14,13 @@
14
14
  "repository": "github:mighdoll/clank",
15
15
  "dependencies": {
16
16
  "commander": "^14.0.2",
17
- "cosmiconfig": "^9.0.0"
17
+ "cosmiconfig": "^9.0.0",
18
+ "picomatch": "^4.0.3"
18
19
  },
19
20
  "devDependencies": {
20
- "@biomejs/biome": "^2.3.8",
21
+ "@biomejs/biome": "^2.3.10",
21
22
  "@types/node": "^24.10.1",
23
+ "@types/picomatch": "^4.0.2",
22
24
  "@typescript/native-preview": "7.0.0-dev.20251221.1",
23
25
  "@vitest/ui": "^4.0.16",
24
26
  "execa": "^9.6.1",
package/src/Cli.ts CHANGED
@@ -4,6 +4,7 @@ import { defaultOverlayDir, setConfigPath } from "./Config.ts";
4
4
  import { addCommand } from "./commands/Add.ts";
5
5
  import { checkCommand } from "./commands/Check.ts";
6
6
  import { commitCommand } from "./commands/Commit.ts";
7
+ import { filesCommand } from "./commands/Files.ts";
7
8
  import { initCommand } from "./commands/Init.ts";
8
9
  import { linkCommand } from "./commands/Link.ts";
9
10
  import { moveCommand } from "./commands/Move.ts";
@@ -145,6 +146,8 @@ function registerUtilityCommands(program: Command): void {
145
146
  .description("Show overlay status and check for issues")
146
147
  .action(withErrorHandling(checkCommand));
147
148
 
149
+ registerFilesCommand(program);
150
+
148
151
  program
149
152
  .command("vscode")
150
153
  .description("Generate VS Code settings to show clank files")
@@ -152,22 +155,64 @@ function registerUtilityCommands(program: Command): void {
152
155
  .action(withErrorHandling(vscodeCommand));
153
156
  }
154
157
 
158
+ function registerFilesCommand(program: Command): void {
159
+ const files = program
160
+ .command("files")
161
+ .description("List clank-managed files (paths relative to cwd)")
162
+ .argument(
163
+ "[path]",
164
+ "Limit to this directory/subtree (relative to cwd; default: repo root)",
165
+ )
166
+ .option("--hidden", "Include files under dot-prefixed directories")
167
+ .option("--depth <n>", "Max depth under clank/ directories")
168
+ .option("-0, --null", "NUL-separate output paths")
169
+ .option("--no-dedupe", "Disable deduplication");
170
+
171
+ files.addOption(
172
+ new Option(
173
+ "-g, --global",
174
+ "Only include linked files from global scope",
175
+ ).conflicts(["project", "worktree"]),
176
+ );
177
+ files.addOption(
178
+ new Option(
179
+ "-p, --project",
180
+ "Only include linked files from project scope",
181
+ ).conflicts(["global", "worktree"]),
182
+ );
183
+ files.addOption(
184
+ new Option(
185
+ "-w, --worktree",
186
+ "Only include linked files from worktree scope",
187
+ ).conflicts(["global", "project"]),
188
+ );
189
+ files.addOption(
190
+ new Option(
191
+ "--linked-only",
192
+ "Only include symlinks into the overlay",
193
+ ).conflicts(["unlinkedOnly"]),
194
+ );
195
+ files.addOption(
196
+ new Option(
197
+ "--unlinked-only",
198
+ "Only include non-overlay files/symlinks",
199
+ ).conflicts(["linkedOnly"]),
200
+ );
201
+
202
+ files.action(withErrorHandling(filesCommand));
203
+ }
204
+
155
205
  function registerHelpCommands(program: Command): void {
156
206
  const help = program
157
207
  .command("help")
158
208
  .description("Show help information")
159
209
  .argument("[command]", "Command to show help for")
160
210
  .action((commandName?: string) => {
161
- if (!commandName) {
162
- program.help();
163
- }
211
+ if (!commandName) return program.help();
164
212
  const subcommand = program.commands.find((c) => c.name() === commandName);
165
- if (subcommand) {
166
- subcommand.help();
167
- } else {
168
- console.error(`Unknown command: ${commandName}`);
169
- process.exit(1);
170
- }
213
+ if (subcommand) return subcommand.help();
214
+ console.error(`Unknown command: ${commandName}`);
215
+ process.exit(1);
171
216
  });
172
217
  help
173
218
  .command("structure")
package/src/Config.ts CHANGED
@@ -13,6 +13,8 @@ export interface ClankConfig {
13
13
  vscodeSettings?: "auto" | "always" | "never";
14
14
  /** Add .vscode/settings.json to .git/info/exclude (default: true) */
15
15
  vscodeGitignore?: boolean;
16
+ /** Patterns to ignore when walking overlay (e.g., [".obsidian", "*.bak"]) */
17
+ ignore?: string[];
16
18
  }
17
19
 
18
20
  const defaultConfig: ClankConfig = {
package/src/FsUtil.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Dirent } from "node:fs";
1
2
  import {
2
3
  lstat,
3
4
  mkdir,
@@ -11,6 +12,22 @@ import {
11
12
  import { dirname, isAbsolute, join, relative } from "node:path";
12
13
  import { execFileAsync } from "./Exec.ts";
13
14
 
15
+ export interface WalkOptions {
16
+ /** Directories to skip (default: [".git", "node_modules"]) */
17
+ skipDirs?: string[];
18
+ /** Include dot-prefixed directories (default: true) */
19
+ includeHiddenDirs?: boolean;
20
+ /** Skip entries matching this predicate (checked before recursing into directories) */
21
+ skip?: (relPath: string, isDirectory: boolean) => boolean;
22
+ }
23
+
24
+ interface WalkContext {
25
+ root: string;
26
+ skipDirs: string[];
27
+ includeHidden: boolean;
28
+ skip?: (relPath: string, isDirectory: boolean) => boolean;
29
+ }
30
+
14
31
  /**
15
32
  * Create a symbolic link, removing existing link/file first
16
33
  * @param target - The path the symlink should point to (absolute)
@@ -56,28 +73,27 @@ export function getLinkTarget(_from: string, to: string): string {
56
73
  /** Recursively walk a directory, yielding all files and directories */
57
74
  export async function* walkDirectory(
58
75
  dir: string,
59
- options: { skipDirs?: string[] } = {},
76
+ options: WalkOptions = {},
77
+ rootDir?: string,
60
78
  ): AsyncGenerator<{ path: string; isDirectory: boolean }> {
61
- const skipDirs = options.skipDirs || [".git", "node_modules"];
62
-
63
- try {
64
- const entries = await readdir(dir, { withFileTypes: true });
65
-
66
- for (const entry of entries) {
67
- if (skipDirs.includes(entry.name)) continue;
68
-
69
- const fullPath = join(dir, entry.name);
70
-
71
- if (entry.isDirectory()) {
72
- yield { path: fullPath, isDirectory: true };
73
- yield* walkDirectory(fullPath, options);
74
- } else if (entry.isFile() || entry.isSymbolicLink()) {
75
- yield { path: fullPath, isDirectory: false };
79
+ const ctx: WalkContext = {
80
+ root: rootDir ?? dir,
81
+ skipDirs: options.skipDirs ?? [".git", "node_modules"],
82
+ includeHidden: options.includeHiddenDirs ?? true,
83
+ skip: options.skip,
84
+ };
85
+
86
+ const entries = await tryReadDir(dir);
87
+ if (!entries) return;
88
+
89
+ for (const entry of entries) {
90
+ for (const result of processEntry(entry, dir, ctx)) {
91
+ if ("recurse" in result) {
92
+ yield* walkDirectory(result.recurse, result.options, result.root);
93
+ } else {
94
+ yield result;
76
95
  }
77
96
  }
78
- } catch (_error) {
79
- // Directory doesn't exist or can't be read
80
- return;
81
97
  }
82
98
  }
83
99
 
@@ -161,3 +177,49 @@ export async function writeJsonFile(
161
177
  ): Promise<void> {
162
178
  await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`);
163
179
  }
180
+
181
+ async function tryReadDir(dir: string): Promise<Dirent[] | null> {
182
+ try {
183
+ return await readdir(dir, { withFileTypes: true });
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ function* processEntry(
190
+ entry: Dirent,
191
+ dir: string,
192
+ ctx: WalkContext,
193
+ ): Generator<
194
+ | { path: string; isDirectory: boolean }
195
+ | { recurse: string; options: WalkOptions; root: string }
196
+ > {
197
+ const fullPath = join(dir, entry.name);
198
+ const relPath = relative(ctx.root, fullPath);
199
+
200
+ if (entry.isDirectory()) {
201
+ if (shouldSkipDir(entry.name, ctx.skipDirs, ctx.includeHidden)) return;
202
+ if (ctx.skip?.(relPath, true)) return;
203
+ yield { path: fullPath, isDirectory: true };
204
+ yield {
205
+ recurse: fullPath,
206
+ options: {
207
+ skipDirs: ctx.skipDirs,
208
+ includeHiddenDirs: ctx.includeHidden,
209
+ skip: ctx.skip,
210
+ },
211
+ root: ctx.root,
212
+ };
213
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
214
+ if (ctx.skip?.(relPath, false)) return;
215
+ yield { path: fullPath, isDirectory: false };
216
+ }
217
+ }
218
+
219
+ function shouldSkipDir(
220
+ name: string,
221
+ skipDirs: string[],
222
+ includeHidden: boolean,
223
+ ): boolean {
224
+ return skipDirs.includes(name) || (!includeHidden && name.startsWith("."));
225
+ }
package/src/Mapper.ts CHANGED
@@ -153,7 +153,7 @@ function encodeTargetPath(relPath: string, overlayBase: string): string {
153
153
  }
154
154
  }
155
155
  // Files with clank/ in path → preserve structure
156
- if (relPath.includes("clank/")) {
156
+ if (isClankPath(relPath)) {
157
157
  return join(overlayBase, relPath);
158
158
  }
159
159
  // Plain files → add clank/ prefix
@@ -217,6 +217,11 @@ export function normalizeAddPath(
217
217
  return join(normalizedCwd, "clank", filename);
218
218
  }
219
219
 
220
+ /** Check if a relative path contains a clank/ directory component */
221
+ export function isClankPath(relPath: string): boolean {
222
+ return relPath.startsWith("clank/") || relPath.includes("/clank/");
223
+ }
224
+
220
225
  /** Check if a filename is an agent file (CLAUDE.md, GEMINI.md, AGENTS.md) */
221
226
  export function isAgentFile(filename: string): boolean {
222
227
  const name = basename(filename).toLowerCase();
@@ -278,7 +283,7 @@ function decodeOverlayPath(
278
283
  scope: Scope,
279
284
  ): TargetMapping | null {
280
285
  // clank/ files (at root or in subdirectories)
281
- if (relPath.startsWith("clank/") || relPath.includes("/clank/")) {
286
+ if (isClankPath(relPath)) {
282
287
  return { targetPath: join(targetRoot, relPath), scope };
283
288
  }
284
289
 
@@ -1,5 +1,6 @@
1
1
  import { lstat } from "node:fs/promises";
2
- import { dirname, join, relative } from "node:path";
2
+ import { dirname, join } from "node:path";
3
+ import picomatch from "picomatch";
3
4
  import { managedAgentDirs } from "./AgentFiles.ts";
4
5
  import {
5
6
  createSymlink,
@@ -73,16 +74,6 @@ export async function verifyManaged(
73
74
  }
74
75
  }
75
76
 
76
- /** Check if two paths are equivalent prompt files in different agent directories */
77
- function isMatchingPromptPath(
78
- canonicalPath: string,
79
- actualPath: string,
80
- ): boolean {
81
- const canonicalPrompt = getPromptRelPath(canonicalPath);
82
- const actualPrompt = getPromptRelPath(actualPath);
83
- return canonicalPrompt !== null && canonicalPrompt === actualPrompt;
84
- }
85
-
86
77
  /** Check if a path is a symlink pointing to the overlay repository */
87
78
  export async function isSymlinkToOverlay(
88
79
  linkPath: string,
@@ -104,15 +95,25 @@ export async function isSymlinkToOverlay(
104
95
  /** Walk overlay directory and yield all files that should be linked (excludes init/ templates) */
105
96
  export async function* walkOverlayFiles(
106
97
  overlayRoot: string,
98
+ ignorePatterns: string[] = [],
107
99
  ): AsyncGenerator<string> {
108
- for await (const { path, isDirectory } of walkDirectory(overlayRoot, {
109
- skipDirs: [".git", "node_modules"],
110
- })) {
111
- if (isDirectory) continue;
100
+ const isIgnored =
101
+ ignorePatterns.length > 0 ? picomatch(ignorePatterns) : null;
112
102
 
113
- const relPath = relative(overlayRoot, path);
114
- if (relPath.startsWith("clank/init/")) continue;
103
+ const skip = (relPath: string): boolean => {
104
+ // Skip templates
105
+ if (relPath.startsWith("clank/init/")) return true;
106
+ // Check ignore patterns against relative path and basename
107
+ if (isIgnored) {
108
+ const basename = relPath.split("/").at(-1) ?? "";
109
+ if (isIgnored(relPath) || isIgnored(basename)) return true;
110
+ }
111
+ return false;
112
+ };
115
113
 
114
+ const genEntries = walkDirectory(overlayRoot, { skip });
115
+ for await (const { path, isDirectory } of genEntries) {
116
+ if (isDirectory) continue;
116
117
  yield path;
117
118
  }
118
119
  }
@@ -133,3 +134,13 @@ export async function createPromptLinks(
133
134
  }
134
135
  return created;
135
136
  }
137
+
138
+ /** Check if two paths are equivalent prompt files in different agent directories */
139
+ function isMatchingPromptPath(
140
+ canonicalPath: string,
141
+ actualPath: string,
142
+ ): boolean {
143
+ const canonicalPrompt = getPromptRelPath(canonicalPath);
144
+ const actualPrompt = getPromptRelPath(actualPath);
145
+ return canonicalPrompt !== null && canonicalPrompt === actualPrompt;
146
+ }
package/src/Util.ts CHANGED
@@ -11,3 +11,13 @@ export function partition<T>(
11
11
  }
12
12
  return [pass, fail];
13
13
  }
14
+
15
+ /** Filter an array, returning the truthy results of the filter function */
16
+ export function filterMap<T, U>(arr: T[], fn: (t: T) => U | undefined): U[] {
17
+ const out: U[] = [];
18
+ for (const t of arr) {
19
+ const u = fn(t);
20
+ if (u) out.push(u);
21
+ }
22
+ return out;
23
+ }
@@ -0,0 +1,38 @@
1
+ import { dedupeEntries } from "./files/Dedupe.ts";
2
+ import {
3
+ collectEntries,
4
+ type FileEntry,
5
+ type FilesOptions,
6
+ getFilesContext,
7
+ normalizeFilesOptions,
8
+ } from "./files/Scan.ts";
9
+
10
+ /** List clank-managed files in the current target repository. */
11
+ export async function filesCommand(
12
+ inputPath?: string,
13
+ options?: FilesOptions,
14
+ ): Promise<void> {
15
+ const ctx = await getFilesContext(inputPath);
16
+ const opts = normalizeFilesOptions(options);
17
+ const entries = await collectEntries(ctx, opts);
18
+ const output = buildOutput(entries, opts.dedupe, ctx.agentsPreference);
19
+ writeOutput(output, opts.null);
20
+ }
21
+
22
+ /** Convert filtered entries to sorted, cwd-relative output paths. */
23
+ function buildOutput(
24
+ entries: FileEntry[],
25
+ dedupe: boolean,
26
+ agentsPreference: string[],
27
+ ): string[] {
28
+ const filtered = dedupe ? dedupeEntries(entries, agentsPreference) : entries;
29
+ return filtered
30
+ .map((e) => e.cwdRelativePath)
31
+ .sort((a, b) => a.localeCompare(b));
32
+ }
33
+
34
+ /** Emit the final list in a form that is friendly to `xargs` and friends. */
35
+ function writeOutput(paths: string[], nul: boolean): void {
36
+ const sep = nul ? "\0" : "\n";
37
+ process.stdout.write(paths.join(sep) + (paths.length === 0 ? "" : sep));
38
+ }
@@ -69,7 +69,13 @@ export async function linkCommand(targetDir?: string): Promise<void> {
69
69
  await maybeInitWorktree(overlayRoot, gitContext);
70
70
 
71
71
  // Collect and separate mappings (agents.md and prompts get special handling)
72
- const mappings = await overlayMappings(overlayRoot, gitContext, targetRoot);
72
+ const ignorePatterns = config.ignore ?? [];
73
+ const mappings = await overlayMappings(
74
+ overlayRoot,
75
+ gitContext,
76
+ targetRoot,
77
+ ignorePatterns,
78
+ );
73
79
  const agentsMappings = mappings.filter(
74
80
  (m) => basename(m.targetPath) === "agents.md",
75
81
  );
@@ -170,25 +176,27 @@ async function overlayMappings(
170
176
  overlayRoot: string,
171
177
  gitContext: GitContext,
172
178
  targetRoot: string,
179
+ ignorePatterns: string[] = [],
173
180
  ): Promise<FileMapping[]> {
174
181
  const context: MapperContext = { overlayRoot, targetRoot, gitContext };
175
182
  const overlayGlobal = join(overlayRoot, "global");
176
183
  const overlayProject = overlayProjectDir(overlayRoot, gitContext.projectName);
177
184
 
178
185
  return [
179
- ...(await dirMappings(overlayGlobal, context)),
180
- ...(await dirMappings(overlayProject, context)),
186
+ ...(await dirMappings(overlayGlobal, context, ignorePatterns)),
187
+ ...(await dirMappings(overlayProject, context, ignorePatterns)),
181
188
  ];
182
189
  }
183
190
 
184
191
  async function dirMappings(
185
192
  dir: string,
186
193
  context: MapperContext,
194
+ ignorePatterns: string[] = [],
187
195
  ): Promise<FileMapping[]> {
188
196
  if (!(await fileExists(dir))) return [];
189
197
 
190
198
  const mappings: FileMapping[] = [];
191
- for await (const overlayPath of walkOverlayFiles(dir)) {
199
+ for await (const overlayPath of walkOverlayFiles(dir, ignorePatterns)) {
192
200
  const result = overlayToTarget(overlayPath, context);
193
201
  if (result) {
194
202
  mappings.push({ overlayPath, ...result });
@@ -0,0 +1,134 @@
1
+ import { dirname } from "node:path";
2
+ import { agentFiles } from "../../AgentFiles.ts";
3
+ import { getPromptRelPath } from "../../Mapper.ts";
4
+ import { partition } from "../../Util.ts";
5
+ import { type FileEntry, isInDirectory } from "./Scan.ts";
6
+
7
+ /** Apply dedupe rules for agent files and prompt fanout paths. */
8
+ export function dedupeEntries(
9
+ entries: FileEntry[],
10
+ agentsPreference: string[],
11
+ ): FileEntry[] {
12
+ const dedupedAgents = dedupeAgentFiles(entries, agentsPreference);
13
+ return dedupePromptFiles(dedupedAgents, agentsPreference);
14
+ }
15
+
16
+ /** Check if a relative path ends with an agent filename (CLAUDE.md, etc.) */
17
+ export function isAgentFilePath(relPath: string): boolean {
18
+ const base = relPath.split("/").at(-1)?.toLowerCase();
19
+ if (!base) return false;
20
+ return agentFiles.some((f) => f.toLowerCase() === base);
21
+ }
22
+
23
+ /** Keep at most one agent file per directory, using the configured preference order. */
24
+ function dedupeAgentFiles(
25
+ entries: FileEntry[],
26
+ agentsPreference: string[],
27
+ ): FileEntry[] {
28
+ const byDir = Map.groupBy(entries, (e) =>
29
+ isAgentFilePath(e.targetRelativePath) ? dirname(e.targetRelativePath) : "",
30
+ );
31
+ const preferred = agentPreferenceToFilename(agentsPreference);
32
+
33
+ return [...byDir].flatMap(([dirKey, group]) => {
34
+ if (dirKey === "") return group;
35
+ const [candidates, others] = partition(group, (e) =>
36
+ isAgentFilePath(e.targetRelativePath),
37
+ );
38
+ const chosen = chooseByBasename(candidates, preferred);
39
+ return chosen ? [...others, chosen] : others;
40
+ });
41
+ }
42
+
43
+ /** Keep only one prompt path per prompt-relative filename, using agent preference order. */
44
+ function dedupePromptFiles(
45
+ entries: FileEntry[],
46
+ agentsPreference: string[],
47
+ ): FileEntry[] {
48
+ const preferred = agentPreferenceToDotAgentDir(agentsPreference);
49
+ const promptGroups = Map.groupBy(
50
+ entries,
51
+ (e) => getPromptRelPath(e.absolutePath) ?? "",
52
+ );
53
+
54
+ return [...promptGroups].flatMap(([key, group]) => {
55
+ if (key === "") return group;
56
+ const chosen = choosePreferredPrompt(group, preferred);
57
+ return chosen ? [chosen] : [];
58
+ });
59
+ }
60
+
61
+ /** Convert config preference strings into a basename priority list. */
62
+ function agentPreferenceToFilename(preference: string[]): string[] {
63
+ return mapPreference(
64
+ preference,
65
+ { agents: "AGENTS.md", claude: "CLAUDE.md", gemini: "GEMINI.md" },
66
+ ["AGENTS.md", "CLAUDE.md", "GEMINI.md"],
67
+ );
68
+ }
69
+
70
+ /** Select a single representative entry, preferring basenames in priority order. */
71
+ function chooseByBasename(
72
+ entries: FileEntry[],
73
+ preferredBasenames: string[],
74
+ ): FileEntry | null {
75
+ const byBase = new Map(
76
+ entries.map((e) => [basenameUpper(e.targetRelativePath), e]),
77
+ );
78
+ for (const base of preferredBasenames) {
79
+ const found = byBase.get(base.toUpperCase());
80
+ if (found) return found;
81
+ }
82
+ // Fallback: pick first alphabetically (entries should never be empty here)
83
+ const sorted = entries.toSorted((a, b) =>
84
+ a.cwdRelativePath.localeCompare(b.cwdRelativePath),
85
+ );
86
+ return sorted[0] ?? null;
87
+ }
88
+
89
+ /** Convert config preference strings into a dot-agent directory priority list. */
90
+ function agentPreferenceToDotAgentDir(preference: string[]): string[] {
91
+ return mapPreference(preference, { claude: ".claude", gemini: ".gemini" }, [
92
+ ".claude",
93
+ ".gemini",
94
+ ]);
95
+ }
96
+
97
+ /** Pick the prompt entry to keep based on which agent directory it lives under. */
98
+ function choosePreferredPrompt(
99
+ entries: FileEntry[],
100
+ preferredDirs: string[],
101
+ ): FileEntry | null {
102
+ const byDir = new Map<string, FileEntry>();
103
+ for (const e of entries) {
104
+ const rel = e.targetRelativePath;
105
+ if (isInDirectory(rel, ".claude/prompts")) {
106
+ byDir.set(".claude", e);
107
+ } else if (isInDirectory(rel, ".gemini/prompts")) {
108
+ byDir.set(".gemini", e);
109
+ }
110
+ }
111
+ for (const dir of preferredDirs) {
112
+ const found = byDir.get(dir);
113
+ if (found) return found;
114
+ }
115
+ const sorted = entries.toSorted((a, b) =>
116
+ a.cwdRelativePath.localeCompare(b.cwdRelativePath),
117
+ );
118
+ return sorted[0];
119
+ }
120
+
121
+ /** Map preference strings through a mapping, with defaults if empty. */
122
+ function mapPreference(
123
+ preference: string[],
124
+ mapping: Record<string, string>,
125
+ defaults: string[],
126
+ ): string[] {
127
+ const order = preference.map((p) => mapping[p.toLowerCase()]).filter(Boolean);
128
+ return order.length > 0 ? order : defaults;
129
+ }
130
+
131
+ function basenameUpper(relPath: string): string {
132
+ const base = relPath.split("/").at(-1) ?? "";
133
+ return base.toUpperCase();
134
+ }
@@ -0,0 +1,278 @@
1
+ import { join, relative } from "node:path";
2
+ import { expandPath, loadConfig } from "../../Config.ts";
3
+ import { resolveSymlinkTarget, walkDirectory } from "../../FsUtil.ts";
4
+ import { getGitContext } from "../../Git.ts";
5
+ import { isClankPath } from "../../Mapper.ts";
6
+ import { isAgentFilePath } from "./Dedupe.ts";
7
+
8
+ export interface FilesOptions {
9
+ /** Include files under dot-prefixed directories (e.g. .claude/) */
10
+ hidden?: boolean;
11
+
12
+ /** Max depth under clank/ directories (segments after clank/) */
13
+ depth?: string;
14
+
15
+ /** Output NUL-separated paths */
16
+ null?: boolean;
17
+
18
+ /** Disable deduplication */
19
+ dedupe?: boolean;
20
+
21
+ /** Only include symlinks into the overlay */
22
+ linkedOnly?: boolean;
23
+
24
+ /** Only include non-overlay files/symlinks */
25
+ unlinkedOnly?: boolean;
26
+
27
+ /** Only include linked files from global scope (implies `linkedOnly`) */
28
+ global?: boolean;
29
+
30
+ /** Only include linked files from project scope (implies `linkedOnly`) */
31
+ project?: boolean;
32
+
33
+ /** Only include linked files from worktree scope (implies `linkedOnly`) */
34
+ worktree?: boolean;
35
+ }
36
+
37
+ export type LinkState = LinkedToOverlay | NotLinkedToOverlay;
38
+
39
+ export interface FileEntry {
40
+ absolutePath: string;
41
+ cwdRelativePath: string;
42
+ targetRelativePath: string;
43
+ link: LinkState;
44
+ }
45
+
46
+ export interface FilesContext {
47
+ cwd: string;
48
+ gitContext: Awaited<ReturnType<typeof getGitContext>>;
49
+ targetRoot: string;
50
+ scanRoot: string;
51
+ overlayRoot: string;
52
+ agentsPreference: string[];
53
+ }
54
+
55
+ export interface NormalizedFilesOptions {
56
+ hidden: boolean;
57
+ depth: number | null;
58
+ null: boolean;
59
+ dedupe: boolean;
60
+ linkedOnly: boolean;
61
+ unlinkedOnly: boolean;
62
+ scopeFilter: "global" | "project" | "worktree" | null;
63
+ }
64
+
65
+ interface LinkedToOverlay {
66
+ kind: "linked";
67
+ overlayPath: string;
68
+ scope: "global" | "project" | "worktree";
69
+ }
70
+
71
+ interface NotLinkedToOverlay {
72
+ kind: "unlinked";
73
+ }
74
+
75
+ /** Gather derived paths and configuration needed to scan the repository. */
76
+ export async function getFilesContext(
77
+ inputPath?: string,
78
+ ): Promise<FilesContext> {
79
+ const cwd = process.cwd();
80
+ const gitContext = await getGitContext(cwd);
81
+ const targetRoot = gitContext.gitRoot;
82
+ const scanRoot = resolveScanRoot(targetRoot, cwd, inputPath);
83
+ const config = await loadConfig();
84
+
85
+ return {
86
+ cwd,
87
+ gitContext,
88
+ targetRoot,
89
+ scanRoot,
90
+ overlayRoot: expandPath(config.overlayRepo),
91
+ agentsPreference: config.agents,
92
+ };
93
+ }
94
+
95
+ /** Apply defaults and turn user-facing flags into a stable internal shape. */
96
+ export function normalizeFilesOptions(
97
+ options?: FilesOptions,
98
+ ): NormalizedFilesOptions {
99
+ const hidden = options?.hidden ?? false;
100
+ const depthRaw = options?.depth?.trim() ?? "";
101
+ const depth = depthRaw === "" ? null : parseDepth(depthRaw);
102
+
103
+ const scopeFilter = scopeFilterFromOptions({
104
+ global: options?.global ?? false,
105
+ project: options?.project ?? false,
106
+ worktree: options?.worktree ?? false,
107
+ });
108
+
109
+ return {
110
+ hidden,
111
+ depth,
112
+ null: options?.null ?? false,
113
+ dedupe: options?.dedupe ?? true,
114
+ linkedOnly: options?.linkedOnly ?? false,
115
+ unlinkedOnly: options?.unlinkedOnly ?? false,
116
+ scopeFilter,
117
+ };
118
+ }
119
+
120
+ /** Walk the scan root and collect candidates that match filters. */
121
+ export async function collectEntries(
122
+ ctx: FilesContext,
123
+ opts: NormalizedFilesOptions,
124
+ ): Promise<FileEntry[]> {
125
+ const entries: FileEntry[] = [];
126
+ for await (const { path, isDirectory } of walkDirectory(ctx.scanRoot, {
127
+ includeHiddenDirs: opts.hidden,
128
+ })) {
129
+ if (isDirectory) continue;
130
+ const entry = await maybeCreateEntry(ctx, opts, path);
131
+ if (entry) entries.push(entry);
132
+ }
133
+ return entries;
134
+ }
135
+
136
+ /** Check if a relative path is under a specific directory component. */
137
+ export function isInDirectory(relPath: string, dirName: string): boolean {
138
+ return relPath.startsWith(`${dirName}/`) || relPath.includes(`/${dirName}/`);
139
+ }
140
+
141
+ /** Resolve the scan root and reject paths that escape the git repository. */
142
+ function resolveScanRoot(
143
+ targetRoot: string,
144
+ cwd: string,
145
+ input?: string,
146
+ ): string {
147
+ if (!input) return targetRoot;
148
+ const resolved = join(cwd, input);
149
+ const rel = normalizeRelPath(relative(targetRoot, resolved));
150
+ if (rel.startsWith("..")) {
151
+ throw new Error(`Path is outside the git repository: ${input}`);
152
+ }
153
+ return resolved;
154
+ }
155
+
156
+ /** Parse a user-supplied numeric flag and reject negative/invalid values. */
157
+ function parseDepth(raw: string): number {
158
+ const n = Number.parseInt(raw, 10);
159
+ if (!Number.isFinite(n) || Number.isNaN(n) || n < 0) {
160
+ throw new Error(`Invalid --depth value: ${raw}`);
161
+ }
162
+ return n;
163
+ }
164
+
165
+ /** Resolve scope filter from CLI options (null = no filter, show all scopes) */
166
+ function scopeFilterFromOptions(
167
+ options: Required<Pick<FilesOptions, "global" | "project" | "worktree">>,
168
+ ): "global" | "project" | "worktree" | null {
169
+ if (options.global) return "global";
170
+ if (options.project) return "project";
171
+ if (options.worktree) return "worktree";
172
+ return null;
173
+ }
174
+
175
+ /** Build a single output entry, or skip the file based on filters. */
176
+ async function maybeCreateEntry(
177
+ ctx: FilesContext,
178
+ opts: NormalizedFilesOptions,
179
+ filePath: string,
180
+ ): Promise<FileEntry | null> {
181
+ const targetRel = normalizeRelPath(relative(ctx.targetRoot, filePath));
182
+ if (!isManagedTargetPath(targetRel, opts.hidden)) return null;
183
+ if (!passesDepthFilter(targetRel, opts.depth)) return null;
184
+
185
+ const link = await classifyLink(filePath, ctx.overlayRoot, ctx.gitContext);
186
+ if (!passesLinkFilter(link, opts)) return null;
187
+
188
+ return {
189
+ absolutePath: filePath,
190
+ cwdRelativePath: normalizeRelPath(relative(ctx.cwd, filePath) || "."),
191
+ targetRelativePath: targetRel,
192
+ link,
193
+ };
194
+ }
195
+
196
+ function normalizeRelPath(p: string): string {
197
+ return p.replaceAll("\\", "/");
198
+ }
199
+
200
+ /** Decide whether a target-relative path is managed by clank for listing. */
201
+ function isManagedTargetPath(relPath: string, includeHidden: boolean): boolean {
202
+ if (isAgentFilePath(relPath)) return true;
203
+ if (isClankPath(relPath)) return true;
204
+ if (includeHidden && isInDotAgentDir(relPath)) return true;
205
+ return false;
206
+ }
207
+
208
+ /** Apply the clank-depth constraint only to paths under `clank/`. */
209
+ function passesDepthFilter(relPath: string, depth: number | null): boolean {
210
+ if (depth === null) return true;
211
+ if (!isClankPath(relPath)) return true;
212
+ return passesClankDepth(relPath, depth);
213
+ }
214
+
215
+ /** Classify a path as linked to the overlay (and which scope) vs not. */
216
+ async function classifyLink(
217
+ filePath: string,
218
+ overlayRoot: string,
219
+ gitContext: Awaited<ReturnType<typeof getGitContext>>,
220
+ ): Promise<LinkState> {
221
+ try {
222
+ const overlayPath = await resolveSymlinkTarget(filePath);
223
+ if (!overlayPath.startsWith(overlayRoot)) return { kind: "unlinked" };
224
+ const scope = inferScopeFromOverlay(overlayPath, overlayRoot, gitContext);
225
+ if (!scope) return { kind: "unlinked" };
226
+ return { kind: "linked", overlayPath, scope };
227
+ } catch {
228
+ return { kind: "unlinked" };
229
+ }
230
+ }
231
+
232
+ /** Apply linked/unlinked and scope filters consistently. */
233
+ function passesLinkFilter(
234
+ link: LinkState,
235
+ opts: NormalizedFilesOptions,
236
+ ): boolean {
237
+ const effectiveLinkedOnly = opts.linkedOnly || opts.scopeFilter !== null;
238
+ if (effectiveLinkedOnly && link.kind !== "linked") return false;
239
+ if (opts.unlinkedOnly && link.kind !== "unlinked") return false;
240
+ if (opts.scopeFilter === null) return true;
241
+ return link.kind === "linked" && link.scope === opts.scopeFilter;
242
+ }
243
+
244
+ function isInDotAgentDir(relPath: string): boolean {
245
+ return isInDirectory(relPath, ".claude") || isInDirectory(relPath, ".gemini");
246
+ }
247
+
248
+ /** Enforce a max segment count beneath the nearest `clank/` path component. */
249
+ function passesClankDepth(relPath: string, depth: number): boolean {
250
+ const marker = "/clank/";
251
+ const idx = relPath.includes(marker) ? relPath.lastIndexOf(marker) : -1;
252
+ const after =
253
+ idx === -1
254
+ ? relPath.slice("clank/".length)
255
+ : relPath.slice(idx + marker.length);
256
+ const segments = after.split("/").filter(Boolean);
257
+ return segments.length <= depth;
258
+ }
259
+
260
+ /** Infer the overlay scope from a resolved overlay path and current git context. */
261
+ function inferScopeFromOverlay(
262
+ overlayPath: string,
263
+ overlayRoot: string,
264
+ gitContext: Awaited<ReturnType<typeof getGitContext>>,
265
+ ): "global" | "project" | "worktree" | null {
266
+ const globalPrefix = `${join(overlayRoot, "global")}/`;
267
+ if (overlayPath.startsWith(globalPrefix)) return "global";
268
+
269
+ const { projectName, worktreeName } = gitContext;
270
+ const projectPath = join(overlayRoot, "targets", projectName);
271
+ const worktreePath = join(projectPath, "worktrees", worktreeName);
272
+ if (overlayPath.startsWith(`${worktreePath}/`)) return "worktree";
273
+
274
+ const projectPrefix = `${projectPath}/`;
275
+ if (overlayPath.startsWith(projectPrefix)) return "project";
276
+
277
+ return null;
278
+ }