clank-cli 0.1.62 → 0.1.66

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.66",
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)")
@@ -170,6 +171,7 @@ function registerUtilityCommands(program: Command): void {
170
171
  .command("vscode")
171
172
  .description("Generate VS Code settings to show clank files")
172
173
  .option("--remove", "Remove clank-generated VS Code settings")
174
+ .option("--force", "Generate even if settings.json is tracked by git")
173
175
  .action(withErrorHandling(vscodeCommand));
174
176
  }
175
177
 
@@ -202,7 +204,7 @@ function registerMvCommand(program: Command): void {
202
204
  const cmd = program
203
205
  .command("mv")
204
206
  .alias("move")
205
- .description("Move file(s) between overlay scopes")
207
+ .description("Move or rename file(s) in overlay")
206
208
  .argument("<files...>", "File(s) to move");
207
209
 
208
210
  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
+ }
@@ -40,7 +40,11 @@ import {
40
40
  isWorktreeInitialized,
41
41
  } from "../Templates.ts";
42
42
  import { findOrphans } from "./Check.ts";
43
- import { generateVscodeSettings, isVscodeProject } from "./VsCode.ts";
43
+ import {
44
+ checkVscodeTracking,
45
+ generateVscodeSettings,
46
+ isVscodeProject,
47
+ } from "./VsCode.ts";
44
48
 
45
49
  interface FileMapping extends TargetMapping {
46
50
  overlayPath: string;
@@ -291,6 +295,7 @@ async function maybeGenerateVscodeSettings(
291
295
  ): Promise<void> {
292
296
  const setting = config.vscodeSettings ?? "auto";
293
297
 
298
+ // User opted out - skip silently
294
299
  if (setting === "never") return;
295
300
 
296
301
  if (setting === "auto") {
@@ -298,7 +303,14 @@ async function maybeGenerateVscodeSettings(
298
303
  if (!isVscode) return;
299
304
  }
300
305
 
301
- // setting === "always" or (setting === "auto" && isVscodeProject)
306
+ // Check if settings.json is tracked and show appropriate warning
307
+ const check = await checkVscodeTracking(targetRoot);
308
+ if (!check.canGenerate) {
309
+ console.log(`\n${check.warning}`);
310
+ return;
311
+ }
312
+
313
+ // Generate: "always" mode, or "auto" with untracked/layered settings
302
314
  console.log("");
303
315
  await generateVscodeSettings(targetRoot);
304
316
  }
@@ -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 */
@@ -16,6 +16,95 @@ import {
16
16
 
17
17
  export interface VscodeOptions {
18
18
  remove?: boolean;
19
+ force?: boolean;
20
+ }
21
+
22
+ /** Result of checking if VS Code settings can be generated */
23
+ export interface VscodeTrackingCheck {
24
+ canGenerate: boolean;
25
+ warning?: string;
26
+ hasBase: boolean;
27
+ }
28
+
29
+ /** Check if settings.base.json exists */
30
+ export async function hasBaseSettings(targetRoot: string): Promise<boolean> {
31
+ return fileExists(join(targetRoot, ".vscode/settings.base.json"));
32
+ }
33
+
34
+ /** Read layered settings (base + local) if base exists */
35
+ async function readLayeredSettings(
36
+ targetRoot: string,
37
+ ): Promise<Record<string, unknown> | null> {
38
+ const basePath = join(targetRoot, ".vscode/settings.base.json");
39
+ const localPath = join(targetRoot, ".vscode/settings.local.json");
40
+
41
+ if (!(await fileExists(basePath))) return null;
42
+
43
+ let base: Record<string, unknown> = {};
44
+ const baseContent = await readFile(basePath, "utf-8");
45
+ try {
46
+ base = JSON.parse(baseContent);
47
+ } catch {
48
+ console.warn("Warning: Could not parse settings.base.json, ignoring");
49
+ return null;
50
+ }
51
+
52
+ let local: Record<string, unknown> = {};
53
+ if (await fileExists(localPath)) {
54
+ const localContent = await readFile(localPath, "utf-8");
55
+ try {
56
+ local = JSON.parse(localContent);
57
+ } catch {
58
+ console.warn("Warning: Could not parse settings.local.json, ignoring");
59
+ }
60
+ }
61
+
62
+ // Merge exclude patterns specially (combine, don't replace)
63
+ const baseSearch = (base["search.exclude"] as Record<string, boolean>) || {};
64
+ const localSearch =
65
+ (local["search.exclude"] as Record<string, boolean>) || {};
66
+ const baseFiles = (base["files.exclude"] as Record<string, boolean>) || {};
67
+ const localFiles = (local["files.exclude"] as Record<string, boolean>) || {};
68
+
69
+ return {
70
+ ...base,
71
+ ...local,
72
+ "search.exclude": { ...baseSearch, ...localSearch },
73
+ "files.exclude": { ...baseFiles, ...localFiles },
74
+ };
75
+ }
76
+
77
+ /** Check if settings.json is tracked and return appropriate warning */
78
+ export async function checkVscodeTracking(
79
+ targetRoot: string,
80
+ ): Promise<VscodeTrackingCheck> {
81
+ const settingsPath = join(targetRoot, ".vscode/settings.json");
82
+ const hasBase = await hasBaseSettings(targetRoot);
83
+ const isTracked = await isTrackedByGit(settingsPath, targetRoot);
84
+
85
+ if (!isTracked) {
86
+ return { canGenerate: true, hasBase };
87
+ }
88
+
89
+ // settings.json is tracked
90
+ if (hasBase) {
91
+ return {
92
+ canGenerate: false,
93
+ hasBase,
94
+ warning:
95
+ "settings.base.json found but settings.json is still tracked.\n" +
96
+ "Complete migration: git rm --cached .vscode/settings.json",
97
+ };
98
+ }
99
+
100
+ return {
101
+ canGenerate: false,
102
+ hasBase,
103
+ warning:
104
+ "Skipping: .vscode/settings.json is tracked.\n" +
105
+ "Use `clank vscode --force` to override, or migrate:\n" +
106
+ " mv .vscode/settings.json .vscode/settings.base.json && git rm --cached .vscode/settings.json",
107
+ };
19
108
  }
20
109
 
21
110
  /** Generate VS Code settings to show clank files in search and explorer */
@@ -27,6 +116,19 @@ export async function vscodeCommand(options?: VscodeOptions): Promise<void> {
27
116
  return;
28
117
  }
29
118
 
119
+ // Check if we can generate (tracking check)
120
+ const check = await checkVscodeTracking(targetRoot);
121
+ if (!check.canGenerate && !options?.force) {
122
+ console.log(check.warning);
123
+ return;
124
+ }
125
+
126
+ if (options?.force && !check.canGenerate) {
127
+ console.log(
128
+ "Note: settings.json is tracked, this will create uncommitted changes.\n",
129
+ );
130
+ }
131
+
30
132
  await generateVscodeSettings(targetRoot);
31
133
  }
32
134
 
@@ -70,10 +172,38 @@ export async function generateVscodeSettings(
70
172
  );
71
173
  }
72
174
 
175
+ /** Regenerate settings.json from base+local only (remove clank additions) */
176
+ async function removeLayeredSettings(
177
+ settingsPath: string,
178
+ layered: Record<string, unknown>,
179
+ ): Promise<void> {
180
+ delete layered["search.useIgnoreFiles"];
181
+
182
+ if (Object.keys(layered).length === 0) {
183
+ if (await fileExists(settingsPath)) {
184
+ await unlink(settingsPath);
185
+ console.log("Removed .vscode/settings.json (base+local were empty)");
186
+ }
187
+ } else {
188
+ await writeJsonFile(settingsPath, layered);
189
+ console.log(
190
+ "Regenerated .vscode/settings.json from base+local (removed clank patterns)",
191
+ );
192
+ }
193
+ }
194
+
73
195
  /** Remove clank-generated VS Code settings */
74
196
  export async function removeVscodeSettings(targetRoot: string): Promise<void> {
75
197
  const settingsPath = join(targetRoot, ".vscode/settings.json");
76
198
 
199
+ // If layered settings exist, regenerate from base+local only
200
+ const layered = await readLayeredSettings(targetRoot);
201
+ if (layered) {
202
+ await removeLayeredSettings(settingsPath, layered);
203
+ return;
204
+ }
205
+
206
+ // Legacy mode: selectively remove clank patterns
77
207
  if (!(await fileExists(settingsPath))) {
78
208
  return;
79
209
  }
@@ -123,11 +253,30 @@ export async function isVscodeProject(targetRoot: string): Promise<boolean> {
123
253
  }
124
254
  }
125
255
 
126
- /** Merge clank exclude patterns with existing .vscode/settings.json */
256
+ /** Merge clank exclude patterns with layered or existing settings */
127
257
  async function mergeVscodeSettings(
128
258
  targetRoot: string,
129
259
  excludePatterns: Record<string, boolean>,
130
260
  ): Promise<Record<string, unknown>> {
261
+ // Try layered settings first (base + local)
262
+ const layered = await readLayeredSettings(targetRoot);
263
+
264
+ if (layered) {
265
+ // Layered mode: base + local + clank
266
+ const layeredSearch =
267
+ (layered["search.exclude"] as Record<string, boolean>) || {};
268
+ const layeredFiles =
269
+ (layered["files.exclude"] as Record<string, boolean>) || {};
270
+
271
+ return {
272
+ ...layered,
273
+ "search.useIgnoreFiles": false,
274
+ "search.exclude": { ...layeredSearch, ...excludePatterns },
275
+ "files.exclude": { ...layeredFiles, ...excludePatterns },
276
+ };
277
+ }
278
+
279
+ // Legacy mode: read existing settings.json
131
280
  const settingsPath = join(targetRoot, ".vscode/settings.json");
132
281
 
133
282
  let existingSettings: Record<string, unknown> = {};
@@ -170,11 +319,17 @@ async function writeVscodeSettings(
170
319
  console.log(`Wrote ${settingsPath}`);
171
320
  }
172
321
 
173
- /** Add .vscode/settings.json to .git/info/exclude */
322
+ /** Add .vscode/settings.json and settings.local.json to .git/info/exclude */
174
323
  async function addVscodeToGitExclude(targetRoot: string): Promise<void> {
175
324
  const settingsPath = join(targetRoot, ".vscode/settings.json");
176
- if (await isTrackedByGit(settingsPath, targetRoot)) return;
177
- await addToGitExclude(targetRoot, ".vscode/settings.json");
325
+ const localPath = join(targetRoot, ".vscode/settings.local.json");
326
+
327
+ if (!(await isTrackedByGit(settingsPath, targetRoot))) {
328
+ await addToGitExclude(targetRoot, ".vscode/settings.json");
329
+ }
330
+ if (!(await isTrackedByGit(localPath, targetRoot))) {
331
+ await addToGitExclude(targetRoot, ".vscode/settings.local.json");
332
+ }
178
333
  }
179
334
 
180
335
  /** Remove patterns from an exclude object, deleting the key if empty */