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,161 @@
1
+ import { rm, unlink } from "node:fs/promises";
2
+ import { basename, dirname, relative } from "node:path";
3
+ import { forEachAgentPath } from "../AgentFiles.ts";
4
+ import { expandPath, loadConfig, validateOverlayExists } from "../Config.ts";
5
+ import { fileExists } from "../FsUtil.ts";
6
+ import { getGitContext } from "../Git.ts";
7
+ import {
8
+ isAgentFile,
9
+ type MapperContext,
10
+ normalizeAddPath,
11
+ resolveScopeFromOptions,
12
+ type Scope,
13
+ type ScopeOptions,
14
+ targetToOverlay,
15
+ } from "../Mapper.ts";
16
+ import { isSymlinkToOverlay } from "../OverlayLinks.ts";
17
+ import { scopeFromSymlink } from "../ScopeFromSymlink.ts";
18
+
19
+ export type RmOptions = ScopeOptions;
20
+
21
+ /** Remove file(s) from overlay and target */
22
+ export async function rmCommand(
23
+ filePaths: string[],
24
+ options: RmOptions = {},
25
+ ): Promise<void> {
26
+ const cwd = process.cwd();
27
+ const gitContext = await getGitContext(cwd);
28
+ const config = await loadConfig();
29
+ const overlayRoot = expandPath(config.overlayRepo);
30
+
31
+ await validateOverlayExists(overlayRoot);
32
+
33
+ const { gitRoot } = gitContext;
34
+
35
+ const context: MapperContext = {
36
+ overlayRoot,
37
+ targetRoot: gitRoot,
38
+ gitContext,
39
+ };
40
+
41
+ for (const filePath of filePaths) {
42
+ const normalizedPath = normalizeAddPath(filePath, cwd, gitRoot);
43
+
44
+ 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
+ }
50
+
51
+ if (isAgentFile(filePath)) {
52
+ await removeAgentFiles(normalizedPath, overlayPath, overlayRoot, config);
53
+ } else {
54
+ await removeFile(normalizedPath, overlayPath, overlayRoot, cwd);
55
+ }
56
+ }
57
+ }
58
+
59
+ /** Resolve which scope to remove from */
60
+ async function resolveScope(
61
+ targetPath: string,
62
+ options: RmOptions,
63
+ context: MapperContext,
64
+ ): Promise<Scope> {
65
+ // Explicit scope takes priority
66
+ if (options.global || options.project || options.worktree) {
67
+ return resolveScopeFromOptions(options);
68
+ }
69
+
70
+ const scope = await scopeFromSymlink(targetPath, context);
71
+ if (scope) return scope;
72
+
73
+ const found = await findInScopes(targetPath, context);
74
+
75
+ 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
+ }
81
+
82
+ if (found.length > 1) {
83
+ const scopeList = found.join(", ");
84
+ throw new Error(
85
+ `File exists in multiple scopes: ${scopeList}\n` +
86
+ `Specify --global, --project, or --worktree`,
87
+ );
88
+ }
89
+
90
+ return found[0];
91
+ }
92
+
93
+ /** Search all scopes to find where the file exists */
94
+ async function findInScopes(
95
+ targetPath: string,
96
+ context: MapperContext,
97
+ ): Promise<Scope[]> {
98
+ const found: Scope[] = [];
99
+
100
+ for (const scope of ["worktree", "project", "global"] as const) {
101
+ const overlayPath = targetToOverlay(targetPath, scope, context);
102
+ if (await fileExists(overlayPath)) {
103
+ found.push(scope);
104
+ }
105
+ }
106
+
107
+ return found;
108
+ }
109
+
110
+ /** Remove a regular file */
111
+ async function removeFile(
112
+ targetPath: string,
113
+ overlayPath: string,
114
+ overlayRoot: string,
115
+ cwd: string,
116
+ ): Promise<void> {
117
+ const fileName = relative(cwd, targetPath);
118
+
119
+ // Check if local file exists and handle it
120
+ if (await fileExists(targetPath)) {
121
+ if (await isSymlinkToOverlay(targetPath, overlayRoot)) {
122
+ await unlink(targetPath);
123
+ console.log(`Removed symlink: ${fileName}`);
124
+ } else {
125
+ throw new Error(
126
+ `File exists but is not managed by clank: ${fileName}\n` +
127
+ `Cannot remove a file that is not a symlink to the overlay`,
128
+ );
129
+ }
130
+ }
131
+
132
+ // Remove from overlay
133
+ await rm(overlayPath);
134
+ console.log(`Removed from overlay: ${basename(overlayPath)}`);
135
+ }
136
+
137
+ /** Remove agent files (CLAUDE.md, GEMINI.md, AGENTS.md → agents.md) */
138
+ async function removeAgentFiles(
139
+ targetPath: string,
140
+ overlayPath: string,
141
+ overlayRoot: string,
142
+ config: { agents: string[] },
143
+ ): Promise<void> {
144
+ const dir = dirname(targetPath);
145
+ const removed: string[] = [];
146
+
147
+ await forEachAgentPath(dir, config.agents, async (linkPath) => {
148
+ if (await isSymlinkToOverlay(linkPath, overlayRoot)) {
149
+ await unlink(linkPath);
150
+ removed.push(basename(linkPath));
151
+ }
152
+ });
153
+
154
+ if (removed.length > 0) {
155
+ console.log(`Removed symlinks: ${removed.join(", ")}`);
156
+ }
157
+
158
+ // Remove agents.md from overlay
159
+ await rm(overlayPath);
160
+ console.log(`Removed from overlay: agents.md`);
161
+ }
@@ -0,0 +1,45 @@
1
+ import { expandPath, loadConfig } from "../Config.ts";
2
+ import { removeGitExcludes } from "../Exclude.ts";
3
+ import { fileExists, removeSymlink, walkDirectory } from "../FsUtil.ts";
4
+ import { getGitContext } from "../Git.ts";
5
+ import { isSymlinkToOverlay } from "../OverlayLinks.ts";
6
+ import { removeVscodeSettings } from "./VsCode.ts";
7
+
8
+ /** Remove all symlinks pointing to overlay repository */
9
+ export async function unlinkCommand(targetDir?: string): Promise<void> {
10
+ const gitContext = await getGitContext(targetDir || process.cwd());
11
+ const targetRoot = gitContext.gitRoot;
12
+
13
+ console.log(`Removing clank symlinks from: ${targetRoot}\n`);
14
+
15
+ // Load config to get overlay path
16
+ const config = await loadConfig();
17
+ const overlayRoot = expandPath(config.overlayRepo);
18
+
19
+ if (!(await fileExists(overlayRoot))) {
20
+ console.error(`Warning: Overlay repository not found at ${overlayRoot}`);
21
+ console.log("Will still attempt to remove symlinks...\n");
22
+ }
23
+
24
+ // Walk directory and remove all symlinks to overlay
25
+ let removedCount = 0;
26
+
27
+ for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
28
+ if (isDirectory) continue;
29
+
30
+ if (await isSymlinkToOverlay(path, overlayRoot)) {
31
+ await removeSymlink(path);
32
+ console.log(`Removed: ${path}`);
33
+ removedCount++;
34
+ }
35
+ }
36
+
37
+ await removeGitExcludes(targetRoot);
38
+ await removeVscodeSettings(targetRoot);
39
+
40
+ if (removedCount === 0) {
41
+ console.log("No clank symlinks found.");
42
+ } else {
43
+ console.log(`\nDone! Removed ${removedCount} symlinks.`);
44
+ }
45
+ }
@@ -0,0 +1,195 @@
1
+ import { readdir, readFile, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { loadConfig } from "../Config.ts";
4
+ import { addToGitExclude } from "../Exclude.ts";
5
+ import {
6
+ ensureDir,
7
+ fileExists,
8
+ isTrackedByGit,
9
+ writeJsonFile,
10
+ } from "../FsUtil.ts";
11
+ import { detectGitRoot } from "../Git.ts";
12
+ import {
13
+ collectGitignorePatterns,
14
+ patternsToVscodeExcludes,
15
+ } from "../Gitignore.ts";
16
+
17
+ export interface VscodeOptions {
18
+ remove?: boolean;
19
+ }
20
+
21
+ /** Generate VS Code settings to show clank files in search and explorer */
22
+ export async function vscodeCommand(options?: VscodeOptions): Promise<void> {
23
+ const targetRoot = await detectGitRoot(process.cwd());
24
+
25
+ if (options?.remove) {
26
+ await removeVscodeSettings(targetRoot);
27
+ return;
28
+ }
29
+
30
+ await generateVscodeSettings(targetRoot);
31
+ }
32
+
33
+ /** Generate VS Code settings for a target directory */
34
+ export async function generateVscodeSettings(
35
+ targetRoot: string,
36
+ ): Promise<void> {
37
+ console.log(`Generating VS Code settings for: ${targetRoot}\n`);
38
+
39
+ const { patterns, negationWarnings } =
40
+ await collectGitignorePatterns(targetRoot);
41
+
42
+ const uniqueWarnings = [...new Set(negationWarnings)];
43
+ for (const pattern of uniqueWarnings) {
44
+ console.log(
45
+ `Warning: Cannot represent negation pattern "!${pattern}" in VS Code settings.`,
46
+ );
47
+ console.log(" This file may be incorrectly hidden.\n");
48
+ }
49
+
50
+ const excludeGlobs = patternsToVscodeExcludes(patterns);
51
+ const excludePatterns: Record<string, boolean> = {};
52
+ for (const glob of excludeGlobs) {
53
+ excludePatterns[glob] = true;
54
+ }
55
+
56
+ const mergedSettings = await mergeVscodeSettings(targetRoot, excludePatterns);
57
+
58
+ await writeVscodeSettings(targetRoot, mergedSettings);
59
+
60
+ const config = await loadConfig();
61
+ if (config.vscodeGitignore !== false) {
62
+ await addVscodeToGitExclude(targetRoot);
63
+ }
64
+
65
+ console.log(
66
+ `\nVS Code will now show clank/ and .claude/ in explorer and search`,
67
+ );
68
+ console.log(
69
+ `(while still respecting your ${excludeGlobs.length} gitignore patterns)`,
70
+ );
71
+ }
72
+
73
+ /** Remove clank-generated VS Code settings */
74
+ export async function removeVscodeSettings(targetRoot: string): Promise<void> {
75
+ const settingsPath = join(targetRoot, ".vscode/settings.json");
76
+
77
+ if (!(await fileExists(settingsPath))) {
78
+ return;
79
+ }
80
+
81
+ const content = await readFile(settingsPath, "utf-8");
82
+
83
+ let settings: Record<string, unknown>;
84
+ try {
85
+ settings = JSON.parse(content);
86
+ } catch {
87
+ console.error("Warning: Could not parse .vscode/settings.json");
88
+ return;
89
+ }
90
+
91
+ // Regenerate the patterns clank would have added
92
+ const { patterns } = await collectGitignorePatterns(targetRoot);
93
+ const clankPatterns = patternsToVscodeExcludes(patterns);
94
+
95
+ // Selectively remove only clank-generated patterns
96
+ removePatterns(settings, "search.exclude", clankPatterns);
97
+ removePatterns(settings, "files.exclude", clankPatterns);
98
+
99
+ // Remove useIgnoreFiles (let VS Code use its default)
100
+ delete settings["search.useIgnoreFiles"];
101
+
102
+ if (Object.keys(settings).length === 0) {
103
+ await unlink(settingsPath);
104
+ console.log("Removed empty .vscode/settings.json");
105
+ } else {
106
+ await writeJsonFile(settingsPath, settings);
107
+ console.log("Removed clank settings from .vscode/settings.json");
108
+ }
109
+ }
110
+
111
+ /** Remove patterns from an exclude object, deleting the key if empty */
112
+ function removePatterns(
113
+ settings: Record<string, unknown>,
114
+ key: string,
115
+ patterns: string[],
116
+ ): void {
117
+ const exclude = settings[key] as Record<string, boolean> | undefined;
118
+ if (!exclude) return;
119
+
120
+ for (const pattern of patterns) {
121
+ delete exclude[pattern];
122
+ }
123
+ if (Object.keys(exclude).length === 0) {
124
+ delete settings[key];
125
+ }
126
+ }
127
+
128
+ /** Merge clank exclude patterns with existing .vscode/settings.json */
129
+ async function mergeVscodeSettings(
130
+ targetRoot: string,
131
+ excludePatterns: Record<string, boolean>,
132
+ ): Promise<Record<string, unknown>> {
133
+ const settingsPath = join(targetRoot, ".vscode/settings.json");
134
+
135
+ let existingSettings: Record<string, unknown> = {};
136
+ if (await fileExists(settingsPath)) {
137
+ const content = await readFile(settingsPath, "utf-8");
138
+ try {
139
+ existingSettings = JSON.parse(content);
140
+ } catch {
141
+ console.warn(
142
+ "Warning: Could not parse existing .vscode/settings.json, overwriting",
143
+ );
144
+ }
145
+ }
146
+
147
+ // Get existing exclude patterns (preserve user's patterns)
148
+ const existingSearchExclude =
149
+ (existingSettings["search.exclude"] as Record<string, boolean>) || {};
150
+ const existingFilesExclude =
151
+ (existingSettings["files.exclude"] as Record<string, boolean>) || {};
152
+
153
+ return {
154
+ ...existingSettings,
155
+ "search.useIgnoreFiles": false,
156
+ "search.exclude": { ...existingSearchExclude, ...excludePatterns },
157
+ "files.exclude": { ...existingFilesExclude, ...excludePatterns },
158
+ };
159
+ }
160
+
161
+ /** Write settings to .vscode/settings.json */
162
+ async function writeVscodeSettings(
163
+ targetRoot: string,
164
+ settings: Record<string, unknown>,
165
+ ): Promise<void> {
166
+ const vscodeDir = join(targetRoot, ".vscode");
167
+ const settingsPath = join(vscodeDir, "settings.json");
168
+
169
+ await ensureDir(vscodeDir);
170
+ await writeJsonFile(settingsPath, settings);
171
+
172
+ console.log(`Wrote ${settingsPath}`);
173
+ }
174
+
175
+ /** Add .vscode/settings.json to .git/info/exclude */
176
+ async function addVscodeToGitExclude(targetRoot: string): Promise<void> {
177
+ const settingsPath = join(targetRoot, ".vscode/settings.json");
178
+ if (await isTrackedByGit(settingsPath, targetRoot)) return;
179
+ await addToGitExclude(targetRoot, ".vscode/settings.json");
180
+ }
181
+
182
+ /** Check if target directory has VS Code artifacts */
183
+ export async function isVscodeProject(targetRoot: string): Promise<boolean> {
184
+ // Check for .vscode directory
185
+ const hasVscodeDir = await fileExists(join(targetRoot, ".vscode"));
186
+ if (hasVscodeDir) return true;
187
+
188
+ // Check for *.code-workspace files
189
+ try {
190
+ const files = await readdir(targetRoot);
191
+ return files.some((f) => f.endsWith(".code-workspace"));
192
+ } catch {
193
+ return false;
194
+ }
195
+ }