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 +28 -2
- package/package.json +5 -3
- package/src/Cli.ts +54 -9
- package/src/Config.ts +2 -0
- package/src/FsUtil.ts +81 -19
- package/src/Mapper.ts +7 -2
- package/src/OverlayLinks.ts +28 -17
- package/src/Util.ts +10 -0
- package/src/commands/Files.ts +38 -0
- package/src/commands/Link.ts +12 -4
- package/src/commands/files/Dedupe.ts +134 -0
- package/src/commands/files/Scan.ts +278 -0
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.
|
|
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.
|
|
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
|
-
|
|
167
|
-
|
|
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:
|
|
76
|
+
options: WalkOptions = {},
|
|
77
|
+
rootDir?: string,
|
|
60
78
|
): AsyncGenerator<{ path: string; isDirectory: boolean }> {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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 (
|
|
286
|
+
if (isClankPath(relPath)) {
|
|
282
287
|
return { targetPath: join(targetRoot, relPath), scope };
|
|
283
288
|
}
|
|
284
289
|
|
package/src/OverlayLinks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lstat } from "node:fs/promises";
|
|
2
|
-
import { dirname, join
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
})) {
|
|
111
|
-
if (isDirectory) continue;
|
|
100
|
+
const isIgnored =
|
|
101
|
+
ignorePatterns.length > 0 ? picomatch(ignorePatterns) : null;
|
|
112
102
|
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|
package/src/commands/Link.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|