clank-cli 0.1.52
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 +312 -0
- package/bin/clank.ts +8 -0
- package/package.json +50 -0
- package/src/AgentFiles.ts +40 -0
- package/src/ClassifyFiles.ts +203 -0
- package/src/Cli.ts +229 -0
- package/src/Config.ts +98 -0
- package/src/Exclude.ts +154 -0
- package/src/Exec.ts +5 -0
- package/src/FsUtil.ts +154 -0
- package/src/Git.ts +140 -0
- package/src/Gitignore.ts +226 -0
- package/src/Mapper.ts +330 -0
- package/src/OverlayGit.ts +78 -0
- package/src/OverlayLinks.ts +125 -0
- package/src/ScopeFromSymlink.ts +22 -0
- package/src/Templates.ts +87 -0
- package/src/Util.ts +13 -0
- package/src/commands/Add.ts +301 -0
- package/src/commands/Check.ts +314 -0
- package/src/commands/Commit.ts +35 -0
- package/src/commands/Init.ts +62 -0
- package/src/commands/Link.ts +415 -0
- package/src/commands/Move.ts +172 -0
- package/src/commands/Rm.ts +161 -0
- package/src/commands/Unlink.ts +45 -0
- package/src/commands/VsCode.ts +195 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import { managedDirs } from "./AgentFiles.ts";
|
|
3
|
+
|
|
4
|
+
/** Get git status of the overlay repository */
|
|
5
|
+
export async function getOverlayStatus(
|
|
6
|
+
overlayRoot: string,
|
|
7
|
+
): Promise<{ lines: string[]; raw: string }> {
|
|
8
|
+
const { stdout } = await execa({
|
|
9
|
+
cwd: overlayRoot,
|
|
10
|
+
})`git status --porcelain -uall`;
|
|
11
|
+
|
|
12
|
+
const lines = stdout.trimEnd() ? stdout.trimEnd().split("\n") : [];
|
|
13
|
+
return { lines, raw: stdout };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Format git status --porcelain output into readable lines */
|
|
17
|
+
export function formatStatusLines(lines: string[]): string[] {
|
|
18
|
+
return lines.map((line) => {
|
|
19
|
+
const statusCode = formatStatusCode(line.slice(0, 2));
|
|
20
|
+
const filePath = line.slice(3);
|
|
21
|
+
const { scope, pathParts } = parseScopedPath(filePath);
|
|
22
|
+
const displayPath = shortPath(pathParts);
|
|
23
|
+
return `${statusCode} ${displayPath} (${scope})`;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Format git status code to single letter */
|
|
28
|
+
export function formatStatusCode(code: string): string {
|
|
29
|
+
const c = code.trim();
|
|
30
|
+
if (c === "??") return "A";
|
|
31
|
+
if (c.includes("D")) return "D";
|
|
32
|
+
if (c.includes("M")) return "M";
|
|
33
|
+
if (c.includes("A")) return "A";
|
|
34
|
+
if (c.includes("R")) return "R";
|
|
35
|
+
return "?";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse overlay path into scope and path parts within that scope */
|
|
39
|
+
function parseScopedPath(filePath: string): {
|
|
40
|
+
scope: string;
|
|
41
|
+
pathParts: string[];
|
|
42
|
+
} {
|
|
43
|
+
const segments = filePath.split("/");
|
|
44
|
+
if (segments[0] === "global") {
|
|
45
|
+
return { scope: "global", pathParts: segments.slice(1) };
|
|
46
|
+
}
|
|
47
|
+
if (segments[0] === "targets") {
|
|
48
|
+
const project = segments[1];
|
|
49
|
+
if (segments[2] === "worktrees") {
|
|
50
|
+
// worktrees/<branch>/ - branch is max 2 segments (main, feat/foo)
|
|
51
|
+
const afterWorktrees = segments.slice(3);
|
|
52
|
+
const branchSegments = Math.min(
|
|
53
|
+
2,
|
|
54
|
+
Math.max(0, afterWorktrees.length - 1),
|
|
55
|
+
);
|
|
56
|
+
const branch = afterWorktrees.slice(0, branchSegments).join("/");
|
|
57
|
+
return {
|
|
58
|
+
scope: `${project}/${branch}`,
|
|
59
|
+
pathParts: afterWorktrees.slice(branchSegments),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { scope: project || "unknown", pathParts: segments.slice(2) };
|
|
63
|
+
}
|
|
64
|
+
return { scope: "unknown", pathParts: segments };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shorten path parts to last 2-3 meaningful segments for display */
|
|
68
|
+
function shortPath(pathParts: string[]): string {
|
|
69
|
+
if (pathParts.length <= 2) return pathParts.join("/");
|
|
70
|
+
|
|
71
|
+
// If file is in a managed dir, show 3 segments (parent/clank/file)
|
|
72
|
+
const parentDir = pathParts[pathParts.length - 2];
|
|
73
|
+
if (managedDirs.includes(parentDir)) {
|
|
74
|
+
return pathParts.slice(-3).join("/");
|
|
75
|
+
}
|
|
76
|
+
// Otherwise show 2 segments (parent/file)
|
|
77
|
+
return pathParts.slice(-2).join("/");
|
|
78
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { managedAgentDirs } from "./AgentFiles.ts";
|
|
4
|
+
import { createSymlink, ensureDir, getLinkTarget, resolveSymlinkTarget, walkDirectory } from "./FsUtil.ts";
|
|
5
|
+
import { getPromptRelPath, type MapperContext, overlayToTarget } from "./Mapper.ts";
|
|
6
|
+
|
|
7
|
+
export type ManagedFileState =
|
|
8
|
+
| { kind: "valid" }
|
|
9
|
+
| { kind: "unadded" }
|
|
10
|
+
| { kind: "outside-overlay"; currentTarget: string }
|
|
11
|
+
| {
|
|
12
|
+
kind: "wrong-mapping";
|
|
13
|
+
currentTarget: string;
|
|
14
|
+
expectedTarget: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Check if a file in a managed directory is a valid symlink to the overlay.
|
|
18
|
+
* Returns null if valid, or an issue object if not. */
|
|
19
|
+
export async function verifyManaged(
|
|
20
|
+
linkPath: string,
|
|
21
|
+
context: MapperContext,
|
|
22
|
+
): Promise<ManagedFileState> {
|
|
23
|
+
const { overlayRoot } = context;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const stats = await lstat(linkPath);
|
|
27
|
+
if (!stats.isSymbolicLink()) {
|
|
28
|
+
return { kind: "unadded" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const absoluteTarget = await resolveSymlinkTarget(linkPath);
|
|
32
|
+
|
|
33
|
+
// Check if symlink points to overlay at all
|
|
34
|
+
if (!absoluteTarget.startsWith(overlayRoot)) {
|
|
35
|
+
return { kind: "outside-overlay", currentTarget: absoluteTarget };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check if symlink points to correct overlay location
|
|
39
|
+
const mapping = overlayToTarget(absoluteTarget, context);
|
|
40
|
+
if (!mapping) {
|
|
41
|
+
return {
|
|
42
|
+
kind: "wrong-mapping",
|
|
43
|
+
currentTarget: absoluteTarget,
|
|
44
|
+
expectedTarget: "(no valid mapping)",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Prompt files are fanned out to all agent directories (.claude/prompts/, .gemini/prompts/)
|
|
49
|
+
// Accept any agent's prompts dir as valid if the filename matches
|
|
50
|
+
if (mapping.targetPath !== linkPath) {
|
|
51
|
+
if (!isMatchingPromptPath(mapping.targetPath, linkPath)) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "wrong-mapping",
|
|
54
|
+
currentTarget: absoluteTarget,
|
|
55
|
+
expectedTarget: mapping.targetPath,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { kind: "valid" };
|
|
61
|
+
} catch {
|
|
62
|
+
return { kind: "unadded" };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Check if two paths are equivalent prompt files in different agent directories */
|
|
67
|
+
function isMatchingPromptPath(
|
|
68
|
+
canonicalPath: string,
|
|
69
|
+
actualPath: string,
|
|
70
|
+
): boolean {
|
|
71
|
+
const canonicalPrompt = getPromptRelPath(canonicalPath);
|
|
72
|
+
const actualPrompt = getPromptRelPath(actualPath);
|
|
73
|
+
return canonicalPrompt !== null && canonicalPrompt === actualPrompt;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Check if a path is a symlink pointing to the overlay repository */
|
|
77
|
+
export async function isSymlinkToOverlay(
|
|
78
|
+
linkPath: string,
|
|
79
|
+
overlayRoot: string,
|
|
80
|
+
): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
const stats = await lstat(linkPath);
|
|
83
|
+
if (!stats.isSymbolicLink()) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const absoluteTarget = await resolveSymlinkTarget(linkPath);
|
|
88
|
+
return absoluteTarget.startsWith(overlayRoot);
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Walk overlay directory and yield all files that should be linked (excludes init/ templates) */
|
|
95
|
+
export async function* walkOverlayFiles(
|
|
96
|
+
overlayRoot: string,
|
|
97
|
+
): AsyncGenerator<string> {
|
|
98
|
+
for await (const { path, isDirectory } of walkDirectory(overlayRoot, {
|
|
99
|
+
skipDirs: [".git", "node_modules"],
|
|
100
|
+
})) {
|
|
101
|
+
if (isDirectory) continue;
|
|
102
|
+
|
|
103
|
+
const relPath = relative(overlayRoot, path);
|
|
104
|
+
if (relPath.startsWith("clank/init/")) continue;
|
|
105
|
+
|
|
106
|
+
yield path;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Create prompt symlinks in all agent directories */
|
|
111
|
+
export async function createPromptLinks(
|
|
112
|
+
overlayPath: string,
|
|
113
|
+
promptRelPath: string,
|
|
114
|
+
gitRoot: string,
|
|
115
|
+
): Promise<string[]> {
|
|
116
|
+
const created: string[] = [];
|
|
117
|
+
for (const agentDir of managedAgentDirs) {
|
|
118
|
+
const targetPath = join(gitRoot, agentDir, "prompts", promptRelPath);
|
|
119
|
+
await ensureDir(dirname(targetPath));
|
|
120
|
+
const linkTarget = getLinkTarget(targetPath, overlayPath);
|
|
121
|
+
await createSymlink(linkTarget, targetPath);
|
|
122
|
+
created.push(targetPath);
|
|
123
|
+
}
|
|
124
|
+
return created;
|
|
125
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { resolveSymlinkTarget } from "./FsUtil.ts";
|
|
3
|
+
import { type MapperContext, overlayToTarget, type Scope } from "./Mapper.ts";
|
|
4
|
+
|
|
5
|
+
/** Get scope from symlink target if it points to overlay */
|
|
6
|
+
export async function scopeFromSymlink(
|
|
7
|
+
targetPath: string,
|
|
8
|
+
context: MapperContext,
|
|
9
|
+
): Promise<Scope | null> {
|
|
10
|
+
try {
|
|
11
|
+
const stats = await lstat(targetPath);
|
|
12
|
+
if (!stats.isSymbolicLink()) return null;
|
|
13
|
+
|
|
14
|
+
const overlayPath = await resolveSymlinkTarget(targetPath);
|
|
15
|
+
if (!overlayPath.startsWith(context.overlayRoot)) return null;
|
|
16
|
+
|
|
17
|
+
const mapping = overlayToTarget(overlayPath, context);
|
|
18
|
+
return mapping?.scope ?? null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/Templates.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { ensureDir, fileExists, walkDirectory } from "./FsUtil.ts";
|
|
4
|
+
import type { GitContext } from "./Git.ts";
|
|
5
|
+
import { overlayWorktreeDir } from "./Mapper.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* clank stores worktree specific files in the overlay repo
|
|
9
|
+
* intended for user notes and plans for each branch.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Template variables that can be used in template files */
|
|
13
|
+
export interface TemplateVars {
|
|
14
|
+
worktree_message: string;
|
|
15
|
+
project_name: string;
|
|
16
|
+
branch_name: string;
|
|
17
|
+
[key: string]: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Check if worktree has already been initialized */
|
|
21
|
+
export async function isWorktreeInitialized(
|
|
22
|
+
overlayRoot: string,
|
|
23
|
+
gitContext: GitContext,
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
return await fileExists(overlayWorktreeDir(overlayRoot, gitContext));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Initialize worktree-specific files from templates (overlay/global/init/ ==> worktrees/{branch}/) */
|
|
29
|
+
export async function initializeWorktreeOverlay(
|
|
30
|
+
overlayRoot: string,
|
|
31
|
+
gitContext: GitContext,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const templateDir = join(overlayRoot, "global/init");
|
|
34
|
+
const overlayWorktree = overlayWorktreeDir(overlayRoot, gitContext);
|
|
35
|
+
|
|
36
|
+
if (!(await fileExists(templateDir))) {
|
|
37
|
+
await ensureDir(overlayWorktree);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const vars = generateTemplateVars(gitContext);
|
|
42
|
+
|
|
43
|
+
for await (const { path, isDirectory } of walkDirectory(templateDir)) {
|
|
44
|
+
if (isDirectory) continue;
|
|
45
|
+
|
|
46
|
+
const relPath = relative(templateDir, path);
|
|
47
|
+
const targetPath = join(overlayWorktree, relPath);
|
|
48
|
+
await createFromTemplate(path, targetPath, vars);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Generate template variables from git context */
|
|
53
|
+
export function generateTemplateVars(gitContext: GitContext): TemplateVars {
|
|
54
|
+
const { worktreeName, projectName } = gitContext;
|
|
55
|
+
const worktreeMessage = `This is git worktree ${worktreeName} of project ${projectName}.`;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
worktree_message: worktreeMessage,
|
|
59
|
+
project_name: projectName,
|
|
60
|
+
branch_name: worktreeName,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Create a file from a template with variable substitution */
|
|
65
|
+
export async function createFromTemplate(
|
|
66
|
+
templatePath: string,
|
|
67
|
+
targetPath: string,
|
|
68
|
+
vars: TemplateVars,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const templateContent = await readFile(templatePath, "utf-8");
|
|
71
|
+
const processedContent = applyTemplate(templateContent, vars);
|
|
72
|
+
|
|
73
|
+
await ensureDir(join(targetPath, ".."));
|
|
74
|
+
await writeFile(targetPath, processedContent, "utf-8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Replace template variables in content (uses {{variable_name}} syntax) */
|
|
78
|
+
export function applyTemplate(content: string, vars: TemplateVars): string {
|
|
79
|
+
let result = content;
|
|
80
|
+
|
|
81
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
82
|
+
const pattern = new RegExp(`\\{\\{${key}\\}\\}`, "g");
|
|
83
|
+
result = result.replace(pattern, value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
package/src/Util.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Partition an array into two arrays based on a predicate */
|
|
2
|
+
export function partition<T>(
|
|
3
|
+
arr: T[],
|
|
4
|
+
predicate: (item: T) => boolean,
|
|
5
|
+
): [T[], T[]] {
|
|
6
|
+
const pass: T[] = [];
|
|
7
|
+
const fail: T[] = [];
|
|
8
|
+
for (const item of arr) {
|
|
9
|
+
if (predicate(item)) pass.push(item);
|
|
10
|
+
else fail.push(item);
|
|
11
|
+
}
|
|
12
|
+
return [pass, fail];
|
|
13
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lstat,
|
|
3
|
+
readFile,
|
|
4
|
+
readlink,
|
|
5
|
+
symlink,
|
|
6
|
+
writeFile,
|
|
7
|
+
} from "node:fs/promises";
|
|
8
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
9
|
+
import { forEachAgentPath } from "../AgentFiles.ts";
|
|
10
|
+
import { expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
|
|
11
|
+
import {
|
|
12
|
+
createSymlink,
|
|
13
|
+
ensureDir,
|
|
14
|
+
fileExists,
|
|
15
|
+
getLinkTarget,
|
|
16
|
+
isSymlink,
|
|
17
|
+
isTrackedByGit,
|
|
18
|
+
walkDirectory,
|
|
19
|
+
} from "../FsUtil.ts";
|
|
20
|
+
import { type GitContext, getGitContext } from "../Git.ts";
|
|
21
|
+
import {
|
|
22
|
+
getPromptRelPath,
|
|
23
|
+
isAgentFile,
|
|
24
|
+
isPromptFile,
|
|
25
|
+
type MapperContext,
|
|
26
|
+
normalizeAddPath,
|
|
27
|
+
resolveScopeFromOptions,
|
|
28
|
+
type Scope,
|
|
29
|
+
type ScopeOptions,
|
|
30
|
+
targetToOverlay,
|
|
31
|
+
} from "../Mapper.ts";
|
|
32
|
+
import { createPromptLinks, isSymlinkToOverlay } from "../OverlayLinks.ts";
|
|
33
|
+
import { scopeFromSymlink } from "../ScopeFromSymlink.ts";
|
|
34
|
+
|
|
35
|
+
const scopeLabels: Record<Scope, string> = {
|
|
36
|
+
global: "global",
|
|
37
|
+
project: "project",
|
|
38
|
+
worktree: "worktree",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type AddOptions = ScopeOptions;
|
|
42
|
+
|
|
43
|
+
/** Add file(s) to overlay and create symlinks in target */
|
|
44
|
+
export async function addCommand(
|
|
45
|
+
filePaths: string[],
|
|
46
|
+
options: AddOptions = {},
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const cwd = process.cwd();
|
|
49
|
+
const gitContext = await getGitContext(cwd);
|
|
50
|
+
const config = await loadConfig();
|
|
51
|
+
const overlayRoot = expandPath(config.overlayRepo);
|
|
52
|
+
|
|
53
|
+
await validateAddOptions(options, overlayRoot, gitContext);
|
|
54
|
+
|
|
55
|
+
const ctx = { cwd, gitContext, config, overlayRoot };
|
|
56
|
+
|
|
57
|
+
for (const filePath of filePaths) {
|
|
58
|
+
const inputPath = join(cwd, filePath);
|
|
59
|
+
|
|
60
|
+
if (await isDirectory(inputPath)) {
|
|
61
|
+
for await (const { path, isDirectory } of walkDirectory(inputPath)) {
|
|
62
|
+
if (isDirectory) continue;
|
|
63
|
+
await addSingleFile(relative(cwd, path), options, ctx);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
await addSingleFile(filePath, options, ctx);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface AddContext {
|
|
72
|
+
cwd: string;
|
|
73
|
+
gitContext: GitContext;
|
|
74
|
+
config: { overlayRepo: string; agents: string[] };
|
|
75
|
+
overlayRoot: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Add a single file to overlay and create symlink */
|
|
79
|
+
async function addSingleFile(
|
|
80
|
+
filePath: string,
|
|
81
|
+
options: AddOptions,
|
|
82
|
+
ctx: AddContext,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const { cwd, gitContext, config, overlayRoot } = ctx;
|
|
85
|
+
const { gitRoot } = gitContext;
|
|
86
|
+
|
|
87
|
+
const scope = resolveScopeFromOptions(options);
|
|
88
|
+
/** Absolute path where symlink will be created in target repo */
|
|
89
|
+
const normalizedPath = normalizeAddPath(filePath, cwd, gitRoot);
|
|
90
|
+
const context: MapperContext = {
|
|
91
|
+
overlayRoot,
|
|
92
|
+
targetRoot: gitRoot,
|
|
93
|
+
gitContext,
|
|
94
|
+
};
|
|
95
|
+
const overlayPath = targetToOverlay(normalizedPath, scope, context);
|
|
96
|
+
|
|
97
|
+
const fileName = basename(normalizedPath);
|
|
98
|
+
const scopeLabel =
|
|
99
|
+
scope === "global" ? "global" : `${gitContext.projectName} ${scope}`;
|
|
100
|
+
|
|
101
|
+
// Only check barePath for symlink - we want the user's input file, not clank/foo.md
|
|
102
|
+
const barePath = join(cwd, filePath);
|
|
103
|
+
|
|
104
|
+
// Check if already in overlay at a different scope
|
|
105
|
+
await checkScopeConflict(barePath, scope, context, cwd);
|
|
106
|
+
|
|
107
|
+
if (await fileExists(overlayPath)) {
|
|
108
|
+
console.log(`${fileName} already exists in ${scopeLabel} overlay`);
|
|
109
|
+
} else if (await isSymlink(barePath)) {
|
|
110
|
+
await addSymlinkToOverlay(barePath, overlayPath, scopeLabel);
|
|
111
|
+
} else {
|
|
112
|
+
await addFileToOverlay(normalizedPath, barePath, overlayPath, scopeLabel);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check if this is an agent file (CLAUDE.md, AGENTS.md, GEMINI.md)
|
|
116
|
+
if (isAgentFile(filePath)) {
|
|
117
|
+
const symlinkDir = dirname(normalizedPath);
|
|
118
|
+
const { agents } = config;
|
|
119
|
+
const params = { overlayPath, symlinkDir, gitRoot, overlayRoot, agents };
|
|
120
|
+
await createAgentLinks(params);
|
|
121
|
+
} else if (isPromptFile(normalizedPath)) {
|
|
122
|
+
// Prompt files get symlinks in all agent directories
|
|
123
|
+
const promptRelPath = getPromptRelPath(normalizedPath);
|
|
124
|
+
if (promptRelPath) {
|
|
125
|
+
const created = await createPromptLinks(overlayPath, promptRelPath, gitRoot);
|
|
126
|
+
if (created.length) {
|
|
127
|
+
console.log(`Created symlinks: ${created.map((p) => relative(cwd, p)).join(", ")}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
if (await isSymlinkToOverlay(normalizedPath, overlayRoot)) {
|
|
132
|
+
console.log(`Symlink already exists: ${relative(cwd, normalizedPath)}`);
|
|
133
|
+
} else {
|
|
134
|
+
const linkTarget = getLinkTarget(normalizedPath, overlayPath);
|
|
135
|
+
await createSymlink(linkTarget, normalizedPath);
|
|
136
|
+
console.log(`Created symlink: ${relative(cwd, normalizedPath)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
142
|
+
try {
|
|
143
|
+
return (await lstat(path)).isDirectory();
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Copy a symlink to the overlay, preserving its target */
|
|
150
|
+
async function addSymlinkToOverlay(
|
|
151
|
+
inputPath: string,
|
|
152
|
+
overlayPath: string,
|
|
153
|
+
scopeLabel: string,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const target = await readlink(inputPath);
|
|
156
|
+
await ensureDir(dirname(overlayPath));
|
|
157
|
+
await symlink(target, overlayPath);
|
|
158
|
+
console.log(`Copied symlink ${basename(inputPath)} to ${scopeLabel} overlay`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Copy file content to overlay */
|
|
162
|
+
async function addFileToOverlay(
|
|
163
|
+
normalizedPath: string,
|
|
164
|
+
barePath: string,
|
|
165
|
+
overlayPath: string,
|
|
166
|
+
scopeLabel: string,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
await ensureDir(dirname(overlayPath));
|
|
169
|
+
const content = await findSourceContent(normalizedPath, barePath);
|
|
170
|
+
await writeFile(overlayPath, content, "utf-8");
|
|
171
|
+
const fileName = basename(overlayPath);
|
|
172
|
+
if (content) {
|
|
173
|
+
console.log(`Copied ${fileName} to ${scopeLabel} overlay`);
|
|
174
|
+
} else {
|
|
175
|
+
console.log(`Created empty ${fileName} in ${scopeLabel} overlay`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Find content from normalized path or bare input path */
|
|
180
|
+
async function findSourceContent(
|
|
181
|
+
normalizedPath: string,
|
|
182
|
+
barePath: string,
|
|
183
|
+
): Promise<string> {
|
|
184
|
+
// Try normalized path first (e.g., cwd/clank/foo.md)
|
|
185
|
+
if (
|
|
186
|
+
(await fileExists(normalizedPath)) &&
|
|
187
|
+
!(await isSymlink(normalizedPath))
|
|
188
|
+
) {
|
|
189
|
+
return await readFile(normalizedPath, "utf-8");
|
|
190
|
+
}
|
|
191
|
+
// Fall back to bare input path (e.g., cwd/foo.md)
|
|
192
|
+
if ((await fileExists(barePath)) && !(await isSymlink(barePath))) {
|
|
193
|
+
return await readFile(barePath, "utf-8");
|
|
194
|
+
}
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** fail if we can't do an add with the given options */
|
|
199
|
+
async function validateAddOptions(
|
|
200
|
+
options: AddOptions,
|
|
201
|
+
overlayRoot: string,
|
|
202
|
+
gitContext: GitContext,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
await validateOverlayExists(overlayRoot);
|
|
205
|
+
|
|
206
|
+
if (options.worktree && !gitContext.isWorktree) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`--worktree scope requires a git worktree.\n` +
|
|
209
|
+
`You're on branch '${gitContext.worktreeName}' in the main repository.\n` +
|
|
210
|
+
`Use 'git worktree add' to create a worktree, or use --project scope instead.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
|
|
216
|
+
interface AgentLinkParams {
|
|
217
|
+
overlayPath: string;
|
|
218
|
+
symlinkDir: string;
|
|
219
|
+
gitRoot: string;
|
|
220
|
+
overlayRoot: string;
|
|
221
|
+
agents: string[];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function createAgentLinks(p: AgentLinkParams): Promise<void> {
|
|
225
|
+
const { overlayPath, ...classifyParams } = p;
|
|
226
|
+
const { toCreate, existing, skipped } =
|
|
227
|
+
await classifyAgentLinks(classifyParams);
|
|
228
|
+
|
|
229
|
+
const promisedLinks = toCreate.map(({ targetPath }) => {
|
|
230
|
+
const linkTarget = getLinkTarget(targetPath, overlayPath);
|
|
231
|
+
return createSymlink(linkTarget, targetPath);
|
|
232
|
+
});
|
|
233
|
+
await Promise.all(promisedLinks);
|
|
234
|
+
|
|
235
|
+
if (toCreate.length) {
|
|
236
|
+
const created = toCreate.map(({ name }) => name);
|
|
237
|
+
console.log(`Created symlinks: ${created.join(", ")}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (existing.length) {
|
|
241
|
+
console.log(`Symlinks already exist: ${existing.join(", ")}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (skipped.length) {
|
|
245
|
+
console.log(`Skipped (already tracked in git): ${skipped.join(", ")}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Check if file is already in overlay at a different scope, throw helpful error */
|
|
250
|
+
async function checkScopeConflict(
|
|
251
|
+
barePath: string,
|
|
252
|
+
requestedScope: Scope,
|
|
253
|
+
context: MapperContext,
|
|
254
|
+
cwd: string,
|
|
255
|
+
): Promise<void> {
|
|
256
|
+
const currentScope = await scopeFromSymlink(barePath, context);
|
|
257
|
+
if (currentScope && currentScope !== requestedScope) {
|
|
258
|
+
const fileName = relative(cwd, barePath);
|
|
259
|
+
throw new Error(
|
|
260
|
+
`${fileName} is already in ${scopeLabels[currentScope]} overlay.\n` +
|
|
261
|
+
`To move it to ${scopeLabels[requestedScope]} scope, use: clank mv ${fileName} --${requestedScope}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface AgentLinkClassification {
|
|
267
|
+
toCreate: { targetPath: string; name: string }[];
|
|
268
|
+
existing: string[];
|
|
269
|
+
skipped: string[];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Classify which agent symlinks to create vs skip */
|
|
273
|
+
async function classifyAgentLinks(
|
|
274
|
+
p: Omit<AgentLinkParams, "overlayPath">,
|
|
275
|
+
): Promise<AgentLinkClassification> {
|
|
276
|
+
const { symlinkDir, gitRoot, overlayRoot, agents } = p;
|
|
277
|
+
const skipped: string[] = [];
|
|
278
|
+
const existing: string[] = [];
|
|
279
|
+
const toCreate: { targetPath: string; name: string }[] = [];
|
|
280
|
+
|
|
281
|
+
await forEachAgentPath(symlinkDir, agents, async (targetPath) => {
|
|
282
|
+
// Check if symlink already points to overlay
|
|
283
|
+
if (await isSymlinkToOverlay(targetPath, overlayRoot)) {
|
|
284
|
+
existing.push(basename(targetPath));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const isTrackedFile =
|
|
289
|
+
(await fileExists(targetPath)) &&
|
|
290
|
+
!(await isSymlink(targetPath)) &&
|
|
291
|
+
(await isTrackedByGit(targetPath, gitRoot));
|
|
292
|
+
|
|
293
|
+
if (isTrackedFile) {
|
|
294
|
+
skipped.push(basename(targetPath));
|
|
295
|
+
} else {
|
|
296
|
+
toCreate.push({ targetPath, name: basename(targetPath) });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return { toCreate, existing, skipped };
|
|
301
|
+
}
|