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/README.md ADDED
@@ -0,0 +1,312 @@
1
+ # Clank
2
+
3
+ Store AI agent files and notes in a separate git repository. Git-ignored symlinks make the files visible in your projects.
4
+
5
+ - **`clank add`** to move files to the overlay.
6
+ - **`clank link`** to connect overlay files to your project.
7
+ - **`clank unlink`** to disconnect.
8
+ - **`clank commit`** to commit changes in the overlay repository.
9
+ - **`clank check`** to show overlay status and help realign overlay files when your project restructures.
10
+
11
+ ## Why a Separate Repository?
12
+
13
+ Clank stores your AI agent files (CLAUDE.md, commands, notes) in a separate git repository
14
+ symlinks them into your project. This separation provides key advantages:
15
+
16
+ - **Different Review Cadence**: Update agent instructions, commands, and notes without requiring the same review process as production code.
17
+ - **Work on Repos You Don't Control**: Add agent context to open source projects or third-party codebases without forking or modifying.
18
+ - **Persist Knowledge Across Forks**: Keep your agent context when working across multiple forks of the same project.
19
+
20
+ ## Features
21
+
22
+ - **Separate Tracking**: Agent files live in their own repository with independent version control.
23
+ - **Multi-Agent Support**: Single source file, multiple symlinks (AGENTS.md, CLAUDE.md, GEMINI.md).
24
+ - **Worktree-Aware**: Works seamlessly with git worktrees.
25
+ - **Git Ignored**: Agent files are ignored in the main repo, tracked in the overlay repo.
26
+ - **Three Scopes**: Global (all projects), Project (all branches), Worktree (this branch only).
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ npm install -g clank
32
+ ```
33
+
34
+ Or use directly with npx:
35
+
36
+ ```bash
37
+ npx clank init
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ### 1. Initialize Overlay Repository
43
+
44
+ ```bash
45
+ clank init
46
+ # Creates ~/clankover with default structure
47
+ # Creates ~/.config/clank/config.js
48
+ ```
49
+
50
+ ### 2. Link to Your Project
51
+
52
+ ```bash
53
+ cd ~/my-project
54
+ clank link
55
+ # Auto-detects project name from git
56
+ # Creates symlinks from overlay to current directory
57
+ ```
58
+
59
+ ### 3. Add Files with `clank add`
60
+
61
+ The `clank add` command moves files to the overlay and creates symlinks.
62
+ Agent files (CLAUDE.md, AGENTS.md) stay in place; other files go in `clank/`.
63
+
64
+ > You can run `clank add` from any subdirectory. Agent files and `clank/` folders work at any level in your project tree.
65
+
66
+ ```bash
67
+ # Add to project scope (default) - shared across all branches
68
+ clank add CLAUDE.md
69
+
70
+ # Add to global scope - shared across all projects
71
+ clank add style.md --global
72
+
73
+ # Add to worktree scope - this branch only
74
+ clank add notes.md --worktree
75
+
76
+ # Add commands
77
+ clank add .claude/commands/review.md --global # All projects
78
+ clank add .claude/commands/build.md # This project
79
+
80
+ # Add a directory (all files inside)
81
+ clank add clank/
82
+ ```
83
+
84
+ ## Commands
85
+
86
+ ### `clank init [overlay-path]`
87
+
88
+ Run once to create the overlay repository (default: `~/clankover`) and config file.
89
+
90
+ ```bash
91
+ clank init # Default: ~/clankover
92
+ clank init ~/my-clankover # Custom location
93
+ ```
94
+
95
+ ### `clank link [target]`
96
+
97
+ Create symlinks from the overlay's agent files and notes into your project (current project by default).
98
+
99
+ ```bash
100
+ clank link # Link current project to clank
101
+ clank link ~/my-project # Link to specific project
102
+ ```
103
+
104
+ ### `clank add <file> [options]`
105
+
106
+ Move a file to the overlay and replace it with a symlink.
107
+ If the file doesn't exist, an empty file is created.
108
+
109
+ **Scope Options:**
110
+
111
+ | Flag | Scope | Shared Across |
112
+ |------|-------|---------------|
113
+ | `--global` | Global | All projects |
114
+ | `--project` | Project (default) | All branches in project |
115
+ | `--worktree` | Worktree | This branch only |
116
+
117
+ **Examples:**
118
+ ```bash
119
+ clank add style.md # Project scope (default)
120
+ clank add style.md --global # Global scope
121
+ clank add notes.md --worktree # Worktree scope
122
+ clank add .claude/commands/review.md --global # Global command
123
+ clank add .claude/commands/build.md # Project command (default)
124
+ clank add CLAUDE.md # Creates agents.md + agent symlinks
125
+ ```
126
+
127
+ ### `clank unlink [target]`
128
+
129
+ Remove all clank symlinks from target directory.
130
+
131
+ **Example:**
132
+ ```bash
133
+ clank unlink # Unlink current directory
134
+ clank unlink ~/my-project
135
+ ```
136
+
137
+ ### `clank commit [-m message]`
138
+
139
+ Commit all changes in the overlay repository.
140
+
141
+ **Options:**
142
+ - `-m, --message <message>` - Commit message (default: "update")
143
+
144
+ All commits are prefixed with `[clank]` and include a summary of changed files.
145
+
146
+ **Example:**
147
+ ```bash
148
+ clank commit # Commits with "[clank] update"
149
+ clank commit -m "add style guide" # Commits with "[clank] add style guide"
150
+ ```
151
+
152
+ ### `clank check`
153
+
154
+ Check for orphaned overlay paths that don't match the target project structure.
155
+ Useful when a target project has renamed directories and the overlay needs updating.
156
+
157
+ Outputs an agent-friendly prompt to help fix mismatches.
158
+
159
+ **Example:**
160
+ ```bash
161
+ clank check
162
+ # Found 2 orphaned overlay path(s):
163
+ # notes.md (my-project)
164
+ # Expected dir: packages/old-name
165
+ # ...
166
+ # To fix with an agent, copy this prompt:
167
+ # ──────────────────────────────────────────────────
168
+ # The following overlay files no longer match...
169
+ ```
170
+
171
+ ### `clank help structure`
172
+
173
+ Show the overlay directory structure and mapping rules.
174
+
175
+ ```bash
176
+ clank help structure
177
+ ```
178
+
179
+ ### `--config <path>` (global option)
180
+
181
+ Specify a custom config file location (default `~/.config/clank/config.js`).
182
+
183
+ ```bash
184
+ clank --config /tmp/test-config.js init /tmp/test-overlay
185
+ clank --config /tmp/test-config.js link
186
+ ```
187
+
188
+ ## Project Symlinks
189
+
190
+ Clank places symlinks in your project referencing the relevant files in the overlay repository.
191
+
192
+ ```
193
+ ~/my-project/
194
+ ├── CLAUDE.md # Agent file (→ overlay)
195
+ ├── GEMINI.md # Same content, different name
196
+ ├── .claude/commands/ # Claude commands (→ overlay)
197
+ ├── clank/notes.md # Notes and other files (→ overlay)
198
+ └── packages/core/
199
+ ├── CLAUDE.md # Package-level agent file
200
+ ├── GEMINI.md
201
+ └── clank/architecture.md # Package-level notes
202
+ ```
203
+
204
+ `clank link` configures git to ignore the symlinks.
205
+
206
+ ### Scope Suffixes
207
+
208
+ If you add a file with the same name at different scopes (e.g., `notes.md` with both `--global` and `--worktree`), Clank distinguishes them with suffixes:
209
+
210
+ ```
211
+ clank/
212
+ ├── notes.md # Global (no suffix)
213
+ ├── notes-project.md # Project
214
+ └── notes-worktree.md # Worktree
215
+ ```
216
+
217
+ ## Configuration
218
+
219
+ Global configuration is stored by default in `~/.config/clank/config.js`:
220
+
221
+ ```javascript
222
+ export default {
223
+ overlayRepo: "~/clankover",
224
+ agents: ["agents", "claude", "gemini"],
225
+ vscodeSettings: "auto", // "auto" | "always" | "never"
226
+ vscodeGitignore: true
227
+ };
228
+ ```
229
+
230
+ - `agents` - which symlinks to create for agent files like CLAUDE.md
231
+ - `vscodeSettings` - when to generate `.vscode/settings.json` to show clank files in VS Code
232
+ - `"auto"` (default): only if project already has a `.vscode` directory
233
+ - `"always"`: always generate settings
234
+ - `"never"`: never auto-generate (you can still run `clank vscode` manually)
235
+ - `vscodeGitignore` - add `.vscode/settings.json` to `.git/info/exclude` (default: true)
236
+
237
+ By default, clank creates symlinks for AGENTS.md, CLAUDE.md, and GEMINI.md.
238
+ Run `clank unlink` then `clank link` to apply config changes.
239
+
240
+ ## Worktree Templates
241
+
242
+ Customize `overlay/global/init/` to create starter notes and planning files
243
+ for worktrees.
244
+ When you run `clank link` in a new worktree,
245
+ these templates are copied into your overlay.
246
+
247
+ Available placeholders:
248
+
249
+ - `{{worktree_message}}` - "This is git worktree {branch} of project {project}."
250
+ - `{{project_name}}` - Project name from git
251
+ - `{{branch_name}}` - Current branch/worktree name
252
+
253
+ ## Design Principles
254
+
255
+ 1. **Everything is linked, nothing is copied** - Single source of truth in overlay
256
+ 2. **Git-aware** - Automatic project and worktree detection
257
+ 3. **Explicit scopes** - Three clear levels: global, project, worktree
258
+ 4. **Flat target structure** - All clank notes show up together in `clank/`
259
+ 5. **Simple commands** - mostly `clank add` and `clank link`
260
+
261
+ ## Reference
262
+
263
+ ### Overlay Repository Structure
264
+
265
+ ```
266
+ ~/clankover/
267
+ ├── global/
268
+ │ ├── clank/ # Global files (--global)
269
+ │ │ └── style.md
270
+ │ ├── prompts/ # -> .claude/prompts/, .gemini/prompts/
271
+ │ │ └── review.md
272
+ │ ├── claude/ # Claude Code specific
273
+ │ │ ├── commands/ # -> .claude/commands/
274
+ │ │ └── agents/ # -> .claude/agents/
275
+ │ ├── gemini/ # Gemini specific
276
+ │ │ └── commands/ # -> .gemini/commands/
277
+ │ └── init/ # Templates for new worktrees
278
+ │ └── clank/
279
+ │ └── notes.md
280
+ └── targets/
281
+ └── my-project/
282
+ ├── agents.md # Agent instructions (source of truth)
283
+ ├── clank/ # Project files (--project)
284
+ │ └── overview.md
285
+ ├── prompts/ # -> .claude/prompts/, .gemini/prompts/
286
+ │ └── manifest.md
287
+ ├── claude/ # Claude Code specific
288
+ │ ├── settings.json # -> .claude/settings.json
289
+ │ ├── commands/ # -> .claude/commands/
290
+ │ └── agents/ # -> .claude/agents/
291
+ ├── gemini/ # Gemini specific
292
+ │ └── commands/ # -> .gemini/commands/
293
+ └── worktrees/
294
+ ├── main/
295
+ │ ├── clank/ # Worktree files (--worktree)
296
+ │ │ └── notes.md
297
+ │ ├── prompts/ # Worktree-specific prompts
298
+ │ └── agents.md # Worktree agents file (optional)
299
+ └── feature-auth/
300
+ └── clank/
301
+ └── notes.md
302
+ ```
303
+
304
+ ## Requirements
305
+
306
+ - Node.js >= 22.6.0
307
+ - Git repository (for project/worktree detection)
308
+ - macOS, Linux, or [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)
309
+
310
+ ## License
311
+
312
+ MIT
package/bin/clank.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env -S node --experimental-strip-types
2
+
3
+ import { runCLI } from "../src/Cli.ts";
4
+
5
+ runCLI().catch((error) => {
6
+ console.error("Fatal error:", error);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "clank-cli",
3
+ "version": "0.1.52",
4
+ "description": "Keep AI files in a separate overlay repository",
5
+ "type": "module",
6
+ "bin": {
7
+ "clank": "./bin/clank.ts"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22.6.0"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "assistant",
19
+ "claude",
20
+ "gemini",
21
+ "copilot",
22
+ "configuration",
23
+ "worktree",
24
+ "git"
25
+ ],
26
+ "author": "",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "commander": "^14.0.2",
30
+ "cosmiconfig": "^9.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@biomejs/biome": "^2.3.8",
34
+ "@types/node": "^24.10.1",
35
+ "execa": "^9.6.1",
36
+ "monobump": "^0.1.5",
37
+ "typescript": "^5.9.3",
38
+ "vitest": "^4.0.14"
39
+ },
40
+ "scripts": {
41
+ "test": "vitest",
42
+ "test:ui": "vitest --ui",
43
+ "typecheck": "tsc --noEmit",
44
+ "bump": "monobump",
45
+ "global": "pnpm link --global",
46
+ "build": "tsc",
47
+ "lint": "biome check",
48
+ "fix": "biome check --fix --unsafe"
49
+ }
50
+ }
@@ -0,0 +1,40 @@
1
+ import { join } from "node:path";
2
+
3
+ /** Base agent directory names (without leading dot) */
4
+ export const agentDirNames = ["claude", "gemini"];
5
+
6
+ /** Agent directories as they appear in target (with leading dot) */
7
+ export const managedAgentDirs = agentDirNames.map((d) => `.${d}`);
8
+
9
+ /** Agent file names that clank manages */
10
+ export const agentFiles = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"];
11
+
12
+ /** Directory names managed by clank (as stored in overlay, without leading dot) */
13
+ export const managedDirs = ["clank", "prompts", ...agentDirNames];
14
+
15
+ /** Directory names managed by clank (as stored in target, with leading dot) */
16
+ export const targetManagedDirs = ["clank", ...managedAgentDirs];
17
+
18
+ /** Build agent file paths mapping for a directory */
19
+ export function getAgentFilePaths(dir: string): Record<string, string> {
20
+ return {
21
+ agents: join(dir, "AGENTS.md"),
22
+ claude: join(dir, "CLAUDE.md"),
23
+ gemini: join(dir, "GEMINI.md"),
24
+ };
25
+ }
26
+
27
+ /** Iterate over agent file paths, calling fn for each configured agent */
28
+ export async function forEachAgentPath(
29
+ dir: string,
30
+ agents: string[],
31
+ fn: (agentPath: string, agentName: string) => Promise<void>,
32
+ ): Promise<void> {
33
+ const agentPaths = getAgentFilePaths(dir);
34
+ for (const agent of agents) {
35
+ const key = agent.toLowerCase();
36
+ const agentPath = agentPaths[key];
37
+ if (!agentPath) continue;
38
+ await fn(agentPath, agent);
39
+ }
40
+ }
@@ -0,0 +1,203 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { agentFiles } from "./AgentFiles.ts";
4
+ import { isTrackedByGit, relativePath, resolveSymlinkTarget, walkDirectory } from "./FsUtil.ts";
5
+ import type { GitContext } from "./Git.ts";
6
+ import { type MapperContext, targetToOverlay } from "./Mapper.ts";
7
+
8
+ /** Agent files grouped by problem type */
9
+ export interface AgentFileClassification {
10
+ /** Tracked in git - needs conversion (git rm --cached + clank add) */
11
+ tracked: string[];
12
+
13
+ /** Untracked real files - needs clank add first */
14
+ untracked: string[];
15
+
16
+ /** Symlinks pointing somewhere other than overlay - needs removal */
17
+ staleSymlinks: string[];
18
+
19
+ /** Symlinks pointing to wrong path in overlay (e.g., after rename) */
20
+ outdatedSymlinks: OutdatedSymlink[];
21
+ }
22
+
23
+ type PartialClassification = Partial<AgentFileClassification>;
24
+
25
+ export interface OutdatedSymlink {
26
+ /** Path to the symlink in the target */
27
+ symlinkPath: string;
28
+
29
+ /** Where it currently points */
30
+ currentTarget: string;
31
+
32
+ /** Where it should point based on current location */
33
+ expectedTarget: string;
34
+ }
35
+
36
+ /** Find all agent files in the repository and classify them.
37
+ * Returns absolute paths in the classification.
38
+ */
39
+ export async function classifyAgentFiles(
40
+ targetRoot: string,
41
+ overlayRoot: string,
42
+ gitContext?: GitContext,
43
+ ): Promise<AgentFileClassification> {
44
+ const allAgentFiles = await findAllAgentFiles(targetRoot);
45
+ const mapperCtx: MapperContext | undefined = gitContext
46
+ ? { overlayRoot, targetRoot, gitContext }
47
+ : undefined;
48
+
49
+ const classifications = await Promise.all(
50
+ allAgentFiles.map((f) =>
51
+ classifySingleAgentFile(f, targetRoot, overlayRoot, mapperCtx),
52
+ ),
53
+ );
54
+
55
+ return mergeClassifications(classifications);
56
+ }
57
+
58
+ /** @return true if classification has any problems */
59
+ export function agentFileProblems(
60
+ classification: AgentFileClassification,
61
+ ): boolean {
62
+ return (
63
+ classification.tracked.length > 0 ||
64
+ classification.untracked.length > 0 ||
65
+ classification.staleSymlinks.length > 0 ||
66
+ classification.outdatedSymlinks.length > 0
67
+ );
68
+ }
69
+
70
+ /** Format all agent file problems as a single message.
71
+ * Paths are formatted relative to cwd for copy-paste convenience.
72
+ */
73
+ export function formatAgentFileProblems(
74
+ classified: AgentFileClassification,
75
+ cwd: string,
76
+ ): string {
77
+ const sections: string[] = [];
78
+ const rel = (p: string) => relativePath(cwd, p);
79
+
80
+ if (classified.tracked.length > 0) {
81
+ const commands = [
82
+ ...classified.tracked.map((p) => ` git rm --cached ${rel(p)}`),
83
+ ...classified.tracked.map((p) => ` clank add ${rel(p)}`),
84
+ ];
85
+ sections.push(`Found tracked agent files. Clank manages agent files via symlinks.
86
+
87
+ To convert to clank management:
88
+ ${commands.join("\n")}`);
89
+ }
90
+
91
+ if (classified.untracked.length > 0) {
92
+ const commands = classified.untracked.map((p) => ` clank add ${rel(p)}`);
93
+ sections.push(`Found untracked agent files.
94
+
95
+ Add them to clank:
96
+ ${commands.join("\n")}`);
97
+ }
98
+
99
+ if (classified.staleSymlinks.length > 0) {
100
+ const commands = classified.staleSymlinks.map((p) => ` rm ${rel(p)}`);
101
+ sections.push(`Found stale agent symlinks (not pointing to clank overlay).
102
+
103
+ Remove them, then run \`clank link\` to recreate:
104
+ ${commands.join("\n")}`);
105
+ }
106
+
107
+ if (classified.outdatedSymlinks.length > 0) {
108
+ const details = classified.outdatedSymlinks.map((s) => {
109
+ const symlinkRel = rel(s.symlinkPath);
110
+ return ` ${symlinkRel}\n points to: ${s.currentTarget}\n expected: ${s.expectedTarget}`;
111
+ });
112
+ sections.push(`Found outdated agent symlinks (pointing to wrong overlay path).
113
+
114
+ This typically happens after a directory rename. Remove symlinks and run \`clank link\`:
115
+ ${details.join("\n\n")}
116
+
117
+ To fix:
118
+ rm ${classified.outdatedSymlinks.map((s) => rel(s.symlinkPath)).join(" ")}
119
+ clank link`);
120
+ }
121
+
122
+ return sections.join("\n\n");
123
+ }
124
+
125
+ /** Classify a single agent file */
126
+ async function classifySingleAgentFile(
127
+ filePath: string,
128
+ targetRoot: string,
129
+ overlayRoot: string,
130
+ mapperCtx?: MapperContext,
131
+ ): Promise<PartialClassification> {
132
+ const stat = await lstat(filePath);
133
+
134
+ if (stat.isSymbolicLink()) {
135
+ return classifyAgentSymlink(filePath, overlayRoot, mapperCtx);
136
+ }
137
+ if (stat.isFile()) {
138
+ const isTracked = await isTrackedByGit(filePath, targetRoot);
139
+ return isTracked ? { tracked: [filePath] } : { untracked: [filePath] };
140
+ }
141
+ return {};
142
+ }
143
+
144
+ /** Classify an agent symlink - check if stale or outdated */
145
+ async function classifyAgentSymlink(
146
+ filePath: string,
147
+ overlayRoot: string,
148
+ mapperCtx?: MapperContext,
149
+ ): Promise<PartialClassification> {
150
+ const absoluteTarget = await resolveSymlinkTarget(filePath);
151
+
152
+ // Symlink doesn't point to overlay at all
153
+ if (!absoluteTarget.startsWith(overlayRoot)) {
154
+ return { staleSymlinks: [filePath] };
155
+ }
156
+
157
+ // Check if it points to the correct path within overlay
158
+ if (mapperCtx) {
159
+ // Agent files (CLAUDE.md, etc.) map to agents.md in overlay
160
+ const agentsMdPath = join(dirname(filePath), "agents.md");
161
+ const expectedTarget = targetToOverlay(agentsMdPath, "project", mapperCtx);
162
+ if (absoluteTarget !== expectedTarget) {
163
+ return {
164
+ outdatedSymlinks: [
165
+ {
166
+ symlinkPath: filePath,
167
+ currentTarget: absoluteTarget,
168
+ expectedTarget,
169
+ },
170
+ ],
171
+ };
172
+ }
173
+ }
174
+
175
+ return {};
176
+ }
177
+
178
+ /** Merge sparse classifications into a complete classification with arrays */
179
+ function mergeClassifications(
180
+ items: PartialClassification[],
181
+ ): AgentFileClassification {
182
+ return {
183
+ tracked: items.flatMap((i) => i.tracked ?? []),
184
+ untracked: items.flatMap((i) => i.untracked ?? []),
185
+ staleSymlinks: items.flatMap((i) => i.staleSymlinks ?? []),
186
+ outdatedSymlinks: items.flatMap((i) => i.outdatedSymlinks ?? []),
187
+ };
188
+ }
189
+
190
+ /** Find all agent files in the repository */
191
+ async function findAllAgentFiles(targetRoot: string): Promise<string[]> {
192
+ const files: string[] = [];
193
+ const agentFileSet = new Set(agentFiles);
194
+
195
+ for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
196
+ if (isDirectory) continue;
197
+ if (agentFileSet.has(basename(path))) {
198
+ files.push(path);
199
+ }
200
+ }
201
+
202
+ return files;
203
+ }