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.
@@ -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
- interface AddContext {
72
- cwd: string;
73
- gitContext: GitContext;
74
- config: { overlayRepo: string; agents: string[] };
75
- overlayRoot: string;
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
- console.log(`${fileName} already exists in ${scopeLabel} overlay`);
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(normalizedPath, barePath, overlayPath, scopeLabel);
211
+ await addFileToOverlay(
212
+ normalizedPath,
213
+ barePath,
214
+ overlayPath,
215
+ scopeLabel,
216
+ quiet,
217
+ );
113
218
  }
114
219
 
115
- // Check if this is an agent file (CLAUDE.md, AGENTS.md, GEMINI.md)
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
- const { agents } = config;
119
- const params = { overlayPath, symlinkDir, gitRoot, overlayRoot, agents };
120
- await createAgentLinks(params);
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(normalizedPath, overlayPath, overlayRoot, cwd);
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
- console.log(`Symlink already exists: ${relative(cwd, normalizedPath)}`);
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
- console.log(`Created symlink: ${relative(cwd, normalizedPath)}`);
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
- /** Copy a symlink to the overlay, preserving its target */
175
- async function addSymlinkToOverlay(
176
- inputPath: string,
177
- overlayPath: string,
178
- scopeLabel: string,
179
- ): Promise<void> {
180
- const target = await readlink(inputPath);
181
- await ensureDir(dirname(overlayPath));
182
- await symlink(target, overlayPath);
183
- console.log(`Copied symlink ${basename(inputPath)} to ${scopeLabel} overlay`);
184
- }
185
-
186
- /** Copy file content to overlay */
187
- async function addFileToOverlay(
188
- normalizedPath: string,
189
- barePath: string,
190
- overlayPath: string,
191
- scopeLabel: string,
192
- ): Promise<void> {
193
- await ensureDir(dirname(overlayPath));
194
- const content = await findSourceContent(normalizedPath, barePath);
195
- await writeFile(overlayPath, content, "utf-8");
196
- const fileName = basename(overlayPath);
197
- if (content) {
198
- console.log(`Copied ${fileName} to ${scopeLabel} overlay`);
199
- } else {
200
- console.log(`Created empty ${fileName} in ${scopeLabel} overlay`);
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
+ }