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 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
- └── agents/ # -> .claude/agents/
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
@@ -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.67",
4
+ "version": "0.1.72",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
@@ -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 commands = classified.untracked.map((p) => ` clank add ${rel(p)}`);
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
- ${commands.join("\n")}`);
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
- await symlink(target, linkPath);
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
- return toplevel;
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 projectPrefix = join(overlayRoot, "targets", gitContext.projectName);
87
- const globalPrefix = join(overlayRoot, "global");
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 (overlayPath.startsWith(globalPrefix)) {
93
+ if (op.startsWith(globalPrefix)) {
90
94
  return mapGlobalOverlay(overlayPath, globalPrefix, targetRoot);
91
95
  }
92
96
 
93
- if (overlayPath.startsWith(projectPrefix)) {
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
- const normalized = input.replace(/^\.\//, "");
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
- for (const agentDir of managedAgentDirs) {
160
- if (normalized.startsWith(`${agentDir}/`)) {
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
- const relCwd = relative(gitRoot, cwd);
168
- for (const agentDir of managedAgentDirs) {
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 (normalizedPath.includes(`/${agentDir}/prompts/`)) {
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 = normalizedPath.indexOf(marker);
225
+ const idx = p.indexOf(marker);
218
226
  if (idx !== -1) {
219
- return normalizedPath.slice(idx + marker.length);
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 = join("worktrees", gitContext.worktreeName);
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,
@@ -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
- const basename = relPath.split("/").at(-1) ?? "";
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("/");
@@ -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)) return null;
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;
@@ -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 = process.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
- if (regularFiles.length === 0) {
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 ${regularFiles.length} unadded file(s):\n`);
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 < regularFiles.length; i++) {
157
- const file = regularFiles[i];
158
- const relPath = relative(cwd, file.targetPath);
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
  }
@@ -7,8 +7,15 @@ import {
7
7
  formatAgentFileProblems,
8
8
  } from "../ClassifyFiles.ts";
9
9
  import { expandPath, loadConfig } from "../Config.ts";
10
- import { fileExists, relativePath, walkDirectory } from "../FsUtil.ts";
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 = process.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.split("/").at(-1) ?? "";
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(` clank add ${relativePath(cwd, file.targetPath)}`);
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
 
@@ -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
 
@@ -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 || process.cwd());
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
- // Clean up symlinks pointing to wrong worktree before linking
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
- /** Check for problematic agent files and error if found */
123
- async function checkAgentFiles(
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 classification = await classifyAgentFiles(targetRoot, overlayRoot);
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, process.cwd()));
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) {
@@ -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 = process.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);
@@ -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 = process.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);
@@ -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 || process.cwd());
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
 
@@ -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(process.cwd());
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
- const base = relPath.split("/").at(-1)?.toLowerCase();
19
- if (!base) return false;
20
- return agentFiles.some((f) => f.toLowerCase() === base);
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
- const base = relPath.split("/").at(-1) ?? "";
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 { resolveSymlinkTarget, walkDirectory } from "../../FsUtil.ts";
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 = process.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 = normalizeRelPath(relative(targetRoot, resolved));
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 = normalizeRelPath(relative(ctx.targetRoot, filePath));
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: normalizeRelPath(relative(ctx.cwd, filePath) || "."),
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)) return { kind: "unlinked" };
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 globalPrefix = `${join(overlayRoot, "global")}/`;
267
- if (overlayPath.startsWith(globalPrefix)) return "global";
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 projectPath = join(overlayRoot, "targets", projectName);
271
- const worktreePath = join(projectPath, "worktrees", worktreeName);
272
- if (overlayPath.startsWith(`${worktreePath}/`)) return "worktree";
273
+ const projectBase = toSlash(join(overlayRoot, "targets", projectName));
274
+ const worktreePrefix = `${projectBase}/worktrees/${worktreeName}/`;
275
+ if (op.startsWith(worktreePrefix)) return "worktree";
273
276
 
274
- const projectPrefix = `${projectPath}/`;
275
- if (overlayPath.startsWith(projectPrefix)) return "project";
277
+ if (op.startsWith(`${projectBase}/`)) return "project";
276
278
 
277
279
  return null;
278
280
  }