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
package/src/Git.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
2
|
+
import { exec } from "./Exec.ts";
|
|
3
|
+
|
|
4
|
+
/** Selected git project/worktree metadata */
|
|
5
|
+
export interface GitContext {
|
|
6
|
+
projectName: string;
|
|
7
|
+
worktreeName: string;
|
|
8
|
+
isWorktree: boolean;
|
|
9
|
+
gitRoot: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** @return metadata about the current git project and worktree */
|
|
13
|
+
export async function getGitContext(
|
|
14
|
+
cwd: string = process.cwd(),
|
|
15
|
+
): Promise<GitContext> {
|
|
16
|
+
const [projectName, worktreeName, isWorktree, gitRoot] = await Promise.all([
|
|
17
|
+
detectProjectName(cwd),
|
|
18
|
+
detectWorktreeName(cwd),
|
|
19
|
+
isGitWorktree(cwd),
|
|
20
|
+
detectGitRoot(cwd),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
projectName,
|
|
25
|
+
worktreeName,
|
|
26
|
+
isWorktree,
|
|
27
|
+
gitRoot,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Get the git repository root directory */
|
|
32
|
+
export async function detectGitRoot(
|
|
33
|
+
cwd: string = process.cwd(),
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
const toplevel = await gitCommand("rev-parse --show-toplevel", cwd);
|
|
36
|
+
if (toplevel) {
|
|
37
|
+
return toplevel;
|
|
38
|
+
}
|
|
39
|
+
throw new Error("Not in a git repository");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Detect project name from git remote or repository directory */
|
|
43
|
+
export async function detectProjectName(
|
|
44
|
+
cwd: string = process.cwd(),
|
|
45
|
+
): Promise<string> {
|
|
46
|
+
// Try git remote first (works for clones and worktrees)
|
|
47
|
+
const remoteUrl = await gitCommand("config --get remote.origin.url", cwd);
|
|
48
|
+
if (remoteUrl) {
|
|
49
|
+
const repoName = parseRepoName(remoteUrl);
|
|
50
|
+
if (repoName) {
|
|
51
|
+
return repoName;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fall back to git toplevel directory name
|
|
56
|
+
const toplevel = await gitCommand("rev-parse --show-toplevel", cwd);
|
|
57
|
+
if (toplevel) {
|
|
58
|
+
return basename(toplevel);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error("Not in a git repository");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Detect worktree/branch name */
|
|
65
|
+
export async function detectWorktreeName(
|
|
66
|
+
cwd: string = process.cwd(),
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
const branch = await gitCommand("branch --show-current", cwd);
|
|
69
|
+
if (branch) {
|
|
70
|
+
return branch;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Fallback: detached HEAD or other edge case
|
|
74
|
+
const rev = await gitCommand("rev-parse --short HEAD", cwd);
|
|
75
|
+
if (rev) {
|
|
76
|
+
return `detached-${rev}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new Error("Could not determine branch/worktree name");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Check if current directory is a git worktree (not the main repository) */
|
|
83
|
+
export async function isGitWorktree(
|
|
84
|
+
cwd: string = process.cwd(),
|
|
85
|
+
): Promise<boolean> {
|
|
86
|
+
const gitDir = await gitCommand("rev-parse --git-dir", cwd);
|
|
87
|
+
if (!gitDir) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Worktrees have .git/worktrees/* in their git-dir path
|
|
92
|
+
return gitDir.includes("/worktrees/");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Parse repository name from git remote URL (handles HTTPS and SSH formats) */
|
|
96
|
+
export function parseRepoName(url: string): string | null {
|
|
97
|
+
// Remove trailing .git
|
|
98
|
+
const normalizedUrl = url.replace(/\.git$/, "");
|
|
99
|
+
|
|
100
|
+
// Handle HTTPS: https://github.com/user/repo
|
|
101
|
+
const httpsMatch = normalizedUrl.match(
|
|
102
|
+
/https?:\/\/[^/]+\/(?:[^/]+\/)?([\w-]+)$/,
|
|
103
|
+
);
|
|
104
|
+
if (httpsMatch) {
|
|
105
|
+
return httpsMatch[1];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Handle SSH: git@github.com:user/repo
|
|
109
|
+
const sshMatch = normalizedUrl.match(/:(?:[^/]+\/)?([\w-]+)$/);
|
|
110
|
+
if (sshMatch) {
|
|
111
|
+
return sshMatch[1];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Last resort: take last path component
|
|
115
|
+
return basename(normalizedUrl);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Execute a git command and return stdout, or null if it fails */
|
|
119
|
+
async function gitCommand(args: string, cwd?: string): Promise<string | null> {
|
|
120
|
+
try {
|
|
121
|
+
const { stdout } = await exec(`git ${args}`, { cwd });
|
|
122
|
+
return stdout.trim();
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Get the .git directory for the current worktree */
|
|
129
|
+
export async function getGitDir(cwd: string): Promise<string | null> {
|
|
130
|
+
const gitDir = await gitCommand("rev-parse --git-dir", cwd);
|
|
131
|
+
if (!gitDir) return null;
|
|
132
|
+
return isAbsolute(gitDir) ? gitDir : join(cwd, gitDir);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Get the common .git directory (shared across worktrees) */
|
|
136
|
+
export async function getGitCommonDir(cwd: string): Promise<string | null> {
|
|
137
|
+
const gitDir = await gitCommand("rev-parse --git-common-dir", cwd);
|
|
138
|
+
if (!gitDir) return null;
|
|
139
|
+
return isAbsolute(gitDir) ? gitDir : join(cwd, gitDir);
|
|
140
|
+
}
|
package/src/Gitignore.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import { agentFiles, targetManagedDirs } from "./AgentFiles.ts";
|
|
4
|
+
import { filterClankLines } from "./Exclude.ts";
|
|
5
|
+
import { fileExists, walkDirectory } from "./FsUtil.ts";
|
|
6
|
+
import { getGitDir } from "./Git.ts";
|
|
7
|
+
import { partition } from "./Util.ts";
|
|
8
|
+
|
|
9
|
+
/** A parsed gitignore pattern with context */
|
|
10
|
+
export interface GitignorePattern {
|
|
11
|
+
/** The gitignore pattern (e.g., "node_modules/", "*.log") */
|
|
12
|
+
pattern: string;
|
|
13
|
+
/** Directory containing the .gitignore, relative to repo root (empty for root) */
|
|
14
|
+
basePath: string;
|
|
15
|
+
/** Whether this is a negation pattern (starts with !) */
|
|
16
|
+
negation: boolean;
|
|
17
|
+
/** Path to the source file this pattern came from */
|
|
18
|
+
source: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Result of collecting patterns, including warnings */
|
|
22
|
+
export interface CollectResult {
|
|
23
|
+
/** All collected gitignore patterns */
|
|
24
|
+
patterns: GitignorePattern[];
|
|
25
|
+
/** Negation patterns that were skipped (can't be represented in VS Code) */
|
|
26
|
+
negationWarnings: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Options for parsing a gitignore file */
|
|
30
|
+
interface ParseOptions {
|
|
31
|
+
/** Directory containing the .gitignore, relative to repo root (default: "") */
|
|
32
|
+
basePath?: string;
|
|
33
|
+
/** Whether to skip the clank-managed section in .git/info/exclude */
|
|
34
|
+
skipClankSection?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Collect all gitignore patterns from a repository */
|
|
38
|
+
export async function collectGitignorePatterns(
|
|
39
|
+
targetRoot: string,
|
|
40
|
+
): Promise<CollectResult> {
|
|
41
|
+
const result: CollectResult = { patterns: [], negationWarnings: [] };
|
|
42
|
+
|
|
43
|
+
// 1. Read .git/info/exclude
|
|
44
|
+
const gitDir = await getGitDir(targetRoot);
|
|
45
|
+
if (gitDir) {
|
|
46
|
+
const excludePath = join(gitDir, "info/exclude");
|
|
47
|
+
await parseGitignoreFile(excludePath, result, { skipClankSection: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. Read root .gitignore
|
|
51
|
+
await parseGitignoreFile(join(targetRoot, ".gitignore"), result);
|
|
52
|
+
|
|
53
|
+
// 3. Find nested .gitignore files
|
|
54
|
+
for (const path of await findNestedGitignores(targetRoot)) {
|
|
55
|
+
const basePath = relative(targetRoot, dirname(path));
|
|
56
|
+
await parseGitignoreFile(path, result, { basePath });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Convert a gitignore pattern to VS Code glob format */
|
|
63
|
+
export function gitignoreToVscodeGlob(pattern: GitignorePattern): string {
|
|
64
|
+
let glob = pattern.pattern;
|
|
65
|
+
|
|
66
|
+
// Handle trailing slash (directory only) - strip it
|
|
67
|
+
if (glob.endsWith("/")) {
|
|
68
|
+
glob = glob.slice(0, -1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Handle leading slash (anchored to base directory)
|
|
72
|
+
if (glob.startsWith("/")) {
|
|
73
|
+
glob = glob.slice(1);
|
|
74
|
+
// If in subdirectory, prefix with basePath
|
|
75
|
+
if (pattern.basePath) {
|
|
76
|
+
glob = `${pattern.basePath}/${glob}`;
|
|
77
|
+
}
|
|
78
|
+
// Anchored patterns don't need ** prefix
|
|
79
|
+
return glob;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Unanchored patterns in subdirectories
|
|
83
|
+
if (pattern.basePath) {
|
|
84
|
+
// Pattern can match anywhere under basePath
|
|
85
|
+
return `${pattern.basePath}/**/${glob}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Unanchored patterns at root - can match anywhere
|
|
89
|
+
if (!glob.startsWith("**/") && !glob.includes("/")) {
|
|
90
|
+
glob = `**/${glob}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return glob;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Check if a pattern matches clank-managed files */
|
|
97
|
+
export function isClankPattern(glob: string): boolean {
|
|
98
|
+
// Normalize: remove leading **/ and trailing /
|
|
99
|
+
const normalized = glob.replace(/^\*\*\//, "").replace(/\/$/, "");
|
|
100
|
+
|
|
101
|
+
// Check against managed directories
|
|
102
|
+
for (const dir of targetManagedDirs) {
|
|
103
|
+
if (
|
|
104
|
+
normalized === dir ||
|
|
105
|
+
normalized.startsWith(`${dir}/`) ||
|
|
106
|
+
normalized.endsWith(`/${dir}`)
|
|
107
|
+
) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check against agent files
|
|
113
|
+
for (const agentFile of agentFiles) {
|
|
114
|
+
if (normalized === agentFile || normalized.endsWith(`/${agentFile}`)) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Convert collected patterns to VS Code exclude globs, filtering clank patterns */
|
|
123
|
+
export function patternsToVscodeExcludes(
|
|
124
|
+
patterns: GitignorePattern[],
|
|
125
|
+
): string[] {
|
|
126
|
+
const globs = patterns
|
|
127
|
+
.filter((p) => !p.negation)
|
|
128
|
+
.map(gitignoreToVscodeGlob)
|
|
129
|
+
.filter((glob) => !isClankPattern(glob));
|
|
130
|
+
|
|
131
|
+
return deduplicateGlobs([...new Set(globs)]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Remove globs that are already covered by broader patterns.
|
|
136
|
+
* e.g., if `** /node_modules` exists, remove `tools/** /node_modules`
|
|
137
|
+
*/
|
|
138
|
+
export function deduplicateGlobs(globs: string[]): string[] {
|
|
139
|
+
// Partition into universal (**/) and specific patterns
|
|
140
|
+
const [universal, specific] = partition(globs, (g) => g.startsWith("**/"));
|
|
141
|
+
|
|
142
|
+
// Get suffixes that universal patterns cover (without **/ prefix)
|
|
143
|
+
const coveredSuffixes = new Set(universal.map((g) => g.slice(3)));
|
|
144
|
+
|
|
145
|
+
// Keep specific patterns not covered by a universal pattern
|
|
146
|
+
const uncovered = specific.filter((glob) => {
|
|
147
|
+
for (const suffix of coveredSuffixes) {
|
|
148
|
+
if (glob.endsWith(`/**/${suffix}`) || glob.endsWith(`/${suffix}`)) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return [...universal, ...uncovered];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Parse a gitignore file and accumulate results */
|
|
159
|
+
async function parseGitignoreFile(
|
|
160
|
+
source: string,
|
|
161
|
+
result: CollectResult,
|
|
162
|
+
options: ParseOptions = {},
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
if (!(await fileExists(source))) return;
|
|
165
|
+
|
|
166
|
+
const content = await readFile(source, "utf-8");
|
|
167
|
+
const parsed = parseGitignoreContent(content, source, options);
|
|
168
|
+
result.patterns.push(...parsed.patterns);
|
|
169
|
+
result.negationWarnings.push(...parsed.negationWarnings);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
/** Parse a single gitignore line */
|
|
174
|
+
function parseLine(
|
|
175
|
+
trimmed: string,
|
|
176
|
+
source: string,
|
|
177
|
+
basePath: string,
|
|
178
|
+
): { pattern?: GitignorePattern; negation?: string } {
|
|
179
|
+
// Skip empty lines and comments
|
|
180
|
+
if (!trimmed || trimmed.startsWith("#")) return {};
|
|
181
|
+
|
|
182
|
+
const isNegation = trimmed.startsWith("!");
|
|
183
|
+
const pattern = isNegation ? trimmed.slice(1) : trimmed;
|
|
184
|
+
|
|
185
|
+
if (isNegation) {
|
|
186
|
+
return { negation: pattern };
|
|
187
|
+
}
|
|
188
|
+
return { pattern: { pattern, basePath, negation: false, source } };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Parse gitignore file content into patterns */
|
|
192
|
+
function parseGitignoreContent(
|
|
193
|
+
content: string,
|
|
194
|
+
source: string,
|
|
195
|
+
options: ParseOptions,
|
|
196
|
+
): { patterns: GitignorePattern[]; negationWarnings: string[] } {
|
|
197
|
+
const rawLines = content.split("\n");
|
|
198
|
+
const lines = options.skipClankSection ? filterClankLines(rawLines) : rawLines;
|
|
199
|
+
const basePath = options.basePath ?? "";
|
|
200
|
+
|
|
201
|
+
const patterns: GitignorePattern[] = [];
|
|
202
|
+
const negationWarnings: string[] = [];
|
|
203
|
+
|
|
204
|
+
for (const line of lines) {
|
|
205
|
+
const { pattern, negation } = parseLine(line.trim(), source, basePath);
|
|
206
|
+
if (pattern) patterns.push(pattern);
|
|
207
|
+
if (negation) negationWarnings.push(negation);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { patterns, negationWarnings };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Find all nested .gitignore files (excluding root) */
|
|
214
|
+
async function findNestedGitignores(targetRoot: string): Promise<string[]> {
|
|
215
|
+
const gitignores: string[] = [];
|
|
216
|
+
const rootGitignore = join(targetRoot, ".gitignore");
|
|
217
|
+
|
|
218
|
+
for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
|
|
219
|
+
if (isDirectory) continue;
|
|
220
|
+
if (basename(path) === ".gitignore" && path !== rootGitignore) {
|
|
221
|
+
gitignores.push(path);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return gitignores;
|
|
226
|
+
}
|
package/src/Mapper.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
2
|
+
import { managedAgentDirs } from "./AgentFiles.ts";
|
|
3
|
+
import type { GitContext } from "./Git.ts";
|
|
4
|
+
|
|
5
|
+
/** overlay mappings can be cross project, per project, or per worktree */
|
|
6
|
+
export type Scope = "global" | "project" | "worktree";
|
|
7
|
+
|
|
8
|
+
/** CLI options for scope selection */
|
|
9
|
+
export interface ScopeOptions {
|
|
10
|
+
global?: boolean;
|
|
11
|
+
project?: boolean;
|
|
12
|
+
worktree?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Resolve scope from CLI options
|
|
16
|
+
* @param options - The CLI options
|
|
17
|
+
* @param defaultScope - Default scope if none specified, or "require" to throw
|
|
18
|
+
*/
|
|
19
|
+
export function resolveScopeFromOptions(
|
|
20
|
+
options: ScopeOptions,
|
|
21
|
+
defaultScope: Scope | "require" = "project",
|
|
22
|
+
): Scope {
|
|
23
|
+
if (options.global) return "global";
|
|
24
|
+
if (options.project) return "project";
|
|
25
|
+
if (options.worktree) return "worktree";
|
|
26
|
+
|
|
27
|
+
if (defaultScope === "require") {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"Must specify target scope: --global, --project, or --worktree",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return defaultScope;
|
|
33
|
+
}
|
|
34
|
+
|
|
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
|
+
/** Get overlay path for a project: overlay/targets/{projectName} */
|
|
49
|
+
export function overlayProjectDir(
|
|
50
|
+
overlayRoot: string,
|
|
51
|
+
projectName: string,
|
|
52
|
+
): string {
|
|
53
|
+
return join(overlayRoot, "targets", projectName);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Get overlay path for a worktree: overlay/targets/{project}/worktrees/{branch} */
|
|
57
|
+
export function overlayWorktreeDir(
|
|
58
|
+
overlayRoot: string,
|
|
59
|
+
gitContext: GitContext,
|
|
60
|
+
): string {
|
|
61
|
+
const { projectName, worktreeName } = gitContext;
|
|
62
|
+
return join(overlayRoot, "targets", projectName, "worktrees", worktreeName);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Map overlay path to target path
|
|
67
|
+
*
|
|
68
|
+
* Structure:
|
|
69
|
+
* - overlay/global/clank/ ==> target/clank/
|
|
70
|
+
* - overlay/global/claude/commands/ ==> target/.claude/commands/
|
|
71
|
+
* - overlay/global/claude/agents/ ==> target/.claude/agents/
|
|
72
|
+
* - overlay/targets/{project}/clank/ ==> target/clank/
|
|
73
|
+
* - overlay/targets/{project}/claude/settings.json ==> target/.claude/settings.json
|
|
74
|
+
* - overlay/targets/{project}/claude/commands/ ==> target/.claude/commands/
|
|
75
|
+
* - overlay/targets/{project}/claude/agents/ ==> target/.claude/agents/
|
|
76
|
+
* - overlay/targets/{project}/agents.md ==> target/agents.md (etc.)
|
|
77
|
+
* - overlay/targets/{project}/worktrees/{branch}/clank/ ==> target/clank/
|
|
78
|
+
* - overlay/targets/{project}/worktrees/{branch}/claude/commands/ ==> target/.claude/commands/
|
|
79
|
+
* - overlay/targets/{project}/worktrees/{branch}/agents.md ==> target/agents.md
|
|
80
|
+
*/
|
|
81
|
+
export function overlayToTarget(
|
|
82
|
+
overlayPath: string,
|
|
83
|
+
context: MapperContext,
|
|
84
|
+
): TargetMapping | null {
|
|
85
|
+
const { overlayRoot, targetRoot, gitContext } = context;
|
|
86
|
+
const projectPrefix = join(overlayRoot, "targets", gitContext.projectName);
|
|
87
|
+
const globalPrefix = join(overlayRoot, "global");
|
|
88
|
+
|
|
89
|
+
if (overlayPath.startsWith(globalPrefix)) {
|
|
90
|
+
return mapGlobalOverlay(overlayPath, globalPrefix, targetRoot);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (overlayPath.startsWith(projectPrefix)) {
|
|
94
|
+
return mapProjectOverlay(overlayPath, projectPrefix, context);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Map target path to overlay path (for clank add command)
|
|
102
|
+
*
|
|
103
|
+
* Files go to overlay based on scope:
|
|
104
|
+
* - --global: overlay/global/clank/
|
|
105
|
+
* - --project: overlay/targets/{project}/clank/
|
|
106
|
+
* - --worktree: overlay/targets/{project}/worktrees/{branch}/clank/
|
|
107
|
+
*
|
|
108
|
+
* .claude/ files:
|
|
109
|
+
* - --global: overlay/global/claude/{commands,agents}/
|
|
110
|
+
* - --project: overlay/targets/{project}/claude/{commands,agents}/
|
|
111
|
+
* - --worktree: overlay/targets/{project}/worktrees/{branch}/claude/{commands,agents}/
|
|
112
|
+
*
|
|
113
|
+
* agents.md files stay at their natural path in the overlay
|
|
114
|
+
*/
|
|
115
|
+
export function targetToOverlay(
|
|
116
|
+
targetPath: string,
|
|
117
|
+
scope: Scope,
|
|
118
|
+
context: MapperContext,
|
|
119
|
+
): string {
|
|
120
|
+
const { overlayRoot, targetRoot, gitContext } = context;
|
|
121
|
+
const relPath = relative(targetRoot, targetPath);
|
|
122
|
+
|
|
123
|
+
let overlayBase: string;
|
|
124
|
+
if (scope === "global") {
|
|
125
|
+
overlayBase = join(overlayRoot, "global");
|
|
126
|
+
} else if (scope === "worktree") {
|
|
127
|
+
overlayBase = overlayWorktreeDir(overlayRoot, gitContext);
|
|
128
|
+
} else {
|
|
129
|
+
overlayBase = overlayProjectDir(overlayRoot, gitContext.projectName);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return encodeTargetPath(relPath, overlayBase);
|
|
133
|
+
}
|
|
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
|
+
/**
|
|
164
|
+
* Normalize file path argument from clank add command
|
|
165
|
+
* All files go to clank/ in target (except .claude/ files and agent files)
|
|
166
|
+
*
|
|
167
|
+
* @param input - The file path provided by the user
|
|
168
|
+
* @param cwd - The current working directory
|
|
169
|
+
* @param gitRoot - The git repository root
|
|
170
|
+
*/
|
|
171
|
+
export function normalizeAddPath(
|
|
172
|
+
input: string,
|
|
173
|
+
cwd: string,
|
|
174
|
+
gitRoot: string,
|
|
175
|
+
): string {
|
|
176
|
+
const normalized = input.replace(/^\.\//, "");
|
|
177
|
+
|
|
178
|
+
// Treat agent files (CLAUDE.md, GEMINI.md) as aliases for agents.md
|
|
179
|
+
// Support both relative paths (packages/foo/CLAUDE.md) and running from subdirectory
|
|
180
|
+
if (isAgentFile(normalized)) {
|
|
181
|
+
const inputDir = dirname(normalized);
|
|
182
|
+
const targetDir = inputDir === "." ? cwd : join(cwd, inputDir);
|
|
183
|
+
return join(targetDir, "agents.md");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// .claude/ and .gemini/ files keep their path (relative to git root)
|
|
187
|
+
for (const agentDir of managedAgentDirs) {
|
|
188
|
+
if (normalized.startsWith(`${agentDir}/`)) {
|
|
189
|
+
return join(gitRoot, normalized);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// If path already contains /clank/ in the middle, preserve its structure
|
|
194
|
+
if (normalized.includes("/clank/")) {
|
|
195
|
+
return join(cwd, normalized);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Strip clank/ prefix if present at start
|
|
199
|
+
const filename = normalized.startsWith("clank/")
|
|
200
|
+
? normalized.slice("clank/".length)
|
|
201
|
+
: normalized;
|
|
202
|
+
|
|
203
|
+
// Strip trailing /clank from cwd to avoid clank/clank nesting
|
|
204
|
+
// But don't strip if we're at the git root (project might be named "clank")
|
|
205
|
+
const inClankSubdir = cwd.endsWith("/clank") && cwd !== gitRoot;
|
|
206
|
+
const normalizedCwd = inClankSubdir ? cwd.slice(0, -"/clank".length) : cwd;
|
|
207
|
+
|
|
208
|
+
return join(normalizedCwd, "clank", filename);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Check if a filename is an agent file (CLAUDE.md, GEMINI.md, AGENTS.md) */
|
|
212
|
+
export function isAgentFile(filename: string): boolean {
|
|
213
|
+
const name = basename(filename).toLowerCase();
|
|
214
|
+
return name === "claude.md" || name === "gemini.md" || name === "agents.md";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Check if a path is a prompt file in an agent's prompts directory */
|
|
218
|
+
export function isPromptFile(normalizedPath: string): boolean {
|
|
219
|
+
for (const agentDir of managedAgentDirs) {
|
|
220
|
+
if (normalizedPath.includes(`/${agentDir}/prompts/`)) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Extract the prompt-relative path from a full prompt path */
|
|
228
|
+
export function getPromptRelPath(normalizedPath: string): string | null {
|
|
229
|
+
for (const agentDir of managedAgentDirs) {
|
|
230
|
+
const marker = `/${agentDir}/prompts/`;
|
|
231
|
+
const idx = normalizedPath.indexOf(marker);
|
|
232
|
+
if (idx !== -1) {
|
|
233
|
+
return normalizedPath.slice(idx + marker.length);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Add scope suffix to filename for conflict resolution
|
|
241
|
+
* e.g., "notes.md" + "project" => "notes-project.md"
|
|
242
|
+
*/
|
|
243
|
+
export function addScopeSuffix(filename: string, scope: Scope): string {
|
|
244
|
+
if (scope === "global") return filename;
|
|
245
|
+
const dotIndex = filename.lastIndexOf(".");
|
|
246
|
+
const base = dotIndex === -1 ? filename : filename.slice(0, dotIndex);
|
|
247
|
+
const ext = dotIndex === -1 ? "" : filename.slice(dotIndex);
|
|
248
|
+
return `${base}-${scope}${ext}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Map global overlay files to target */
|
|
252
|
+
function mapGlobalOverlay(
|
|
253
|
+
overlayPath: string,
|
|
254
|
+
globalPrefix: string,
|
|
255
|
+
targetRoot: string,
|
|
256
|
+
): TargetMapping | null {
|
|
257
|
+
const relPath = relative(globalPrefix, overlayPath);
|
|
258
|
+
|
|
259
|
+
// Skip init templates
|
|
260
|
+
if (relPath.startsWith("init/")) return null;
|
|
261
|
+
|
|
262
|
+
return decodeOverlayPath(relPath, targetRoot, "global");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Decode an overlay-relative path to target (shared by all scopes) */
|
|
266
|
+
function decodeOverlayPath(
|
|
267
|
+
relPath: string,
|
|
268
|
+
targetRoot: string,
|
|
269
|
+
scope: Scope,
|
|
270
|
+
): TargetMapping | null {
|
|
271
|
+
// clank/ files (at root or in subdirectories)
|
|
272
|
+
if (relPath.startsWith("clank/") || relPath.includes("/clank/")) {
|
|
273
|
+
return { targetPath: join(targetRoot, relPath), scope };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// prompts/ files → map to .claude/prompts/ (primary target, multiplexed in Link.ts)
|
|
277
|
+
if (relPath.startsWith("prompts/")) {
|
|
278
|
+
const promptRelPath = relPath.slice("prompts/".length);
|
|
279
|
+
return {
|
|
280
|
+
targetPath: join(targetRoot, ".claude/prompts", promptRelPath),
|
|
281
|
+
scope,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// claude/, gemini/ files → map to .claude/, .gemini/ in target
|
|
286
|
+
for (const agentDir of managedAgentDirs) {
|
|
287
|
+
const overlayDir = agentDir.slice(1); // "claude" or "gemini"
|
|
288
|
+
if (relPath.startsWith(`${overlayDir}/`)) {
|
|
289
|
+
const subPath = relPath.slice(overlayDir.length + 1);
|
|
290
|
+
return { targetPath: join(targetRoot, agentDir, subPath), scope };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Agent files (agents.md at any level)
|
|
295
|
+
if (basename(relPath) === "agents.md") {
|
|
296
|
+
return { targetPath: join(targetRoot, relPath), scope };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Map project overlay files to target */
|
|
303
|
+
function mapProjectOverlay(
|
|
304
|
+
overlayPath: string,
|
|
305
|
+
projectPrefix: string,
|
|
306
|
+
context: MapperContext,
|
|
307
|
+
): TargetMapping | null {
|
|
308
|
+
const { targetRoot, gitContext } = context;
|
|
309
|
+
const relPath = relative(projectPrefix, overlayPath);
|
|
310
|
+
|
|
311
|
+
// Worktree-specific files
|
|
312
|
+
const worktreePrefix = join("worktrees", gitContext.worktreeName);
|
|
313
|
+
if (relPath.startsWith(`${worktreePrefix}/`)) {
|
|
314
|
+
const innerPath = relative(worktreePrefix, relPath);
|
|
315
|
+
return decodeOverlayPath(innerPath, targetRoot, "worktree");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Skip other worktrees
|
|
319
|
+
if (relPath.startsWith("worktrees/")) return null;
|
|
320
|
+
|
|
321
|
+
// Project settings.json (project-only, before shared logic)
|
|
322
|
+
if (relPath === "claude/settings.json") {
|
|
323
|
+
return {
|
|
324
|
+
targetPath: join(targetRoot, ".claude/settings.json"),
|
|
325
|
+
scope: "project",
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return decodeOverlayPath(relPath, targetRoot, "project");
|
|
330
|
+
}
|