clank-cli 0.1.67 → 0.1.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -284,14 +292,14 @@ function encodeTargetPath(relPath: string, overlayBase: string): string {
284
292
  if (basename(relPath) === "agents.md") {
285
293
  return join(overlayBase, relPath);
286
294
  }
287
- // .claude/prompts/ and .gemini/prompts/ → prompts/ in overlay (agent-agnostic)
295
+ // <agentDir>/prompts/ (.claude/, .gemini/, .codex/) → prompts/ in overlay (agent-agnostic)
288
296
  for (const agentDir of managedAgentDirs) {
289
297
  const prefix = `${agentDir}/prompts/`;
290
298
  if (relPath.startsWith(prefix)) {
291
299
  return join(overlayBase, "prompts", relPath.slice(prefix.length));
292
300
  }
293
301
  }
294
- // .claude/* and .gemini/* → claude/*, gemini/* in overlay (agent-specific)
302
+ // .claude/*, .gemini/*, .codex/* → claude/*, gemini/*, codex/* in overlay (agent-specific)
295
303
  for (const agentDir of managedAgentDirs) {
296
304
  if (relPath.startsWith(`${agentDir}/`)) {
297
305
  const subPath = relPath.slice(agentDir.length + 1);
@@ -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/, .codex/) */
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/, .codex/) */
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,
@@ -326,7 +346,7 @@ function decodeOverlayPath(
326
346
  };
327
347
  }
328
348
 
329
- // claude/, gemini/ files → map to .claude/, .gemini/ in target
349
+ // claude/, gemini/, codex/ files → map to .claude/, .gemini/, .codex/ in target
330
350
  for (const agentDir of managedAgentDirs) {
331
351
  const overlayDir = agentDir.slice(1); // "claude" or "gemini"
332
352
  if (relPath.startsWith(`${overlayDir}/`)) {
@@ -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
 
@@ -58,7 +59,7 @@ export async function verifyManaged(
58
59
  };
59
60
  }
60
61
 
61
- // Prompt files are fanned out to all agent directories (.claude/prompts/, .gemini/prompts/)
62
+ // Prompt files are fanned out to all agent directories (.claude/prompts/, .gemini/prompts/, .codex/prompts/)
62
63
  // Accept any agent's prompts dir as valid if the filename matches
63
64
  if (mapping.targetPath !== linkPath) {
64
65
  if (!isMatchingPromptPath(mapping.targetPath, linkPath)) {
@@ -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;