clank-cli 0.1.59 → 0.1.62
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 +30 -4
- package/package.json +5 -3
- package/src/ClassifyFiles.ts +29 -29
- package/src/Cli.ts +78 -32
- package/src/Config.ts +14 -15
- package/src/Exclude.ts +9 -9
- package/src/FsUtil.ts +81 -19
- package/src/Git.ts +10 -10
- package/src/Gitignore.ts +25 -25
- package/src/Mapper.ts +77 -72
- package/src/OverlayGit.ts +30 -3
- package/src/OverlayLinks.ts +28 -17
- package/src/Util.ts +10 -0
- package/src/commands/Add.ts +107 -107
- package/src/commands/Check.ts +159 -139
- package/src/commands/Commit.ts +1 -1
- package/src/commands/Files.ts +38 -0
- package/src/commands/Link.ts +227 -193
- package/src/commands/Move.ts +16 -16
- package/src/commands/Rm.ts +29 -29
- package/src/commands/VsCode.ts +24 -24
- package/src/commands/files/Dedupe.ts +134 -0
- package/src/commands/files/Scan.ts +278 -0
package/src/Mapper.ts
CHANGED
|
@@ -12,6 +12,19 @@ export interface ScopeOptions {
|
|
|
12
12
|
worktree?: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Result of mapping an overlay path to a target path */
|
|
16
|
+
export interface TargetMapping {
|
|
17
|
+
targetPath: string;
|
|
18
|
+
scope: Scope;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** params for mapping from the overlay repo to the target project repo */
|
|
22
|
+
export interface MapperContext {
|
|
23
|
+
overlayRoot: string;
|
|
24
|
+
targetRoot: string;
|
|
25
|
+
gitContext: GitContext;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
/** Resolve scope from CLI options
|
|
16
29
|
* @param options - The CLI options
|
|
17
30
|
* @param defaultScope - Default scope if none specified, or "require" to throw
|
|
@@ -32,19 +45,6 @@ export function resolveScopeFromOptions(
|
|
|
32
45
|
return defaultScope;
|
|
33
46
|
}
|
|
34
47
|
|
|
35
|
-
/** Result of mapping an overlay path to a target path */
|
|
36
|
-
export interface TargetMapping {
|
|
37
|
-
targetPath: string;
|
|
38
|
-
scope: Scope;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** params for mapping from the overlay repo to the target project repo */
|
|
42
|
-
export interface MapperContext {
|
|
43
|
-
overlayRoot: string;
|
|
44
|
-
targetRoot: string;
|
|
45
|
-
gitContext: GitContext;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
48
|
/** Get overlay path for a project: overlay/targets/{projectName} */
|
|
49
49
|
export function overlayProjectDir(
|
|
50
50
|
overlayRoot: string,
|
|
@@ -132,34 +132,6 @@ export function targetToOverlay(
|
|
|
132
132
|
return encodeTargetPath(relPath, overlayBase);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/** Encode a target-relative path to an overlay path */
|
|
136
|
-
function encodeTargetPath(relPath: string, overlayBase: string): string {
|
|
137
|
-
// agents.md stays at natural path
|
|
138
|
-
if (basename(relPath) === "agents.md") {
|
|
139
|
-
return join(overlayBase, relPath);
|
|
140
|
-
}
|
|
141
|
-
// .claude/prompts/ and .gemini/prompts/ → prompts/ in overlay (agent-agnostic)
|
|
142
|
-
for (const agentDir of managedAgentDirs) {
|
|
143
|
-
const prefix = `${agentDir}/prompts/`;
|
|
144
|
-
if (relPath.startsWith(prefix)) {
|
|
145
|
-
return join(overlayBase, "prompts", relPath.slice(prefix.length));
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
// .claude/* and .gemini/* → claude/*, gemini/* in overlay (agent-specific)
|
|
149
|
-
for (const agentDir of managedAgentDirs) {
|
|
150
|
-
if (relPath.startsWith(`${agentDir}/`)) {
|
|
151
|
-
const subPath = relPath.slice(agentDir.length + 1);
|
|
152
|
-
return join(overlayBase, agentDir.slice(1), subPath); // strip leading dot
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
// Files with clank/ in path → preserve structure
|
|
156
|
-
if (relPath.includes("clank/")) {
|
|
157
|
-
return join(overlayBase, relPath);
|
|
158
|
-
}
|
|
159
|
-
// Plain files → add clank/ prefix
|
|
160
|
-
return join(overlayBase, "clank", relPath);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
135
|
/**
|
|
164
136
|
* Normalize file path argument from clank add command
|
|
165
137
|
* All files go to clank/ in target (except .claude/ files and agent files)
|
|
@@ -217,6 +189,11 @@ export function normalizeAddPath(
|
|
|
217
189
|
return join(normalizedCwd, "clank", filename);
|
|
218
190
|
}
|
|
219
191
|
|
|
192
|
+
/** Check if a relative path contains a clank/ directory component */
|
|
193
|
+
export function isClankPath(relPath: string): boolean {
|
|
194
|
+
return relPath.startsWith("clank/") || relPath.includes("/clank/");
|
|
195
|
+
}
|
|
196
|
+
|
|
220
197
|
/** Check if a filename is an agent file (CLAUDE.md, GEMINI.md, AGENTS.md) */
|
|
221
198
|
export function isAgentFile(filename: string): boolean {
|
|
222
199
|
const name = basename(filename).toLowerCase();
|
|
@@ -271,6 +248,64 @@ function mapGlobalOverlay(
|
|
|
271
248
|
return decodeOverlayPath(relPath, targetRoot, "global");
|
|
272
249
|
}
|
|
273
250
|
|
|
251
|
+
/** Map project overlay files to target */
|
|
252
|
+
function mapProjectOverlay(
|
|
253
|
+
overlayPath: string,
|
|
254
|
+
projectPrefix: string,
|
|
255
|
+
context: MapperContext,
|
|
256
|
+
): TargetMapping | null {
|
|
257
|
+
const { targetRoot, gitContext } = context;
|
|
258
|
+
const relPath = relative(projectPrefix, overlayPath);
|
|
259
|
+
|
|
260
|
+
// Worktree-specific files
|
|
261
|
+
const worktreePrefix = join("worktrees", gitContext.worktreeName);
|
|
262
|
+
if (relPath.startsWith(`${worktreePrefix}/`)) {
|
|
263
|
+
const innerPath = relative(worktreePrefix, relPath);
|
|
264
|
+
return decodeOverlayPath(innerPath, targetRoot, "worktree");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Skip other worktrees
|
|
268
|
+
if (relPath.startsWith("worktrees/")) return null;
|
|
269
|
+
|
|
270
|
+
// Project settings.json (project-only, before shared logic)
|
|
271
|
+
if (relPath === "claude/settings.json") {
|
|
272
|
+
return {
|
|
273
|
+
targetPath: join(targetRoot, ".claude/settings.json"),
|
|
274
|
+
scope: "project",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return decodeOverlayPath(relPath, targetRoot, "project");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Encode a target-relative path to an overlay path */
|
|
282
|
+
function encodeTargetPath(relPath: string, overlayBase: string): string {
|
|
283
|
+
// agents.md stays at natural path
|
|
284
|
+
if (basename(relPath) === "agents.md") {
|
|
285
|
+
return join(overlayBase, relPath);
|
|
286
|
+
}
|
|
287
|
+
// .claude/prompts/ and .gemini/prompts/ → prompts/ in overlay (agent-agnostic)
|
|
288
|
+
for (const agentDir of managedAgentDirs) {
|
|
289
|
+
const prefix = `${agentDir}/prompts/`;
|
|
290
|
+
if (relPath.startsWith(prefix)) {
|
|
291
|
+
return join(overlayBase, "prompts", relPath.slice(prefix.length));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// .claude/* and .gemini/* → claude/*, gemini/* in overlay (agent-specific)
|
|
295
|
+
for (const agentDir of managedAgentDirs) {
|
|
296
|
+
if (relPath.startsWith(`${agentDir}/`)) {
|
|
297
|
+
const subPath = relPath.slice(agentDir.length + 1);
|
|
298
|
+
return join(overlayBase, agentDir.slice(1), subPath); // strip leading dot
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Files with clank/ in path → preserve structure
|
|
302
|
+
if (isClankPath(relPath)) {
|
|
303
|
+
return join(overlayBase, relPath);
|
|
304
|
+
}
|
|
305
|
+
// Plain files → add clank/ prefix
|
|
306
|
+
return join(overlayBase, "clank", relPath);
|
|
307
|
+
}
|
|
308
|
+
|
|
274
309
|
/** Decode an overlay-relative path to target (shared by all scopes) */
|
|
275
310
|
function decodeOverlayPath(
|
|
276
311
|
relPath: string,
|
|
@@ -278,7 +313,7 @@ function decodeOverlayPath(
|
|
|
278
313
|
scope: Scope,
|
|
279
314
|
): TargetMapping | null {
|
|
280
315
|
// clank/ files (at root or in subdirectories)
|
|
281
|
-
if (
|
|
316
|
+
if (isClankPath(relPath)) {
|
|
282
317
|
return { targetPath: join(targetRoot, relPath), scope };
|
|
283
318
|
}
|
|
284
319
|
|
|
@@ -307,33 +342,3 @@ function decodeOverlayPath(
|
|
|
307
342
|
|
|
308
343
|
return null;
|
|
309
344
|
}
|
|
310
|
-
|
|
311
|
-
/** Map project overlay files to target */
|
|
312
|
-
function mapProjectOverlay(
|
|
313
|
-
overlayPath: string,
|
|
314
|
-
projectPrefix: string,
|
|
315
|
-
context: MapperContext,
|
|
316
|
-
): TargetMapping | null {
|
|
317
|
-
const { targetRoot, gitContext } = context;
|
|
318
|
-
const relPath = relative(projectPrefix, overlayPath);
|
|
319
|
-
|
|
320
|
-
// Worktree-specific files
|
|
321
|
-
const worktreePrefix = join("worktrees", gitContext.worktreeName);
|
|
322
|
-
if (relPath.startsWith(`${worktreePrefix}/`)) {
|
|
323
|
-
const innerPath = relative(worktreePrefix, relPath);
|
|
324
|
-
return decodeOverlayPath(innerPath, targetRoot, "worktree");
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Skip other worktrees
|
|
328
|
-
if (relPath.startsWith("worktrees/")) return null;
|
|
329
|
-
|
|
330
|
-
// Project settings.json (project-only, before shared logic)
|
|
331
|
-
if (relPath === "claude/settings.json") {
|
|
332
|
-
return {
|
|
333
|
-
targetPath: join(targetRoot, ".claude/settings.json"),
|
|
334
|
-
scope: "project",
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
return decodeOverlayPath(relPath, targetRoot, "project");
|
|
339
|
-
}
|
package/src/OverlayGit.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { execa } from "execa";
|
|
2
|
+
import picomatch from "picomatch";
|
|
2
3
|
import { managedDirs } from "./AgentFiles.ts";
|
|
3
4
|
|
|
4
5
|
/** Get git status of the overlay repository */
|
|
5
6
|
export async function getOverlayStatus(
|
|
6
7
|
overlayRoot: string,
|
|
7
|
-
|
|
8
|
+
ignorePatterns: string[] = [],
|
|
9
|
+
): Promise<string[]> {
|
|
8
10
|
const { stdout } = await execa({
|
|
9
11
|
cwd: overlayRoot,
|
|
10
12
|
})`git status --porcelain -uall`;
|
|
11
13
|
|
|
12
|
-
const
|
|
13
|
-
return
|
|
14
|
+
const allLines = stdout.trimEnd() ? stdout.trimEnd().split("\n") : [];
|
|
15
|
+
return filterIgnoredLines(allLines, ignorePatterns);
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** Format git status --porcelain output into readable lines */
|
|
@@ -35,6 +37,31 @@ export function formatStatusCode(code: string): string {
|
|
|
35
37
|
return "?";
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Filter out lines matching ignore patterns */
|
|
41
|
+
function filterIgnoredLines(
|
|
42
|
+
lines: string[],
|
|
43
|
+
ignorePatterns: string[],
|
|
44
|
+
): string[] {
|
|
45
|
+
if (ignorePatterns.length === 0) return lines;
|
|
46
|
+
|
|
47
|
+
const isIgnored = picomatch(ignorePatterns);
|
|
48
|
+
return lines.filter((line) => {
|
|
49
|
+
const filePath = line.slice(3); // Skip status code + space
|
|
50
|
+
const segments = filePath.split("/");
|
|
51
|
+
const pathBasename = segments.at(-1) ?? "";
|
|
52
|
+
|
|
53
|
+
// Check full path and basename
|
|
54
|
+
if (isIgnored(filePath) || isIgnored(pathBasename)) return false;
|
|
55
|
+
|
|
56
|
+
// Check each directory segment (for patterns like ".obsidian")
|
|
57
|
+
for (const segment of segments) {
|
|
58
|
+
if (isIgnored(segment)) return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return true;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
/** Parse overlay path into scope and path parts within that scope */
|
|
39
66
|
function parseScopedPath(filePath: string): {
|
|
40
67
|
scope: string;
|
package/src/OverlayLinks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lstat } from "node:fs/promises";
|
|
2
|
-
import { dirname, join
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import picomatch from "picomatch";
|
|
3
4
|
import { managedAgentDirs } from "./AgentFiles.ts";
|
|
4
5
|
import {
|
|
5
6
|
createSymlink,
|
|
@@ -73,16 +74,6 @@ export async function verifyManaged(
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
/** Check if two paths are equivalent prompt files in different agent directories */
|
|
77
|
-
function isMatchingPromptPath(
|
|
78
|
-
canonicalPath: string,
|
|
79
|
-
actualPath: string,
|
|
80
|
-
): boolean {
|
|
81
|
-
const canonicalPrompt = getPromptRelPath(canonicalPath);
|
|
82
|
-
const actualPrompt = getPromptRelPath(actualPath);
|
|
83
|
-
return canonicalPrompt !== null && canonicalPrompt === actualPrompt;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
77
|
/** Check if a path is a symlink pointing to the overlay repository */
|
|
87
78
|
export async function isSymlinkToOverlay(
|
|
88
79
|
linkPath: string,
|
|
@@ -104,15 +95,25 @@ export async function isSymlinkToOverlay(
|
|
|
104
95
|
/** Walk overlay directory and yield all files that should be linked (excludes init/ templates) */
|
|
105
96
|
export async function* walkOverlayFiles(
|
|
106
97
|
overlayRoot: string,
|
|
98
|
+
ignorePatterns: string[] = [],
|
|
107
99
|
): AsyncGenerator<string> {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
})) {
|
|
111
|
-
if (isDirectory) continue;
|
|
100
|
+
const isIgnored =
|
|
101
|
+
ignorePatterns.length > 0 ? picomatch(ignorePatterns) : null;
|
|
112
102
|
|
|
113
|
-
|
|
114
|
-
|
|
103
|
+
const skip = (relPath: string): boolean => {
|
|
104
|
+
// Skip templates
|
|
105
|
+
if (relPath.startsWith("clank/init/")) return true;
|
|
106
|
+
// Check ignore patterns against relative path and basename
|
|
107
|
+
if (isIgnored) {
|
|
108
|
+
const basename = relPath.split("/").at(-1) ?? "";
|
|
109
|
+
if (isIgnored(relPath) || isIgnored(basename)) return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
115
113
|
|
|
114
|
+
const genEntries = walkDirectory(overlayRoot, { skip });
|
|
115
|
+
for await (const { path, isDirectory } of genEntries) {
|
|
116
|
+
if (isDirectory) continue;
|
|
116
117
|
yield path;
|
|
117
118
|
}
|
|
118
119
|
}
|
|
@@ -133,3 +134,13 @@ export async function createPromptLinks(
|
|
|
133
134
|
}
|
|
134
135
|
return created;
|
|
135
136
|
}
|
|
137
|
+
|
|
138
|
+
/** Check if two paths are equivalent prompt files in different agent directories */
|
|
139
|
+
function isMatchingPromptPath(
|
|
140
|
+
canonicalPath: string,
|
|
141
|
+
actualPath: string,
|
|
142
|
+
): boolean {
|
|
143
|
+
const canonicalPrompt = getPromptRelPath(canonicalPath);
|
|
144
|
+
const actualPrompt = getPromptRelPath(actualPath);
|
|
145
|
+
return canonicalPrompt !== null && canonicalPrompt === actualPrompt;
|
|
146
|
+
}
|
package/src/Util.ts
CHANGED
|
@@ -11,3 +11,13 @@ export function partition<T>(
|
|
|
11
11
|
}
|
|
12
12
|
return [pass, fail];
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
/** Filter an array, returning the truthy results of the filter function */
|
|
16
|
+
export function filterMap<T, U>(arr: T[], fn: (t: T) => U | undefined): U[] {
|
|
17
|
+
const out: U[] = [];
|
|
18
|
+
for (const t of arr) {
|
|
19
|
+
const u = fn(t);
|
|
20
|
+
if (u) out.push(u);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
package/src/commands/Add.ts
CHANGED
|
@@ -32,14 +32,36 @@ import {
|
|
|
32
32
|
import { createPromptLinks, isSymlinkToOverlay } from "../OverlayLinks.ts";
|
|
33
33
|
import { scopeFromSymlink } from "../ScopeFromSymlink.ts";
|
|
34
34
|
|
|
35
|
+
export type AddOptions = ScopeOptions;
|
|
36
|
+
|
|
37
|
+
interface AddContext {
|
|
38
|
+
cwd: string;
|
|
39
|
+
gitContext: GitContext;
|
|
40
|
+
config: { overlayRepo: string; agents: string[] };
|
|
41
|
+
overlayRoot: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
|
|
45
|
+
interface AgentLinkParams {
|
|
46
|
+
overlayPath: string;
|
|
47
|
+
symlinkDir: string;
|
|
48
|
+
gitRoot: string;
|
|
49
|
+
overlayRoot: string;
|
|
50
|
+
agents: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface AgentLinkClassification {
|
|
54
|
+
toCreate: { targetPath: string; name: string }[];
|
|
55
|
+
existing: string[];
|
|
56
|
+
skipped: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
const scopeLabels: Record<Scope, string> = {
|
|
36
60
|
global: "global",
|
|
37
61
|
project: "project",
|
|
38
62
|
worktree: "worktree",
|
|
39
63
|
};
|
|
40
64
|
|
|
41
|
-
export type AddOptions = ScopeOptions;
|
|
42
|
-
|
|
43
65
|
/** Add file(s) to overlay and create symlinks in target */
|
|
44
66
|
export async function addCommand(
|
|
45
67
|
filePaths: string[],
|
|
@@ -68,11 +90,29 @@ export async function addCommand(
|
|
|
68
90
|
}
|
|
69
91
|
}
|
|
70
92
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
93
|
+
/** fail if we can't do an add with the given options */
|
|
94
|
+
async function validateAddOptions(
|
|
95
|
+
options: AddOptions,
|
|
96
|
+
overlayRoot: string,
|
|
97
|
+
gitContext: GitContext,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
await validateOverlayExists(overlayRoot);
|
|
100
|
+
|
|
101
|
+
if (options.worktree && !gitContext.isWorktree) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`--worktree scope requires a git worktree.\n` +
|
|
104
|
+
`You're on branch '${gitContext.worktreeName}' in the main repository.\n` +
|
|
105
|
+
`Use 'git worktree add' to create a worktree, or use --project scope instead.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
111
|
+
try {
|
|
112
|
+
return (await lstat(path)).isDirectory();
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
/** Add a single file to overlay and create symlink */
|
|
@@ -125,49 +165,20 @@ async function addSingleFile(
|
|
|
125
165
|
}
|
|
126
166
|
}
|
|
127
167
|
|
|
128
|
-
/**
|
|
129
|
-
async function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
168
|
+
/** Check if file is already in overlay at a different scope, throw helpful error */
|
|
169
|
+
async function checkScopeConflict(
|
|
170
|
+
barePath: string,
|
|
171
|
+
requestedScope: Scope,
|
|
172
|
+
context: MapperContext,
|
|
133
173
|
cwd: string,
|
|
134
174
|
): Promise<void> {
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
175
|
+
const currentScope = await scopeFromSymlink(barePath, context);
|
|
176
|
+
if (currentScope && currentScope !== requestedScope) {
|
|
177
|
+
const fileName = relative(cwd, barePath);
|
|
178
|
+
throw new Error(
|
|
179
|
+
`${fileName} is already in ${scopeLabels[currentScope]} overlay.\n` +
|
|
180
|
+
`To move it to ${scopeLabels[requestedScope]} scope, use: clank mv ${fileName} --${requestedScope}`,
|
|
141
181
|
);
|
|
142
|
-
if (created.length) {
|
|
143
|
-
console.log(
|
|
144
|
-
`Created symlinks: ${created.map((p) => relative(cwd, p)).join(", ")}`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Handle regular file symlink creation */
|
|
151
|
-
async function handleRegularFile(
|
|
152
|
-
normalizedPath: string,
|
|
153
|
-
overlayPath: string,
|
|
154
|
-
overlayRoot: string,
|
|
155
|
-
cwd: string,
|
|
156
|
-
): Promise<void> {
|
|
157
|
-
if (await isSymlinkToOverlay(normalizedPath, overlayRoot)) {
|
|
158
|
-
console.log(`Symlink already exists: ${relative(cwd, normalizedPath)}`);
|
|
159
|
-
} else {
|
|
160
|
-
const linkTarget = getLinkTarget(normalizedPath, overlayPath);
|
|
161
|
-
await createSymlink(linkTarget, normalizedPath);
|
|
162
|
-
console.log(`Created symlink: ${relative(cwd, normalizedPath)}`);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function isDirectory(path: string): Promise<boolean> {
|
|
167
|
-
try {
|
|
168
|
-
return (await lstat(path)).isDirectory();
|
|
169
|
-
} catch {
|
|
170
|
-
return false;
|
|
171
182
|
}
|
|
172
183
|
}
|
|
173
184
|
|
|
@@ -201,51 +212,6 @@ async function addFileToOverlay(
|
|
|
201
212
|
}
|
|
202
213
|
}
|
|
203
214
|
|
|
204
|
-
/** Find content from normalized path or bare input path */
|
|
205
|
-
async function findSourceContent(
|
|
206
|
-
normalizedPath: string,
|
|
207
|
-
barePath: string,
|
|
208
|
-
): Promise<string> {
|
|
209
|
-
// Try normalized path first (e.g., cwd/clank/foo.md)
|
|
210
|
-
if (
|
|
211
|
-
(await fileExists(normalizedPath)) &&
|
|
212
|
-
!(await isSymlink(normalizedPath))
|
|
213
|
-
) {
|
|
214
|
-
return await readFile(normalizedPath, "utf-8");
|
|
215
|
-
}
|
|
216
|
-
// Fall back to bare input path (e.g., cwd/foo.md)
|
|
217
|
-
if ((await fileExists(barePath)) && !(await isSymlink(barePath))) {
|
|
218
|
-
return await readFile(barePath, "utf-8");
|
|
219
|
-
}
|
|
220
|
-
return "";
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** fail if we can't do an add with the given options */
|
|
224
|
-
async function validateAddOptions(
|
|
225
|
-
options: AddOptions,
|
|
226
|
-
overlayRoot: string,
|
|
227
|
-
gitContext: GitContext,
|
|
228
|
-
): Promise<void> {
|
|
229
|
-
await validateOverlayExists(overlayRoot);
|
|
230
|
-
|
|
231
|
-
if (options.worktree && !gitContext.isWorktree) {
|
|
232
|
-
throw new Error(
|
|
233
|
-
`--worktree scope requires a git worktree.\n` +
|
|
234
|
-
`You're on branch '${gitContext.worktreeName}' in the main repository.\n` +
|
|
235
|
-
`Use 'git worktree add' to create a worktree, or use --project scope instead.`,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
|
|
241
|
-
interface AgentLinkParams {
|
|
242
|
-
overlayPath: string;
|
|
243
|
-
symlinkDir: string;
|
|
244
|
-
gitRoot: string;
|
|
245
|
-
overlayRoot: string;
|
|
246
|
-
agents: string[];
|
|
247
|
-
}
|
|
248
|
-
|
|
249
215
|
async function createAgentLinks(p: AgentLinkParams): Promise<void> {
|
|
250
216
|
const { overlayPath, ...classifyParams } = p;
|
|
251
217
|
const { toCreate, existing, skipped } =
|
|
@@ -271,27 +237,61 @@ async function createAgentLinks(p: AgentLinkParams): Promise<void> {
|
|
|
271
237
|
}
|
|
272
238
|
}
|
|
273
239
|
|
|
274
|
-
/**
|
|
275
|
-
async function
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
240
|
+
/** Handle prompt file symlink creation */
|
|
241
|
+
async function handlePromptFile(
|
|
242
|
+
normalizedPath: string,
|
|
243
|
+
overlayPath: string,
|
|
244
|
+
gitRoot: string,
|
|
279
245
|
cwd: string,
|
|
280
246
|
): Promise<void> {
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
247
|
+
const promptRelPath = getPromptRelPath(normalizedPath);
|
|
248
|
+
if (promptRelPath) {
|
|
249
|
+
const created = await createPromptLinks(
|
|
250
|
+
overlayPath,
|
|
251
|
+
promptRelPath,
|
|
252
|
+
gitRoot,
|
|
287
253
|
);
|
|
254
|
+
if (created.length) {
|
|
255
|
+
console.log(
|
|
256
|
+
`Created symlinks: ${created.map((p) => relative(cwd, p)).join(", ")}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
288
259
|
}
|
|
289
260
|
}
|
|
290
261
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
262
|
+
/** Handle regular file symlink creation */
|
|
263
|
+
async function handleRegularFile(
|
|
264
|
+
normalizedPath: string,
|
|
265
|
+
overlayPath: string,
|
|
266
|
+
overlayRoot: string,
|
|
267
|
+
cwd: string,
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
if (await isSymlinkToOverlay(normalizedPath, overlayRoot)) {
|
|
270
|
+
console.log(`Symlink already exists: ${relative(cwd, normalizedPath)}`);
|
|
271
|
+
} else {
|
|
272
|
+
const linkTarget = getLinkTarget(normalizedPath, overlayPath);
|
|
273
|
+
await createSymlink(linkTarget, normalizedPath);
|
|
274
|
+
console.log(`Created symlink: ${relative(cwd, normalizedPath)}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Find content from normalized path or bare input path */
|
|
279
|
+
async function findSourceContent(
|
|
280
|
+
normalizedPath: string,
|
|
281
|
+
barePath: string,
|
|
282
|
+
): Promise<string> {
|
|
283
|
+
// Try normalized path first (e.g., cwd/clank/foo.md)
|
|
284
|
+
if (
|
|
285
|
+
(await fileExists(normalizedPath)) &&
|
|
286
|
+
!(await isSymlink(normalizedPath))
|
|
287
|
+
) {
|
|
288
|
+
return await readFile(normalizedPath, "utf-8");
|
|
289
|
+
}
|
|
290
|
+
// Fall back to bare input path (e.g., cwd/foo.md)
|
|
291
|
+
if ((await fileExists(barePath)) && !(await isSymlink(barePath))) {
|
|
292
|
+
return await readFile(barePath, "utf-8");
|
|
293
|
+
}
|
|
294
|
+
return "";
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
/** Classify which agent symlinks to create vs skip */
|