clank-cli 0.1.67 → 0.1.74

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.
@@ -8,14 +8,16 @@ import {
8
8
  import { basename, dirname, join, relative } from "node:path";
9
9
  import * as readline from "node:readline";
10
10
  import { forEachAgentPath } from "../AgentFiles.ts";
11
+ import { classifyAgentFiles } from "../ClassifyFiles.ts";
11
12
  import { expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
12
13
  import {
13
14
  createSymlink,
14
15
  ensureDir,
15
16
  fileExists,
17
+ getCwd,
16
18
  getLinkTarget,
17
19
  isSymlink,
18
- isTrackedByGit,
20
+ isTrackedRealFile,
19
21
  walkDirectory,
20
22
  } from "../FsUtil.ts";
21
23
  import { type GitContext, getGitContext } from "../Git.ts";
@@ -46,7 +48,6 @@ interface AddContext {
46
48
  overlayRoot: string;
47
49
  }
48
50
 
49
- /** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
50
51
  interface AgentLinkParams {
51
52
  overlayPath: string;
52
53
  symlinkDir: string;
@@ -70,6 +71,17 @@ type ScopeCounts = {
70
71
  skip: number;
71
72
  };
72
73
 
74
+ interface CreateSymlinksParams {
75
+ filePath: string;
76
+ normalizedPath: string;
77
+ overlayPath: string;
78
+ config: { agents: string[] };
79
+ gitRoot: string;
80
+ overlayRoot: string;
81
+ cwd: string;
82
+ quiet?: boolean;
83
+ }
84
+
73
85
  const scopeLabels: Record<Scope, string> = {
74
86
  global: "global",
75
87
  project: "project",
@@ -81,7 +93,7 @@ export async function addCommand(
81
93
  filePaths: string[],
82
94
  options: AddOptions = {},
83
95
  ): Promise<void> {
84
- const cwd = process.cwd();
96
+ const cwd = await getCwd();
85
97
  const gitContext = await getGitContext(cwd);
86
98
  const config = await loadConfig();
87
99
  const overlayRoot = expandPath(config.overlayRepo);
@@ -90,17 +102,11 @@ export async function addCommand(
90
102
 
91
103
  const ctx = { cwd, gitContext, config, overlayRoot };
92
104
 
93
- if (options.interactive) {
105
+ if (options.interactive || filePaths.length === 0) {
94
106
  await addAllInteractive(ctx);
95
107
  return;
96
108
  }
97
109
 
98
- if (filePaths.length === 0) {
99
- throw new Error(
100
- "No files specified. Use --interactive for interactive mode.",
101
- );
102
- }
103
-
104
110
  for (const filePath of filePaths) {
105
111
  const inputPath = join(cwd, filePath);
106
112
 
@@ -135,28 +141,40 @@ async function validateAddOptions(
135
141
  /** Interactive mode: add all unadded files with per-file scope selection */
136
142
  async function addAllInteractive(ctx: AddContext): Promise<void> {
137
143
  const { cwd, gitContext, overlayRoot } = ctx;
138
- const context: MapperContext = {
139
- overlayRoot,
140
- targetRoot: gitContext.gitRoot,
141
- gitContext,
142
- };
144
+ const { gitRoot: targetRoot } = gitContext;
145
+ const context: MapperContext = { overlayRoot, targetRoot, gitContext };
143
146
 
144
147
  const unadded = await findUnaddedFiles(context);
145
148
  const regularFiles = unadded.filter((f) => f.kind === "unadded");
146
149
 
147
- if (regularFiles.length === 0) {
150
+ // Also find untracked agent files outside managed dirs (e.g. packages/foo/CLAUDE.md)
151
+ const agentClassification = await classifyAgentFiles(
152
+ targetRoot,
153
+ overlayRoot,
154
+ gitContext,
155
+ );
156
+ const regularPaths = new Set(regularFiles.map((f) => f.targetPath));
157
+ const extraAgentPaths = agentClassification.untracked.filter(
158
+ (p) => !regularPaths.has(p),
159
+ );
160
+
161
+ const allPaths = [
162
+ ...regularFiles.map((f) => f.targetPath),
163
+ ...extraAgentPaths,
164
+ ];
165
+
166
+ if (allPaths.length === 0) {
148
167
  console.log("No unadded files found.");
149
168
  return;
150
169
  }
151
170
 
152
- console.log(`Found ${regularFiles.length} unadded file(s):\n`);
171
+ console.log(`Found ${allPaths.length} unadded file(s):\n`);
153
172
 
154
173
  const counts: ScopeCounts = { project: 0, worktree: 0, global: 0, skip: 0 };
155
174
 
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);
175
+ for (let i = 0; i < allPaths.length; i++) {
176
+ const relPath = relative(cwd, allPaths[i]);
177
+ const result = await promptAndAddFile(relPath, i, allPaths.length, ctx);
160
178
  if (result === "quit") break;
161
179
  if (result !== "error") counts[result]++;
162
180
  }
@@ -183,7 +201,6 @@ async function addSingleFile(
183
201
  const { quiet } = options;
184
202
 
185
203
  const scope = resolveScopeFromOptions(options);
186
- /** Absolute path where symlink will be created in target repo */
187
204
  const normalizedPath = normalizeAddPath(filePath, cwd, gitRoot);
188
205
  const context: MapperContext = {
189
206
  overlayRoot,
@@ -199,7 +216,6 @@ async function addSingleFile(
199
216
  // Only check barePath for symlink - we want the user's input file, not clank/foo.md
200
217
  const barePath = join(cwd, filePath);
201
218
 
202
- // Check if already in overlay at a different scope
203
219
  await checkScopeConflict(barePath, scope, context, cwd);
204
220
 
205
221
  if (await fileExists(overlayPath)) {
@@ -229,53 +245,6 @@ async function addSingleFile(
229
245
  });
230
246
  }
231
247
 
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
-
256
- if (isAgentFile(filePath)) {
257
- const symlinkDir = dirname(normalizedPath);
258
- await createAgentLinks({
259
- overlayPath,
260
- symlinkDir,
261
- gitRoot,
262
- overlayRoot,
263
- agents: config.agents,
264
- quiet,
265
- });
266
- } else if (isPromptFile(normalizedPath)) {
267
- await handlePromptFile(normalizedPath, overlayPath, gitRoot, cwd, quiet);
268
- } else {
269
- await handleRegularFile(
270
- normalizedPath,
271
- overlayPath,
272
- overlayRoot,
273
- cwd,
274
- quiet,
275
- );
276
- }
277
- }
278
-
279
248
  /** Prompt for scope and add a single file. Returns the choice or "error". */
280
249
  async function promptAndAddFile(
281
250
  relPath: string,
@@ -315,7 +284,7 @@ async function promptAndAddFile(
315
284
  }
316
285
 
317
286
  /** Print summary of interactive add results */
318
- function printSummary(counts: Record<string, number>): void {
287
+ function printSummary(counts: ScopeCounts): void {
319
288
  const parts: string[] = [];
320
289
  if (counts.project > 0) parts.push(`${counts.project} to project`);
321
290
  if (counts.worktree > 0) parts.push(`${counts.worktree} to worktree`);
@@ -381,6 +350,87 @@ async function addFileToOverlay(
381
350
  }
382
351
  }
383
352
 
353
+ /** Create symlinks based on file type (agent, prompt, or regular) */
354
+ async function createSymlinksForFile(params: CreateSymlinksParams) {
355
+ const { filePath, normalizedPath, overlayPath, config } = params;
356
+ const { gitRoot, overlayRoot, cwd, quiet } = params;
357
+
358
+ if (isAgentFile(filePath)) {
359
+ const symlinkDir = dirname(normalizedPath);
360
+ await createAgentLinks({
361
+ overlayPath,
362
+ symlinkDir,
363
+ gitRoot,
364
+ overlayRoot,
365
+ agents: config.agents,
366
+ quiet,
367
+ });
368
+ } else if (isPromptFile(normalizedPath)) {
369
+ await handlePromptFile(normalizedPath, overlayPath, gitRoot, cwd, quiet);
370
+ } else {
371
+ await handleRegularFile(
372
+ normalizedPath,
373
+ overlayPath,
374
+ overlayRoot,
375
+ cwd,
376
+ quiet,
377
+ );
378
+ }
379
+ }
380
+
381
+ /** Read a single keypress for scope selection */
382
+ async function readScopeChoice(
383
+ isWorktree: boolean,
384
+ ): Promise<InteractiveChoice> {
385
+ const key = await readSingleKey();
386
+
387
+ switch (key.toLowerCase()) {
388
+ case "p":
389
+ case "\r":
390
+ case "\n":
391
+ console.log("project");
392
+ return "project";
393
+ case "w":
394
+ if (!isWorktree) {
395
+ console.log("(not in worktree, using project)");
396
+ return "project";
397
+ }
398
+ console.log("worktree");
399
+ return "worktree";
400
+ case "g":
401
+ console.log("global");
402
+ return "global";
403
+ case "s":
404
+ console.log("skip");
405
+ return "skip";
406
+ case "q":
407
+ case "\x03": // Ctrl+C
408
+ return "quit";
409
+ default:
410
+ console.log("project");
411
+ return "project";
412
+ }
413
+ }
414
+
415
+ /** Find content from normalized path or bare input path */
416
+ async function findSourceContent(
417
+ normalizedPath: string,
418
+ barePath: string,
419
+ ): Promise<string> {
420
+ // Try normalized path first (e.g., cwd/clank/foo.md)
421
+ if (
422
+ (await fileExists(normalizedPath)) &&
423
+ !(await isSymlink(normalizedPath))
424
+ ) {
425
+ return await readFile(normalizedPath, "utf-8");
426
+ }
427
+ // Fall back to bare input path (e.g., cwd/foo.md)
428
+ if ((await fileExists(barePath)) && !(await isSymlink(barePath))) {
429
+ return await readFile(barePath, "utf-8");
430
+ }
431
+ return "";
432
+ }
433
+
384
434
  async function createAgentLinks(p: AgentLinkParams): Promise<void> {
385
435
  const { overlayPath, quiet, ...classifyParams } = p;
386
436
  const { toCreate, existing, skipped } =
@@ -424,9 +474,8 @@ async function handlePromptFile(
424
474
  gitRoot,
425
475
  );
426
476
  if (!quiet && created.length) {
427
- console.log(
428
- `Created symlinks: ${created.map((p) => relative(cwd, p)).join(", ")}`,
429
- );
477
+ const names = created.map((p) => relative(cwd, p)).join(", ");
478
+ console.log(`Created symlinks: ${names}`);
430
479
  }
431
480
  }
432
481
  }
@@ -450,57 +499,27 @@ async function handleRegularFile(
450
499
  }
451
500
  }
452
501
 
453
- /** Read a single keypress for scope selection */
454
- async function readScopeChoice(
455
- isWorktree: boolean,
456
- ): Promise<InteractiveChoice> {
457
- const key = await readSingleKey();
502
+ /** Read a single keypress from stdin (raw mode) */
503
+ function readSingleKey(): Promise<string> {
504
+ return new Promise((resolve) => {
505
+ const wasRaw = process.stdin.isRaw;
506
+ if (process.stdin.isTTY) {
507
+ readline.emitKeypressEvents(process.stdin);
508
+ process.stdin.setRawMode(true);
509
+ }
510
+ process.stdin.resume();
458
511
 
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";
512
+ const onKeypress = (data: Buffer): void => {
513
+ if (process.stdin.isTTY) {
514
+ process.stdin.setRawMode(wasRaw ?? false);
469
515
  }
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";
484
- }
485
- }
516
+ process.stdin.pause();
517
+ process.stdin.removeListener("data", onKeypress);
518
+ resolve(data.toString());
519
+ };
486
520
 
487
- /** Find content from normalized path or bare input path */
488
- async function findSourceContent(
489
- normalizedPath: string,
490
- barePath: string,
491
- ): Promise<string> {
492
- // Try normalized path first (e.g., cwd/clank/foo.md)
493
- if (
494
- (await fileExists(normalizedPath)) &&
495
- !(await isSymlink(normalizedPath))
496
- ) {
497
- return await readFile(normalizedPath, "utf-8");
498
- }
499
- // Fall back to bare input path (e.g., cwd/foo.md)
500
- if ((await fileExists(barePath)) && !(await isSymlink(barePath))) {
501
- return await readFile(barePath, "utf-8");
502
- }
503
- return "";
521
+ process.stdin.once("data", onKeypress);
522
+ });
504
523
  }
505
524
 
506
525
  /** Classify which agent symlinks to create vs skip */
@@ -513,18 +532,12 @@ async function classifyAgentLinks(
513
532
  const toCreate: { targetPath: string; name: string }[] = [];
514
533
 
515
534
  await forEachAgentPath(symlinkDir, agents, async (targetPath) => {
516
- // Check if symlink already points to overlay
517
535
  if (await isSymlinkToOverlay(targetPath, overlayRoot)) {
518
536
  existing.push(basename(targetPath));
519
537
  return;
520
538
  }
521
539
 
522
- const isTrackedFile =
523
- (await fileExists(targetPath)) &&
524
- !(await isSymlink(targetPath)) &&
525
- (await isTrackedByGit(targetPath, gitRoot));
526
-
527
- if (isTrackedFile) {
540
+ if (await isTrackedRealFile(targetPath, gitRoot)) {
528
541
  skipped.push(basename(targetPath));
529
542
  } else {
530
543
  toCreate.push({ targetPath, name: basename(targetPath) });
@@ -533,26 +546,3 @@ async function classifyAgentLinks(
533
546
 
534
547
  return { toCreate, existing, skipped };
535
548
  }
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
- }