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 +1 -1
- package/src/Cli.ts +1 -0
- package/src/OverlayLinks.ts +46 -3
- package/src/commands/Check.ts +4 -5
- package/src/commands/Link.ts +28 -2
- package/src/commands/VsCode.ts +159 -4
package/package.json
CHANGED
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
|
|
package/src/OverlayLinks.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/Check.ts
CHANGED
|
@@ -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("
|
|
265
|
+
console.log("Run `clank link` to fix them.\n");
|
|
266
266
|
for (const file of wrongMapping) {
|
|
267
|
-
console.log(`
|
|
268
|
-
if (file.currentTarget
|
|
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();
|
package/src/commands/Link.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
}
|
package/src/commands/VsCode.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
177
|
-
|
|
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 */
|