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,415 @@
|
|
|
1
|
+
import { rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import { forEachAgentPath } from "../AgentFiles.ts";
|
|
4
|
+
import {
|
|
5
|
+
agentFileProblems,
|
|
6
|
+
classifyAgentFiles,
|
|
7
|
+
formatAgentFileProblems,
|
|
8
|
+
} from "../ClassifyFiles.ts";
|
|
9
|
+
import { type ClankConfig, expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
|
|
10
|
+
import { addGitExcludes } from "../Exclude.ts";
|
|
11
|
+
import {
|
|
12
|
+
createSymlink,
|
|
13
|
+
ensureDir,
|
|
14
|
+
fileExists,
|
|
15
|
+
getLinkTarget,
|
|
16
|
+
isSymlink,
|
|
17
|
+
isTrackedByGit,
|
|
18
|
+
} from "../FsUtil.ts";
|
|
19
|
+
import { type GitContext, getGitContext } from "../Git.ts";
|
|
20
|
+
import {
|
|
21
|
+
addScopeSuffix,
|
|
22
|
+
getPromptRelPath,
|
|
23
|
+
type MapperContext,
|
|
24
|
+
overlayProjectDir,
|
|
25
|
+
overlayToTarget,
|
|
26
|
+
type Scope,
|
|
27
|
+
type TargetMapping,
|
|
28
|
+
} from "../Mapper.ts";
|
|
29
|
+
import {
|
|
30
|
+
createPromptLinks as createPromptLinksShared,
|
|
31
|
+
walkOverlayFiles,
|
|
32
|
+
} from "../OverlayLinks.ts";
|
|
33
|
+
import {
|
|
34
|
+
initializeWorktreeOverlay,
|
|
35
|
+
isWorktreeInitialized,
|
|
36
|
+
} from "../Templates.ts";
|
|
37
|
+
import { findOrphans } from "./Check.ts";
|
|
38
|
+
import { generateVscodeSettings, isVscodeProject } from "./VsCode.ts";
|
|
39
|
+
|
|
40
|
+
interface FileMapping extends TargetMapping {
|
|
41
|
+
overlayPath: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface LinkedFile {
|
|
45
|
+
path: string;
|
|
46
|
+
scope: Scope;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Link overlay repository to target directory */
|
|
50
|
+
export async function linkCommand(targetDir?: string): Promise<void> {
|
|
51
|
+
const gitContext = await getGitContext(targetDir || process.cwd());
|
|
52
|
+
const targetRoot = gitContext.gitRoot;
|
|
53
|
+
console.log(`Linking clank overlay to: ${targetRoot}\n`);
|
|
54
|
+
logGitContext(gitContext);
|
|
55
|
+
|
|
56
|
+
const config = await loadConfig();
|
|
57
|
+
const overlayRoot = expandPath(config.overlayRepo);
|
|
58
|
+
await validateOverlayExists(overlayRoot);
|
|
59
|
+
|
|
60
|
+
// Check for problematic agent files before proceeding
|
|
61
|
+
await checkAgentFiles(targetRoot, overlayRoot);
|
|
62
|
+
|
|
63
|
+
await ensureDir(join(overlayRoot, "targets", gitContext.projectName));
|
|
64
|
+
await maybeInitWorktree(overlayRoot, gitContext);
|
|
65
|
+
|
|
66
|
+
// Collect and separate mappings (agents.md and prompts get special handling)
|
|
67
|
+
const mappings = await overlayMappings(overlayRoot, gitContext, targetRoot);
|
|
68
|
+
const agentsMappings = mappings.filter(
|
|
69
|
+
(m) => basename(m.targetPath) === "agents.md",
|
|
70
|
+
);
|
|
71
|
+
const promptsMappings = mappings.filter((m) =>
|
|
72
|
+
m.targetPath.includes("/.claude/prompts/"),
|
|
73
|
+
);
|
|
74
|
+
const regularMappings = mappings.filter(
|
|
75
|
+
(m) =>
|
|
76
|
+
basename(m.targetPath) !== "agents.md" &&
|
|
77
|
+
!m.targetPath.includes("/.claude/prompts/"),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Create symlinks
|
|
81
|
+
const linkedPaths = await createLinks(regularMappings, targetRoot);
|
|
82
|
+
logLinkedPaths(linkedPaths);
|
|
83
|
+
await createAgentLinks(agentsMappings, targetRoot, config.agents);
|
|
84
|
+
await createPromptLinks(promptsMappings, targetRoot);
|
|
85
|
+
|
|
86
|
+
await setupProjectSettings(overlayRoot, gitContext, targetRoot);
|
|
87
|
+
await addGitExcludes(targetRoot);
|
|
88
|
+
await maybeGenerateVscodeSettings(config, targetRoot);
|
|
89
|
+
await warnOrphans(overlayRoot, targetRoot, gitContext.projectName);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Generate VS Code settings if configured */
|
|
93
|
+
async function maybeGenerateVscodeSettings(
|
|
94
|
+
config: ClankConfig,
|
|
95
|
+
targetRoot: string,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const setting = config.vscodeSettings ?? "auto";
|
|
98
|
+
|
|
99
|
+
if (setting === "never") return;
|
|
100
|
+
|
|
101
|
+
if (setting === "auto") {
|
|
102
|
+
const isVscode = await isVscodeProject(targetRoot);
|
|
103
|
+
if (!isVscode) return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// setting === "always" or (setting === "auto" && isVscodeProject)
|
|
107
|
+
console.log("");
|
|
108
|
+
await generateVscodeSettings(targetRoot);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function logGitContext(ctx: GitContext): void {
|
|
112
|
+
const suffix = ctx.isWorktree ? " (worktree)" : "";
|
|
113
|
+
console.log(`Project: ${ctx.projectName}`);
|
|
114
|
+
console.log(`Branch: ${ctx.worktreeName}${suffix}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function logLinkedPaths(files: LinkedFile[]): void {
|
|
118
|
+
if (files.length === 0) return;
|
|
119
|
+
console.log(`\nLinked ${files.length} file(s):`);
|
|
120
|
+
for (const { path, scope } of files) {
|
|
121
|
+
const suffix = scope === "project" ? "" : ` (${scope})`;
|
|
122
|
+
console.log(` ${path}${suffix}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function warnOrphans(
|
|
127
|
+
overlayRoot: string,
|
|
128
|
+
targetRoot: string,
|
|
129
|
+
projectName: string,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const orphans = await findOrphans(overlayRoot, targetRoot, projectName);
|
|
132
|
+
if (orphans.length > 0) {
|
|
133
|
+
console.log(`\nWarning: ${orphans.length} orphaned overlay path(s) found.`);
|
|
134
|
+
console.log("Run 'clank check' for details.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Check for problematic agent files and error if found */
|
|
139
|
+
async function checkAgentFiles(
|
|
140
|
+
targetRoot: string,
|
|
141
|
+
overlayRoot: string,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const classification = await classifyAgentFiles(targetRoot, overlayRoot);
|
|
144
|
+
|
|
145
|
+
if (agentFileProblems(classification)) {
|
|
146
|
+
throw new Error(formatAgentFileProblems(classification, process.cwd()));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function maybeInitWorktree(
|
|
151
|
+
overlayRoot: string,
|
|
152
|
+
gitContext: GitContext,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const initialized = await isWorktreeInitialized(overlayRoot, gitContext);
|
|
155
|
+
if (!initialized) {
|
|
156
|
+
console.log(
|
|
157
|
+
`Initializing worktree ${gitContext.worktreeName} from templates...`,
|
|
158
|
+
);
|
|
159
|
+
await initializeWorktreeOverlay(overlayRoot, gitContext);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Collect all file mappings from global, project, and worktree locations */
|
|
164
|
+
async function overlayMappings(
|
|
165
|
+
overlayRoot: string,
|
|
166
|
+
gitContext: GitContext,
|
|
167
|
+
targetRoot: string,
|
|
168
|
+
): Promise<FileMapping[]> {
|
|
169
|
+
const context: MapperContext = { overlayRoot, targetRoot, gitContext };
|
|
170
|
+
const overlayGlobal = join(overlayRoot, "global");
|
|
171
|
+
const overlayProject = overlayProjectDir(overlayRoot, gitContext.projectName);
|
|
172
|
+
|
|
173
|
+
return [
|
|
174
|
+
...(await dirMappings(overlayGlobal, context)),
|
|
175
|
+
...(await dirMappings(overlayProject, context)),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function dirMappings(
|
|
180
|
+
dir: string,
|
|
181
|
+
context: MapperContext,
|
|
182
|
+
): Promise<FileMapping[]> {
|
|
183
|
+
if (!(await fileExists(dir))) return [];
|
|
184
|
+
|
|
185
|
+
const mappings: FileMapping[] = [];
|
|
186
|
+
for await (const overlayPath of walkOverlayFiles(dir)) {
|
|
187
|
+
const result = overlayToTarget(overlayPath, context);
|
|
188
|
+
if (result) {
|
|
189
|
+
mappings.push({ overlayPath, ...result });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return mappings;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Check if a file is tracked in git (exists as real file, not symlink, and tracked) */
|
|
196
|
+
async function isTrackedFile(path: string, gitRoot: string): Promise<boolean> {
|
|
197
|
+
if (!(await fileExists(path))) return false;
|
|
198
|
+
if (await isSymlink(path)) return false;
|
|
199
|
+
return isTrackedByGit(path, gitRoot);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Process a single agents.md mapping into agent symlink paths */
|
|
203
|
+
async function processAgentMapping(
|
|
204
|
+
mapping: FileMapping,
|
|
205
|
+
targetRoot: string,
|
|
206
|
+
agents: string[],
|
|
207
|
+
): Promise<{ created: string[]; skipped: string[] }> {
|
|
208
|
+
const { overlayPath, targetPath } = mapping;
|
|
209
|
+
const targetDir = dirname(targetPath);
|
|
210
|
+
const created: string[] = [];
|
|
211
|
+
const skipped: string[] = [];
|
|
212
|
+
|
|
213
|
+
await forEachAgentPath(targetDir, agents, async (agentPath) => {
|
|
214
|
+
if (await isTrackedFile(agentPath, targetRoot)) {
|
|
215
|
+
skipped.push(relative(targetRoot, agentPath));
|
|
216
|
+
} else {
|
|
217
|
+
const linkTarget = getLinkTarget(agentPath, overlayPath);
|
|
218
|
+
await createSymlink(linkTarget, agentPath);
|
|
219
|
+
created.push(relative(targetRoot, agentPath));
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return { created, skipped };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) for all agents.md files */
|
|
227
|
+
async function createAgentLinks(
|
|
228
|
+
agentsMappings: FileMapping[],
|
|
229
|
+
targetRoot: string,
|
|
230
|
+
agents: string[],
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
if (agentsMappings.length === 0) return;
|
|
233
|
+
|
|
234
|
+
const results = await Promise.all(
|
|
235
|
+
agentsMappings.map((m) => processAgentMapping(m, targetRoot, agents)),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const created = results.flatMap((r) => r.created);
|
|
239
|
+
const skipped = results.flatMap((r) => r.skipped);
|
|
240
|
+
|
|
241
|
+
if (created.length) {
|
|
242
|
+
console.log(`\nCreated agent symlinks:`);
|
|
243
|
+
for (const path of created) {
|
|
244
|
+
console.log(` ${path}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (skipped.length) {
|
|
249
|
+
console.log(`\nSkipped (already tracked in git):`);
|
|
250
|
+
for (const path of skipped) {
|
|
251
|
+
console.log(` ${path}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Create prompt symlinks in all agent directories (.claude/prompts/, .gemini/prompts/) */
|
|
257
|
+
async function createPromptLinks(
|
|
258
|
+
promptsMappings: FileMapping[],
|
|
259
|
+
targetRoot: string,
|
|
260
|
+
): Promise<void> {
|
|
261
|
+
if (promptsMappings.length === 0) return;
|
|
262
|
+
|
|
263
|
+
const results = await Promise.all(
|
|
264
|
+
promptsMappings.map((m) => processPromptMapping(m, targetRoot)),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const created = results.flatMap((r) => r.created);
|
|
268
|
+
if (created.length) {
|
|
269
|
+
console.log(`\nCreated prompt symlinks:`);
|
|
270
|
+
for (const path of created) {
|
|
271
|
+
console.log(` ${path}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Process a single prompt mapping into symlinks for all agent directories */
|
|
277
|
+
async function processPromptMapping(
|
|
278
|
+
mapping: FileMapping,
|
|
279
|
+
targetRoot: string,
|
|
280
|
+
): Promise<{ created: string[] }> {
|
|
281
|
+
const { overlayPath, targetPath } = mapping;
|
|
282
|
+
const promptRelPath = getPromptRelPath(targetPath);
|
|
283
|
+
if (!promptRelPath) return { created: [] };
|
|
284
|
+
|
|
285
|
+
const createdPaths = await createPromptLinksShared(
|
|
286
|
+
overlayPath,
|
|
287
|
+
promptRelPath,
|
|
288
|
+
targetRoot,
|
|
289
|
+
);
|
|
290
|
+
return { created: createdPaths.map((p) => relative(targetRoot, p)) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Create symlinks, handling conflicts with scope suffixes.
|
|
294
|
+
* Conflicts occur when the same filename exists at multiple scopes (global, project, worktree).
|
|
295
|
+
* Returns linked files with their scopes. */
|
|
296
|
+
async function createLinks(
|
|
297
|
+
mappings: FileMapping[],
|
|
298
|
+
targetRoot: string,
|
|
299
|
+
): Promise<LinkedFile[]> {
|
|
300
|
+
// Filter out subdirectory clank files where parent doesn't exist in target
|
|
301
|
+
const validMappings = await filterValidMappings(mappings, targetRoot);
|
|
302
|
+
|
|
303
|
+
const byTarget = Map.groupBy(validMappings, (m) => m.targetPath);
|
|
304
|
+
const links = [...byTarget].flatMap(([targetPath, files]) =>
|
|
305
|
+
resolveLinks(targetPath, files),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const linkPromises = links.map(({ overlayPath, linkPath }) =>
|
|
309
|
+
createSymlink(getLinkTarget(linkPath, overlayPath), linkPath),
|
|
310
|
+
);
|
|
311
|
+
await Promise.all(linkPromises);
|
|
312
|
+
|
|
313
|
+
return links.map(({ linkPath, scope }) => ({
|
|
314
|
+
path: relative(targetRoot, linkPath),
|
|
315
|
+
scope,
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Filter mappings to exclude subdirectory clank files where target parent doesn't exist.
|
|
320
|
+
* (we'll warn about these as orphans in 'clank check' and warnOrphans() during link)
|
|
321
|
+
*/
|
|
322
|
+
async function filterValidMappings(
|
|
323
|
+
mappings: FileMapping[],
|
|
324
|
+
targetRoot: string,
|
|
325
|
+
): Promise<FileMapping[]> {
|
|
326
|
+
const results = await Promise.all(
|
|
327
|
+
mappings.map((m) => checkMappingParentExists(m, targetRoot)),
|
|
328
|
+
);
|
|
329
|
+
return results.filter((m): m is FileMapping => m !== null);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Check if a subdirectory clank file's parent exists in the target */
|
|
333
|
+
async function checkMappingParentExists(
|
|
334
|
+
m: FileMapping,
|
|
335
|
+
targetRoot: string,
|
|
336
|
+
): Promise<FileMapping | null> {
|
|
337
|
+
const relPath = relative(targetRoot, m.targetPath);
|
|
338
|
+
// Subdirectory clank files have /clank/ in the middle of the path
|
|
339
|
+
const clankIndex = relPath.indexOf("/clank/");
|
|
340
|
+
if (clankIndex !== -1) {
|
|
341
|
+
// Check if parent directory exists (e.g., packages/foo for packages/foo/clank/notes.md)
|
|
342
|
+
const parentDir = join(targetRoot, relPath.slice(0, clankIndex));
|
|
343
|
+
if (!(await fileExists(parentDir))) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return m;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Compute link paths, adding scope suffixes when the same target has multiple sources */
|
|
351
|
+
function resolveLinks(
|
|
352
|
+
targetPath: string,
|
|
353
|
+
files: FileMapping[],
|
|
354
|
+
): Array<{ overlayPath: string; linkPath: string; scope: Scope }> {
|
|
355
|
+
if (files.length === 1) {
|
|
356
|
+
const { overlayPath, scope } = files[0];
|
|
357
|
+
return [{ overlayPath, linkPath: targetPath, scope }];
|
|
358
|
+
}
|
|
359
|
+
return files.map(({ overlayPath, scope }) => ({
|
|
360
|
+
overlayPath,
|
|
361
|
+
linkPath: join(
|
|
362
|
+
dirname(targetPath),
|
|
363
|
+
addScopeSuffix(basename(targetPath), scope),
|
|
364
|
+
),
|
|
365
|
+
scope,
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Setup project settings.json - adopt existing or create new */
|
|
370
|
+
async function setupProjectSettings(
|
|
371
|
+
overlayRoot: string,
|
|
372
|
+
gitContext: GitContext,
|
|
373
|
+
targetRoot: string,
|
|
374
|
+
): Promise<void> {
|
|
375
|
+
const overlayPath = join(
|
|
376
|
+
overlayRoot,
|
|
377
|
+
"targets",
|
|
378
|
+
gitContext.projectName,
|
|
379
|
+
"claude",
|
|
380
|
+
"settings.json",
|
|
381
|
+
);
|
|
382
|
+
const targetPath = join(targetRoot, ".claude/settings.json");
|
|
383
|
+
|
|
384
|
+
const inOverlay = await fileExists(overlayPath);
|
|
385
|
+
const inTarget =
|
|
386
|
+
(await fileExists(targetPath)) && !(await isSymlink(targetPath));
|
|
387
|
+
|
|
388
|
+
if (inTarget && inOverlay) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`Conflict: settings.json exists in both target and overlay.\n` +
|
|
391
|
+
` Target: ${targetPath}\n` +
|
|
392
|
+
` Overlay: ${overlayPath}\n` +
|
|
393
|
+
`Remove one to resolve.`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (inTarget) {
|
|
398
|
+
// Adopt target's settings - move to overlay
|
|
399
|
+
await ensureDir(dirname(overlayPath));
|
|
400
|
+
await rename(targetPath, overlayPath);
|
|
401
|
+
console.log(`Moved .claude/settings.json to overlay`);
|
|
402
|
+
} else if (!inOverlay) {
|
|
403
|
+
// Neither exists - create blank in overlay
|
|
404
|
+
await ensureDir(dirname(overlayPath));
|
|
405
|
+
await writeFile(overlayPath, "{}\n", "utf-8");
|
|
406
|
+
console.log(
|
|
407
|
+
`Created .claude/settings.json symlink (project settings will be stored in overlay)`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Create symlink (overlay now has the file)
|
|
412
|
+
await ensureDir(dirname(targetPath));
|
|
413
|
+
const linkTarget = getLinkTarget(targetPath, overlayPath);
|
|
414
|
+
await createSymlink(linkTarget, targetPath);
|
|
415
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import { forEachAgentPath, managedAgentDirs } from "../AgentFiles.ts";
|
|
4
|
+
import { expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
|
|
5
|
+
import { createSymlink, ensureDir, getLinkTarget } from "../FsUtil.ts";
|
|
6
|
+
import { getGitContext } from "../Git.ts";
|
|
7
|
+
import {
|
|
8
|
+
getPromptRelPath,
|
|
9
|
+
isAgentFile,
|
|
10
|
+
isPromptFile,
|
|
11
|
+
type MapperContext,
|
|
12
|
+
normalizeAddPath,
|
|
13
|
+
resolveScopeFromOptions,
|
|
14
|
+
type Scope,
|
|
15
|
+
type ScopeOptions,
|
|
16
|
+
targetToOverlay,
|
|
17
|
+
} from "../Mapper.ts";
|
|
18
|
+
import { createPromptLinks, isSymlinkToOverlay } from "../OverlayLinks.ts";
|
|
19
|
+
import { scopeFromSymlink } from "../ScopeFromSymlink.ts";
|
|
20
|
+
|
|
21
|
+
export type MoveOptions = ScopeOptions;
|
|
22
|
+
|
|
23
|
+
interface MoveContext {
|
|
24
|
+
overlayRoot: string;
|
|
25
|
+
gitRoot: string;
|
|
26
|
+
agents: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Move file(s) between overlay scopes */
|
|
30
|
+
export async function moveCommand(
|
|
31
|
+
filePaths: string[],
|
|
32
|
+
options: MoveOptions,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const targetScope = resolveScopeFromOptions(options, "require");
|
|
35
|
+
const cwd = process.cwd();
|
|
36
|
+
const gitContext = await getGitContext(cwd);
|
|
37
|
+
const config = await loadConfig();
|
|
38
|
+
const overlayRoot = expandPath(config.overlayRepo);
|
|
39
|
+
|
|
40
|
+
await validateOverlayExists(overlayRoot);
|
|
41
|
+
|
|
42
|
+
const { gitRoot: targetRoot } = gitContext;
|
|
43
|
+
const context: MapperContext = { overlayRoot, targetRoot, gitContext };
|
|
44
|
+
|
|
45
|
+
for (const filePath of filePaths) {
|
|
46
|
+
await moveSingleFile(filePath, targetScope, context, cwd, config);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function moveSingleFile(
|
|
51
|
+
filePath: string,
|
|
52
|
+
targetScope: Scope,
|
|
53
|
+
context: MapperContext,
|
|
54
|
+
cwd: string,
|
|
55
|
+
config: { agents: string[] },
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const { overlayRoot, targetRoot: gitRoot } = context;
|
|
58
|
+
const normalizedPath = normalizeAddPath(filePath, cwd, gitRoot);
|
|
59
|
+
const barePath = join(cwd, filePath);
|
|
60
|
+
|
|
61
|
+
// Check if file is a symlink to overlay
|
|
62
|
+
const currentScope = await scopeFromSymlink(barePath, context);
|
|
63
|
+
if (!currentScope) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`${relative(cwd, barePath)} is not managed by clank.\nUse 'clank add' to add it to the overlay first.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (currentScope === targetScope) {
|
|
70
|
+
console.log(
|
|
71
|
+
`${basename(barePath)} is already in ${targetScope} scope, nothing to do.`,
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const currentOverlayPath = targetToOverlay(
|
|
77
|
+
normalizedPath,
|
|
78
|
+
currentScope,
|
|
79
|
+
context,
|
|
80
|
+
);
|
|
81
|
+
const newOverlayPath = targetToOverlay(normalizedPath, targetScope, context);
|
|
82
|
+
|
|
83
|
+
// Move the file in the overlay
|
|
84
|
+
await ensureDir(dirname(newOverlayPath));
|
|
85
|
+
await rename(currentOverlayPath, newOverlayPath);
|
|
86
|
+
|
|
87
|
+
const moveCtx: MoveContext = { overlayRoot, gitRoot, agents: config.agents };
|
|
88
|
+
|
|
89
|
+
// Recreate symlinks
|
|
90
|
+
if (isAgentFile(filePath)) {
|
|
91
|
+
await recreateAgentLinks(normalizedPath, newOverlayPath, moveCtx);
|
|
92
|
+
} else if (isPromptFile(normalizedPath)) {
|
|
93
|
+
await recreatePromptLinks(normalizedPath, newOverlayPath, moveCtx);
|
|
94
|
+
} else {
|
|
95
|
+
await recreateSymlink(normalizedPath, newOverlayPath, moveCtx);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fileName = relative(cwd, barePath);
|
|
99
|
+
console.log(
|
|
100
|
+
`Moved ${fileName} from ${currentScope} → ${targetScope} overlay`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Recreate a regular symlink after moving */
|
|
105
|
+
async function recreateSymlink(
|
|
106
|
+
targetPath: string,
|
|
107
|
+
overlayPath: string,
|
|
108
|
+
ctx: MoveContext,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
// Remove old symlink if it exists
|
|
111
|
+
if (await isSymlinkToOverlay(targetPath, ctx.overlayRoot)) {
|
|
112
|
+
await unlink(targetPath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Create new symlink
|
|
116
|
+
const linkTarget = getLinkTarget(targetPath, overlayPath);
|
|
117
|
+
await createSymlink(linkTarget, targetPath);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Recreate agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md) after moving */
|
|
121
|
+
async function recreateAgentLinks(
|
|
122
|
+
normalizedPath: string,
|
|
123
|
+
overlayPath: string,
|
|
124
|
+
ctx: MoveContext,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const { overlayRoot, gitRoot, agents } = ctx;
|
|
127
|
+
const targetDir = dirname(normalizedPath);
|
|
128
|
+
const updated: string[] = [];
|
|
129
|
+
|
|
130
|
+
await forEachAgentPath(targetDir, agents, async (agentPath) => {
|
|
131
|
+
// Remove old symlink
|
|
132
|
+
if (await isSymlinkToOverlay(agentPath, overlayRoot)) {
|
|
133
|
+
await unlink(agentPath);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Create new symlink
|
|
137
|
+
await ensureDir(dirname(agentPath));
|
|
138
|
+
const linkTarget = getLinkTarget(agentPath, overlayPath);
|
|
139
|
+
await createSymlink(linkTarget, agentPath);
|
|
140
|
+
updated.push(relative(gitRoot, agentPath));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (updated.length > 0) {
|
|
144
|
+
console.log(`Updated symlinks: ${updated.join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Recreate prompt symlinks in all agent directories after moving */
|
|
149
|
+
async function recreatePromptLinks(
|
|
150
|
+
normalizedPath: string,
|
|
151
|
+
overlayPath: string,
|
|
152
|
+
ctx: MoveContext,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const { overlayRoot, gitRoot } = ctx;
|
|
155
|
+
const promptRelPath = getPromptRelPath(normalizedPath);
|
|
156
|
+
if (!promptRelPath) return;
|
|
157
|
+
|
|
158
|
+
// Remove old symlinks
|
|
159
|
+
for (const agentDir of managedAgentDirs) {
|
|
160
|
+
const targetPath = join(gitRoot, agentDir, "prompts", promptRelPath);
|
|
161
|
+
if (await isSymlinkToOverlay(targetPath, overlayRoot)) {
|
|
162
|
+
await unlink(targetPath);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Create new symlinks
|
|
167
|
+
const created = await createPromptLinks(overlayPath, promptRelPath, gitRoot);
|
|
168
|
+
|
|
169
|
+
if (created.length > 0) {
|
|
170
|
+
console.log(`Updated symlinks: ${created.map((p) => relative(gitRoot, p)).join(", ")}`);
|
|
171
|
+
}
|
|
172
|
+
}
|