clank-cli 0.1.61 → 0.1.62
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 +2 -2
- package/package.json +1 -1
- package/src/ClassifyFiles.ts +29 -29
- package/src/Cli.ts +72 -71
- package/src/Config.ts +12 -15
- package/src/Exclude.ts +9 -9
- package/src/Git.ts +10 -10
- package/src/Gitignore.ts +25 -25
- package/src/Mapper.ts +71 -71
- package/src/OverlayGit.ts +30 -3
- package/src/commands/Add.ts +107 -107
- package/src/commands/Check.ts +159 -139
- package/src/commands/Commit.ts +1 -1
- package/src/commands/Link.ts +226 -200
- package/src/commands/Move.ts +16 -16
- package/src/commands/Rm.ts +29 -29
- package/src/commands/VsCode.ts +24 -24
package/src/OverlayGit.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { execa } from "execa";
|
|
2
|
+
import picomatch from "picomatch";
|
|
2
3
|
import { managedDirs } from "./AgentFiles.ts";
|
|
3
4
|
|
|
4
5
|
/** Get git status of the overlay repository */
|
|
5
6
|
export async function getOverlayStatus(
|
|
6
7
|
overlayRoot: string,
|
|
7
|
-
|
|
8
|
+
ignorePatterns: string[] = [],
|
|
9
|
+
): Promise<string[]> {
|
|
8
10
|
const { stdout } = await execa({
|
|
9
11
|
cwd: overlayRoot,
|
|
10
12
|
})`git status --porcelain -uall`;
|
|
11
13
|
|
|
12
|
-
const
|
|
13
|
-
return
|
|
14
|
+
const allLines = stdout.trimEnd() ? stdout.trimEnd().split("\n") : [];
|
|
15
|
+
return filterIgnoredLines(allLines, ignorePatterns);
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** Format git status --porcelain output into readable lines */
|
|
@@ -35,6 +37,31 @@ export function formatStatusCode(code: string): string {
|
|
|
35
37
|
return "?";
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Filter out lines matching ignore patterns */
|
|
41
|
+
function filterIgnoredLines(
|
|
42
|
+
lines: string[],
|
|
43
|
+
ignorePatterns: string[],
|
|
44
|
+
): string[] {
|
|
45
|
+
if (ignorePatterns.length === 0) return lines;
|
|
46
|
+
|
|
47
|
+
const isIgnored = picomatch(ignorePatterns);
|
|
48
|
+
return lines.filter((line) => {
|
|
49
|
+
const filePath = line.slice(3); // Skip status code + space
|
|
50
|
+
const segments = filePath.split("/");
|
|
51
|
+
const pathBasename = segments.at(-1) ?? "";
|
|
52
|
+
|
|
53
|
+
// Check full path and basename
|
|
54
|
+
if (isIgnored(filePath) || isIgnored(pathBasename)) return false;
|
|
55
|
+
|
|
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;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
/** Parse overlay path into scope and path parts within that scope */
|
|
39
66
|
function parseScopedPath(filePath: string): {
|
|
40
67
|
scope: string;
|
package/src/commands/Add.ts
CHANGED
|
@@ -32,14 +32,36 @@ import {
|
|
|
32
32
|
import { createPromptLinks, isSymlinkToOverlay } from "../OverlayLinks.ts";
|
|
33
33
|
import { scopeFromSymlink } from "../ScopeFromSymlink.ts";
|
|
34
34
|
|
|
35
|
+
export type AddOptions = ScopeOptions;
|
|
36
|
+
|
|
37
|
+
interface AddContext {
|
|
38
|
+
cwd: string;
|
|
39
|
+
gitContext: GitContext;
|
|
40
|
+
config: { overlayRepo: string; agents: string[] };
|
|
41
|
+
overlayRoot: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
|
|
45
|
+
interface AgentLinkParams {
|
|
46
|
+
overlayPath: string;
|
|
47
|
+
symlinkDir: string;
|
|
48
|
+
gitRoot: string;
|
|
49
|
+
overlayRoot: string;
|
|
50
|
+
agents: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface AgentLinkClassification {
|
|
54
|
+
toCreate: { targetPath: string; name: string }[];
|
|
55
|
+
existing: string[];
|
|
56
|
+
skipped: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
const scopeLabels: Record<Scope, string> = {
|
|
36
60
|
global: "global",
|
|
37
61
|
project: "project",
|
|
38
62
|
worktree: "worktree",
|
|
39
63
|
};
|
|
40
64
|
|
|
41
|
-
export type AddOptions = ScopeOptions;
|
|
42
|
-
|
|
43
65
|
/** Add file(s) to overlay and create symlinks in target */
|
|
44
66
|
export async function addCommand(
|
|
45
67
|
filePaths: string[],
|
|
@@ -68,11 +90,29 @@ export async function addCommand(
|
|
|
68
90
|
}
|
|
69
91
|
}
|
|
70
92
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
93
|
+
/** fail if we can't do an add with the given options */
|
|
94
|
+
async function validateAddOptions(
|
|
95
|
+
options: AddOptions,
|
|
96
|
+
overlayRoot: string,
|
|
97
|
+
gitContext: GitContext,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
await validateOverlayExists(overlayRoot);
|
|
100
|
+
|
|
101
|
+
if (options.worktree && !gitContext.isWorktree) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`--worktree scope requires a git worktree.\n` +
|
|
104
|
+
`You're on branch '${gitContext.worktreeName}' in the main repository.\n` +
|
|
105
|
+
`Use 'git worktree add' to create a worktree, or use --project scope instead.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
111
|
+
try {
|
|
112
|
+
return (await lstat(path)).isDirectory();
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
/** Add a single file to overlay and create symlink */
|
|
@@ -125,49 +165,20 @@ async function addSingleFile(
|
|
|
125
165
|
}
|
|
126
166
|
}
|
|
127
167
|
|
|
128
|
-
/**
|
|
129
|
-
async function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
168
|
+
/** Check if file is already in overlay at a different scope, throw helpful error */
|
|
169
|
+
async function checkScopeConflict(
|
|
170
|
+
barePath: string,
|
|
171
|
+
requestedScope: Scope,
|
|
172
|
+
context: MapperContext,
|
|
133
173
|
cwd: string,
|
|
134
174
|
): Promise<void> {
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
175
|
+
const currentScope = await scopeFromSymlink(barePath, context);
|
|
176
|
+
if (currentScope && currentScope !== requestedScope) {
|
|
177
|
+
const fileName = relative(cwd, barePath);
|
|
178
|
+
throw new Error(
|
|
179
|
+
`${fileName} is already in ${scopeLabels[currentScope]} overlay.\n` +
|
|
180
|
+
`To move it to ${scopeLabels[requestedScope]} scope, use: clank mv ${fileName} --${requestedScope}`,
|
|
141
181
|
);
|
|
142
|
-
if (created.length) {
|
|
143
|
-
console.log(
|
|
144
|
-
`Created symlinks: ${created.map((p) => relative(cwd, p)).join(", ")}`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Handle regular file symlink creation */
|
|
151
|
-
async function handleRegularFile(
|
|
152
|
-
normalizedPath: string,
|
|
153
|
-
overlayPath: string,
|
|
154
|
-
overlayRoot: string,
|
|
155
|
-
cwd: string,
|
|
156
|
-
): Promise<void> {
|
|
157
|
-
if (await isSymlinkToOverlay(normalizedPath, overlayRoot)) {
|
|
158
|
-
console.log(`Symlink already exists: ${relative(cwd, normalizedPath)}`);
|
|
159
|
-
} else {
|
|
160
|
-
const linkTarget = getLinkTarget(normalizedPath, overlayPath);
|
|
161
|
-
await createSymlink(linkTarget, normalizedPath);
|
|
162
|
-
console.log(`Created symlink: ${relative(cwd, normalizedPath)}`);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function isDirectory(path: string): Promise<boolean> {
|
|
167
|
-
try {
|
|
168
|
-
return (await lstat(path)).isDirectory();
|
|
169
|
-
} catch {
|
|
170
|
-
return false;
|
|
171
182
|
}
|
|
172
183
|
}
|
|
173
184
|
|
|
@@ -201,51 +212,6 @@ async function addFileToOverlay(
|
|
|
201
212
|
}
|
|
202
213
|
}
|
|
203
214
|
|
|
204
|
-
/** Find content from normalized path or bare input path */
|
|
205
|
-
async function findSourceContent(
|
|
206
|
-
normalizedPath: string,
|
|
207
|
-
barePath: string,
|
|
208
|
-
): Promise<string> {
|
|
209
|
-
// Try normalized path first (e.g., cwd/clank/foo.md)
|
|
210
|
-
if (
|
|
211
|
-
(await fileExists(normalizedPath)) &&
|
|
212
|
-
!(await isSymlink(normalizedPath))
|
|
213
|
-
) {
|
|
214
|
-
return await readFile(normalizedPath, "utf-8");
|
|
215
|
-
}
|
|
216
|
-
// Fall back to bare input path (e.g., cwd/foo.md)
|
|
217
|
-
if ((await fileExists(barePath)) && !(await isSymlink(barePath))) {
|
|
218
|
-
return await readFile(barePath, "utf-8");
|
|
219
|
-
}
|
|
220
|
-
return "";
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** fail if we can't do an add with the given options */
|
|
224
|
-
async function validateAddOptions(
|
|
225
|
-
options: AddOptions,
|
|
226
|
-
overlayRoot: string,
|
|
227
|
-
gitContext: GitContext,
|
|
228
|
-
): Promise<void> {
|
|
229
|
-
await validateOverlayExists(overlayRoot);
|
|
230
|
-
|
|
231
|
-
if (options.worktree && !gitContext.isWorktree) {
|
|
232
|
-
throw new Error(
|
|
233
|
-
`--worktree scope requires a git worktree.\n` +
|
|
234
|
-
`You're on branch '${gitContext.worktreeName}' in the main repository.\n` +
|
|
235
|
-
`Use 'git worktree add' to create a worktree, or use --project scope instead.`,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Create agent symlinks (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
|
|
241
|
-
interface AgentLinkParams {
|
|
242
|
-
overlayPath: string;
|
|
243
|
-
symlinkDir: string;
|
|
244
|
-
gitRoot: string;
|
|
245
|
-
overlayRoot: string;
|
|
246
|
-
agents: string[];
|
|
247
|
-
}
|
|
248
|
-
|
|
249
215
|
async function createAgentLinks(p: AgentLinkParams): Promise<void> {
|
|
250
216
|
const { overlayPath, ...classifyParams } = p;
|
|
251
217
|
const { toCreate, existing, skipped } =
|
|
@@ -271,27 +237,61 @@ async function createAgentLinks(p: AgentLinkParams): Promise<void> {
|
|
|
271
237
|
}
|
|
272
238
|
}
|
|
273
239
|
|
|
274
|
-
/**
|
|
275
|
-
async function
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
240
|
+
/** Handle prompt file symlink creation */
|
|
241
|
+
async function handlePromptFile(
|
|
242
|
+
normalizedPath: string,
|
|
243
|
+
overlayPath: string,
|
|
244
|
+
gitRoot: string,
|
|
279
245
|
cwd: string,
|
|
280
246
|
): Promise<void> {
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
247
|
+
const promptRelPath = getPromptRelPath(normalizedPath);
|
|
248
|
+
if (promptRelPath) {
|
|
249
|
+
const created = await createPromptLinks(
|
|
250
|
+
overlayPath,
|
|
251
|
+
promptRelPath,
|
|
252
|
+
gitRoot,
|
|
287
253
|
);
|
|
254
|
+
if (created.length) {
|
|
255
|
+
console.log(
|
|
256
|
+
`Created symlinks: ${created.map((p) => relative(cwd, p)).join(", ")}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
288
259
|
}
|
|
289
260
|
}
|
|
290
261
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
262
|
+
/** Handle regular file symlink creation */
|
|
263
|
+
async function handleRegularFile(
|
|
264
|
+
normalizedPath: string,
|
|
265
|
+
overlayPath: string,
|
|
266
|
+
overlayRoot: string,
|
|
267
|
+
cwd: string,
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
if (await isSymlinkToOverlay(normalizedPath, overlayRoot)) {
|
|
270
|
+
console.log(`Symlink already exists: ${relative(cwd, normalizedPath)}`);
|
|
271
|
+
} else {
|
|
272
|
+
const linkTarget = getLinkTarget(normalizedPath, overlayPath);
|
|
273
|
+
await createSymlink(linkTarget, normalizedPath);
|
|
274
|
+
console.log(`Created symlink: ${relative(cwd, normalizedPath)}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Find content from normalized path or bare input path */
|
|
279
|
+
async function findSourceContent(
|
|
280
|
+
normalizedPath: string,
|
|
281
|
+
barePath: string,
|
|
282
|
+
): Promise<string> {
|
|
283
|
+
// Try normalized path first (e.g., cwd/clank/foo.md)
|
|
284
|
+
if (
|
|
285
|
+
(await fileExists(normalizedPath)) &&
|
|
286
|
+
!(await isSymlink(normalizedPath))
|
|
287
|
+
) {
|
|
288
|
+
return await readFile(normalizedPath, "utf-8");
|
|
289
|
+
}
|
|
290
|
+
// Fall back to bare input path (e.g., cwd/foo.md)
|
|
291
|
+
if ((await fileExists(barePath)) && !(await isSymlink(barePath))) {
|
|
292
|
+
return await readFile(barePath, "utf-8");
|
|
293
|
+
}
|
|
294
|
+
return "";
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
/** Classify which agent symlinks to create vs skip */
|
package/src/commands/Check.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename, join, relative } from "node:path";
|
|
2
|
+
import picomatch from "picomatch";
|
|
2
3
|
import { managedDirs, targetManagedDirs } from "../AgentFiles.ts";
|
|
3
4
|
import {
|
|
4
5
|
agentFileProblems,
|
|
@@ -27,6 +28,9 @@ export type UnaddedFile = ManagedFileState & {
|
|
|
27
28
|
relativePath: string;
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
/** Files that should remain local and not be tracked by clank */
|
|
32
|
+
const localOnlyFiles = ["settings.local.json"];
|
|
33
|
+
|
|
30
34
|
/** Check for orphaned overlay paths that don't match target structure */
|
|
31
35
|
export async function checkCommand(): Promise<void> {
|
|
32
36
|
const cwd = process.cwd();
|
|
@@ -35,19 +39,131 @@ export async function checkCommand(): Promise<void> {
|
|
|
35
39
|
const overlayRoot = expandPath(config.overlayRepo);
|
|
36
40
|
const { gitRoot: targetRoot } = gitContext;
|
|
37
41
|
const ctx: MapperContext = { overlayRoot, targetRoot, gitContext };
|
|
42
|
+
const ignorePatterns = config.ignore ?? [];
|
|
38
43
|
|
|
39
|
-
await showOverlayStatus(overlayRoot);
|
|
44
|
+
await showOverlayStatus(overlayRoot, ignorePatterns);
|
|
40
45
|
|
|
41
|
-
const problems = await checkAllProblems(ctx, cwd);
|
|
46
|
+
const problems = await checkAllProblems(ctx, cwd, ignorePatterns);
|
|
42
47
|
if (!problems) {
|
|
43
48
|
console.log("No issues found. Overlay matches target structure.");
|
|
44
49
|
}
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
/** Find files in clank-managed directories that aren't valid symlinks to the overlay */
|
|
53
|
+
export async function findUnaddedFiles(
|
|
54
|
+
context: MapperContext,
|
|
55
|
+
): Promise<UnaddedFile[]> {
|
|
56
|
+
const { targetRoot } = context;
|
|
57
|
+
const unadded: UnaddedFile[] = [];
|
|
58
|
+
|
|
59
|
+
for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
|
|
60
|
+
if (isDirectory) continue;
|
|
61
|
+
|
|
62
|
+
const relPath = relative(targetRoot, path);
|
|
63
|
+
if (!isInManagedDir(relPath)) continue;
|
|
64
|
+
if (isLocalOnlyFile(relPath)) continue;
|
|
65
|
+
|
|
66
|
+
const managed = await verifyManaged(path, context);
|
|
67
|
+
if (managed.kind !== "valid") {
|
|
68
|
+
unadded.push({ targetPath: path, relativePath: relPath, ...managed });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return unadded;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Find overlay paths whose target directories don't exist */
|
|
76
|
+
export async function findOrphans(
|
|
77
|
+
overlayRoot: string,
|
|
78
|
+
targetRoot: string,
|
|
79
|
+
projectName: string,
|
|
80
|
+
ignorePatterns: string[] = [],
|
|
81
|
+
): Promise<OrphanedPath[]> {
|
|
82
|
+
const orphans: OrphanedPath[] = [];
|
|
83
|
+
const projectDir = overlayProjectDir(overlayRoot, projectName);
|
|
84
|
+
|
|
85
|
+
if (!(await fileExists(projectDir))) {
|
|
86
|
+
return orphans;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isIgnored =
|
|
90
|
+
ignorePatterns.length > 0 ? picomatch(ignorePatterns) : null;
|
|
91
|
+
|
|
92
|
+
const skip = (relPath: string): boolean => {
|
|
93
|
+
if (isIgnored) {
|
|
94
|
+
const pathBasename = relPath.split("/").at(-1) ?? "";
|
|
95
|
+
if (isIgnored(relPath) || isIgnored(pathBasename)) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for await (const { path, isDirectory } of walkDirectory(projectDir, {
|
|
101
|
+
skipDirs: [".git", "node_modules", "worktrees"],
|
|
102
|
+
skip,
|
|
103
|
+
})) {
|
|
104
|
+
if (isDirectory) continue;
|
|
105
|
+
|
|
106
|
+
const relPath = relative(projectDir, path);
|
|
107
|
+
|
|
108
|
+
// Skip files at project root (agents.md, settings.json)
|
|
109
|
+
if (!relPath.includes("/")) continue;
|
|
110
|
+
|
|
111
|
+
// Skip standard directories that don't map to target subdirs
|
|
112
|
+
if (managedDirs.some((dir) => relPath.startsWith(`${dir}/`))) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// This is a subdirectory file - check if target dir exists
|
|
117
|
+
// e.g., tools/packages/wesl/clank/notes.md -> check tools/packages/wesl/
|
|
118
|
+
const targetSubdir = extractTargetSubdir(relPath);
|
|
119
|
+
if (!targetSubdir) continue;
|
|
120
|
+
|
|
121
|
+
const expectedTargetDir = join(targetRoot, targetSubdir);
|
|
122
|
+
if (!(await fileExists(expectedTargetDir))) {
|
|
123
|
+
orphans.push({
|
|
124
|
+
overlayPath: path,
|
|
125
|
+
expectedTargetDir: targetSubdir,
|
|
126
|
+
fileName: basename(path),
|
|
127
|
+
scope: projectName,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return orphans;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Show git status of the overlay repository */
|
|
136
|
+
async function showOverlayStatus(
|
|
137
|
+
overlayRoot: string,
|
|
138
|
+
ignorePatterns: string[] = [],
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
if (!(await fileExists(overlayRoot))) {
|
|
141
|
+
console.log("Overlay repository not found\n");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lines = await getOverlayStatus(overlayRoot, ignorePatterns);
|
|
146
|
+
|
|
147
|
+
console.log(`Overlay: ${overlayRoot}`);
|
|
148
|
+
|
|
149
|
+
if (lines.length === 0) {
|
|
150
|
+
console.log("Status: clean\n");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log(`Status: ${lines.length} uncommitted change(s)\n`);
|
|
155
|
+
|
|
156
|
+
for (const formatted of formatStatusLines(lines)) {
|
|
157
|
+
console.log(` ${formatted}`);
|
|
158
|
+
}
|
|
159
|
+
console.log();
|
|
160
|
+
}
|
|
161
|
+
|
|
47
162
|
/** Run all checks and display problems. Returns true if any problems found. */
|
|
48
163
|
async function checkAllProblems(
|
|
49
164
|
ctx: MapperContext,
|
|
50
165
|
cwd: string,
|
|
166
|
+
ignorePatterns: string[] = [],
|
|
51
167
|
): Promise<boolean> {
|
|
52
168
|
const { overlayRoot, targetRoot, gitContext } = ctx;
|
|
53
169
|
let hasProblems = false;
|
|
@@ -73,6 +189,7 @@ async function checkAllProblems(
|
|
|
73
189
|
overlayRoot,
|
|
74
190
|
targetRoot,
|
|
75
191
|
gitContext.projectName,
|
|
192
|
+
ignorePatterns,
|
|
76
193
|
);
|
|
77
194
|
if (orphans.length > 0) {
|
|
78
195
|
hasProblems = true;
|
|
@@ -82,49 +199,35 @@ async function checkAllProblems(
|
|
|
82
199
|
return hasProblems;
|
|
83
200
|
}
|
|
84
201
|
|
|
85
|
-
/**
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
overlayRoot: string,
|
|
90
|
-
): void {
|
|
91
|
-
console.log(`Found ${orphans.length} orphaned overlay path(s):\n`);
|
|
92
|
-
for (const orphan of orphans) {
|
|
93
|
-
console.log(` ${orphan.fileName} (${orphan.scope})`);
|
|
94
|
-
console.log(` Overlay: ${orphan.overlayPath}`);
|
|
95
|
-
console.log(` Expected dir: ${orphan.expectedTargetDir}\n`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
console.log("Target project:", targetRoot);
|
|
99
|
-
console.log("Overlay:", overlayRoot);
|
|
100
|
-
console.log("\nTo fix with an agent, copy this prompt:");
|
|
101
|
-
console.log("─".repeat(50));
|
|
102
|
-
console.log(generateAgentPrompt(orphans, targetRoot, overlayRoot));
|
|
103
|
-
console.log("─".repeat(50));
|
|
202
|
+
/** Check if a path is inside a clank-managed directory */
|
|
203
|
+
function isInManagedDir(relPath: string): boolean {
|
|
204
|
+
const parts = relPath.split("/");
|
|
205
|
+
return parts.some((part) => targetManagedDirs.includes(part));
|
|
104
206
|
}
|
|
105
207
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const { lines } = await getOverlayStatus(overlayRoot);
|
|
114
|
-
|
|
115
|
-
console.log(`Overlay: ${overlayRoot}`);
|
|
208
|
+
/** Check if a file should remain local (not tracked by clank) */
|
|
209
|
+
function isLocalOnlyFile(relPath: string): boolean {
|
|
210
|
+
const fileName = basename(relPath);
|
|
211
|
+
return localOnlyFiles.includes(fileName);
|
|
212
|
+
}
|
|
116
213
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
214
|
+
/** Extract the target subdirectory from an overlay path
|
|
215
|
+
* @param relPath - Path relative to overlay project dir (e.g., targets/wesl-js/)
|
|
216
|
+
* @returns Target subdirectory path, or null if not a subdirectory file
|
|
217
|
+
* @example "tools/packages/wesl/clank/notes.md" -> "tools/packages/wesl"
|
|
218
|
+
* @example "tools/packages/wesl/agents.md" -> "tools/packages/wesl"
|
|
219
|
+
*/
|
|
220
|
+
function extractTargetSubdir(relPath: string): string | null {
|
|
221
|
+
// Check for /clank/ or /claude/ in path
|
|
222
|
+
for (const dir of managedDirs) {
|
|
223
|
+
const idx = relPath.indexOf(`/${dir}/`);
|
|
224
|
+
if (idx !== -1) return relPath.slice(0, idx);
|
|
120
225
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
for (const formatted of formatStatusLines(lines)) {
|
|
125
|
-
console.log(` ${formatted}`);
|
|
226
|
+
// Check for agents.md in a subdirectory
|
|
227
|
+
if (relPath.endsWith("/agents.md")) {
|
|
228
|
+
return relPath.slice(0, -"/agents.md".length);
|
|
126
229
|
}
|
|
127
|
-
|
|
230
|
+
return null;
|
|
128
231
|
}
|
|
129
232
|
|
|
130
233
|
/** Display unadded files in clank-managed directories */
|
|
@@ -181,108 +284,25 @@ function showUnaddedFiles(
|
|
|
181
284
|
}
|
|
182
285
|
}
|
|
183
286
|
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
/** Check if a path is inside a clank-managed directory */
|
|
188
|
-
function isInManagedDir(relPath: string): boolean {
|
|
189
|
-
const parts = relPath.split("/");
|
|
190
|
-
return parts.some((part) => targetManagedDirs.includes(part));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Check if a file should remain local (not tracked by clank) */
|
|
194
|
-
function isLocalOnlyFile(relPath: string): boolean {
|
|
195
|
-
const fileName = basename(relPath);
|
|
196
|
-
return localOnlyFiles.includes(fileName);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Find files in clank-managed directories that aren't valid symlinks to the overlay */
|
|
200
|
-
export async function findUnaddedFiles(
|
|
201
|
-
context: MapperContext,
|
|
202
|
-
): Promise<UnaddedFile[]> {
|
|
203
|
-
const { targetRoot } = context;
|
|
204
|
-
const unadded: UnaddedFile[] = [];
|
|
205
|
-
|
|
206
|
-
for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
|
|
207
|
-
if (isDirectory) continue;
|
|
208
|
-
|
|
209
|
-
const relPath = relative(targetRoot, path);
|
|
210
|
-
if (!isInManagedDir(relPath)) continue;
|
|
211
|
-
if (isLocalOnlyFile(relPath)) continue;
|
|
212
|
-
|
|
213
|
-
const managed = await verifyManaged(path, context);
|
|
214
|
-
if (managed.kind !== "valid") {
|
|
215
|
-
unadded.push({ targetPath: path, relativePath: relPath, ...managed });
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return unadded;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Find overlay paths whose target directories don't exist */
|
|
223
|
-
export async function findOrphans(
|
|
224
|
-
overlayRoot: string,
|
|
287
|
+
/** Display orphaned paths and remediation prompt */
|
|
288
|
+
function showOrphanedPaths(
|
|
289
|
+
orphans: OrphanedPath[],
|
|
225
290
|
targetRoot: string,
|
|
226
|
-
|
|
227
|
-
):
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
for await (const { path, isDirectory } of walkDirectory(projectDir, {
|
|
236
|
-
skipDirs: [".git", "node_modules", "worktrees"],
|
|
237
|
-
})) {
|
|
238
|
-
if (isDirectory) continue;
|
|
239
|
-
|
|
240
|
-
const relPath = relative(projectDir, path);
|
|
241
|
-
|
|
242
|
-
// Skip files at project root (agents.md, settings.json)
|
|
243
|
-
if (!relPath.includes("/")) continue;
|
|
244
|
-
|
|
245
|
-
// Skip standard directories that don't map to target subdirs
|
|
246
|
-
if (managedDirs.some((dir) => relPath.startsWith(`${dir}/`))) {
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// This is a subdirectory file - check if target dir exists
|
|
251
|
-
// e.g., tools/packages/wesl/clank/notes.md -> check tools/packages/wesl/
|
|
252
|
-
const targetSubdir = extractTargetSubdir(relPath);
|
|
253
|
-
if (!targetSubdir) continue;
|
|
254
|
-
|
|
255
|
-
const expectedTargetDir = join(targetRoot, targetSubdir);
|
|
256
|
-
if (!(await fileExists(expectedTargetDir))) {
|
|
257
|
-
orphans.push({
|
|
258
|
-
overlayPath: path,
|
|
259
|
-
expectedTargetDir: targetSubdir,
|
|
260
|
-
fileName: basename(path),
|
|
261
|
-
scope: projectName,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
291
|
+
overlayRoot: string,
|
|
292
|
+
): void {
|
|
293
|
+
console.log(`Found ${orphans.length} orphaned overlay path(s):\n`);
|
|
294
|
+
for (const orphan of orphans) {
|
|
295
|
+
console.log(` ${orphan.fileName} (${orphan.scope})`);
|
|
296
|
+
console.log(` Overlay: ${orphan.overlayPath}`);
|
|
297
|
+
console.log(` Expected dir: ${orphan.expectedTargetDir}\n`);
|
|
264
298
|
}
|
|
265
299
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
* @example "tools/packages/wesl/clank/notes.md" -> "tools/packages/wesl"
|
|
273
|
-
* @example "tools/packages/wesl/agents.md" -> "tools/packages/wesl"
|
|
274
|
-
*/
|
|
275
|
-
function extractTargetSubdir(relPath: string): string | null {
|
|
276
|
-
// Check for /clank/ or /claude/ in path
|
|
277
|
-
for (const dir of managedDirs) {
|
|
278
|
-
const idx = relPath.indexOf(`/${dir}/`);
|
|
279
|
-
if (idx !== -1) return relPath.slice(0, idx);
|
|
280
|
-
}
|
|
281
|
-
// Check for agents.md in a subdirectory
|
|
282
|
-
if (relPath.endsWith("/agents.md")) {
|
|
283
|
-
return relPath.slice(0, -"/agents.md".length);
|
|
284
|
-
}
|
|
285
|
-
return null;
|
|
300
|
+
console.log("Target project:", targetRoot);
|
|
301
|
+
console.log("Overlay:", overlayRoot);
|
|
302
|
+
console.log("\nTo fix with an agent, copy this prompt:");
|
|
303
|
+
console.log("─".repeat(50));
|
|
304
|
+
console.log(generateAgentPrompt(orphans, targetRoot, overlayRoot));
|
|
305
|
+
console.log("─".repeat(50));
|
|
286
306
|
}
|
|
287
307
|
|
|
288
308
|
/** Generate agent prompt for fixing orphaned paths */
|
package/src/commands/Commit.ts
CHANGED
|
@@ -18,7 +18,7 @@ export async function commitCommand(
|
|
|
18
18
|
const message = options.message || "update";
|
|
19
19
|
const fullMessage = `[clank] ${message}`;
|
|
20
20
|
|
|
21
|
-
const
|
|
21
|
+
const lines = await getOverlayStatus(overlayRoot);
|
|
22
22
|
|
|
23
23
|
if (lines.length === 0) {
|
|
24
24
|
console.log("Nothing to commit");
|