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/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
+ }
@@ -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
+ }