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/README.md +9 -1
- package/package.json +1 -1
- package/src/AgentFiles.ts +1 -1
- package/src/ClassifyFiles.ts +9 -3
- package/src/Cli.ts +23 -23
- package/src/Consolidate.ts +250 -0
- package/src/FsUtil.ts +54 -4
- package/src/Git.ts +5 -2
- package/src/Gitignore.ts +20 -2
- package/src/Mapper.ts +47 -27
- package/src/OverlayLinks.ts +18 -18
- package/src/ScopeFromSymlink.ts +3 -2
- package/src/commands/Add.ts +142 -152
- package/src/commands/Check.ts +144 -61
- package/src/commands/Init.ts +1 -0
- package/src/commands/Link.ts +49 -32
- 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 +21 -18
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
|
|
|
@@ -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
|
-
//
|
|
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
|
|
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}/`)) {
|
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
|
|
|
@@ -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
|
-
|
|
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;
|