clank-cli 0.1.65 → 0.1.67

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/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.65",
4
+ "version": "0.1.67",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
package/src/Cli.ts CHANGED
@@ -171,6 +171,7 @@ function registerUtilityCommands(program: Command): void {
171
171
  .command("vscode")
172
172
  .description("Generate VS Code settings to show clank files")
173
173
  .option("--remove", "Remove clank-generated VS Code settings")
174
+ .option("--force", "Generate even if settings.json is tracked by git")
174
175
  .action(withErrorHandling(vscodeCommand));
175
176
  }
176
177
 
@@ -1,14 +1,16 @@
1
- import { lstat } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
1
+ import { lstat, unlink } from "node:fs/promises";
2
+ import { dirname, join, relative } from "node:path";
3
3
  import picomatch from "picomatch";
4
- import { managedAgentDirs } from "./AgentFiles.ts";
4
+ import { managedAgentDirs, targetManagedDirs } from "./AgentFiles.ts";
5
5
  import {
6
6
  createSymlink,
7
7
  ensureDir,
8
8
  getLinkTarget,
9
+ isSymlink,
9
10
  resolveSymlinkTarget,
10
11
  walkDirectory,
11
12
  } from "./FsUtil.ts";
13
+ import type { GitContext } from "./Git.ts";
12
14
  import {
13
15
  getPromptRelPath,
14
16
  type MapperContext,
@@ -139,3 +141,44 @@ function isMatchingPromptPath(
139
141
  const canonical = getPromptRelPath(canonicalPath);
140
142
  return canonical !== null && canonical === getPromptRelPath(actualPath);
141
143
  }
144
+
145
+ /** Find and remove symlinks pointing to wrong worktree in the overlay.
146
+ * Returns paths that were removed. */
147
+ export async function cleanStaleWorktreeSymlinks(
148
+ targetRoot: string,
149
+ overlayRoot: string,
150
+ gitContext: GitContext,
151
+ ): Promise<string[]> {
152
+ const removed: string[] = [];
153
+ const currentWorktree = gitContext.worktreeName;
154
+ const projectName = gitContext.projectName;
155
+ const worktreesPrefix = `${overlayRoot}/targets/${projectName}/worktrees/`;
156
+
157
+ for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
158
+ if (isDirectory) continue;
159
+
160
+ const relPath = relative(targetRoot, path);
161
+ if (!isInManagedDir(relPath)) continue;
162
+ if (!(await isSymlink(path))) continue;
163
+
164
+ const target = await resolveSymlinkTarget(path);
165
+ if (!target.startsWith(worktreesPrefix)) continue;
166
+
167
+ // Extract worktree name from path like .../worktrees/main/clank/notes.md
168
+ const afterPrefix = target.slice(worktreesPrefix.length);
169
+ const worktreeName = afterPrefix.split("/")[0];
170
+
171
+ if (worktreeName && worktreeName !== currentWorktree) {
172
+ await unlink(path);
173
+ removed.push(relPath);
174
+ }
175
+ }
176
+
177
+ return removed;
178
+ }
179
+
180
+ /** Check if a path is inside a clank-managed directory */
181
+ function isInManagedDir(relPath: string): boolean {
182
+ const parts = relPath.split("/");
183
+ return parts.some((part) => targetManagedDirs.includes(part));
184
+ }
@@ -250,7 +250,7 @@ function showUnaddedFiles(
250
250
  `Found ${outsideOverlay.length} stale symlink(s) in ${targetName}:\n`,
251
251
  );
252
252
  console.log("These symlinks point outside the clank overlay.");
253
- console.log("Remove them, then run `clank link` to recreate:\n");
253
+ console.log("Remove them manually, then run `clank link` to recreate:\n");
254
254
  for (const file of outsideOverlay) {
255
255
  console.log(` rm ${relativePath(cwd, file.targetPath)}`);
256
256
  }
@@ -262,12 +262,11 @@ function showUnaddedFiles(
262
262
  `Found ${wrongMapping.length} mislinked symlink(s) in ${targetName}:\n`,
263
263
  );
264
264
  console.log("These symlinks point to the wrong overlay location.");
265
- console.log("Remove them, then run `clank link` to recreate:\n");
265
+ console.log("Run `clank link` to fix them.\n");
266
266
  for (const file of wrongMapping) {
267
- console.log(` rm ${relativePath(cwd, file.targetPath)}`);
268
- if (file.currentTarget && file.expectedTarget) {
267
+ console.log(` ${relativePath(cwd, file.targetPath)}`);
268
+ if (file.currentTarget) {
269
269
  console.log(` points to: ${file.currentTarget}`);
270
- console.log(` expected: ${file.expectedTarget}`);
271
270
  }
272
271
  }
273
272
  console.log();
@@ -32,6 +32,7 @@ import {
32
32
  type TargetMapping,
33
33
  } from "../Mapper.ts";
34
34
  import {
35
+ cleanStaleWorktreeSymlinks,
35
36
  createPromptLinks as createPromptLinksShared,
36
37
  walkOverlayFiles,
37
38
  } from "../OverlayLinks.ts";
@@ -40,7 +41,11 @@ import {
40
41
  isWorktreeInitialized,
41
42
  } from "../Templates.ts";
42
43
  import { findOrphans } from "./Check.ts";
43
- import { generateVscodeSettings, isVscodeProject } from "./VsCode.ts";
44
+ import {
45
+ checkVscodeTracking,
46
+ generateVscodeSettings,
47
+ isVscodeProject,
48
+ } from "./VsCode.ts";
44
49
 
45
50
  interface FileMapping extends TargetMapping {
46
51
  overlayPath: string;
@@ -68,6 +73,19 @@ export async function linkCommand(targetDir?: string): Promise<void> {
68
73
  const overlayRoot = expandPath(config.overlayRepo);
69
74
  await validateOverlayExists(overlayRoot);
70
75
 
76
+ // Clean up symlinks pointing to wrong worktree before linking
77
+ const staleRemoved = await cleanStaleWorktreeSymlinks(
78
+ targetRoot,
79
+ overlayRoot,
80
+ gitContext,
81
+ );
82
+ if (staleRemoved.length > 0) {
83
+ console.log(`\nCleaned ${staleRemoved.length} stale worktree symlink(s):`);
84
+ for (const path of staleRemoved) {
85
+ console.log(` ${path}`);
86
+ }
87
+ }
88
+
71
89
  // Check for problematic agent files before proceeding
72
90
  await checkAgentFiles(targetRoot, overlayRoot);
73
91
 
@@ -291,6 +309,7 @@ async function maybeGenerateVscodeSettings(
291
309
  ): Promise<void> {
292
310
  const setting = config.vscodeSettings ?? "auto";
293
311
 
312
+ // User opted out - skip silently
294
313
  if (setting === "never") return;
295
314
 
296
315
  if (setting === "auto") {
@@ -298,7 +317,14 @@ async function maybeGenerateVscodeSettings(
298
317
  if (!isVscode) return;
299
318
  }
300
319
 
301
- // setting === "always" or (setting === "auto" && isVscodeProject)
320
+ // Check if settings.json is tracked and show appropriate warning
321
+ const check = await checkVscodeTracking(targetRoot);
322
+ if (!check.canGenerate) {
323
+ console.log(`\n${check.warning}`);
324
+ return;
325
+ }
326
+
327
+ // Generate: "always" mode, or "auto" with untracked/layered settings
302
328
  console.log("");
303
329
  await generateVscodeSettings(targetRoot);
304
330
  }
@@ -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 */