clank-cli 0.1.67 → 0.1.72
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 +9 -1
- package/package.json +1 -1
- package/src/ClassifyFiles.ts +9 -3
- package/src/Consolidate.ts +250 -0
- package/src/FsUtil.ts +44 -4
- package/src/Git.ts +5 -2
- package/src/Gitignore.ts +20 -2
- package/src/Mapper.ts +44 -24
- package/src/OverlayLinks.ts +17 -17
- package/src/ScopeFromSymlink.ts +3 -2
- package/src/commands/Add.ts +24 -7
- package/src/commands/Check.ts +19 -7
- package/src/commands/Init.ts +1 -0
- package/src/commands/Link.ts +46 -22
- package/src/commands/Move.ts +2 -1
- package/src/commands/Rm.ts +2 -2
- package/src/commands/Unlink.ts +7 -2
- package/src/commands/VsCode.ts +2 -1
- package/src/commands/files/Dedupe.ts +5 -6
- package/src/commands/files/Scan.ts +19 -17
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ Clank stores your AI agent files (CLAUDE.md, commands, notes) in a separate git
|
|
|
24
24
|
- **Multi-Agent Support**: Single source file, multiple symlinks (AGENTS.md, CLAUDE.md, GEMINI.md).
|
|
25
25
|
- **Worktree-Aware**: Works seamlessly with git worktrees.
|
|
26
26
|
- **Git Ignored**: Agent files are ignored in the main repo, tracked in the overlay repo.
|
|
27
|
+
- **Rules Consolidation**: Use `.claude/rules/` for modular instructions; clank auto-generates AGENTS.md/GEMINI.md with consolidated content for cross-agent compatibility.
|
|
27
28
|
- **Three Scopes**: Global (all projects), Project (all branches), Worktree (this branch only).
|
|
28
29
|
|
|
29
30
|
## Installation
|
|
@@ -300,6 +301,12 @@ Available placeholders:
|
|
|
300
301
|
- `{{project_name}}` - Project name from git
|
|
301
302
|
- `{{branch_name}}` - Current branch/worktree name
|
|
302
303
|
|
|
304
|
+
## Rules Consolidation
|
|
305
|
+
|
|
306
|
+
If you use Claude Code's `.claude/rules/` for modular instructions, clank automatically generates AGENTS.md and GEMINI.md by consolidating `agents.md` with all your rules. Claude reads rules natively via symlinks; other agents get everything in one file.
|
|
307
|
+
|
|
308
|
+
Store rules in `claude/rules/` in your overlay, then run `clank link`. Consolidation activates automatically when rules files exist -- when there are no rules, all agent files remain symlinks as before.
|
|
309
|
+
|
|
303
310
|
## Design Principles
|
|
304
311
|
|
|
305
312
|
1. **Everything is linked, nothing is copied** - Single source of truth in overlay
|
|
@@ -336,7 +343,8 @@ Available placeholders:
|
|
|
336
343
|
├── claude/ # Claude Code specific
|
|
337
344
|
│ ├── settings.json # -> .claude/settings.json
|
|
338
345
|
│ ├── commands/ # -> .claude/commands/
|
|
339
|
-
│
|
|
346
|
+
│ ├── agents/ # -> .claude/agents/
|
|
347
|
+
│ └── rules/ # -> .claude/rules/ (also consolidated into AGENTS.md/GEMINI.md)
|
|
340
348
|
├── gemini/ # Gemini specific
|
|
341
349
|
│ └── commands/ # -> .gemini/commands/
|
|
342
350
|
└── worktrees/
|
package/package.json
CHANGED
package/src/ClassifyFiles.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { lstat } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { agentFiles } from "./AgentFiles.ts";
|
|
4
|
+
import { isGeneratedByClank } from "./Consolidate.ts";
|
|
4
5
|
import {
|
|
5
6
|
isTrackedByGit,
|
|
6
7
|
relativePath,
|
|
7
8
|
resolveSymlinkTarget,
|
|
9
|
+
toSlash,
|
|
8
10
|
walkDirectory,
|
|
9
11
|
} from "./FsUtil.ts";
|
|
10
12
|
import type { GitContext } from "./Git.ts";
|
|
@@ -90,11 +92,14 @@ ${commands.join("\n")}`);
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
if (classified.untracked.length > 0) {
|
|
93
|
-
const
|
|
95
|
+
const files = classified.untracked.map((p) => ` ${rel(p)}`);
|
|
94
96
|
sections.push(`Found untracked agent files.
|
|
95
97
|
|
|
96
98
|
Add them to clank:
|
|
97
|
-
${
|
|
99
|
+
${files.join("\n")}
|
|
100
|
+
|
|
101
|
+
clank add -i # add interactively
|
|
102
|
+
clank add <file> [<file>...] # add specific files`);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
if (classified.staleSymlinks.length > 0) {
|
|
@@ -153,6 +158,7 @@ async function classifySingleAgentFile(
|
|
|
153
158
|
return classifyAgentSymlink(filePath, overlayRoot, mapperCtx);
|
|
154
159
|
}
|
|
155
160
|
if (stat.isFile()) {
|
|
161
|
+
if (await isGeneratedByClank(filePath)) return {};
|
|
156
162
|
const isTracked = await isTrackedByGit(filePath, targetRoot);
|
|
157
163
|
return isTracked ? { tracked: [filePath] } : { untracked: [filePath] };
|
|
158
164
|
}
|
|
@@ -180,7 +186,7 @@ async function classifyAgentSymlink(
|
|
|
180
186
|
const absoluteTarget = await resolveSymlinkTarget(filePath);
|
|
181
187
|
|
|
182
188
|
// Symlink doesn't point to overlay at all
|
|
183
|
-
if (!absoluteTarget.startsWith(overlayRoot)) {
|
|
189
|
+
if (!toSlash(absoluteTarget).startsWith(toSlash(overlayRoot))) {
|
|
184
190
|
return { staleSymlinks: [filePath] };
|
|
185
191
|
}
|
|
186
192
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { agentFiles, forEachAgentPath } from "./AgentFiles.ts";
|
|
4
|
+
import { fileExists, isSymlink, walkDirectory } from "./FsUtil.ts";
|
|
5
|
+
import type { GitContext } from "./Git.ts";
|
|
6
|
+
import { overlayProjectDir, overlayWorktreeDir } from "./Mapper.ts";
|
|
7
|
+
|
|
8
|
+
/** Params for the consolidation entry point */
|
|
9
|
+
export interface ConsolidateParams {
|
|
10
|
+
overlayRoot: string;
|
|
11
|
+
targetRoot: string;
|
|
12
|
+
gitContext: GitContext;
|
|
13
|
+
agents: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A rules file with its parsed metadata and content */
|
|
17
|
+
interface RuleFile {
|
|
18
|
+
filename: string;
|
|
19
|
+
content: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
paths?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** First-line marker for generated agent files */
|
|
25
|
+
export const generatedMarker = "<!-- Generated by clank";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Consolidate agents.md + .claude/rules/ into generated AGENTS.md/GEMINI.md.
|
|
29
|
+
*
|
|
30
|
+
* Only runs when rules files exist in the overlay. Root-level only --
|
|
31
|
+
* subdirectory agents.md files continue as pure symlinks.
|
|
32
|
+
*/
|
|
33
|
+
export async function consolidateRulesIntoAgentFiles(
|
|
34
|
+
params: ConsolidateParams,
|
|
35
|
+
): Promise<string[]> {
|
|
36
|
+
const { overlayRoot, targetRoot, gitContext, agents } = params;
|
|
37
|
+
|
|
38
|
+
// Collect rules from all applicable overlay scopes
|
|
39
|
+
const rulesFiles = await collectRulesFiles(overlayRoot, gitContext);
|
|
40
|
+
if (rulesFiles.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
// Read root-level agents.md from overlay (if exists)
|
|
43
|
+
const agentsMdContent = await readRootAgentsMd(overlayRoot, gitContext);
|
|
44
|
+
|
|
45
|
+
// Build the consolidated content
|
|
46
|
+
const overlayPaths = getOverlayPaths(overlayRoot, gitContext);
|
|
47
|
+
const merged = buildConsolidatedContent(
|
|
48
|
+
agentsMdContent,
|
|
49
|
+
rulesFiles,
|
|
50
|
+
overlayPaths,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// For each non-claude agent, replace the symlink with a generated file
|
|
54
|
+
const generated: string[] = [];
|
|
55
|
+
await forEachAgentPath(targetRoot, agents, async (agentPath, agentName) => {
|
|
56
|
+
if (agentName.toLowerCase() === "claude") return;
|
|
57
|
+
// Remove existing symlink if present
|
|
58
|
+
if (await isSymlink(agentPath)) {
|
|
59
|
+
await unlink(agentPath);
|
|
60
|
+
}
|
|
61
|
+
await writeFile(agentPath, merged, "utf-8");
|
|
62
|
+
generated.push(basename(agentPath));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return generated;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Check if a file is generated by clank (first line starts with marker) */
|
|
69
|
+
export async function isGeneratedByClank(filePath: string): Promise<boolean> {
|
|
70
|
+
if (!(await fileExists(filePath))) return false;
|
|
71
|
+
if (await isSymlink(filePath)) return false;
|
|
72
|
+
try {
|
|
73
|
+
const content = await readFile(filePath, "utf-8");
|
|
74
|
+
return content.startsWith(generatedMarker);
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Strip YAML frontmatter and extract description/paths */
|
|
81
|
+
export function stripFrontmatter(raw: string): {
|
|
82
|
+
content: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
paths?: string;
|
|
85
|
+
} {
|
|
86
|
+
const fence = "---";
|
|
87
|
+
const fenceNewline = `\n${fence}`;
|
|
88
|
+
|
|
89
|
+
if (!raw.startsWith(fence)) {
|
|
90
|
+
return { content: raw };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const endIndex = raw.indexOf(fenceNewline, fence.length);
|
|
94
|
+
if (endIndex === -1) {
|
|
95
|
+
return { content: raw };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const frontmatter = raw.slice(fence.length, endIndex).trim();
|
|
99
|
+
const content = raw.slice(endIndex + fenceNewline.length).replace(/^\n+/, "");
|
|
100
|
+
|
|
101
|
+
let description: string | undefined;
|
|
102
|
+
let paths: string | undefined;
|
|
103
|
+
|
|
104
|
+
for (const line of frontmatter.split("\n")) {
|
|
105
|
+
const descMatch = line.match(/^description:\s*(.+)/);
|
|
106
|
+
if (descMatch) {
|
|
107
|
+
description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
108
|
+
}
|
|
109
|
+
const pathsMatch = line.match(/^paths:\s*(.+)/);
|
|
110
|
+
if (pathsMatch) {
|
|
111
|
+
// paths can be inline array: ["src/**/*.ts"] or plain string
|
|
112
|
+
paths = pathsMatch[1]
|
|
113
|
+
.trim()
|
|
114
|
+
.replace(/^\[|\]$/g, "")
|
|
115
|
+
.replace(/["']/g, "");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { content, description, paths };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Convert a filename to a human-readable heading */
|
|
123
|
+
export function humanizeFilename(filename: string): string {
|
|
124
|
+
return filename.replace(/\.md$/, "").replace(/[-_]/g, " ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Remove generated agent files from target (for unlink) */
|
|
128
|
+
export async function removeGeneratedAgentFiles(
|
|
129
|
+
targetRoot: string,
|
|
130
|
+
): Promise<string[]> {
|
|
131
|
+
const removed: string[] = [];
|
|
132
|
+
for (const name of agentFiles) {
|
|
133
|
+
const path = join(targetRoot, name);
|
|
134
|
+
if (await isGeneratedByClank(path)) {
|
|
135
|
+
await unlink(path);
|
|
136
|
+
removed.push(name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return removed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Collect all .md files from claude/rules/ across overlay scopes */
|
|
143
|
+
async function collectRulesFiles(
|
|
144
|
+
overlayRoot: string,
|
|
145
|
+
gitContext: GitContext,
|
|
146
|
+
): Promise<RuleFile[]> {
|
|
147
|
+
const dirs = [
|
|
148
|
+
join(overlayRoot, "global/claude/rules"),
|
|
149
|
+
join(
|
|
150
|
+
overlayProjectDir(overlayRoot, gitContext.projectName),
|
|
151
|
+
"claude/rules",
|
|
152
|
+
),
|
|
153
|
+
join(overlayWorktreeDir(overlayRoot, gitContext), "claude/rules"),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
const allFiles: RuleFile[] = [];
|
|
157
|
+
for (const dir of dirs) {
|
|
158
|
+
if (!(await fileExists(dir))) continue;
|
|
159
|
+
for await (const { path, isDirectory } of walkDirectory(dir)) {
|
|
160
|
+
if (isDirectory) continue;
|
|
161
|
+
if (!path.endsWith(".md")) continue;
|
|
162
|
+
const raw = await readFile(path, "utf-8");
|
|
163
|
+
const parsed = stripFrontmatter(raw);
|
|
164
|
+
allFiles.push({
|
|
165
|
+
filename: basename(path),
|
|
166
|
+
...parsed,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Sort alphabetically by filename for deterministic output
|
|
172
|
+
allFiles.sort((a, b) => a.filename.localeCompare(b.filename));
|
|
173
|
+
return allFiles;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Read root-level agents.md from the overlay (project scope, then global) */
|
|
177
|
+
async function readRootAgentsMd(
|
|
178
|
+
overlayRoot: string,
|
|
179
|
+
gitContext: GitContext,
|
|
180
|
+
): Promise<string | null> {
|
|
181
|
+
const projectPath = join(
|
|
182
|
+
overlayProjectDir(overlayRoot, gitContext.projectName),
|
|
183
|
+
"agents.md",
|
|
184
|
+
);
|
|
185
|
+
if (await fileExists(projectPath)) {
|
|
186
|
+
return readFile(projectPath, "utf-8");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Fall back to global agents.md
|
|
190
|
+
const globalPath = join(overlayRoot, "global/agents.md");
|
|
191
|
+
if (await fileExists(globalPath)) {
|
|
192
|
+
return readFile(globalPath, "utf-8");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Get overlay paths for the header comments */
|
|
199
|
+
function getOverlayPaths(
|
|
200
|
+
overlayRoot: string,
|
|
201
|
+
gitContext: GitContext,
|
|
202
|
+
): { agentsMdPath: string; rulesDir: string } {
|
|
203
|
+
const projectDir = overlayProjectDir(overlayRoot, gitContext.projectName);
|
|
204
|
+
return {
|
|
205
|
+
agentsMdPath: join(projectDir, "agents.md"),
|
|
206
|
+
rulesDir: join(projectDir, "claude/rules"),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Build the consolidated markdown content */
|
|
211
|
+
function buildConsolidatedContent(
|
|
212
|
+
agentsMdContent: string | null,
|
|
213
|
+
rules: RuleFile[],
|
|
214
|
+
overlayPaths: { agentsMdPath: string; rulesDir: string },
|
|
215
|
+
): string {
|
|
216
|
+
const lines: string[] = [];
|
|
217
|
+
|
|
218
|
+
// Header comments
|
|
219
|
+
lines.push(`${generatedMarker} - do not edit -->`);
|
|
220
|
+
if (agentsMdContent) {
|
|
221
|
+
lines.push(`<!-- Source: ${overlayPaths.agentsMdPath} -->`);
|
|
222
|
+
}
|
|
223
|
+
lines.push(`<!-- Rules: ${overlayPaths.rulesDir} -->`);
|
|
224
|
+
lines.push("");
|
|
225
|
+
|
|
226
|
+
// Base agents.md content
|
|
227
|
+
if (agentsMdContent) {
|
|
228
|
+
lines.push(agentsMdContent.trimEnd());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Append each rule as a section
|
|
232
|
+
for (const rule of rules) {
|
|
233
|
+
lines.push("");
|
|
234
|
+
lines.push("---");
|
|
235
|
+
lines.push("");
|
|
236
|
+
|
|
237
|
+
const heading = rule.description || humanizeFilename(rule.filename);
|
|
238
|
+
lines.push(`## ${heading}`);
|
|
239
|
+
|
|
240
|
+
if (rule.paths) {
|
|
241
|
+
lines.push(`Applies to: ${rule.paths}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
lines.push("");
|
|
245
|
+
lines.push(rule.content.trimEnd());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
lines.push("");
|
|
249
|
+
return lines.join("\n");
|
|
250
|
+
}
|
package/src/FsUtil.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
readdir,
|
|
6
6
|
readFile,
|
|
7
7
|
readlink,
|
|
8
|
+
realpath,
|
|
8
9
|
symlink,
|
|
9
10
|
unlink,
|
|
10
11
|
writeFile,
|
|
@@ -28,6 +29,27 @@ interface WalkContext {
|
|
|
28
29
|
skip?: (relPath: string, isDirectory: boolean) => boolean;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** Normalize path separators to forward slashes (for cross-platform string comparisons) */
|
|
33
|
+
export function toSlash(p: string): string {
|
|
34
|
+
return p.replaceAll("\\", "/");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve Windows 8.3 short names to canonical long paths.
|
|
38
|
+
* On non-Windows, returns the path unchanged. */
|
|
39
|
+
export async function resolveWindowsPath(p: string): Promise<string> {
|
|
40
|
+
if (process.platform !== "win32") return p;
|
|
41
|
+
try {
|
|
42
|
+
return await realpath(p);
|
|
43
|
+
} catch {
|
|
44
|
+
return p;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Get the current working directory, resolving Windows 8.3 short names */
|
|
49
|
+
export async function getCwd(): Promise<string> {
|
|
50
|
+
return resolveWindowsPath(process.cwd());
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
/**
|
|
32
54
|
* Create a symbolic link, removing existing link/file first
|
|
33
55
|
* @param target - The path the symlink should point to (absolute)
|
|
@@ -45,7 +67,24 @@ export async function createSymlink(
|
|
|
45
67
|
// File doesn't exist, which is fine
|
|
46
68
|
}
|
|
47
69
|
|
|
48
|
-
|
|
70
|
+
try {
|
|
71
|
+
await symlink(target, linkPath);
|
|
72
|
+
} catch (error: unknown) {
|
|
73
|
+
if (
|
|
74
|
+
error instanceof Error &&
|
|
75
|
+
"code" in error &&
|
|
76
|
+
error.code === "EPERM" &&
|
|
77
|
+
process.platform === "win32"
|
|
78
|
+
) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Permission denied creating symlink.\n` +
|
|
81
|
+
`On Windows, symlinks require Developer Mode or administrator privileges.\n` +
|
|
82
|
+
`Enable Developer Mode: Settings > Update & Security > For Developers\n` +
|
|
83
|
+
`Or use clank from WSL.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
49
88
|
}
|
|
50
89
|
|
|
51
90
|
/** Remove a symlink if it exists */
|
|
@@ -62,12 +101,13 @@ export async function removeSymlink(linkPath: string): Promise<void> {
|
|
|
62
101
|
|
|
63
102
|
/**
|
|
64
103
|
* Get the symlink target path (absolute)
|
|
104
|
+
* Uses forward slashes so readlink returns consistent paths across platforms.
|
|
65
105
|
* @param _from - The location of the symlink (unused, kept for API compatibility)
|
|
66
106
|
* @param to - The target of the symlink (absolute)
|
|
67
107
|
* @returns Absolute path to the target
|
|
68
108
|
*/
|
|
69
109
|
export function getLinkTarget(_from: string, to: string): string {
|
|
70
|
-
return to;
|
|
110
|
+
return toSlash(to);
|
|
71
111
|
}
|
|
72
112
|
|
|
73
113
|
/** Recursively walk a directory, yielding all files and directories */
|
|
@@ -118,7 +158,7 @@ export async function isTrackedByGit(
|
|
|
118
158
|
repoRoot: string,
|
|
119
159
|
): Promise<boolean> {
|
|
120
160
|
try {
|
|
121
|
-
const relPath = relative(repoRoot, filePath);
|
|
161
|
+
const relPath = toSlash(relative(repoRoot, filePath));
|
|
122
162
|
await execFileAsync("git", ["ls-files", "--error-unmatch", relPath], {
|
|
123
163
|
cwd: repoRoot,
|
|
124
164
|
});
|
|
@@ -195,7 +235,7 @@ function* processEntry(
|
|
|
195
235
|
| { recurse: string; options: WalkOptions; root: string }
|
|
196
236
|
> {
|
|
197
237
|
const fullPath = join(dir, entry.name);
|
|
198
|
-
const relPath = relative(ctx.root, fullPath);
|
|
238
|
+
const relPath = toSlash(relative(ctx.root, fullPath));
|
|
199
239
|
|
|
200
240
|
if (entry.isDirectory()) {
|
|
201
241
|
if (shouldSkipDir(entry.name, ctx.skipDirs, ctx.includeHidden)) return;
|
package/src/Git.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename, isAbsolute, join } from "node:path";
|
|
2
2
|
import { exec } from "./Exec.ts";
|
|
3
|
+
import { resolveWindowsPath } from "./FsUtil.ts";
|
|
3
4
|
|
|
4
5
|
/** Selected git project/worktree metadata */
|
|
5
6
|
export interface GitContext {
|
|
@@ -34,7 +35,9 @@ export async function detectGitRoot(
|
|
|
34
35
|
): Promise<string> {
|
|
35
36
|
const toplevel = await gitCommand("rev-parse --show-toplevel", cwd);
|
|
36
37
|
if (toplevel) {
|
|
37
|
-
|
|
38
|
+
// On Windows, git returns forward-slash paths that may use long names
|
|
39
|
+
// while cwd uses 8.3 short names. Resolve to canonical form.
|
|
40
|
+
return await resolveWindowsPath(toplevel);
|
|
38
41
|
}
|
|
39
42
|
throw new Error("Not in a git repository");
|
|
40
43
|
}
|
|
@@ -89,7 +92,7 @@ export async function isGitWorktree(
|
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
// Worktrees have .git/worktrees/* in their git-dir path
|
|
92
|
-
return gitDir.includes("/worktrees/");
|
|
95
|
+
return gitDir.replaceAll("\\", "/").includes("/worktrees/");
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
/** Parse repository name from git remote URL (handles HTTPS and SSH formats) */
|
package/src/Gitignore.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import picomatch from "picomatch";
|
|
3
4
|
import { agentFiles, targetManagedDirs } from "./AgentFiles.ts";
|
|
4
5
|
import { filterClankLines } from "./Exclude.ts";
|
|
5
|
-
import { fileExists, walkDirectory } from "./FsUtil.ts";
|
|
6
|
+
import { fileExists, toSlash, walkDirectory } from "./FsUtil.ts";
|
|
6
7
|
import { getGitDir } from "./Git.ts";
|
|
7
8
|
import { partition } from "./Util.ts";
|
|
8
9
|
|
|
@@ -52,7 +53,7 @@ export async function collectGitignorePatterns(
|
|
|
52
53
|
|
|
53
54
|
// 3. Find nested .gitignore files
|
|
54
55
|
for (const path of await findNestedGitignores(targetRoot)) {
|
|
55
|
-
const basePath = relative(targetRoot, dirname(path));
|
|
56
|
+
const basePath = toSlash(relative(targetRoot, dirname(path)));
|
|
56
57
|
await parseGitignoreFile(path, result, { basePath });
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -144,6 +145,23 @@ export function deduplicateGlobs(globs: string[]): string[] {
|
|
|
144
145
|
return [...universal, ...uncovered];
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
/** Load a repo's .gitignore and return a matcher for ignored filenames */
|
|
149
|
+
export async function loadGitignore(
|
|
150
|
+
repoRoot: string,
|
|
151
|
+
): Promise<(name: string) => boolean> {
|
|
152
|
+
const gitignorePath = join(repoRoot, ".gitignore");
|
|
153
|
+
if (!(await fileExists(gitignorePath))) return () => false;
|
|
154
|
+
|
|
155
|
+
const content = await readFile(gitignorePath, "utf-8");
|
|
156
|
+
const patterns = content
|
|
157
|
+
.split("\n")
|
|
158
|
+
.map((line) => line.trim())
|
|
159
|
+
.filter((line) => line && !line.startsWith("#") && !line.startsWith("!"));
|
|
160
|
+
|
|
161
|
+
if (patterns.length === 0) return () => false;
|
|
162
|
+
return picomatch(patterns);
|
|
163
|
+
}
|
|
164
|
+
|
|
147
165
|
/** Parse a gitignore file and accumulate results */
|
|
148
166
|
async function parseGitignoreFile(
|
|
149
167
|
source: string,
|
package/src/Mapper.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { basename, dirname, join, relative } from "node:path";
|
|
1
|
+
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
|
2
2
|
import { managedAgentDirs } from "./AgentFiles.ts";
|
|
3
|
+
import { toSlash } from "./FsUtil.ts";
|
|
3
4
|
import type { GitContext } from "./Git.ts";
|
|
4
5
|
|
|
5
6
|
/** overlay mappings can be cross project, per project, or per worktree */
|
|
@@ -83,14 +84,17 @@ export function overlayToTarget(
|
|
|
83
84
|
context: MapperContext,
|
|
84
85
|
): TargetMapping | null {
|
|
85
86
|
const { overlayRoot, targetRoot, gitContext } = context;
|
|
86
|
-
const
|
|
87
|
-
const
|
|
87
|
+
const op = toSlash(overlayPath);
|
|
88
|
+
const projectPrefix = toSlash(
|
|
89
|
+
join(overlayRoot, "targets", gitContext.projectName),
|
|
90
|
+
);
|
|
91
|
+
const globalPrefix = toSlash(join(overlayRoot, "global"));
|
|
88
92
|
|
|
89
|
-
if (
|
|
93
|
+
if (op.startsWith(globalPrefix)) {
|
|
90
94
|
return mapGlobalOverlay(overlayPath, globalPrefix, targetRoot);
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
if (
|
|
97
|
+
if (op.startsWith(projectPrefix)) {
|
|
94
98
|
return mapProjectOverlay(overlayPath, projectPrefix, context);
|
|
95
99
|
}
|
|
96
100
|
|
|
@@ -118,7 +122,7 @@ export function targetToOverlay(
|
|
|
118
122
|
context: MapperContext,
|
|
119
123
|
): string {
|
|
120
124
|
const { overlayRoot, targetRoot, gitContext } = context;
|
|
121
|
-
const relPath = relative(targetRoot, targetPath);
|
|
125
|
+
const relPath = toSlash(relative(targetRoot, targetPath));
|
|
122
126
|
|
|
123
127
|
let overlayBase: string;
|
|
124
128
|
if (scope === "global") {
|
|
@@ -145,7 +149,14 @@ export function normalizeAddPath(
|
|
|
145
149
|
cwd: string,
|
|
146
150
|
gitRoot: string,
|
|
147
151
|
): string {
|
|
148
|
-
|
|
152
|
+
// Normalize separators: strip leading ./ or .\ then use forward slashes
|
|
153
|
+
// for consistent string matching (path.join handles both on Windows)
|
|
154
|
+
const normalized = input.replace(/^\.[\\/]/, "").replaceAll("\\", "/");
|
|
155
|
+
|
|
156
|
+
// Absolute paths are already resolved — just handle agent file aliasing
|
|
157
|
+
if (isAbsolute(input)) {
|
|
158
|
+
return isAgentFile(normalized) ? join(dirname(input), "agents.md") : input;
|
|
159
|
+
}
|
|
149
160
|
|
|
150
161
|
// Treat agent files (CLAUDE.md, GEMINI.md) as aliases for agents.md
|
|
151
162
|
// Support both relative paths (packages/foo/CLAUDE.md) and running from subdirectory
|
|
@@ -156,19 +167,14 @@ export function normalizeAddPath(
|
|
|
156
167
|
}
|
|
157
168
|
|
|
158
169
|
// .claude/ and .gemini/ files keep their path (relative to git root)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return join(gitRoot, normalized);
|
|
162
|
-
}
|
|
170
|
+
if (startsWithAgentDir(normalized)) {
|
|
171
|
+
return join(gitRoot, normalized);
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
// If cwd is inside a .claude/ or .gemini/ directory, join directly
|
|
166
175
|
// (e.g., running `clank rm foo.md` from .claude/commands/)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (relCwd.startsWith(`${agentDir}/`) || relCwd === agentDir) {
|
|
170
|
-
return join(cwd, normalized);
|
|
171
|
-
}
|
|
176
|
+
if (isInsideAgentDir(toSlash(relative(gitRoot, cwd)))) {
|
|
177
|
+
return join(cwd, normalized);
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
// If path already contains /clank/ in the middle, preserve its structure
|
|
@@ -183,7 +189,7 @@ export function normalizeAddPath(
|
|
|
183
189
|
|
|
184
190
|
// Strip trailing /clank from cwd to avoid clank/clank nesting
|
|
185
191
|
// But don't strip if we're at the git root (project might be named "clank")
|
|
186
|
-
const inClankSubdir = cwd.endsWith("/clank") && cwd !== gitRoot;
|
|
192
|
+
const inClankSubdir = toSlash(cwd).endsWith("/clank") && cwd !== gitRoot;
|
|
187
193
|
const normalizedCwd = inClankSubdir ? cwd.slice(0, -"/clank".length) : cwd;
|
|
188
194
|
|
|
189
195
|
return join(normalizedCwd, "clank", filename);
|
|
@@ -202,8 +208,9 @@ export function isAgentFile(filename: string): boolean {
|
|
|
202
208
|
|
|
203
209
|
/** Check if a path is a prompt file in an agent's prompts directory */
|
|
204
210
|
export function isPromptFile(normalizedPath: string): boolean {
|
|
211
|
+
const p = toSlash(normalizedPath);
|
|
205
212
|
for (const agentDir of managedAgentDirs) {
|
|
206
|
-
if (
|
|
213
|
+
if (p.includes(`/${agentDir}/prompts/`)) {
|
|
207
214
|
return true;
|
|
208
215
|
}
|
|
209
216
|
}
|
|
@@ -212,11 +219,12 @@ export function isPromptFile(normalizedPath: string): boolean {
|
|
|
212
219
|
|
|
213
220
|
/** Extract the prompt-relative path from a full prompt path */
|
|
214
221
|
export function getPromptRelPath(normalizedPath: string): string | null {
|
|
222
|
+
const p = toSlash(normalizedPath);
|
|
215
223
|
for (const agentDir of managedAgentDirs) {
|
|
216
224
|
const marker = `/${agentDir}/prompts/`;
|
|
217
|
-
const idx =
|
|
225
|
+
const idx = p.indexOf(marker);
|
|
218
226
|
if (idx !== -1) {
|
|
219
|
-
return
|
|
227
|
+
return p.slice(idx + marker.length);
|
|
220
228
|
}
|
|
221
229
|
}
|
|
222
230
|
return null;
|
|
@@ -240,7 +248,7 @@ function mapGlobalOverlay(
|
|
|
240
248
|
globalPrefix: string,
|
|
241
249
|
targetRoot: string,
|
|
242
250
|
): TargetMapping | null {
|
|
243
|
-
const relPath = relative(globalPrefix, overlayPath);
|
|
251
|
+
const relPath = toSlash(relative(globalPrefix, overlayPath));
|
|
244
252
|
|
|
245
253
|
// Skip init templates
|
|
246
254
|
if (relPath.startsWith("init/")) return null;
|
|
@@ -255,12 +263,12 @@ function mapProjectOverlay(
|
|
|
255
263
|
context: MapperContext,
|
|
256
264
|
): TargetMapping | null {
|
|
257
265
|
const { targetRoot, gitContext } = context;
|
|
258
|
-
const relPath = relative(projectPrefix, overlayPath);
|
|
266
|
+
const relPath = toSlash(relative(projectPrefix, overlayPath));
|
|
259
267
|
|
|
260
268
|
// Worktree-specific files
|
|
261
|
-
const worktreePrefix =
|
|
269
|
+
const worktreePrefix = `worktrees/${gitContext.worktreeName}`;
|
|
262
270
|
if (relPath.startsWith(`${worktreePrefix}/`)) {
|
|
263
|
-
const innerPath = relative(worktreePrefix, relPath);
|
|
271
|
+
const innerPath = toSlash(relative(worktreePrefix, relPath));
|
|
264
272
|
return decodeOverlayPath(innerPath, targetRoot, "worktree");
|
|
265
273
|
}
|
|
266
274
|
|
|
@@ -306,6 +314,18 @@ function encodeTargetPath(relPath: string, overlayBase: string): string {
|
|
|
306
314
|
return join(overlayBase, "clank", relPath);
|
|
307
315
|
}
|
|
308
316
|
|
|
317
|
+
/** Check if path starts with a managed agent dir (.claude/, .gemini/) */
|
|
318
|
+
function startsWithAgentDir(path: string): boolean {
|
|
319
|
+
return managedAgentDirs.some((dir) => path.startsWith(`${dir}/`));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Check if path is inside a managed agent dir (.claude/, .gemini/) */
|
|
323
|
+
function isInsideAgentDir(relPath: string): boolean {
|
|
324
|
+
return managedAgentDirs.some(
|
|
325
|
+
(dir) => relPath.startsWith(`${dir}/`) || relPath === dir,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
309
329
|
/** Decode an overlay-relative path to target (shared by all scopes) */
|
|
310
330
|
function decodeOverlayPath(
|
|
311
331
|
relPath: string,
|
package/src/OverlayLinks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lstat, unlink } from "node:fs/promises";
|
|
2
|
-
import { dirname, join, relative } from "node:path";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
3
|
import picomatch from "picomatch";
|
|
4
4
|
import { managedAgentDirs, targetManagedDirs } from "./AgentFiles.ts";
|
|
5
5
|
import {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getLinkTarget,
|
|
9
9
|
isSymlink,
|
|
10
10
|
resolveSymlinkTarget,
|
|
11
|
+
toSlash,
|
|
11
12
|
walkDirectory,
|
|
12
13
|
} from "./FsUtil.ts";
|
|
13
14
|
import type { GitContext } from "./Git.ts";
|
|
@@ -44,7 +45,7 @@ export async function verifyManaged(
|
|
|
44
45
|
const absoluteTarget = await resolveSymlinkTarget(linkPath);
|
|
45
46
|
|
|
46
47
|
// Check if symlink points to overlay at all
|
|
47
|
-
if (!absoluteTarget.startsWith(overlayRoot)) {
|
|
48
|
+
if (!toSlash(absoluteTarget).startsWith(toSlash(overlayRoot))) {
|
|
48
49
|
return { kind: "outside-overlay", currentTarget: absoluteTarget };
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -88,7 +89,7 @@ export async function isSymlinkToOverlay(
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
const absoluteTarget = await resolveSymlinkTarget(linkPath);
|
|
91
|
-
return absoluteTarget.startsWith(overlayRoot);
|
|
92
|
+
return toSlash(absoluteTarget).startsWith(toSlash(overlayRoot));
|
|
92
93
|
} catch {
|
|
93
94
|
return false;
|
|
94
95
|
}
|
|
@@ -105,8 +106,7 @@ export async function* walkOverlayFiles(
|
|
|
105
106
|
const skip = (relPath: string): boolean => {
|
|
106
107
|
if (relPath.startsWith("clank/init/")) return true; // Skip templates
|
|
107
108
|
if (!isIgnored) return false;
|
|
108
|
-
|
|
109
|
-
return isIgnored(relPath) || isIgnored(basename);
|
|
109
|
+
return isIgnored(relPath) || isIgnored(basename(relPath));
|
|
110
110
|
};
|
|
111
111
|
|
|
112
112
|
const genEntries = walkDirectory(overlayRoot, { skip });
|
|
@@ -133,15 +133,6 @@ export async function createPromptLinks(
|
|
|
133
133
|
return created;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/** Check if two paths are equivalent prompt files in different agent directories */
|
|
137
|
-
function isMatchingPromptPath(
|
|
138
|
-
canonicalPath: string,
|
|
139
|
-
actualPath: string,
|
|
140
|
-
): boolean {
|
|
141
|
-
const canonical = getPromptRelPath(canonicalPath);
|
|
142
|
-
return canonical !== null && canonical === getPromptRelPath(actualPath);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
136
|
/** Find and remove symlinks pointing to wrong worktree in the overlay.
|
|
146
137
|
* Returns paths that were removed. */
|
|
147
138
|
export async function cleanStaleWorktreeSymlinks(
|
|
@@ -152,16 +143,16 @@ export async function cleanStaleWorktreeSymlinks(
|
|
|
152
143
|
const removed: string[] = [];
|
|
153
144
|
const currentWorktree = gitContext.worktreeName;
|
|
154
145
|
const projectName = gitContext.projectName;
|
|
155
|
-
const worktreesPrefix = `${overlayRoot}/targets/${projectName}/worktrees/`;
|
|
146
|
+
const worktreesPrefix = `${toSlash(overlayRoot)}/targets/${projectName}/worktrees/`;
|
|
156
147
|
|
|
157
148
|
for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
|
|
158
149
|
if (isDirectory) continue;
|
|
159
150
|
|
|
160
|
-
const relPath = relative(targetRoot, path);
|
|
151
|
+
const relPath = toSlash(relative(targetRoot, path));
|
|
161
152
|
if (!isInManagedDir(relPath)) continue;
|
|
162
153
|
if (!(await isSymlink(path))) continue;
|
|
163
154
|
|
|
164
|
-
const target = await resolveSymlinkTarget(path);
|
|
155
|
+
const target = toSlash(await resolveSymlinkTarget(path));
|
|
165
156
|
if (!target.startsWith(worktreesPrefix)) continue;
|
|
166
157
|
|
|
167
158
|
// Extract worktree name from path like .../worktrees/main/clank/notes.md
|
|
@@ -177,6 +168,15 @@ export async function cleanStaleWorktreeSymlinks(
|
|
|
177
168
|
return removed;
|
|
178
169
|
}
|
|
179
170
|
|
|
171
|
+
/** Check if two paths are equivalent prompt files in different agent directories */
|
|
172
|
+
function isMatchingPromptPath(
|
|
173
|
+
canonicalPath: string,
|
|
174
|
+
actualPath: string,
|
|
175
|
+
): boolean {
|
|
176
|
+
const canonical = getPromptRelPath(canonicalPath);
|
|
177
|
+
return canonical !== null && canonical === getPromptRelPath(actualPath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
180
|
/** Check if a path is inside a clank-managed directory */
|
|
181
181
|
function isInManagedDir(relPath: string): boolean {
|
|
182
182
|
const parts = relPath.split("/");
|
package/src/ScopeFromSymlink.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lstat } from "node:fs/promises";
|
|
2
|
-
import { resolveSymlinkTarget } from "./FsUtil.ts";
|
|
2
|
+
import { resolveSymlinkTarget, toSlash } from "./FsUtil.ts";
|
|
3
3
|
import { type MapperContext, overlayToTarget, type Scope } from "./Mapper.ts";
|
|
4
4
|
|
|
5
5
|
/** Get scope from symlink target if it points to overlay */
|
|
@@ -12,7 +12,8 @@ export async function scopeFromSymlink(
|
|
|
12
12
|
if (!stats.isSymbolicLink()) return null;
|
|
13
13
|
|
|
14
14
|
const overlayPath = await resolveSymlinkTarget(targetPath);
|
|
15
|
-
if (!overlayPath.startsWith(context.overlayRoot))
|
|
15
|
+
if (!toSlash(overlayPath).startsWith(toSlash(context.overlayRoot)))
|
|
16
|
+
return null;
|
|
16
17
|
|
|
17
18
|
const mapping = overlayToTarget(overlayPath, context);
|
|
18
19
|
return mapping?.scope ?? null;
|
package/src/commands/Add.ts
CHANGED
|
@@ -8,11 +8,13 @@ import {
|
|
|
8
8
|
import { basename, dirname, join, relative } from "node:path";
|
|
9
9
|
import * as readline from "node:readline";
|
|
10
10
|
import { forEachAgentPath } from "../AgentFiles.ts";
|
|
11
|
+
import { classifyAgentFiles } from "../ClassifyFiles.ts";
|
|
11
12
|
import { expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
|
|
12
13
|
import {
|
|
13
14
|
createSymlink,
|
|
14
15
|
ensureDir,
|
|
15
16
|
fileExists,
|
|
17
|
+
getCwd,
|
|
16
18
|
getLinkTarget,
|
|
17
19
|
isSymlink,
|
|
18
20
|
isTrackedByGit,
|
|
@@ -81,7 +83,7 @@ export async function addCommand(
|
|
|
81
83
|
filePaths: string[],
|
|
82
84
|
options: AddOptions = {},
|
|
83
85
|
): Promise<void> {
|
|
84
|
-
const cwd =
|
|
86
|
+
const cwd = await getCwd();
|
|
85
87
|
const gitContext = await getGitContext(cwd);
|
|
86
88
|
const config = await loadConfig();
|
|
87
89
|
const overlayRoot = expandPath(config.overlayRepo);
|
|
@@ -144,19 +146,34 @@ async function addAllInteractive(ctx: AddContext): Promise<void> {
|
|
|
144
146
|
const unadded = await findUnaddedFiles(context);
|
|
145
147
|
const regularFiles = unadded.filter((f) => f.kind === "unadded");
|
|
146
148
|
|
|
147
|
-
|
|
149
|
+
// Also find untracked agent files outside managed dirs (e.g. packages/foo/CLAUDE.md)
|
|
150
|
+
const agentClassification = await classifyAgentFiles(
|
|
151
|
+
gitContext.gitRoot,
|
|
152
|
+
overlayRoot,
|
|
153
|
+
gitContext,
|
|
154
|
+
);
|
|
155
|
+
const regularPaths = new Set(regularFiles.map((f) => f.targetPath));
|
|
156
|
+
const extraAgentPaths = agentClassification.untracked.filter(
|
|
157
|
+
(p) => !regularPaths.has(p),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const allPaths = [
|
|
161
|
+
...regularFiles.map((f) => f.targetPath),
|
|
162
|
+
...extraAgentPaths,
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
if (allPaths.length === 0) {
|
|
148
166
|
console.log("No unadded files found.");
|
|
149
167
|
return;
|
|
150
168
|
}
|
|
151
169
|
|
|
152
|
-
console.log(`Found ${
|
|
170
|
+
console.log(`Found ${allPaths.length} unadded file(s):\n`);
|
|
153
171
|
|
|
154
172
|
const counts: ScopeCounts = { project: 0, worktree: 0, global: 0, skip: 0 };
|
|
155
173
|
|
|
156
|
-
for (let i = 0; i <
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
const result = await promptAndAddFile(relPath, i, regularFiles.length, ctx);
|
|
174
|
+
for (let i = 0; i < allPaths.length; i++) {
|
|
175
|
+
const relPath = relative(cwd, allPaths[i]);
|
|
176
|
+
const result = await promptAndAddFile(relPath, i, allPaths.length, ctx);
|
|
160
177
|
if (result === "quit") break;
|
|
161
178
|
if (result !== "error") counts[result]++;
|
|
162
179
|
}
|
package/src/commands/Check.ts
CHANGED
|
@@ -7,8 +7,15 @@ import {
|
|
|
7
7
|
formatAgentFileProblems,
|
|
8
8
|
} from "../ClassifyFiles.ts";
|
|
9
9
|
import { expandPath, loadConfig } from "../Config.ts";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
fileExists,
|
|
12
|
+
getCwd,
|
|
13
|
+
relativePath,
|
|
14
|
+
toSlash,
|
|
15
|
+
walkDirectory,
|
|
16
|
+
} from "../FsUtil.ts";
|
|
11
17
|
import { type GitContext, getGitContext } from "../Git.ts";
|
|
18
|
+
import { loadGitignore } from "../Gitignore.ts";
|
|
12
19
|
import { type MapperContext, overlayProjectDir } from "../Mapper.ts";
|
|
13
20
|
import { formatStatusLines, getOverlayStatus } from "../OverlayGit.ts";
|
|
14
21
|
import { type ManagedFileState, verifyManaged } from "../OverlayLinks.ts";
|
|
@@ -33,7 +40,7 @@ const localOnlyFiles = ["settings.local.json"];
|
|
|
33
40
|
|
|
34
41
|
/** Check for orphaned overlay paths that don't match target structure */
|
|
35
42
|
export async function checkCommand(): Promise<void> {
|
|
36
|
-
const cwd =
|
|
43
|
+
const cwd = await getCwd();
|
|
37
44
|
const gitContext = await getGitContext(cwd);
|
|
38
45
|
const config = await loadConfig();
|
|
39
46
|
const overlayRoot = expandPath(config.overlayRepo);
|
|
@@ -53,15 +60,17 @@ export async function checkCommand(): Promise<void> {
|
|
|
53
60
|
export async function findUnaddedFiles(
|
|
54
61
|
context: MapperContext,
|
|
55
62
|
): Promise<UnaddedFile[]> {
|
|
56
|
-
const { targetRoot } = context;
|
|
63
|
+
const { targetRoot, overlayRoot } = context;
|
|
57
64
|
const unadded: UnaddedFile[] = [];
|
|
65
|
+
const isIgnored = await loadGitignore(overlayRoot);
|
|
58
66
|
|
|
59
67
|
for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
|
|
60
68
|
if (isDirectory) continue;
|
|
61
69
|
|
|
62
|
-
const relPath = relative(targetRoot, path);
|
|
70
|
+
const relPath = toSlash(relative(targetRoot, path));
|
|
63
71
|
if (!isInManagedDir(relPath)) continue;
|
|
64
72
|
if (isLocalOnlyFile(relPath)) continue;
|
|
73
|
+
if (isIgnored(basename(relPath))) continue;
|
|
65
74
|
|
|
66
75
|
const managed = await verifyManaged(path, context);
|
|
67
76
|
if (managed.kind !== "valid") {
|
|
@@ -91,7 +100,7 @@ export async function findOrphans(
|
|
|
91
100
|
|
|
92
101
|
const skip = (relPath: string): boolean => {
|
|
93
102
|
if (isIgnored) {
|
|
94
|
-
const pathBasename = relPath
|
|
103
|
+
const pathBasename = basename(relPath);
|
|
95
104
|
if (isIgnored(relPath) || isIgnored(pathBasename)) return true;
|
|
96
105
|
}
|
|
97
106
|
return false;
|
|
@@ -103,7 +112,7 @@ export async function findOrphans(
|
|
|
103
112
|
})) {
|
|
104
113
|
if (isDirectory) continue;
|
|
105
114
|
|
|
106
|
-
const relPath = relative(projectDir, path);
|
|
115
|
+
const relPath = toSlash(relative(projectDir, path));
|
|
107
116
|
|
|
108
117
|
// Skip files at project root (agents.md, settings.json)
|
|
109
118
|
if (!relPath.includes("/")) continue;
|
|
@@ -277,9 +286,12 @@ function showUnaddedFiles(
|
|
|
277
286
|
`Found ${regularFiles.length} unadded file(s) in ${targetName}:\n`,
|
|
278
287
|
);
|
|
279
288
|
for (const file of regularFiles) {
|
|
280
|
-
console.log(`
|
|
289
|
+
console.log(` ${relativePath(cwd, file.targetPath)}`);
|
|
281
290
|
}
|
|
282
291
|
console.log();
|
|
292
|
+
console.log(" clank add -i # add interactively");
|
|
293
|
+
console.log(" clank add <file> [<file>...] # add specific files");
|
|
294
|
+
console.log();
|
|
283
295
|
}
|
|
284
296
|
}
|
|
285
297
|
|
package/src/commands/Init.ts
CHANGED
|
@@ -33,6 +33,7 @@ export async function initCommand(overlayPath?: string): Promise<void> {
|
|
|
33
33
|
await ensureDir(join(targetPath, "targets"));
|
|
34
34
|
|
|
35
35
|
await createDefaultTemplates(targetPath);
|
|
36
|
+
await writeFile(join(targetPath, ".gitignore"), ".DS_Store\n", "utf-8");
|
|
36
37
|
|
|
37
38
|
await createDefaultConfig(targetPath);
|
|
38
39
|
|
package/src/commands/Link.ts
CHANGED
|
@@ -12,14 +12,17 @@ import {
|
|
|
12
12
|
loadConfig,
|
|
13
13
|
validateOverlayExists,
|
|
14
14
|
} from "../Config.ts";
|
|
15
|
+
import { consolidateRulesIntoAgentFiles } from "../Consolidate.ts";
|
|
15
16
|
import { addGitExcludes } from "../Exclude.ts";
|
|
16
17
|
import {
|
|
17
18
|
createSymlink,
|
|
18
19
|
ensureDir,
|
|
19
20
|
fileExists,
|
|
21
|
+
getCwd,
|
|
20
22
|
getLinkTarget,
|
|
21
23
|
isSymlink,
|
|
22
24
|
isTrackedByGit,
|
|
25
|
+
toSlash,
|
|
23
26
|
} from "../FsUtil.ts";
|
|
24
27
|
import { type GitContext, getGitContext } from "../Git.ts";
|
|
25
28
|
import {
|
|
@@ -64,7 +67,7 @@ interface SeparatedMappings {
|
|
|
64
67
|
|
|
65
68
|
/** Link overlay repository to target directory */
|
|
66
69
|
export async function linkCommand(targetDir?: string): Promise<void> {
|
|
67
|
-
const gitContext = await getGitContext(targetDir ||
|
|
70
|
+
const gitContext = await getGitContext(targetDir || (await getCwd()));
|
|
68
71
|
const targetRoot = gitContext.gitRoot;
|
|
69
72
|
console.log(`Linking clank overlay to: ${targetRoot}\n`);
|
|
70
73
|
logGitContext(gitContext);
|
|
@@ -73,21 +76,7 @@ export async function linkCommand(targetDir?: string): Promise<void> {
|
|
|
73
76
|
const overlayRoot = expandPath(config.overlayRepo);
|
|
74
77
|
await validateOverlayExists(overlayRoot);
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
const staleRemoved = await cleanStaleWorktreeSymlinks(
|
|
78
|
-
targetRoot,
|
|
79
|
-
overlayRoot,
|
|
80
|
-
gitContext,
|
|
81
|
-
);
|
|
82
|
-
if (staleRemoved.length > 0) {
|
|
83
|
-
console.log(`\nCleaned ${staleRemoved.length} stale worktree symlink(s):`);
|
|
84
|
-
for (const path of staleRemoved) {
|
|
85
|
-
console.log(` ${path}`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Check for problematic agent files before proceeding
|
|
90
|
-
await checkAgentFiles(targetRoot, overlayRoot);
|
|
79
|
+
await cleanStaleAndCheck(targetRoot, overlayRoot, gitContext);
|
|
91
80
|
|
|
92
81
|
await ensureDir(join(overlayRoot, "targets", gitContext.projectName));
|
|
93
82
|
await maybeInitWorktree(overlayRoot, gitContext);
|
|
@@ -102,6 +91,8 @@ export async function linkCommand(targetDir?: string): Promise<void> {
|
|
|
102
91
|
await createAgentLinks(agentsMappings, targetRoot, config.agents);
|
|
103
92
|
await createPromptLinks(promptsMappings, targetRoot);
|
|
104
93
|
|
|
94
|
+
await maybeConsolidateRules(overlayRoot, targetRoot, gitContext, config);
|
|
95
|
+
|
|
105
96
|
await setupProjectSettings(overlayRoot, gitContext, targetRoot);
|
|
106
97
|
await addGitExcludes(targetRoot);
|
|
107
98
|
await maybeGenerateVscodeSettings(config, targetRoot);
|
|
@@ -119,15 +110,48 @@ function logGitContext(ctx: GitContext): void {
|
|
|
119
110
|
console.log(`Branch: ${ctx.worktreeName}${suffix}`);
|
|
120
111
|
}
|
|
121
112
|
|
|
122
|
-
/**
|
|
123
|
-
async function
|
|
113
|
+
/** Clean stale worktree symlinks and check for problematic agent files */
|
|
114
|
+
async function cleanStaleAndCheck(
|
|
124
115
|
targetRoot: string,
|
|
125
116
|
overlayRoot: string,
|
|
117
|
+
gitContext: GitContext,
|
|
126
118
|
): Promise<void> {
|
|
127
|
-
const
|
|
119
|
+
const staleRemoved = await cleanStaleWorktreeSymlinks(
|
|
120
|
+
targetRoot,
|
|
121
|
+
overlayRoot,
|
|
122
|
+
gitContext,
|
|
123
|
+
);
|
|
124
|
+
if (staleRemoved.length > 0) {
|
|
125
|
+
console.log(`\nCleaned ${staleRemoved.length} stale worktree symlink(s):`);
|
|
126
|
+
for (const path of staleRemoved) {
|
|
127
|
+
console.log(` ${path}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
128
130
|
|
|
131
|
+
const classification = await classifyAgentFiles(targetRoot, overlayRoot);
|
|
129
132
|
if (agentFileProblems(classification)) {
|
|
130
|
-
throw new Error(formatAgentFileProblems(classification,
|
|
133
|
+
throw new Error(formatAgentFileProblems(classification, await getCwd()));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
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
|
+
}
|
|
131
155
|
}
|
|
132
156
|
}
|
|
133
157
|
|
|
@@ -160,7 +184,7 @@ async function collectMappings(
|
|
|
160
184
|
const isAgent = ({ targetPath }: FileMapping) =>
|
|
161
185
|
basename(targetPath) === "agents.md";
|
|
162
186
|
const isPrompt = ({ targetPath }: FileMapping) =>
|
|
163
|
-
targetPath.includes("/.claude/prompts/");
|
|
187
|
+
toSlash(targetPath).includes("/.claude/prompts/");
|
|
164
188
|
|
|
165
189
|
const agentsMappings = mappings.filter(isAgent);
|
|
166
190
|
const promptsMappings = mappings.filter((m) => !isAgent(m) && isPrompt(m));
|
|
@@ -459,7 +483,7 @@ async function checkMappingParentExists(
|
|
|
459
483
|
m: FileMapping,
|
|
460
484
|
targetRoot: string,
|
|
461
485
|
): Promise<FileMapping | null> {
|
|
462
|
-
const relPath = relative(targetRoot, m.targetPath);
|
|
486
|
+
const relPath = toSlash(relative(targetRoot, m.targetPath));
|
|
463
487
|
// Subdirectory clank files have /clank/ in the middle of the path
|
|
464
488
|
const clankIndex = relPath.indexOf("/clank/");
|
|
465
489
|
if (clankIndex !== -1) {
|
package/src/commands/Move.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createSymlink,
|
|
7
7
|
ensureDir,
|
|
8
8
|
fileExists,
|
|
9
|
+
getCwd,
|
|
9
10
|
getLinkTarget,
|
|
10
11
|
} from "../FsUtil.ts";
|
|
11
12
|
import { getGitContext } from "../Git.ts";
|
|
@@ -37,7 +38,7 @@ export async function moveCommand(
|
|
|
37
38
|
options: MoveOptions,
|
|
38
39
|
): Promise<void> {
|
|
39
40
|
const hasScope = options.global || options.project || options.worktree;
|
|
40
|
-
const cwd =
|
|
41
|
+
const cwd = await getCwd();
|
|
41
42
|
const gitContext = await getGitContext(cwd);
|
|
42
43
|
const config = await loadConfig();
|
|
43
44
|
const overlayRoot = expandPath(config.overlayRepo);
|
package/src/commands/Rm.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { rm, unlink } from "node:fs/promises";
|
|
|
2
2
|
import { basename, dirname, relative } from "node:path";
|
|
3
3
|
import { forEachAgentPath } from "../AgentFiles.ts";
|
|
4
4
|
import { expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
|
|
5
|
-
import { fileExists } from "../FsUtil.ts";
|
|
5
|
+
import { fileExists, getCwd } from "../FsUtil.ts";
|
|
6
6
|
import { getGitContext } from "../Git.ts";
|
|
7
7
|
import {
|
|
8
8
|
isAgentFile,
|
|
@@ -23,7 +23,7 @@ export async function rmCommand(
|
|
|
23
23
|
filePaths: string[],
|
|
24
24
|
options: RmOptions = {},
|
|
25
25
|
): Promise<void> {
|
|
26
|
-
const cwd =
|
|
26
|
+
const cwd = await getCwd();
|
|
27
27
|
const gitContext = await getGitContext(cwd);
|
|
28
28
|
const config = await loadConfig();
|
|
29
29
|
const overlayRoot = expandPath(config.overlayRepo);
|
package/src/commands/Unlink.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { expandPath, loadConfig } from "../Config.ts";
|
|
2
|
+
import { removeGeneratedAgentFiles } from "../Consolidate.ts";
|
|
2
3
|
import { removeGitExcludes } from "../Exclude.ts";
|
|
3
|
-
import { fileExists, removeSymlink, walkDirectory } from "../FsUtil.ts";
|
|
4
|
+
import { fileExists, getCwd, removeSymlink, walkDirectory } from "../FsUtil.ts";
|
|
4
5
|
import { getGitContext } from "../Git.ts";
|
|
5
6
|
import { isSymlinkToOverlay } from "../OverlayLinks.ts";
|
|
6
7
|
import { removeVscodeSettings } from "./VsCode.ts";
|
|
7
8
|
|
|
8
9
|
/** Remove all symlinks pointing to overlay repository */
|
|
9
10
|
export async function unlinkCommand(targetDir?: string): Promise<void> {
|
|
10
|
-
const gitContext = await getGitContext(targetDir ||
|
|
11
|
+
const gitContext = await getGitContext(targetDir || (await getCwd()));
|
|
11
12
|
const targetRoot = gitContext.gitRoot;
|
|
12
13
|
|
|
13
14
|
console.log(`Removing clank symlinks from: ${targetRoot}\n`);
|
|
@@ -34,6 +35,10 @@ export async function unlinkCommand(targetDir?: string): Promise<void> {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
// Remove generated agent files (from rules consolidation)
|
|
39
|
+
const generatedRemoved = await removeGeneratedAgentFiles(targetRoot);
|
|
40
|
+
removedCount += generatedRemoved.length;
|
|
41
|
+
|
|
37
42
|
await removeGitExcludes(targetRoot);
|
|
38
43
|
await removeVscodeSettings(targetRoot);
|
|
39
44
|
|
package/src/commands/VsCode.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { addToGitExclude } from "../Exclude.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
ensureDir,
|
|
7
7
|
fileExists,
|
|
8
|
+
getCwd,
|
|
8
9
|
isTrackedByGit,
|
|
9
10
|
writeJsonFile,
|
|
10
11
|
} from "../FsUtil.ts";
|
|
@@ -109,7 +110,7 @@ export async function checkVscodeTracking(
|
|
|
109
110
|
|
|
110
111
|
/** Generate VS Code settings to show clank files in search and explorer */
|
|
111
112
|
export async function vscodeCommand(options?: VscodeOptions): Promise<void> {
|
|
112
|
-
const targetRoot = await detectGitRoot(
|
|
113
|
+
const targetRoot = await detectGitRoot(await getCwd());
|
|
113
114
|
|
|
114
115
|
if (options?.remove) {
|
|
115
116
|
await removeVscodeSettings(targetRoot);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { basename, dirname } from "node:path";
|
|
2
2
|
import { agentFiles } from "../../AgentFiles.ts";
|
|
3
3
|
import { getPromptRelPath } from "../../Mapper.ts";
|
|
4
4
|
import { partition } from "../../Util.ts";
|
|
@@ -15,9 +15,9 @@ export function dedupeEntries(
|
|
|
15
15
|
|
|
16
16
|
/** Check if a relative path ends with an agent filename (CLAUDE.md, etc.) */
|
|
17
17
|
export function isAgentFilePath(relPath: string): boolean {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
return agentFiles.some(
|
|
19
|
+
(f) => f.toLowerCase() === basename(relPath).toLowerCase(),
|
|
20
|
+
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Keep at most one agent file per directory, using the configured preference order. */
|
|
@@ -129,6 +129,5 @@ function mapPreference(
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
function basenameUpper(relPath: string): string {
|
|
132
|
-
|
|
133
|
-
return base.toUpperCase();
|
|
132
|
+
return basename(relPath).toUpperCase();
|
|
134
133
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { join, relative } from "node:path";
|
|
2
2
|
import { expandPath, loadConfig } from "../../Config.ts";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getCwd,
|
|
5
|
+
resolveSymlinkTarget,
|
|
6
|
+
toSlash,
|
|
7
|
+
walkDirectory,
|
|
8
|
+
} from "../../FsUtil.ts";
|
|
4
9
|
import { getGitContext } from "../../Git.ts";
|
|
5
10
|
import { isClankPath } from "../../Mapper.ts";
|
|
6
11
|
import { isAgentFilePath } from "./Dedupe.ts";
|
|
@@ -76,7 +81,7 @@ interface NotLinkedToOverlay {
|
|
|
76
81
|
export async function getFilesContext(
|
|
77
82
|
inputPath?: string,
|
|
78
83
|
): Promise<FilesContext> {
|
|
79
|
-
const cwd =
|
|
84
|
+
const cwd = await getCwd();
|
|
80
85
|
const gitContext = await getGitContext(cwd);
|
|
81
86
|
const targetRoot = gitContext.gitRoot;
|
|
82
87
|
const scanRoot = resolveScanRoot(targetRoot, cwd, inputPath);
|
|
@@ -146,7 +151,7 @@ function resolveScanRoot(
|
|
|
146
151
|
): string {
|
|
147
152
|
if (!input) return targetRoot;
|
|
148
153
|
const resolved = join(cwd, input);
|
|
149
|
-
const rel =
|
|
154
|
+
const rel = toSlash(relative(targetRoot, resolved));
|
|
150
155
|
if (rel.startsWith("..")) {
|
|
151
156
|
throw new Error(`Path is outside the git repository: ${input}`);
|
|
152
157
|
}
|
|
@@ -178,7 +183,7 @@ async function maybeCreateEntry(
|
|
|
178
183
|
opts: NormalizedFilesOptions,
|
|
179
184
|
filePath: string,
|
|
180
185
|
): Promise<FileEntry | null> {
|
|
181
|
-
const targetRel =
|
|
186
|
+
const targetRel = toSlash(relative(ctx.targetRoot, filePath));
|
|
182
187
|
if (!isManagedTargetPath(targetRel, opts.hidden)) return null;
|
|
183
188
|
if (!passesDepthFilter(targetRel, opts.depth)) return null;
|
|
184
189
|
|
|
@@ -187,16 +192,12 @@ async function maybeCreateEntry(
|
|
|
187
192
|
|
|
188
193
|
return {
|
|
189
194
|
absolutePath: filePath,
|
|
190
|
-
cwdRelativePath:
|
|
195
|
+
cwdRelativePath: toSlash(relative(ctx.cwd, filePath) || "."),
|
|
191
196
|
targetRelativePath: targetRel,
|
|
192
197
|
link,
|
|
193
198
|
};
|
|
194
199
|
}
|
|
195
200
|
|
|
196
|
-
function normalizeRelPath(p: string): string {
|
|
197
|
-
return p.replaceAll("\\", "/");
|
|
198
|
-
}
|
|
199
|
-
|
|
200
201
|
/** Decide whether a target-relative path is managed by clank for listing. */
|
|
201
202
|
function isManagedTargetPath(relPath: string, includeHidden: boolean): boolean {
|
|
202
203
|
if (isAgentFilePath(relPath)) return true;
|
|
@@ -220,7 +221,8 @@ async function classifyLink(
|
|
|
220
221
|
): Promise<LinkState> {
|
|
221
222
|
try {
|
|
222
223
|
const overlayPath = await resolveSymlinkTarget(filePath);
|
|
223
|
-
if (!overlayPath.startsWith(overlayRoot))
|
|
224
|
+
if (!toSlash(overlayPath).startsWith(toSlash(overlayRoot)))
|
|
225
|
+
return { kind: "unlinked" };
|
|
224
226
|
const scope = inferScopeFromOverlay(overlayPath, overlayRoot, gitContext);
|
|
225
227
|
if (!scope) return { kind: "unlinked" };
|
|
226
228
|
return { kind: "linked", overlayPath, scope };
|
|
@@ -263,16 +265,16 @@ function inferScopeFromOverlay(
|
|
|
263
265
|
overlayRoot: string,
|
|
264
266
|
gitContext: Awaited<ReturnType<typeof getGitContext>>,
|
|
265
267
|
): "global" | "project" | "worktree" | null {
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
+
const op = toSlash(overlayPath);
|
|
269
|
+
const globalPrefix = `${toSlash(join(overlayRoot, "global"))}/`;
|
|
270
|
+
if (op.startsWith(globalPrefix)) return "global";
|
|
268
271
|
|
|
269
272
|
const { projectName, worktreeName } = gitContext;
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
if (
|
|
273
|
+
const projectBase = toSlash(join(overlayRoot, "targets", projectName));
|
|
274
|
+
const worktreePrefix = `${projectBase}/worktrees/${worktreeName}/`;
|
|
275
|
+
if (op.startsWith(worktreePrefix)) return "worktree";
|
|
273
276
|
|
|
274
|
-
|
|
275
|
-
if (overlayPath.startsWith(projectPrefix)) return "project";
|
|
277
|
+
if (op.startsWith(`${projectBase}/`)) return "project";
|
|
276
278
|
|
|
277
279
|
return null;
|
|
278
280
|
}
|