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.
package/src/Cli.ts ADDED
@@ -0,0 +1,229 @@
1
+ import { Command, Option } from "commander";
2
+ import pkg from "../package.json" with { type: "json" };
3
+ import { defaultOverlayDir, setConfigPath } from "./Config.ts";
4
+ import { addCommand } from "./commands/Add.ts";
5
+ import { checkCommand } from "./commands/Check.ts";
6
+ import { commitCommand } from "./commands/Commit.ts";
7
+ import { initCommand } from "./commands/Init.ts";
8
+ import { linkCommand } from "./commands/Link.ts";
9
+ import { moveCommand } from "./commands/Move.ts";
10
+ import { rmCommand } from "./commands/Rm.ts";
11
+ import { unlinkCommand } from "./commands/Unlink.ts";
12
+ import { vscodeCommand } from "./commands/VsCode.ts";
13
+
14
+ const defaultOverlayMsg = `(default: ~/${defaultOverlayDir})`;
15
+
16
+ const structureHelp = `
17
+ Clank Overlay Directory Structure
18
+ ═════════════════════════════════
19
+
20
+ ~/clankover/
21
+ ├── global/ # Shared across all projects
22
+ │ ├── clank/ # Global files (--global)
23
+ │ │ └── style.md
24
+ │ ├── claude/ # Claude Code specific
25
+ │ │ ├── commands/ # -> .claude/commands/
26
+ │ │ └── agents/ # -> .claude/agents/
27
+ │ └── init/ # Templates for new worktrees
28
+ │ └── notes.md
29
+ └── targets/
30
+ └── <project>/ # Per-project files
31
+ ├── agents.md # Agent instructions (-> CLAUDE.md, etc.)
32
+ ├── clank/ # Project files (--project)
33
+ │ └── overview.md
34
+ ├── claude/ # Claude Code specific
35
+ │ ├── settings.json # -> .claude/settings.json
36
+ │ ├── commands/ # -> .claude/commands/
37
+ │ └── agents/ # -> .claude/agents/
38
+ ├── <subdir>/clank/ # Subdirectory files (monorepo support)
39
+ │ └── notes.md # -> <subdir>/clank/notes.md
40
+ └── worktrees/
41
+ └── <branch>/ # Worktree files (--worktree)
42
+ └── clank/
43
+ └── notes.md
44
+
45
+ Mapping Rules
46
+ ─────────────
47
+ Overlay Path Target Path
48
+ ──────────── ───────────
49
+ global/clank/<file> -> clank/<file>
50
+ global/claude/commands/<file> -> .claude/commands/<file>
51
+ targets/<proj>/clank/<file> -> clank/<file>
52
+ targets/<proj>/claude/commands/ -> .claude/commands/
53
+ targets/<proj>/agents.md -> CLAUDE.md, AGENTS.md, GEMINI.md
54
+ targets/<proj>/<sub>/clank/<file> -> <sub>/clank/<file>
55
+ targets/<proj>/worktrees/<br>/clank -> clank/
56
+
57
+ Scopes
58
+ ──────
59
+ --global Shared across all projects
60
+ --project Shared across all branches (default)
61
+ --worktree This branch only
62
+ `.trim();
63
+ export function createCLI(): Command {
64
+ const program = new Command();
65
+
66
+ program
67
+ .name("clank")
68
+ .description("Keep your AI files in an overlay repository")
69
+ .version(pkg.version)
70
+ .option("-c, --config <path>", `Path to config file ${defaultOverlayMsg}`)
71
+ .hook("preAction", (thisCommand) => {
72
+ const opts = thisCommand.optsWithGlobals();
73
+ if (opts.config) setConfigPath(opts.config);
74
+ });
75
+
76
+ registerCommands(program);
77
+ return program;
78
+ }
79
+
80
+ export async function runCLI(): Promise<void> {
81
+ const program = createCLI();
82
+ await program.parseAsync(process.argv);
83
+ }
84
+
85
+ function registerCommands(program: Command): void {
86
+ registerCoreCommands(program);
87
+ registerHelpCommands(program);
88
+ }
89
+
90
+ function registerCoreCommands(program: Command): void {
91
+ registerOverlayCommands(program);
92
+ registerUtilityCommands(program);
93
+ }
94
+
95
+ function registerOverlayCommands(program: Command): void {
96
+ program
97
+ .command("init")
98
+ .description(
99
+ `Initialize a new clank overlay repository ${defaultOverlayMsg}`,
100
+ )
101
+ .argument(
102
+ "[overlay-path]",
103
+ `Path to overlay repository ${defaultOverlayMsg}`,
104
+ )
105
+ .action(withErrorHandling(initCommand));
106
+
107
+ program
108
+ .command("link")
109
+ .description("Link overlay repository to target directory")
110
+ .argument("[target]", "Target directory (default: current directory)")
111
+ .action(withErrorHandling(linkCommand));
112
+
113
+ program
114
+ .command("add")
115
+ .description("Add file(s) to overlay and create symlinks")
116
+ .argument(
117
+ "<files...>",
118
+ "File path(s) (e.g., style.md, .claude/commands/review.md)",
119
+ )
120
+ .option("-g, --global", "Add to global location (all projects)")
121
+ .option("-p, --project", "Add to project location (default)")
122
+ .option("-w, --worktree", "Add to worktree location (this branch only)")
123
+ .action(withErrorHandling(addCommand));
124
+
125
+ program
126
+ .command("unlink")
127
+ .description("Remove all clank symlinks from target directory")
128
+ .argument("[target]", "Target directory (default: current directory)")
129
+ .action(withErrorHandling(unlinkCommand));
130
+
131
+ registerRmCommand(program);
132
+ registerMvCommand(program);
133
+
134
+ program
135
+ .command("commit")
136
+ .description("Commit all changes in the overlay repository")
137
+ .option("-m, --message <message>", "Commit message")
138
+ .action(withErrorHandling(commitCommand));
139
+ }
140
+
141
+ function registerUtilityCommands(program: Command): void {
142
+ program
143
+ .command("check")
144
+ .alias("status")
145
+ .description("Show overlay status and check for issues")
146
+ .action(withErrorHandling(checkCommand));
147
+
148
+ program
149
+ .command("vscode")
150
+ .description("Generate VS Code settings to show clank files")
151
+ .option("--remove", "Remove clank-generated VS Code settings")
152
+ .action(withErrorHandling(vscodeCommand));
153
+ }
154
+
155
+ function registerHelpCommands(program: Command): void {
156
+ const help = program
157
+ .command("help")
158
+ .description("Show help information")
159
+ .argument("[command]", "Command to show help for")
160
+ .action((commandName?: string) => {
161
+ if (!commandName) {
162
+ program.help();
163
+ }
164
+ const subcommand = program.commands.find((c) => c.name() === commandName);
165
+ if (subcommand) {
166
+ subcommand.help();
167
+ } else {
168
+ console.error(`Unknown command: ${commandName}`);
169
+ process.exit(1);
170
+ }
171
+ });
172
+ help
173
+ .command("structure")
174
+ .description("Show overlay directory structure")
175
+ .action(() => console.log(structureHelp));
176
+ }
177
+
178
+ function registerRmCommand(program: Command): void {
179
+ program
180
+ .command("rm")
181
+ .alias("remove")
182
+ .description("Remove file(s) from overlay and target")
183
+ .argument("<files...>", "File(s) to remove")
184
+ .option("-g, --global", "Remove from global scope")
185
+ .option("-p, --project", "Remove from project scope")
186
+ .option("-w, --worktree", "Remove from worktree scope")
187
+ .action(withErrorHandling(rmCommand));
188
+ }
189
+
190
+ function registerMvCommand(program: Command): void {
191
+ const cmd = program
192
+ .command("mv")
193
+ .alias("move")
194
+ .description("Move file(s) between overlay scopes")
195
+ .argument("<files...>", "File(s) to move");
196
+
197
+ cmd.addOption(
198
+ new Option("-g, --global", "Move to global scope").conflicts([
199
+ "project",
200
+ "worktree",
201
+ ]),
202
+ );
203
+ cmd.addOption(
204
+ new Option("-p, --project", "Move to project scope").conflicts([
205
+ "global",
206
+ "worktree",
207
+ ]),
208
+ );
209
+ cmd.addOption(
210
+ new Option("-w, --worktree", "Move to worktree scope").conflicts([
211
+ "global",
212
+ "project",
213
+ ]),
214
+ );
215
+ cmd.action(withErrorHandling(moveCommand));
216
+ }
217
+
218
+ function withErrorHandling<T extends unknown[]>(
219
+ fn: (...args: T) => Promise<void>,
220
+ ): (...args: T) => Promise<void> {
221
+ return async (...args: T) => {
222
+ try {
223
+ await fn(...args);
224
+ } catch (error) {
225
+ console.error("Error:", error instanceof Error ? error.message : error);
226
+ process.exit(1);
227
+ }
228
+ };
229
+ }
package/src/Config.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { cosmiconfig } from "cosmiconfig";
5
+ import { fileExists } from "./FsUtil.ts";
6
+
7
+ export const defaultOverlayDir = "clankover";
8
+
9
+ export interface ClankConfig {
10
+ overlayRepo: string;
11
+ agents: string[];
12
+ /** Generate .vscode/settings.json to make clank files visible in search/explorer */
13
+ vscodeSettings?: "auto" | "always" | "never";
14
+ /** Add .vscode/settings.json to .git/info/exclude (default: true) */
15
+ vscodeGitignore?: boolean;
16
+ }
17
+
18
+ const defaultConfig: ClankConfig = {
19
+ overlayRepo: join(homedir(), defaultOverlayDir),
20
+ agents: ["agents", "claude", "gemini"],
21
+ };
22
+
23
+ const explorer = cosmiconfig("clank");
24
+ let customConfigPath: string | undefined;
25
+
26
+ /** path to user's clank config file */
27
+ export function setConfigPath(path: string | undefined): void {
28
+ customConfigPath = path;
29
+ }
30
+
31
+ /** Load global clank configuration from ~/.config/clank/config.js or similar */
32
+ export async function loadConfig(): Promise<ClankConfig> {
33
+ if (customConfigPath) {
34
+ const result = await explorer.load(customConfigPath);
35
+ if (!result) {
36
+ throw new Error(`Config file not found: ${customConfigPath}`);
37
+ }
38
+ if (result.isEmpty) {
39
+ return defaultConfig;
40
+ }
41
+ return { ...defaultConfig, ...result.config };
42
+ }
43
+
44
+ const result = await explorer.search(getConfigDir());
45
+ if (!result || result.isEmpty) {
46
+ return defaultConfig;
47
+ }
48
+ return { ...defaultConfig, ...result.config };
49
+ }
50
+
51
+ /** Create default configuration file at ~/.config/clank/config.js */
52
+ export async function createDefaultConfig(overlayRepo?: string): Promise<void> {
53
+ const configDir = getConfigDir();
54
+ const configPath = customConfigPath || join(configDir, "config.js");
55
+
56
+ const config = {
57
+ ...defaultConfig,
58
+ ...(overlayRepo && { overlayRepo }),
59
+ };
60
+
61
+ const content = `export default ${JSON.stringify(config, null, 2)};\n`;
62
+
63
+ if (!customConfigPath) {
64
+ await mkdir(configDir, { recursive: true });
65
+ }
66
+
67
+ await writeFile(configPath, content, "utf-8");
68
+ console.log(`Config file created at: ${configPath}`);
69
+ }
70
+
71
+ /** Expand ~ in paths to home directory */
72
+ export function expandPath(path: string): string {
73
+ if (path.startsWith("~/") || path === "~") {
74
+ return join(homedir(), path.slice(1));
75
+ }
76
+ return path;
77
+ }
78
+
79
+ /** Get the expanded overlay repository path from config */
80
+ export async function getOverlayPath(): Promise<string> {
81
+ const config = await loadConfig();
82
+ return expandPath(config.overlayRepo);
83
+ }
84
+
85
+ /** Get the XDG config directory (respects XDG_CONFIG_HOME, defaults to ~/.config/clank) */
86
+ function getConfigDir(): string {
87
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
88
+ return join(xdgConfig, "clank");
89
+ }
90
+
91
+ /** Validate overlay repository exists, throw if not */
92
+ export async function validateOverlayExists(overlayRoot: string): Promise<void> {
93
+ if (!(await fileExists(overlayRoot))) {
94
+ throw new Error(
95
+ `Overlay repository not found at ${overlayRoot}\nRun 'clank init' to create it`,
96
+ );
97
+ }
98
+ }
package/src/Exclude.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { agentFiles, targetManagedDirs } from "./AgentFiles.ts";
4
+ import { execFileAsync } from "./Exec.ts";
5
+ import { isTrackedByGit, readFileIfExists } from "./FsUtil.ts";
6
+ import { getGitCommonDir, getGitDir } from "./Git.ts";
7
+
8
+ export const clankMarkerStart = "# Added by clank";
9
+ export const clankMarkerEnd = "# End clank";
10
+
11
+ /** Add clank entries to .git/info/exclude */
12
+ export async function addGitExcludes(targetRoot: string): Promise<void> {
13
+ const gitDir = await getGitCommonDir(targetRoot);
14
+ if (!gitDir) return;
15
+
16
+ const infoDir = join(gitDir, "info");
17
+ const excludePath = join(infoDir, "exclude");
18
+
19
+ // Ensure info directory exists (may not exist in worktrees)
20
+ await mkdir(infoDir, { recursive: true });
21
+
22
+ let content = (await readFileIfExists(excludePath)) ?? "";
23
+
24
+ // Remove existing clank section to rebuild with current entries
25
+ if (content.includes(clankMarkerStart)) {
26
+ content = removeClankSection(content);
27
+ }
28
+
29
+ // Build exclude list dynamically based on what's not tracked
30
+ const excludeList: string[] = [];
31
+
32
+ // Only exclude managed directories if they have no tracked files
33
+ for (const dir of targetManagedDirs) {
34
+ if (!(await hasTrackedFiles(dir, targetRoot))) {
35
+ excludeList.push(`${dir}/`);
36
+ }
37
+ }
38
+
39
+ // Only exclude agent files if not tracked
40
+ for (const agentFile of agentFiles) {
41
+ const agentPath = join(targetRoot, agentFile);
42
+ if (!(await isTrackedByGit(agentPath, targetRoot))) {
43
+ excludeList.push(agentFile);
44
+ }
45
+ }
46
+
47
+ const clankSection = [
48
+ "",
49
+ clankMarkerStart,
50
+ ...excludeList,
51
+ clankMarkerEnd,
52
+ "",
53
+ ].join("\n");
54
+
55
+ await writeFile(excludePath, content + clankSection, "utf-8");
56
+ console.log("Added clank entries to .git/info/exclude");
57
+ }
58
+
59
+ /** Add a single entry to the clank block in .git/info/exclude */
60
+ export async function addToGitExclude(
61
+ targetRoot: string,
62
+ entry: string,
63
+ ): Promise<void> {
64
+ const gitDir = await getGitCommonDir(targetRoot);
65
+ if (!gitDir) return;
66
+
67
+ const infoDir = join(gitDir, "info");
68
+ const excludePath = join(infoDir, "exclude");
69
+
70
+ await mkdir(infoDir, { recursive: true });
71
+
72
+ let content = (await readFileIfExists(excludePath)) ?? "";
73
+ if (content.includes(entry)) return;
74
+
75
+ if (content.includes(clankMarkerStart)) {
76
+ content = content.replace(clankMarkerEnd, `${entry}\n${clankMarkerEnd}`);
77
+ } else {
78
+ content += `\n${clankMarkerStart}\n${entry}\n${clankMarkerEnd}\n`;
79
+ }
80
+
81
+ await writeFile(excludePath, content, "utf-8");
82
+ }
83
+
84
+ /** Remove clank entries from .git/info/exclude */
85
+ export async function removeGitExcludes(targetRoot: string): Promise<void> {
86
+ // Don't modify shared excludes from a worktree
87
+ if (await isWorktree(targetRoot)) return;
88
+
89
+ const gitDir = await getGitCommonDir(targetRoot);
90
+ if (!gitDir) return;
91
+
92
+ const excludePath = join(gitDir, "info/exclude");
93
+ const content = await readFileIfExists(excludePath);
94
+
95
+ if (!content || !content.includes(clankMarkerStart)) {
96
+ return; // No clank entries
97
+ }
98
+
99
+ const newContent = removeClankSection(content);
100
+
101
+ await writeFile(excludePath, newContent, "utf-8");
102
+ console.log("Removed clank entries from .git/info/exclude");
103
+ }
104
+
105
+ /** Remove the clank section from exclude file content */
106
+ function removeClankSection(content: string): string {
107
+ const pattern = new RegExp(
108
+ `\\n*${clankMarkerStart}[\\s\\S]*?${clankMarkerEnd}\\n*`,
109
+ "g",
110
+ );
111
+ return content.replace(pattern, "\n");
112
+ }
113
+
114
+ /** Filter out clank section from lines */
115
+ export function filterClankLines(lines: string[]): string[] {
116
+ const result: string[] = [];
117
+ let inClankSection = false;
118
+
119
+ for (const line of lines) {
120
+ const trimmed = line.trim();
121
+ if (trimmed === clankMarkerStart) {
122
+ inClankSection = true;
123
+ } else if (trimmed === clankMarkerEnd) {
124
+ inClankSection = false;
125
+ } else if (!inClankSection) {
126
+ result.push(line);
127
+ }
128
+ }
129
+ return result;
130
+ }
131
+
132
+ /** Check if a directory has any tracked files */
133
+ async function hasTrackedFiles(
134
+ dirPath: string,
135
+ repoRoot: string,
136
+ ): Promise<boolean> {
137
+ try {
138
+ const { stdout } = await execFileAsync("git", ["ls-files", "--", dirPath], {
139
+ cwd: repoRoot,
140
+ });
141
+ return stdout.trim().length > 0;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /** Check if we're in a worktree (git-dir differs from git-common-dir) */
148
+ async function isWorktree(targetRoot: string): Promise<boolean> {
149
+ const [gitDir, commonDir] = await Promise.all([
150
+ getGitDir(targetRoot),
151
+ getGitCommonDir(targetRoot),
152
+ ]);
153
+ return gitDir !== commonDir;
154
+ }
package/src/Exec.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { exec as execCallback, execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ export const exec = promisify(execCallback);
5
+ export const execFileAsync = promisify(execFile);
package/src/FsUtil.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { lstat, mkdir, readdir, readFile, readlink, symlink, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative } from "node:path";
3
+ import { execFileAsync } from "./Exec.ts";
4
+
5
+ /**
6
+ * Create a symbolic link, removing existing link/file first
7
+ * @param target - The path the symlink should point to (absolute)
8
+ * @param linkPath - The location of the symlink itself (absolute)
9
+ */
10
+ export async function createSymlink(
11
+ target: string,
12
+ linkPath: string,
13
+ ): Promise<void> {
14
+ await ensureDir(dirname(linkPath));
15
+
16
+ try {
17
+ await unlink(linkPath);
18
+ } catch {
19
+ // File doesn't exist, which is fine
20
+ }
21
+
22
+ await symlink(target, linkPath);
23
+ }
24
+
25
+ /** Remove a symlink if it exists */
26
+ export async function removeSymlink(linkPath: string): Promise<void> {
27
+ try {
28
+ const stats = await lstat(linkPath);
29
+ if (stats.isSymbolicLink()) {
30
+ await unlink(linkPath);
31
+ }
32
+ } catch {
33
+ // Symlink doesn't exist, which is fine
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Get the symlink target path (absolute)
39
+ * @param _from - The location of the symlink (unused, kept for API compatibility)
40
+ * @param to - The target of the symlink (absolute)
41
+ * @returns Absolute path to the target
42
+ */
43
+ export function getLinkTarget(_from: string, to: string): string {
44
+ return to;
45
+ }
46
+
47
+ /** Recursively walk a directory, yielding all files and directories */
48
+ export async function* walkDirectory(
49
+ dir: string,
50
+ options: { skipDirs?: string[] } = {},
51
+ ): AsyncGenerator<{ path: string; isDirectory: boolean }> {
52
+ const skipDirs = options.skipDirs || [".git", "node_modules"];
53
+
54
+ try {
55
+ const entries = await readdir(dir, { withFileTypes: true });
56
+
57
+ for (const entry of entries) {
58
+ if (skipDirs.includes(entry.name)) continue;
59
+
60
+ const fullPath = join(dir, entry.name);
61
+
62
+ if (entry.isDirectory()) {
63
+ yield { path: fullPath, isDirectory: true };
64
+ yield* walkDirectory(fullPath, options);
65
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
66
+ yield { path: fullPath, isDirectory: false };
67
+ }
68
+ }
69
+ } catch (_error) {
70
+ // Directory doesn't exist or can't be read
71
+ return;
72
+ }
73
+ }
74
+
75
+ /** Ensure directory exists, creating it recursively if needed */
76
+ export async function ensureDir(dirPath: string): Promise<void> {
77
+ await mkdir(dirPath, { recursive: true });
78
+ }
79
+
80
+ /** Check if a file exists */
81
+ export async function fileExists(filePath: string): Promise<boolean> {
82
+ try {
83
+ await lstat(filePath);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /** Check if a file is tracked by git */
91
+ export async function isTrackedByGit(
92
+ filePath: string,
93
+ repoRoot: string,
94
+ ): Promise<boolean> {
95
+ try {
96
+ const relPath = relative(repoRoot, filePath);
97
+ await execFileAsync("git", ["ls-files", "--error-unmatch", relPath], {
98
+ cwd: repoRoot,
99
+ });
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** Check if a path is a symlink */
107
+ export async function isSymlink(filePath: string): Promise<boolean> {
108
+ try {
109
+ const stats = await lstat(filePath);
110
+ return stats.isSymbolicLink();
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /** Get path relative to cwd, or "." if same directory */
117
+ export function relativePath(cwd: string, path: string): string {
118
+ return relative(cwd, path) || ".";
119
+ }
120
+
121
+ /** Resolve a symlink to its absolute target path */
122
+ export async function resolveSymlinkTarget(linkPath: string): Promise<string> {
123
+ const target = await readlink(linkPath);
124
+ return isAbsolute(target) ? target : join(dirname(linkPath), target);
125
+ }
126
+
127
+ /** Read a file if it exists, returning null if not found */
128
+ export async function readFileIfExists(
129
+ filePath: string,
130
+ ): Promise<string | null> {
131
+ if (!(await fileExists(filePath))) return null;
132
+ return await readFile(filePath, "utf-8");
133
+ }
134
+
135
+ /** Read and parse a JSON file */
136
+ export async function readJsonFile<T = Record<string, unknown>>(
137
+ filePath: string,
138
+ ): Promise<T | null> {
139
+ const content = await readFileIfExists(filePath);
140
+ if (!content) return null;
141
+ try {
142
+ return JSON.parse(content) as T;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /** Write an object as formatted JSON */
149
+ export async function writeJsonFile(
150
+ filePath: string,
151
+ data: unknown,
152
+ ): Promise<void> {
153
+ await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`);
154
+ }