clank-cli 0.1.52

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.
@@ -0,0 +1,314 @@
1
+ import { basename, join, relative } from "node:path";
2
+ import { managedDirs, targetManagedDirs } from "../AgentFiles.ts";
3
+ import {
4
+ agentFileProblems,
5
+ classifyAgentFiles,
6
+ formatAgentFileProblems,
7
+ } from "../ClassifyFiles.ts";
8
+ import { expandPath, loadConfig } from "../Config.ts";
9
+ import { fileExists, relativePath, walkDirectory } from "../FsUtil.ts";
10
+ import { type GitContext, getGitContext } from "../Git.ts";
11
+ import { type MapperContext, overlayProjectDir } from "../Mapper.ts";
12
+ import { formatStatusLines, getOverlayStatus } from "../OverlayGit.ts";
13
+ import { type ManagedFileState, verifyManaged } from "../OverlayLinks.ts";
14
+
15
+ export interface OrphanedPath {
16
+ overlayPath: string;
17
+ expectedTargetDir: string;
18
+ fileName: string;
19
+ scope: string;
20
+ }
21
+
22
+ export type UnaddedFile = ManagedFileState & {
23
+ /** Absolute path to the file in the target */
24
+ targetPath: string;
25
+
26
+ /** Path relative to target root */
27
+ relativePath: string;
28
+ };
29
+
30
+ /** Check for orphaned overlay paths that don't match target structure */
31
+ export async function checkCommand(): Promise<void> {
32
+ const cwd = process.cwd();
33
+ const gitContext = await getGitContext(cwd);
34
+ const config = await loadConfig();
35
+ const overlayRoot = expandPath(config.overlayRepo);
36
+ const { gitRoot: targetRoot } = gitContext;
37
+ const ctx: MapperContext = { overlayRoot, targetRoot, gitContext };
38
+
39
+ await showOverlayStatus(overlayRoot);
40
+
41
+ const problems = await checkAllProblems(ctx, cwd);
42
+ if (!problems) {
43
+ console.log("No issues found. Overlay matches target structure.");
44
+ }
45
+ }
46
+
47
+ /** Run all checks and display problems. Returns true if any problems found. */
48
+ async function checkAllProblems(
49
+ ctx: MapperContext,
50
+ cwd: string,
51
+ ): Promise<boolean> {
52
+ const { overlayRoot, targetRoot, gitContext } = ctx;
53
+ let hasProblems = false;
54
+
55
+ const unadded = await findUnaddedFiles(ctx);
56
+ if (unadded.length > 0) {
57
+ hasProblems = true;
58
+ showUnaddedFiles(unadded, cwd, gitContext);
59
+ }
60
+
61
+ const agentClassification = await classifyAgentFiles(
62
+ targetRoot,
63
+ overlayRoot,
64
+ gitContext,
65
+ );
66
+ if (agentFileProblems(agentClassification)) {
67
+ hasProblems = true;
68
+ console.log(formatAgentFileProblems(agentClassification, cwd));
69
+ console.log();
70
+ }
71
+
72
+ const orphans = await findOrphans(
73
+ overlayRoot,
74
+ targetRoot,
75
+ gitContext.projectName,
76
+ );
77
+ if (orphans.length > 0) {
78
+ hasProblems = true;
79
+ showOrphanedPaths(orphans, targetRoot, overlayRoot);
80
+ }
81
+
82
+ return hasProblems;
83
+ }
84
+
85
+ /** Display orphaned paths and remediation prompt */
86
+ function showOrphanedPaths(
87
+ orphans: OrphanedPath[],
88
+ targetRoot: string,
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));
104
+ }
105
+
106
+ /** Show git status of the overlay repository */
107
+ async function showOverlayStatus(overlayRoot: string): Promise<void> {
108
+ if (!(await fileExists(overlayRoot))) {
109
+ console.log("Overlay repository not found\n");
110
+ return;
111
+ }
112
+
113
+ const { lines } = await getOverlayStatus(overlayRoot);
114
+
115
+ console.log(`Overlay: ${overlayRoot}`);
116
+
117
+ if (lines.length === 0) {
118
+ console.log("Status: clean\n");
119
+ return;
120
+ }
121
+
122
+ console.log(`Status: ${lines.length} uncommitted change(s)\n`);
123
+
124
+ for (const formatted of formatStatusLines(lines)) {
125
+ console.log(` ${formatted}`);
126
+ }
127
+ console.log();
128
+ }
129
+
130
+ /** Display unadded files in clank-managed directories */
131
+ function showUnaddedFiles(
132
+ unadded: UnaddedFile[],
133
+ cwd: string,
134
+ gitContext: GitContext,
135
+ ): void {
136
+ const { isWorktree, worktreeName, projectName } = gitContext;
137
+ const targetName = isWorktree
138
+ ? `${projectName}/${worktreeName}`
139
+ : projectName;
140
+
141
+ const outsideOverlay = unadded.filter((f) => f.kind === "outside-overlay");
142
+ const wrongMapping = unadded.filter((f) => f.kind === "wrong-mapping");
143
+ const regularFiles = unadded.filter((f) => f.kind === "unadded");
144
+
145
+ if (outsideOverlay.length > 0) {
146
+ console.log(
147
+ `Found ${outsideOverlay.length} stale symlink(s) in ${targetName}:\n`,
148
+ );
149
+ console.log("These symlinks point outside the clank overlay.");
150
+ console.log("Remove them, then run `clank link` to recreate:\n");
151
+ for (const file of outsideOverlay) {
152
+ console.log(` rm ${relativePath(cwd, file.targetPath)}`);
153
+ }
154
+ console.log();
155
+ }
156
+
157
+ if (wrongMapping.length > 0) {
158
+ console.log(
159
+ `Found ${wrongMapping.length} mislinked symlink(s) in ${targetName}:\n`,
160
+ );
161
+ console.log("These symlinks point to the wrong overlay location.");
162
+ console.log("Remove them, then run `clank link` to recreate:\n");
163
+ for (const file of wrongMapping) {
164
+ console.log(` rm ${relativePath(cwd, file.targetPath)}`);
165
+ if (file.currentTarget && file.expectedTarget) {
166
+ console.log(` points to: ${file.currentTarget}`);
167
+ console.log(` expected: ${file.expectedTarget}`);
168
+ }
169
+ }
170
+ console.log();
171
+ }
172
+
173
+ if (regularFiles.length > 0) {
174
+ console.log(
175
+ `Found ${regularFiles.length} unadded file(s) in ${targetName}:\n`,
176
+ );
177
+ for (const file of regularFiles) {
178
+ console.log(` clank add ${relativePath(cwd, file.targetPath)}`);
179
+ }
180
+ console.log();
181
+ }
182
+ }
183
+
184
+ /** Files that should remain local and not be tracked by clank */
185
+ const localOnlyFiles = ["settings.local.json"];
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,
225
+ targetRoot: string,
226
+ projectName: string,
227
+ ): Promise<OrphanedPath[]> {
228
+ const orphans: OrphanedPath[] = [];
229
+ const projectDir = overlayProjectDir(overlayRoot, projectName);
230
+
231
+ if (!(await fileExists(projectDir))) {
232
+ return orphans;
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
+ }
264
+ }
265
+
266
+ return orphans;
267
+ }
268
+
269
+ /** Extract the target subdirectory from an overlay path
270
+ * @param relPath - Path relative to overlay project dir (e.g., targets/wesl-js/)
271
+ * @returns Target subdirectory path, or null if not a subdirectory file
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;
286
+ }
287
+
288
+ /** Generate agent prompt for fixing orphaned paths */
289
+ function generateAgentPrompt(
290
+ orphans: OrphanedPath[],
291
+ targetRoot: string,
292
+ overlayRoot: string,
293
+ ): string {
294
+ const dirs = [...new Set(orphans.map((o) => o.expectedTargetDir))];
295
+ const dirList = dirs.map((d) => ` - ${d}`).join("\n");
296
+
297
+ return `Clank stores agent files (CLAUDE.md, etc.) in a separate overlay repository and symlinks them into target projects. The overlay directory structure mirrors the target project structure.
298
+
299
+ The following overlay files no longer match the target project structure.
300
+ These directories no longer exist in the target:
301
+ ${dirList}
302
+
303
+ Target project: ${targetRoot}
304
+ Overlay repository: ${overlayRoot}
305
+
306
+ First, run 'clank status' to see the current state.
307
+
308
+ Investigate where these directories moved to in the target project,
309
+ then update the overlay paths to match the new structure.
310
+
311
+ Run 'clank help structure' to see how the overlay maps to targets.
312
+
313
+ When finished, run 'clank status' to verify the fix.`;
314
+ }
@@ -0,0 +1,35 @@
1
+ import { execa } from "execa";
2
+ import { expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
3
+ import { formatStatusLines, getOverlayStatus } from "../OverlayGit.ts";
4
+
5
+ export interface CommitOptions {
6
+ message?: string;
7
+ }
8
+
9
+ /** Commit all changes in the overlay repository */
10
+ export async function commitCommand(
11
+ options: CommitOptions = {},
12
+ ): Promise<void> {
13
+ const config = await loadConfig();
14
+ const overlayRoot = expandPath(config.overlayRepo);
15
+
16
+ await validateOverlayExists(overlayRoot);
17
+
18
+ const message = options.message || "update";
19
+ const fullMessage = `[clank] ${message}`;
20
+
21
+ const { lines } = await getOverlayStatus(overlayRoot);
22
+
23
+ if (lines.length === 0) {
24
+ console.log("Nothing to commit");
25
+ return;
26
+ }
27
+
28
+ await execa({ cwd: overlayRoot })`git add .`;
29
+ await execa({ cwd: overlayRoot })`git commit -m ${fullMessage}`;
30
+
31
+ console.log(`Committed: ${fullMessage}`);
32
+ for (const line of formatStatusLines(lines)) {
33
+ console.log(` ${line}`);
34
+ }
35
+ }
@@ -0,0 +1,62 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { execa } from "execa";
4
+ import {
5
+ createDefaultConfig,
6
+ defaultOverlayDir,
7
+ expandPath,
8
+ } from "../Config.ts";
9
+ import { ensureDir, fileExists } from "../FsUtil.ts";
10
+
11
+ /** Initialize a new clank overlay repository */
12
+ export async function initCommand(overlayPath?: string): Promise<void> {
13
+ const targetPath = overlayPath
14
+ ? expandPath(overlayPath)
15
+ : join(process.env.HOME || "~", defaultOverlayDir);
16
+
17
+ if (await fileExists(targetPath)) {
18
+ const hasTargets = await fileExists(join(targetPath, "targets"));
19
+ const hasGlobal = await fileExists(join(targetPath, "global"));
20
+
21
+ if (hasTargets && hasGlobal) {
22
+ console.log("Overlay repository already exists!");
23
+ return;
24
+ }
25
+ }
26
+
27
+ console.log(`Initializing clank overlay repository at: ${targetPath}`);
28
+
29
+ await ensureDir(join(targetPath, "global/clank"));
30
+ await ensureDir(join(targetPath, "global/claude/commands"));
31
+ await ensureDir(join(targetPath, "global/claude/agents"));
32
+ await ensureDir(join(targetPath, "global/init"));
33
+ await ensureDir(join(targetPath, "targets"));
34
+
35
+ await createDefaultTemplates(targetPath);
36
+
37
+ await createDefaultConfig(targetPath);
38
+
39
+ // Initialize git repo and create initial commit
40
+ await execa({ cwd: targetPath })`git init`;
41
+ await execa({ cwd: targetPath })`git add .`;
42
+ await execa({ cwd: targetPath })`git commit -m ${`[clank] init`}`;
43
+
44
+ console.log(`\nOverlay repository initialized successfully!`);
45
+ console.log(`\nNext steps:`);
46
+ console.log(` 1. cd to your project directory`);
47
+ console.log(` 2. Run 'clank link' to connect your project`);
48
+ console.log(` 3. Use 'clank add <file>' to add files`);
49
+ }
50
+
51
+ /** Create default template files in overlay/global/init/clank/ */
52
+ async function createDefaultTemplates(overlayPath: string): Promise<void> {
53
+ const initClankDir = join(overlayPath, "global/init/clank");
54
+ await ensureDir(initClankDir);
55
+
56
+ const planTemplate = "{{worktree_message}}\n\n# Goals\n";
57
+ await writeFile(join(initClankDir, "plan.md"), planTemplate, "utf-8");
58
+
59
+ await writeFile(join(initClankDir, "notes.md"), "# Notes\n\n", "utf-8");
60
+
61
+ console.log("Created default templates in global/init/clank/");
62
+ }