clank-cli 0.1.62 → 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 CHANGED
@@ -84,6 +84,9 @@ clank link ~/my-project # Link to specific project
84
84
 
85
85
  Move a file to the overlay and replace it with a symlink. If the file doesn't exist, an empty file is created. Accepts [scope options](#scope-options).
86
86
 
87
+ **Options:**
88
+ - `-i, --interactive` - Interactively add all unadded files, prompting for scope on each
89
+
87
90
  **Examples:**
88
91
  ```bash
89
92
  clank add style.md # Project scope (default)
@@ -92,6 +95,24 @@ clank add notes.md --worktree # Worktree scope
92
95
  clank add .claude/commands/review.md --global # Global command
93
96
  clank add .claude/commands/build.md # Project command (default)
94
97
  clank add CLAUDE.md # Creates agents.md + agent symlinks
98
+ clank add -i # Interactive mode for all unadded files
99
+ ```
100
+
101
+ **Interactive mode:**
102
+
103
+ Running `clank add --interactive` finds all unadded files and prompts for each:
104
+
105
+ ```
106
+ Found 3 unadded file(s):
107
+
108
+ [1/3] clank/notes.md
109
+ (P)roject (W)orktree (G)lobal (S)kip (Q)uit [P]:
110
+ ```
111
+
112
+ Press a single key to select the scope (default: Project). The summary shows what was added:
113
+
114
+ ```
115
+ Added 2 to project, 1 skipped
95
116
  ```
96
117
 
97
118
  ### `clank unlink [target]`
@@ -173,10 +194,13 @@ clank rm style.md --global # Remove global style guide
173
194
 
174
195
  ### `clank mv <files...>` (alias: `move`)
175
196
 
176
- Move file(s) between overlay scopes. Requires one [scope option](#scope-options) to specify the destination.
197
+ Move or rename file(s) in the overlay. With a [scope option](#scope-options), moves files to that scope. With two arguments and no scope, renames within the current scope.
177
198
 
178
- **Example:**
199
+ **Examples:**
179
200
  ```bash
201
+ # Rename a file (keeps same scope)
202
+ clank mv batch-dry-check.md batch-dry.md
203
+
180
204
  # Promote a worktree note to project scope
181
205
  clank mv clank/notes.md --project
182
206
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clank-cli",
3
3
  "description": "Keep AI files in a separate overlay repository",
4
- "version": "0.1.62",
4
+ "version": "0.1.65",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
@@ -46,10 +46,11 @@
46
46
  },
47
47
  "scripts": {
48
48
  "bump": "monobump",
49
- "fix": "biome check --fix --unsafe",
49
+ "fix": "biome check --fix --unsafe --error-on-warnings",
50
50
  "fix:pkgJsonFormat": "syncpack format",
51
51
  "global": "pnpm link --global",
52
52
  "lint": "biome check --error-on-warnings",
53
+ "prepush": "run-s fix typecheck fix:pkgJsonFormat test:once",
53
54
  "test": "vitest",
54
55
  "test:once": "vitest run",
55
56
  "test:ui": "vitest --ui",
package/src/AgentFiles.ts CHANGED
@@ -32,8 +32,7 @@ export async function forEachAgentPath(
32
32
  ): Promise<void> {
33
33
  const agentPaths = getAgentFilePaths(dir);
34
34
  for (const agent of agents) {
35
- const key = agent.toLowerCase();
36
- const agentPath = agentPaths[key];
35
+ const agentPath = agentPaths[agent.toLowerCase()];
37
36
  if (!agentPath) continue;
38
37
  await fn(agentPath, agent);
39
38
  }
@@ -61,14 +61,10 @@ export async function classifyAgentFiles(
61
61
  }
62
62
 
63
63
  /** @return true if classification has any problems */
64
- export function agentFileProblems(
65
- classification: AgentFileClassification,
66
- ): boolean {
67
- return (
68
- classification.tracked.length > 0 ||
69
- classification.untracked.length > 0 ||
70
- classification.staleSymlinks.length > 0 ||
71
- classification.outdatedSymlinks.length > 0
64
+ export function agentFileProblems(c: AgentFileClassification): boolean {
65
+ const { tracked, untracked, staleSymlinks, outdatedSymlinks } = c;
66
+ return [tracked, untracked, staleSymlinks, outdatedSymlinks].some(
67
+ (a) => a.length > 0,
72
68
  );
73
69
  }
74
70
 
@@ -110,10 +106,12 @@ ${commands.join("\n")}`);
110
106
  }
111
107
 
112
108
  if (classified.outdatedSymlinks.length > 0) {
113
- const details = classified.outdatedSymlinks.map((s) => {
114
- const symlinkRel = rel(s.symlinkPath);
115
- return ` ${symlinkRel}\n points to: ${s.currentTarget}\n expected: ${s.expectedTarget}`;
116
- });
109
+ const details = classified.outdatedSymlinks.map(
110
+ (s) =>
111
+ ` ${rel(s.symlinkPath)}\n` +
112
+ ` points to: ${s.currentTarget}\n` +
113
+ ` expected: ${s.expectedTarget}`,
114
+ );
117
115
  sections.push(`Found outdated agent symlinks (pointing to wrong overlay path).
118
116
 
119
117
  This typically happens after a directory rename. Remove symlinks and run \`clank link\`:
@@ -192,15 +190,12 @@ async function classifyAgentSymlink(
192
190
  const agentsMdPath = join(dirname(filePath), "agents.md");
193
191
  const expectedTarget = targetToOverlay(agentsMdPath, "project", mapperCtx);
194
192
  if (absoluteTarget !== expectedTarget) {
195
- return {
196
- outdatedSymlinks: [
197
- {
198
- symlinkPath: filePath,
199
- currentTarget: absoluteTarget,
200
- expectedTarget,
201
- },
202
- ],
193
+ const symlink = {
194
+ symlinkPath: filePath,
195
+ currentTarget: absoluteTarget,
196
+ expectedTarget,
203
197
  };
198
+ return { outdatedSymlinks: [symlink] };
204
199
  }
205
200
  }
206
201
 
package/src/Cli.ts CHANGED
@@ -133,9 +133,10 @@ function registerOverlayCommands(program: Command): void {
133
133
  .command("add")
134
134
  .description("Add file(s) to overlay and create symlinks")
135
135
  .argument(
136
- "<files...>",
136
+ "[files...]",
137
137
  "File path(s) (e.g., style.md, .claude/commands/review.md)",
138
138
  )
139
+ .option("-i, --interactive", "Interactively add all unadded files")
139
140
  .option("-g, --global", "Add to global location (all projects)")
140
141
  .option("-p, --project", "Add to project location (default)")
141
142
  .option("-w, --worktree", "Add to worktree location (this branch only)")
@@ -202,7 +203,7 @@ function registerMvCommand(program: Command): void {
202
203
  const cmd = program
203
204
  .command("mv")
204
205
  .alias("move")
205
- .description("Move file(s) between overlay scopes")
206
+ .description("Move or rename file(s) in overlay")
206
207
  .argument("<files...>", "File(s) to move");
207
208
 
208
209
  cmd.addOption(
package/src/Gitignore.ts CHANGED
@@ -99,24 +99,16 @@ export function isClankPattern(glob: string): boolean {
99
99
  const normalized = glob.replace(/^\*\*\//, "").replace(/\/$/, "");
100
100
 
101
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
- }
102
+ const matchesDir = (dir: string) =>
103
+ normalized === dir ||
104
+ normalized.startsWith(`${dir}/`) ||
105
+ normalized.endsWith(`/${dir}`);
106
+ if (targetManagedDirs.some(matchesDir)) return true;
111
107
 
112
108
  // 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;
109
+ const matchesAgent = (f: string) =>
110
+ normalized === f || normalized.endsWith(`/${f}`);
111
+ return agentFiles.some(matchesAgent);
120
112
  }
121
113
 
122
114
  /** Convert collected patterns to VS Code exclude globs, filtering clank patterns */
@@ -143,14 +135,11 @@ export function deduplicateGlobs(globs: string[]): string[] {
143
135
  const coveredSuffixes = new Set(universal.map((g) => g.slice(3)));
144
136
 
145
137
  // 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
- });
138
+ const isCovered = (glob: string) =>
139
+ [...coveredSuffixes].some(
140
+ (s) => glob.endsWith(`/**/${s}`) || glob.endsWith(`/${s}`),
141
+ );
142
+ const uncovered = specific.filter((glob) => !isCovered(glob));
154
143
 
155
144
  return [...universal, ...uncovered];
156
145
  }
package/src/OverlayGit.ts CHANGED
@@ -54,11 +54,7 @@ function filterIgnoredLines(
54
54
  if (isIgnored(filePath) || isIgnored(pathBasename)) return false;
55
55
 
56
56
  // Check each directory segment (for patterns like ".obsidian")
57
- for (const segment of segments) {
58
- if (isIgnored(segment)) return false;
59
- }
60
-
61
- return true;
57
+ return !segments.some((segment) => isIgnored(segment));
62
58
  });
63
59
  }
64
60
 
@@ -81,10 +77,8 @@ function parseScopedPath(filePath: string): {
81
77
  Math.max(0, afterWorktrees.length - 1),
82
78
  );
83
79
  const branch = afterWorktrees.slice(0, branchSegments).join("/");
84
- return {
85
- scope: `${project}/${branch}`,
86
- pathParts: afterWorktrees.slice(branchSegments),
87
- };
80
+ const scope = `${project}/${branch}`;
81
+ return { scope, pathParts: afterWorktrees.slice(branchSegments) };
88
82
  }
89
83
  return { scope: project || "unknown", pathParts: segments.slice(2) };
90
84
  }
@@ -101,14 +101,10 @@ export async function* walkOverlayFiles(
101
101
  ignorePatterns.length > 0 ? picomatch(ignorePatterns) : null;
102
102
 
103
103
  const skip = (relPath: string): boolean => {
104
- // Skip templates
105
- if (relPath.startsWith("clank/init/")) return true;
106
- // Check ignore patterns against relative path and basename
107
- if (isIgnored) {
108
- const basename = relPath.split("/").at(-1) ?? "";
109
- if (isIgnored(relPath) || isIgnored(basename)) return true;
110
- }
111
- return false;
104
+ if (relPath.startsWith("clank/init/")) return true; // Skip templates
105
+ if (!isIgnored) return false;
106
+ const basename = relPath.split("/").at(-1) ?? "";
107
+ return isIgnored(relPath) || isIgnored(basename);
112
108
  };
113
109
 
114
110
  const genEntries = walkDirectory(overlayRoot, { skip });
@@ -140,7 +136,6 @@ function isMatchingPromptPath(
140
136
  canonicalPath: string,
141
137
  actualPath: string,
142
138
  ): boolean {
143
- const canonicalPrompt = getPromptRelPath(canonicalPath);
144
- const actualPrompt = getPromptRelPath(actualPath);
145
- return canonicalPrompt !== null && canonicalPrompt === actualPrompt;
139
+ const canonical = getPromptRelPath(canonicalPath);
140
+ return canonical !== null && canonical === getPromptRelPath(actualPath);
146
141
  }
@@ -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,8 +32,12 @@ 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";
34
36
 
35
- export type AddOptions = ScopeOptions;
37
+ export type AddOptions = ScopeOptions & {
38
+ interactive?: boolean;
39
+ quiet?: boolean;
40
+ };
36
41
 
37
42
  interface AddContext {
38
43
  cwd: string;
@@ -48,6 +53,7 @@ interface AgentLinkParams {
48
53
  gitRoot: string;
49
54
  overlayRoot: string;
50
55
  agents: string[];
56
+ quiet?: boolean;
51
57
  }
52
58
 
53
59
  interface AgentLinkClassification {
@@ -56,6 +62,14 @@ interface AgentLinkClassification {
56
62
  skipped: string[];
57
63
  }
58
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
+ };
72
+
59
73
  const scopeLabels: Record<Scope, string> = {
60
74
  global: "global",
61
75
  project: "project",
@@ -76,6 +90,17 @@ export async function addCommand(
76
90
 
77
91
  const ctx = { cwd, gitContext, config, overlayRoot };
78
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
+
79
104
  for (const filePath of filePaths) {
80
105
  const inputPath = join(cwd, filePath);
81
106
 
@@ -107,6 +132,38 @@ async function validateAddOptions(
107
132
  }
108
133
  }
109
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
+
110
167
  async function isDirectory(path: string): Promise<boolean> {
111
168
  try {
112
169
  return (await lstat(path)).isDirectory();
@@ -123,6 +180,7 @@ async function addSingleFile(
123
180
  ): Promise<void> {
124
181
  const { cwd, gitContext, config, overlayRoot } = ctx;
125
182
  const { gitRoot } = gitContext;
183
+ const { quiet } = options;
126
184
 
127
185
  const scope = resolveScopeFromOptions(options);
128
186
  /** Absolute path where symlink will be created in target repo */
@@ -145,23 +203,127 @@ async function addSingleFile(
145
203
  await checkScopeConflict(barePath, scope, context, cwd);
146
204
 
147
205
  if (await fileExists(overlayPath)) {
148
- console.log(`${fileName} already exists in ${scopeLabel} overlay`);
206
+ if (!quiet)
207
+ console.log(`${fileName} already exists in ${scopeLabel} overlay`);
149
208
  } else if (await isSymlink(barePath)) {
150
- await addSymlinkToOverlay(barePath, overlayPath, scopeLabel);
209
+ await addSymlinkToOverlay(barePath, overlayPath, scopeLabel, quiet);
151
210
  } else {
152
- await addFileToOverlay(normalizedPath, barePath, overlayPath, scopeLabel);
211
+ await addFileToOverlay(
212
+ normalizedPath,
213
+ barePath,
214
+ overlayPath,
215
+ scopeLabel,
216
+ quiet,
217
+ );
153
218
  }
154
219
 
155
- // 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
+
156
256
  if (isAgentFile(filePath)) {
157
257
  const symlinkDir = dirname(normalizedPath);
158
- const { agents } = config;
159
- const params = { overlayPath, symlinkDir, gitRoot, overlayRoot, agents };
160
- await createAgentLinks(params);
258
+ await createAgentLinks({
259
+ overlayPath,
260
+ symlinkDir,
261
+ gitRoot,
262
+ overlayRoot,
263
+ agents: config.agents,
264
+ quiet,
265
+ });
161
266
  } else if (isPromptFile(normalizedPath)) {
162
- await handlePromptFile(normalizedPath, overlayPath, gitRoot, cwd);
267
+ await handlePromptFile(normalizedPath, overlayPath, gitRoot, cwd, quiet);
163
268
  } else {
164
- 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(", ")}`);
165
327
  }
166
328
  }
167
329
 
@@ -187,11 +349,15 @@ async function addSymlinkToOverlay(
187
349
  inputPath: string,
188
350
  overlayPath: string,
189
351
  scopeLabel: string,
352
+ quiet?: boolean,
190
353
  ): Promise<void> {
191
354
  const target = await readlink(inputPath);
192
355
  await ensureDir(dirname(overlayPath));
193
356
  await symlink(target, overlayPath);
194
- console.log(`Copied symlink ${basename(inputPath)} to ${scopeLabel} overlay`);
357
+ if (!quiet)
358
+ console.log(
359
+ `Copied symlink ${basename(inputPath)} to ${scopeLabel} overlay`,
360
+ );
195
361
  }
196
362
 
197
363
  /** Copy file content to overlay */
@@ -200,20 +366,23 @@ async function addFileToOverlay(
200
366
  barePath: string,
201
367
  overlayPath: string,
202
368
  scopeLabel: string,
369
+ quiet?: boolean,
203
370
  ): Promise<void> {
204
371
  await ensureDir(dirname(overlayPath));
205
372
  const content = await findSourceContent(normalizedPath, barePath);
206
373
  await writeFile(overlayPath, content, "utf-8");
207
- const fileName = basename(overlayPath);
208
- if (content) {
209
- console.log(`Copied ${fileName} to ${scopeLabel} overlay`);
210
- } else {
211
- console.log(`Created empty ${fileName} in ${scopeLabel} overlay`);
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
+ }
212
381
  }
213
382
  }
214
383
 
215
384
  async function createAgentLinks(p: AgentLinkParams): Promise<void> {
216
- const { overlayPath, ...classifyParams } = p;
385
+ const { overlayPath, quiet, ...classifyParams } = p;
217
386
  const { toCreate, existing, skipped } =
218
387
  await classifyAgentLinks(classifyParams);
219
388
 
@@ -223,17 +392,19 @@ async function createAgentLinks(p: AgentLinkParams): Promise<void> {
223
392
  });
224
393
  await Promise.all(promisedLinks);
225
394
 
226
- if (toCreate.length) {
227
- const created = toCreate.map(({ name }) => name);
228
- console.log(`Created symlinks: ${created.join(", ")}`);
229
- }
395
+ if (!quiet) {
396
+ if (toCreate.length) {
397
+ const created = toCreate.map(({ name }) => name);
398
+ console.log(`Created symlinks: ${created.join(", ")}`);
399
+ }
230
400
 
231
- if (existing.length) {
232
- console.log(`Symlinks already exist: ${existing.join(", ")}`);
233
- }
401
+ if (existing.length) {
402
+ console.log(`Symlinks already exist: ${existing.join(", ")}`);
403
+ }
234
404
 
235
- if (skipped.length) {
236
- console.log(`Skipped (already tracked in git): ${skipped.join(", ")}`);
405
+ if (skipped.length) {
406
+ console.log(`Skipped (already tracked in git): ${skipped.join(", ")}`);
407
+ }
237
408
  }
238
409
  }
239
410
 
@@ -243,6 +414,7 @@ async function handlePromptFile(
243
414
  overlayPath: string,
244
415
  gitRoot: string,
245
416
  cwd: string,
417
+ quiet?: boolean,
246
418
  ): Promise<void> {
247
419
  const promptRelPath = getPromptRelPath(normalizedPath);
248
420
  if (promptRelPath) {
@@ -251,7 +423,7 @@ async function handlePromptFile(
251
423
  promptRelPath,
252
424
  gitRoot,
253
425
  );
254
- if (created.length) {
426
+ if (!quiet && created.length) {
255
427
  console.log(
256
428
  `Created symlinks: ${created.map((p) => relative(cwd, p)).join(", ")}`,
257
429
  );
@@ -265,13 +437,50 @@ async function handleRegularFile(
265
437
  overlayPath: string,
266
438
  overlayRoot: string,
267
439
  cwd: string,
440
+ quiet?: boolean,
268
441
  ): Promise<void> {
269
442
  if (await isSymlinkToOverlay(normalizedPath, overlayRoot)) {
270
- console.log(`Symlink already exists: ${relative(cwd, normalizedPath)}`);
443
+ if (!quiet)
444
+ console.log(`Symlink already exists: ${relative(cwd, normalizedPath)}`);
271
445
  } else {
272
446
  const linkTarget = getLinkTarget(normalizedPath, overlayPath);
273
447
  await createSymlink(linkTarget, normalizedPath);
274
- console.log(`Created symlink: ${relative(cwd, normalizedPath)}`);
448
+ if (!quiet)
449
+ console.log(`Created symlink: ${relative(cwd, normalizedPath)}`);
450
+ }
451
+ }
452
+
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";
275
484
  }
276
485
  }
277
486
 
@@ -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
+ }
@@ -2,7 +2,12 @@ import { rename, unlink } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
3
  import { forEachAgentPath, managedAgentDirs } from "../AgentFiles.ts";
4
4
  import { expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
5
- import { createSymlink, ensureDir, getLinkTarget } from "../FsUtil.ts";
5
+ import {
6
+ createSymlink,
7
+ ensureDir,
8
+ fileExists,
9
+ getLinkTarget,
10
+ } from "../FsUtil.ts";
6
11
  import { getGitContext } from "../Git.ts";
7
12
  import {
8
13
  getPromptRelPath,
@@ -26,12 +31,12 @@ interface MoveContext {
26
31
  agents: string[];
27
32
  }
28
33
 
29
- /** Move file(s) between overlay scopes */
34
+ /** Move or rename file(s) in overlay */
30
35
  export async function moveCommand(
31
36
  filePaths: string[],
32
37
  options: MoveOptions,
33
38
  ): Promise<void> {
34
- const targetScope = resolveScopeFromOptions(options, "require");
39
+ const hasScope = options.global || options.project || options.worktree;
35
40
  const cwd = process.cwd();
36
41
  const gitContext = await getGitContext(cwd);
37
42
  const config = await loadConfig();
@@ -42,8 +47,23 @@ export async function moveCommand(
42
47
  const { gitRoot: targetRoot } = gitContext;
43
48
  const context: MapperContext = { overlayRoot, targetRoot, gitContext };
44
49
 
45
- for (const filePath of filePaths) {
46
- await moveSingleFile(filePath, targetScope, context, cwd, config);
50
+ if (hasScope) {
51
+ // Scope-move mode (existing behavior)
52
+ const targetScope = resolveScopeFromOptions(options, "require");
53
+ for (const filePath of filePaths) {
54
+ await moveSingleFile(filePath, targetScope, context, cwd, config);
55
+ }
56
+ } else if (filePaths.length === 2) {
57
+ // Rename mode
58
+ await renameFile(filePaths[0], filePaths[1], context, cwd, config);
59
+ } else if (filePaths.length === 1) {
60
+ throw new Error(
61
+ "Must specify destination or scope flag (--global, --project, --worktree)",
62
+ );
63
+ } else {
64
+ throw new Error(
65
+ "Too many arguments without scope flag. Use --global, --project, or --worktree",
66
+ );
47
67
  }
48
68
  }
49
69
 
@@ -101,6 +121,64 @@ async function moveSingleFile(
101
121
  );
102
122
  }
103
123
 
124
+ /** Rename a file within its current scope */
125
+ async function renameFile(
126
+ sourcePath: string,
127
+ destName: string,
128
+ context: MapperContext,
129
+ cwd: string,
130
+ config: { agents: string[] },
131
+ ): Promise<void> {
132
+ const { overlayRoot, targetRoot: gitRoot } = context;
133
+ const sourceBarePath = join(cwd, sourcePath);
134
+
135
+ // Verify source is managed by clank
136
+ const currentScope = await scopeFromSymlink(sourceBarePath, context);
137
+ if (!currentScope) {
138
+ throw new Error(`${sourcePath} is not managed by clank`);
139
+ }
140
+ if (isAgentFile(sourcePath)) {
141
+ throw new Error(
142
+ "Cannot rename agent files (CLAUDE.md, AGENTS.md, GEMINI.md)",
143
+ );
144
+ }
145
+
146
+ // Build dest path (same directory as source)
147
+ const sourceDir = dirname(sourcePath);
148
+ const destPath = sourceDir === "." ? destName : join(sourceDir, destName);
149
+
150
+ // Calculate overlay paths
151
+ const normalizedSource = normalizeAddPath(sourcePath, cwd, gitRoot);
152
+ const normalizedDest = normalizeAddPath(destPath, cwd, gitRoot);
153
+ const sourceOverlay = targetToOverlay(
154
+ normalizedSource,
155
+ currentScope,
156
+ context,
157
+ );
158
+ const destOverlay = targetToOverlay(normalizedDest, currentScope, context);
159
+
160
+ if (await fileExists(destOverlay)) {
161
+ throw new Error(`Destination already exists: ${destName}`);
162
+ }
163
+
164
+ // Rename in overlay and update symlinks
165
+ await ensureDir(dirname(destOverlay));
166
+ await rename(sourceOverlay, destOverlay);
167
+ const moveCtx: MoveContext = { overlayRoot, gitRoot, agents: config.agents };
168
+ if (isPromptFile(normalizedSource)) {
169
+ await renamePromptLinks(
170
+ normalizedSource,
171
+ normalizedDest,
172
+ destOverlay,
173
+ moveCtx,
174
+ );
175
+ } else {
176
+ await renameSymlink(normalizedSource, normalizedDest, destOverlay, moveCtx);
177
+ }
178
+
179
+ console.log(`Renamed ${sourcePath} → ${destName} (${currentScope} scope)`);
180
+ }
181
+
104
182
  /** Recreate agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md) after moving */
105
183
  async function recreateAgentLinks(
106
184
  normalizedPath: string,
@@ -172,3 +250,55 @@ async function recreateSymlink(
172
250
  const linkTarget = getLinkTarget(targetPath, overlayPath);
173
251
  await createSymlink(linkTarget, targetPath);
174
252
  }
253
+
254
+ /** Rename prompt symlinks in all agent directories */
255
+ async function renamePromptLinks(
256
+ oldNormPath: string,
257
+ newNormPath: string,
258
+ newOverlayPath: string,
259
+ ctx: MoveContext,
260
+ ): Promise<void> {
261
+ const { overlayRoot, gitRoot } = ctx;
262
+ const oldPromptRel = getPromptRelPath(oldNormPath);
263
+ const newPromptRel = getPromptRelPath(newNormPath);
264
+ if (!oldPromptRel || !newPromptRel) return;
265
+
266
+ // Remove old symlinks
267
+ for (const agentDir of managedAgentDirs) {
268
+ const oldTarget = join(gitRoot, agentDir, "prompts", oldPromptRel);
269
+ if (await isSymlinkToOverlay(oldTarget, overlayRoot)) {
270
+ await unlink(oldTarget);
271
+ }
272
+ }
273
+
274
+ // Create new symlinks
275
+ const created = await createPromptLinks(
276
+ newOverlayPath,
277
+ newPromptRel,
278
+ gitRoot,
279
+ );
280
+
281
+ if (created.length > 0) {
282
+ console.log(
283
+ `Updated symlinks: ${created.map((p) => relative(gitRoot, p)).join(", ")}`,
284
+ );
285
+ }
286
+ }
287
+
288
+ /** Rename a regular symlink */
289
+ async function renameSymlink(
290
+ oldTargetPath: string,
291
+ newTargetPath: string,
292
+ newOverlayPath: string,
293
+ ctx: MoveContext,
294
+ ): Promise<void> {
295
+ // Remove old symlink
296
+ if (await isSymlinkToOverlay(oldTargetPath, ctx.overlayRoot)) {
297
+ await unlink(oldTargetPath);
298
+ }
299
+
300
+ // Create new symlink
301
+ await ensureDir(dirname(newTargetPath));
302
+ const linkTarget = getLinkTarget(newTargetPath, newOverlayPath);
303
+ await createSymlink(linkTarget, newTargetPath);
304
+ }
@@ -41,14 +41,18 @@ export async function rmCommand(
41
41
  for (const filePath of filePaths) {
42
42
  const normalizedPath = normalizeAddPath(filePath, cwd, gitRoot);
43
43
 
44
+ // Try to find overlay path (may not exist for unmanaged files)
44
45
  const scope = await resolveScope(normalizedPath, options, context);
45
- const overlayPath = targetToOverlay(normalizedPath, scope, context);
46
-
47
- if (!(await fileExists(overlayPath))) {
48
- throw new Error(`Not found in overlay: ${relative(cwd, normalizedPath)}`);
49
- }
46
+ const overlayPath = scope
47
+ ? targetToOverlay(normalizedPath, scope, context)
48
+ : null;
50
49
 
51
50
  if (isAgentFile(filePath)) {
51
+ if (!overlayPath || !(await fileExists(overlayPath))) {
52
+ throw new Error(
53
+ `Not found in overlay: ${relative(cwd, normalizedPath)}`,
54
+ );
55
+ }
52
56
  await removeAgentFiles(normalizedPath, overlayPath, overlayRoot, config);
53
57
  } else {
54
58
  await removeFile(normalizedPath, overlayPath, overlayRoot, cwd);
@@ -56,12 +60,12 @@ export async function rmCommand(
56
60
  }
57
61
  }
58
62
 
59
- /** Resolve which scope to remove from */
63
+ /** Resolve which scope to remove from, or null if not in overlay */
60
64
  async function resolveScope(
61
65
  targetPath: string,
62
66
  options: RmOptions,
63
67
  context: MapperContext,
64
- ): Promise<Scope> {
68
+ ): Promise<Scope | null> {
65
69
  // Explicit scope takes priority
66
70
  if (options.global || options.project || options.worktree) {
67
71
  return resolveScopeFromOptions(options);
@@ -73,10 +77,8 @@ async function resolveScope(
73
77
  const found = await findInScopes(targetPath, context);
74
78
 
75
79
  if (found.length === 0) {
76
- throw new Error(
77
- `Not found in overlay: ${basename(targetPath)}\n` +
78
- `File does not exist in any scope (global, project, worktree)`,
79
- );
80
+ // Not in overlay - might be an unmanaged local file
81
+ return null;
80
82
  }
81
83
 
82
84
  if (found.length > 1) {
@@ -119,28 +121,36 @@ async function removeAgentFiles(
119
121
  /** Remove a regular file */
120
122
  async function removeFile(
121
123
  targetPath: string,
122
- overlayPath: string,
124
+ overlayPath: string | null,
123
125
  overlayRoot: string,
124
126
  cwd: string,
125
127
  ): Promise<void> {
126
128
  const fileName = relative(cwd, targetPath);
129
+ const targetExists = await fileExists(targetPath);
130
+ const overlayExists = overlayPath ? await fileExists(overlayPath) : false;
127
131
 
128
- // Check if local file exists and handle it
129
- if (await fileExists(targetPath)) {
132
+ // Nothing to remove
133
+ if (!targetExists && !overlayExists) {
134
+ throw new Error(`File not found: ${fileName}`);
135
+ }
136
+
137
+ // Handle target file
138
+ if (targetExists) {
130
139
  if (await isSymlinkToOverlay(targetPath, overlayRoot)) {
131
140
  await unlink(targetPath);
132
141
  console.log(`Removed symlink: ${fileName}`);
133
142
  } else {
134
- throw new Error(
135
- `File exists but is not managed by clank: ${fileName}\n` +
136
- `Cannot remove a file that is not a symlink to the overlay`,
137
- );
143
+ // Unmanaged file in clank/ directory - just remove it
144
+ await rm(targetPath);
145
+ console.log(`Removed: ${fileName}`);
138
146
  }
139
147
  }
140
148
 
141
- // Remove from overlay
142
- await rm(overlayPath);
143
- console.log(`Removed from overlay: ${basename(overlayPath)}`);
149
+ // Remove from overlay if it exists
150
+ if (overlayPath && overlayExists) {
151
+ await rm(overlayPath);
152
+ console.log(`Removed from overlay: ${basename(overlayPath)}`);
153
+ }
144
154
  }
145
155
 
146
156
  /** Search all scopes to find where the file exists */