clank-cli 0.1.61 → 0.1.65
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 +28 -4
- package/package.json +3 -2
- package/src/AgentFiles.ts +1 -2
- package/src/ClassifyFiles.ts +44 -49
- package/src/Cli.ts +74 -72
- package/src/Config.ts +12 -15
- package/src/Exclude.ts +9 -9
- package/src/Git.ts +10 -10
- package/src/Gitignore.ts +38 -49
- package/src/Mapper.ts +71 -71
- package/src/OverlayGit.ts +28 -7
- package/src/OverlayLinks.ts +6 -11
- package/src/commands/Add.ts +360 -128
- package/src/commands/Check.ts +159 -139
- package/src/commands/Commit.ts +1 -1
- package/src/commands/Link.ts +226 -200
- package/src/commands/Move.ts +146 -16
- package/src/commands/Rm.ts +60 -50
- package/src/commands/VsCode.ts +24 -24
package/src/commands/Add.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
writeFile,
|
|
7
7
|
} from "node:fs/promises";
|
|
8
8
|
import { basename, dirname, join, relative } from "node:path";
|
|
9
|
+
import * as readline from "node:readline";
|
|
9
10
|
import { forEachAgentPath } from "../AgentFiles.ts";
|
|
10
11
|
import { expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
|
|
11
12
|
import {
|
|
@@ -31,6 +32,43 @@ import {
|
|
|
31
32
|
} from "../Mapper.ts";
|
|
32
33
|
import { createPromptLinks, isSymlinkToOverlay } from "../OverlayLinks.ts";
|
|
33
34
|
import { scopeFromSymlink } from "../ScopeFromSymlink.ts";
|
|
35
|
+
import { findUnaddedFiles } from "./Check.ts";
|
|
36
|
+
|
|
37
|
+
export type AddOptions = ScopeOptions & {
|
|
38
|
+
interactive?: boolean;
|
|
39
|
+
quiet?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface AddContext {
|
|
43
|
+
cwd: string;
|
|
44
|
+
gitContext: GitContext;
|
|
45
|
+
config: { overlayRepo: string; agents: string[] };
|
|
46
|
+
overlayRoot: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
|
|
50
|
+
interface AgentLinkParams {
|
|
51
|
+
overlayPath: string;
|
|
52
|
+
symlinkDir: string;
|
|
53
|
+
gitRoot: string;
|
|
54
|
+
overlayRoot: string;
|
|
55
|
+
agents: string[];
|
|
56
|
+
quiet?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface AgentLinkClassification {
|
|
60
|
+
toCreate: { targetPath: string; name: string }[];
|
|
61
|
+
existing: string[];
|
|
62
|
+
skipped: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type InteractiveChoice = "project" | "worktree" | "global" | "skip" | "quit";
|
|
66
|
+
type ScopeCounts = {
|
|
67
|
+
project: number;
|
|
68
|
+
worktree: number;
|
|
69
|
+
global: number;
|
|
70
|
+
skip: number;
|
|
71
|
+
};
|
|
34
72
|
|
|
35
73
|
const scopeLabels: Record<Scope, string> = {
|
|
36
74
|
global: "global",
|
|
@@ -38,8 +76,6 @@ const scopeLabels: Record<Scope, string> = {
|
|
|
38
76
|
worktree: "worktree",
|
|
39
77
|
};
|
|
40
78
|
|
|
41
|
-
export type AddOptions = ScopeOptions;
|
|
42
|
-
|
|
43
79
|
/** Add file(s) to overlay and create symlinks in target */
|
|
44
80
|
export async function addCommand(
|
|
45
81
|
filePaths: string[],
|
|
@@ -54,6 +90,17 @@ export async function addCommand(
|
|
|
54
90
|
|
|
55
91
|
const ctx = { cwd, gitContext, config, overlayRoot };
|
|
56
92
|
|
|
93
|
+
if (options.interactive) {
|
|
94
|
+
await addAllInteractive(ctx);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (filePaths.length === 0) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"No files specified. Use --interactive for interactive mode.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
57
104
|
for (const filePath of filePaths) {
|
|
58
105
|
const inputPath = join(cwd, filePath);
|
|
59
106
|
|
|
@@ -68,11 +115,61 @@ export async function addCommand(
|
|
|
68
115
|
}
|
|
69
116
|
}
|
|
70
117
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
118
|
+
/** fail if we can't do an add with the given options */
|
|
119
|
+
async function validateAddOptions(
|
|
120
|
+
options: AddOptions,
|
|
121
|
+
overlayRoot: string,
|
|
122
|
+
gitContext: GitContext,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
await validateOverlayExists(overlayRoot);
|
|
125
|
+
|
|
126
|
+
if (options.worktree && !gitContext.isWorktree) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`--worktree scope requires a git worktree.\n` +
|
|
129
|
+
`You're on branch '${gitContext.worktreeName}' in the main repository.\n` +
|
|
130
|
+
`Use 'git worktree add' to create a worktree, or use --project scope instead.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Interactive mode: add all unadded files with per-file scope selection */
|
|
136
|
+
async function addAllInteractive(ctx: AddContext): Promise<void> {
|
|
137
|
+
const { cwd, gitContext, overlayRoot } = ctx;
|
|
138
|
+
const context: MapperContext = {
|
|
139
|
+
overlayRoot,
|
|
140
|
+
targetRoot: gitContext.gitRoot,
|
|
141
|
+
gitContext,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const unadded = await findUnaddedFiles(context);
|
|
145
|
+
const regularFiles = unadded.filter((f) => f.kind === "unadded");
|
|
146
|
+
|
|
147
|
+
if (regularFiles.length === 0) {
|
|
148
|
+
console.log("No unadded files found.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log(`Found ${regularFiles.length} unadded file(s):\n`);
|
|
153
|
+
|
|
154
|
+
const counts: ScopeCounts = { project: 0, worktree: 0, global: 0, skip: 0 };
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < regularFiles.length; i++) {
|
|
157
|
+
const file = regularFiles[i];
|
|
158
|
+
const relPath = relative(cwd, file.targetPath);
|
|
159
|
+
const result = await promptAndAddFile(relPath, i, regularFiles.length, ctx);
|
|
160
|
+
if (result === "quit") break;
|
|
161
|
+
if (result !== "error") counts[result]++;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
printSummary(counts);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
168
|
+
try {
|
|
169
|
+
return (await lstat(path)).isDirectory();
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
76
173
|
}
|
|
77
174
|
|
|
78
175
|
/** Add a single file to overlay and create symlink */
|
|
@@ -83,6 +180,7 @@ async function addSingleFile(
|
|
|
83
180
|
): Promise<void> {
|
|
84
181
|
const { cwd, gitContext, config, overlayRoot } = ctx;
|
|
85
182
|
const { gitRoot } = gitContext;
|
|
183
|
+
const { quiet } = options;
|
|
86
184
|
|
|
87
185
|
const scope = resolveScopeFromOptions(options);
|
|
88
186
|
/** Absolute path where symlink will be created in target repo */
|
|
@@ -105,23 +203,208 @@ async function addSingleFile(
|
|
|
105
203
|
await checkScopeConflict(barePath, scope, context, cwd);
|
|
106
204
|
|
|
107
205
|
if (await fileExists(overlayPath)) {
|
|
108
|
-
|
|
206
|
+
if (!quiet)
|
|
207
|
+
console.log(`${fileName} already exists in ${scopeLabel} overlay`);
|
|
109
208
|
} else if (await isSymlink(barePath)) {
|
|
110
|
-
await addSymlinkToOverlay(barePath, overlayPath, scopeLabel);
|
|
209
|
+
await addSymlinkToOverlay(barePath, overlayPath, scopeLabel, quiet);
|
|
111
210
|
} else {
|
|
112
|
-
await addFileToOverlay(
|
|
211
|
+
await addFileToOverlay(
|
|
212
|
+
normalizedPath,
|
|
213
|
+
barePath,
|
|
214
|
+
overlayPath,
|
|
215
|
+
scopeLabel,
|
|
216
|
+
quiet,
|
|
217
|
+
);
|
|
113
218
|
}
|
|
114
219
|
|
|
115
|
-
|
|
220
|
+
await createSymlinksForFile({
|
|
221
|
+
filePath,
|
|
222
|
+
normalizedPath,
|
|
223
|
+
overlayPath,
|
|
224
|
+
config,
|
|
225
|
+
gitRoot,
|
|
226
|
+
overlayRoot,
|
|
227
|
+
cwd,
|
|
228
|
+
quiet,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface CreateSymlinksParams {
|
|
233
|
+
filePath: string;
|
|
234
|
+
normalizedPath: string;
|
|
235
|
+
overlayPath: string;
|
|
236
|
+
config: { agents: string[] };
|
|
237
|
+
gitRoot: string;
|
|
238
|
+
overlayRoot: string;
|
|
239
|
+
cwd: string;
|
|
240
|
+
quiet?: boolean;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Create symlinks based on file type (agent, prompt, or regular) */
|
|
244
|
+
async function createSymlinksForFile(params: CreateSymlinksParams) {
|
|
245
|
+
const {
|
|
246
|
+
filePath,
|
|
247
|
+
normalizedPath,
|
|
248
|
+
overlayPath,
|
|
249
|
+
config,
|
|
250
|
+
gitRoot,
|
|
251
|
+
overlayRoot,
|
|
252
|
+
cwd,
|
|
253
|
+
quiet,
|
|
254
|
+
} = params;
|
|
255
|
+
|
|
116
256
|
if (isAgentFile(filePath)) {
|
|
117
257
|
const symlinkDir = dirname(normalizedPath);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
258
|
+
await createAgentLinks({
|
|
259
|
+
overlayPath,
|
|
260
|
+
symlinkDir,
|
|
261
|
+
gitRoot,
|
|
262
|
+
overlayRoot,
|
|
263
|
+
agents: config.agents,
|
|
264
|
+
quiet,
|
|
265
|
+
});
|
|
121
266
|
} else if (isPromptFile(normalizedPath)) {
|
|
122
|
-
await handlePromptFile(normalizedPath, overlayPath, gitRoot, cwd);
|
|
267
|
+
await handlePromptFile(normalizedPath, overlayPath, gitRoot, cwd, quiet);
|
|
123
268
|
} else {
|
|
124
|
-
await handleRegularFile(
|
|
269
|
+
await handleRegularFile(
|
|
270
|
+
normalizedPath,
|
|
271
|
+
overlayPath,
|
|
272
|
+
overlayRoot,
|
|
273
|
+
cwd,
|
|
274
|
+
quiet,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Prompt for scope and add a single file. Returns the choice or "error". */
|
|
280
|
+
async function promptAndAddFile(
|
|
281
|
+
relPath: string,
|
|
282
|
+
index: number,
|
|
283
|
+
total: number,
|
|
284
|
+
ctx: AddContext,
|
|
285
|
+
): Promise<InteractiveChoice | "error"> {
|
|
286
|
+
process.stdout.write(`\x1b[1m${relPath}\x1b[0m [${index + 1}/${total}]\n`);
|
|
287
|
+
process.stdout.write(
|
|
288
|
+
" (P)roject (W)orktree (G)lobal (S)kip (Q)uit [P]: ",
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const choice = await readScopeChoice(ctx.gitContext.isWorktree);
|
|
292
|
+
|
|
293
|
+
if (choice === "quit" || choice === "skip") {
|
|
294
|
+
if (choice === "quit") console.log("\nAborted.");
|
|
295
|
+
else console.log();
|
|
296
|
+
return choice;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const scopeOptions: AddOptions = {
|
|
300
|
+
project: choice === "project",
|
|
301
|
+
worktree: choice === "worktree",
|
|
302
|
+
global: choice === "global",
|
|
303
|
+
quiet: true,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
await addSingleFile(relPath, scopeOptions, ctx);
|
|
308
|
+
console.log();
|
|
309
|
+
return choice;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
console.error(` Error: ${error instanceof Error ? error.message : error}`);
|
|
312
|
+
console.log();
|
|
313
|
+
return "error";
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Print summary of interactive add results */
|
|
318
|
+
function printSummary(counts: Record<string, number>): void {
|
|
319
|
+
const parts: string[] = [];
|
|
320
|
+
if (counts.project > 0) parts.push(`${counts.project} to project`);
|
|
321
|
+
if (counts.worktree > 0) parts.push(`${counts.worktree} to worktree`);
|
|
322
|
+
if (counts.global > 0) parts.push(`${counts.global} to global`);
|
|
323
|
+
if (counts.skip > 0) parts.push(`${counts.skip} skipped`);
|
|
324
|
+
|
|
325
|
+
if (parts.length > 0) {
|
|
326
|
+
console.log(`Added ${parts.join(", ")}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Check if file is already in overlay at a different scope, throw helpful error */
|
|
331
|
+
async function checkScopeConflict(
|
|
332
|
+
barePath: string,
|
|
333
|
+
requestedScope: Scope,
|
|
334
|
+
context: MapperContext,
|
|
335
|
+
cwd: string,
|
|
336
|
+
): Promise<void> {
|
|
337
|
+
const currentScope = await scopeFromSymlink(barePath, context);
|
|
338
|
+
if (currentScope && currentScope !== requestedScope) {
|
|
339
|
+
const fileName = relative(cwd, barePath);
|
|
340
|
+
throw new Error(
|
|
341
|
+
`${fileName} is already in ${scopeLabels[currentScope]} overlay.\n` +
|
|
342
|
+
`To move it to ${scopeLabels[requestedScope]} scope, use: clank mv ${fileName} --${requestedScope}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Copy a symlink to the overlay, preserving its target */
|
|
348
|
+
async function addSymlinkToOverlay(
|
|
349
|
+
inputPath: string,
|
|
350
|
+
overlayPath: string,
|
|
351
|
+
scopeLabel: string,
|
|
352
|
+
quiet?: boolean,
|
|
353
|
+
): Promise<void> {
|
|
354
|
+
const target = await readlink(inputPath);
|
|
355
|
+
await ensureDir(dirname(overlayPath));
|
|
356
|
+
await symlink(target, overlayPath);
|
|
357
|
+
if (!quiet)
|
|
358
|
+
console.log(
|
|
359
|
+
`Copied symlink ${basename(inputPath)} to ${scopeLabel} overlay`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Copy file content to overlay */
|
|
364
|
+
async function addFileToOverlay(
|
|
365
|
+
normalizedPath: string,
|
|
366
|
+
barePath: string,
|
|
367
|
+
overlayPath: string,
|
|
368
|
+
scopeLabel: string,
|
|
369
|
+
quiet?: boolean,
|
|
370
|
+
): Promise<void> {
|
|
371
|
+
await ensureDir(dirname(overlayPath));
|
|
372
|
+
const content = await findSourceContent(normalizedPath, barePath);
|
|
373
|
+
await writeFile(overlayPath, content, "utf-8");
|
|
374
|
+
if (!quiet) {
|
|
375
|
+
const fileName = basename(overlayPath);
|
|
376
|
+
if (content) {
|
|
377
|
+
console.log(`Copied ${fileName} to ${scopeLabel} overlay`);
|
|
378
|
+
} else {
|
|
379
|
+
console.log(`Created empty ${fileName} in ${scopeLabel} overlay`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function createAgentLinks(p: AgentLinkParams): Promise<void> {
|
|
385
|
+
const { overlayPath, quiet, ...classifyParams } = p;
|
|
386
|
+
const { toCreate, existing, skipped } =
|
|
387
|
+
await classifyAgentLinks(classifyParams);
|
|
388
|
+
|
|
389
|
+
const promisedLinks = toCreate.map(({ targetPath }) => {
|
|
390
|
+
const linkTarget = getLinkTarget(targetPath, overlayPath);
|
|
391
|
+
return createSymlink(linkTarget, targetPath);
|
|
392
|
+
});
|
|
393
|
+
await Promise.all(promisedLinks);
|
|
394
|
+
|
|
395
|
+
if (!quiet) {
|
|
396
|
+
if (toCreate.length) {
|
|
397
|
+
const created = toCreate.map(({ name }) => name);
|
|
398
|
+
console.log(`Created symlinks: ${created.join(", ")}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (existing.length) {
|
|
402
|
+
console.log(`Symlinks already exist: ${existing.join(", ")}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (skipped.length) {
|
|
406
|
+
console.log(`Skipped (already tracked in git): ${skipped.join(", ")}`);
|
|
407
|
+
}
|
|
125
408
|
}
|
|
126
409
|
}
|
|
127
410
|
|
|
@@ -131,6 +414,7 @@ async function handlePromptFile(
|
|
|
131
414
|
overlayPath: string,
|
|
132
415
|
gitRoot: string,
|
|
133
416
|
cwd: string,
|
|
417
|
+
quiet?: boolean,
|
|
134
418
|
): Promise<void> {
|
|
135
419
|
const promptRelPath = getPromptRelPath(normalizedPath);
|
|
136
420
|
if (promptRelPath) {
|
|
@@ -139,7 +423,7 @@ async function handlePromptFile(
|
|
|
139
423
|
promptRelPath,
|
|
140
424
|
gitRoot,
|
|
141
425
|
);
|
|
142
|
-
if (created.length) {
|
|
426
|
+
if (!quiet && created.length) {
|
|
143
427
|
console.log(
|
|
144
428
|
`Created symlinks: ${created.map((p) => relative(cwd, p)).join(", ")}`,
|
|
145
429
|
);
|
|
@@ -153,51 +437,50 @@ async function handleRegularFile(
|
|
|
153
437
|
overlayPath: string,
|
|
154
438
|
overlayRoot: string,
|
|
155
439
|
cwd: string,
|
|
440
|
+
quiet?: boolean,
|
|
156
441
|
): Promise<void> {
|
|
157
442
|
if (await isSymlinkToOverlay(normalizedPath, overlayRoot)) {
|
|
158
|
-
|
|
443
|
+
if (!quiet)
|
|
444
|
+
console.log(`Symlink already exists: ${relative(cwd, normalizedPath)}`);
|
|
159
445
|
} else {
|
|
160
446
|
const linkTarget = getLinkTarget(normalizedPath, overlayPath);
|
|
161
447
|
await createSymlink(linkTarget, normalizedPath);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function isDirectory(path: string): Promise<boolean> {
|
|
167
|
-
try {
|
|
168
|
-
return (await lstat(path)).isDirectory();
|
|
169
|
-
} catch {
|
|
170
|
-
return false;
|
|
448
|
+
if (!quiet)
|
|
449
|
+
console.log(`Created symlink: ${relative(cwd, normalizedPath)}`);
|
|
171
450
|
}
|
|
172
451
|
}
|
|
173
452
|
|
|
174
|
-
/**
|
|
175
|
-
async function
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
453
|
+
/** Read a single keypress for scope selection */
|
|
454
|
+
async function readScopeChoice(
|
|
455
|
+
isWorktree: boolean,
|
|
456
|
+
): Promise<InteractiveChoice> {
|
|
457
|
+
const key = await readSingleKey();
|
|
458
|
+
|
|
459
|
+
switch (key.toLowerCase()) {
|
|
460
|
+
case "p":
|
|
461
|
+
case "\r":
|
|
462
|
+
case "\n":
|
|
463
|
+
console.log("project");
|
|
464
|
+
return "project";
|
|
465
|
+
case "w":
|
|
466
|
+
if (!isWorktree) {
|
|
467
|
+
console.log("(not in worktree, using project)");
|
|
468
|
+
return "project";
|
|
469
|
+
}
|
|
470
|
+
console.log("worktree");
|
|
471
|
+
return "worktree";
|
|
472
|
+
case "g":
|
|
473
|
+
console.log("global");
|
|
474
|
+
return "global";
|
|
475
|
+
case "s":
|
|
476
|
+
console.log("skip");
|
|
477
|
+
return "skip";
|
|
478
|
+
case "q":
|
|
479
|
+
case "\x03": // Ctrl+C
|
|
480
|
+
return "quit";
|
|
481
|
+
default:
|
|
482
|
+
console.log("project");
|
|
483
|
+
return "project";
|
|
201
484
|
}
|
|
202
485
|
}
|
|
203
486
|
|
|
@@ -220,80 +503,6 @@ async function findSourceContent(
|
|
|
220
503
|
return "";
|
|
221
504
|
}
|
|
222
505
|
|
|
223
|
-
/** fail if we can't do an add with the given options */
|
|
224
|
-
async function validateAddOptions(
|
|
225
|
-
options: AddOptions,
|
|
226
|
-
overlayRoot: string,
|
|
227
|
-
gitContext: GitContext,
|
|
228
|
-
): Promise<void> {
|
|
229
|
-
await validateOverlayExists(overlayRoot);
|
|
230
|
-
|
|
231
|
-
if (options.worktree && !gitContext.isWorktree) {
|
|
232
|
-
throw new Error(
|
|
233
|
-
`--worktree scope requires a git worktree.\n` +
|
|
234
|
-
`You're on branch '${gitContext.worktreeName}' in the main repository.\n` +
|
|
235
|
-
`Use 'git worktree add' to create a worktree, or use --project scope instead.`,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
|
|
241
|
-
interface AgentLinkParams {
|
|
242
|
-
overlayPath: string;
|
|
243
|
-
symlinkDir: string;
|
|
244
|
-
gitRoot: string;
|
|
245
|
-
overlayRoot: string;
|
|
246
|
-
agents: string[];
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function createAgentLinks(p: AgentLinkParams): Promise<void> {
|
|
250
|
-
const { overlayPath, ...classifyParams } = p;
|
|
251
|
-
const { toCreate, existing, skipped } =
|
|
252
|
-
await classifyAgentLinks(classifyParams);
|
|
253
|
-
|
|
254
|
-
const promisedLinks = toCreate.map(({ targetPath }) => {
|
|
255
|
-
const linkTarget = getLinkTarget(targetPath, overlayPath);
|
|
256
|
-
return createSymlink(linkTarget, targetPath);
|
|
257
|
-
});
|
|
258
|
-
await Promise.all(promisedLinks);
|
|
259
|
-
|
|
260
|
-
if (toCreate.length) {
|
|
261
|
-
const created = toCreate.map(({ name }) => name);
|
|
262
|
-
console.log(`Created symlinks: ${created.join(", ")}`);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (existing.length) {
|
|
266
|
-
console.log(`Symlinks already exist: ${existing.join(", ")}`);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (skipped.length) {
|
|
270
|
-
console.log(`Skipped (already tracked in git): ${skipped.join(", ")}`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Check if file is already in overlay at a different scope, throw helpful error */
|
|
275
|
-
async function checkScopeConflict(
|
|
276
|
-
barePath: string,
|
|
277
|
-
requestedScope: Scope,
|
|
278
|
-
context: MapperContext,
|
|
279
|
-
cwd: string,
|
|
280
|
-
): Promise<void> {
|
|
281
|
-
const currentScope = await scopeFromSymlink(barePath, context);
|
|
282
|
-
if (currentScope && currentScope !== requestedScope) {
|
|
283
|
-
const fileName = relative(cwd, barePath);
|
|
284
|
-
throw new Error(
|
|
285
|
-
`${fileName} is already in ${scopeLabels[currentScope]} overlay.\n` +
|
|
286
|
-
`To move it to ${scopeLabels[requestedScope]} scope, use: clank mv ${fileName} --${requestedScope}`,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
interface AgentLinkClassification {
|
|
292
|
-
toCreate: { targetPath: string; name: string }[];
|
|
293
|
-
existing: string[];
|
|
294
|
-
skipped: string[];
|
|
295
|
-
}
|
|
296
|
-
|
|
297
506
|
/** Classify which agent symlinks to create vs skip */
|
|
298
507
|
async function classifyAgentLinks(
|
|
299
508
|
p: Omit<AgentLinkParams, "overlayPath">,
|
|
@@ -324,3 +533,26 @@ async function classifyAgentLinks(
|
|
|
324
533
|
|
|
325
534
|
return { toCreate, existing, skipped };
|
|
326
535
|
}
|
|
536
|
+
|
|
537
|
+
/** Read a single keypress from stdin (raw mode) */
|
|
538
|
+
function readSingleKey(): Promise<string> {
|
|
539
|
+
return new Promise((resolve) => {
|
|
540
|
+
const wasRaw = process.stdin.isRaw;
|
|
541
|
+
if (process.stdin.isTTY) {
|
|
542
|
+
readline.emitKeypressEvents(process.stdin);
|
|
543
|
+
process.stdin.setRawMode(true);
|
|
544
|
+
}
|
|
545
|
+
process.stdin.resume();
|
|
546
|
+
|
|
547
|
+
const onKeypress = (data: Buffer): void => {
|
|
548
|
+
if (process.stdin.isTTY) {
|
|
549
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
550
|
+
}
|
|
551
|
+
process.stdin.pause();
|
|
552
|
+
process.stdin.removeListener("data", onKeypress);
|
|
553
|
+
resolve(data.toString());
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
process.stdin.once("data", onKeypress);
|
|
557
|
+
});
|
|
558
|
+
}
|