clank-cli 0.1.67 → 0.1.74
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 +9 -1
- package/package.json +1 -1
- package/src/AgentFiles.ts +1 -1
- package/src/ClassifyFiles.ts +9 -3
- package/src/Cli.ts +23 -23
- package/src/Consolidate.ts +250 -0
- package/src/FsUtil.ts +54 -4
- package/src/Git.ts +5 -2
- package/src/Gitignore.ts +20 -2
- package/src/Mapper.ts +47 -27
- package/src/OverlayLinks.ts +18 -18
- package/src/ScopeFromSymlink.ts +3 -2
- package/src/commands/Add.ts +142 -152
- package/src/commands/Check.ts +144 -61
- package/src/commands/Init.ts +1 -0
- package/src/commands/Link.ts +49 -32
- package/src/commands/Move.ts +2 -1
- package/src/commands/Rm.ts +2 -2
- package/src/commands/Unlink.ts +7 -2
- package/src/commands/VsCode.ts +2 -1
- package/src/commands/files/Dedupe.ts +5 -6
- package/src/commands/files/Scan.ts +21 -18
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ Clank stores your AI agent files (CLAUDE.md, commands, notes) in a separate git
|
|
|
24
24
|
- **Multi-Agent Support**: Single source file, multiple symlinks (AGENTS.md, CLAUDE.md, GEMINI.md).
|
|
25
25
|
- **Worktree-Aware**: Works seamlessly with git worktrees.
|
|
26
26
|
- **Git Ignored**: Agent files are ignored in the main repo, tracked in the overlay repo.
|
|
27
|
+
- **Rules Consolidation**: Use `.claude/rules/` for modular instructions; clank auto-generates AGENTS.md/GEMINI.md with consolidated content for cross-agent compatibility.
|
|
27
28
|
- **Three Scopes**: Global (all projects), Project (all branches), Worktree (this branch only).
|
|
28
29
|
|
|
29
30
|
## Installation
|
|
@@ -300,6 +301,12 @@ Available placeholders:
|
|
|
300
301
|
- `{{project_name}}` - Project name from git
|
|
301
302
|
- `{{branch_name}}` - Current branch/worktree name
|
|
302
303
|
|
|
304
|
+
## Rules Consolidation
|
|
305
|
+
|
|
306
|
+
If you use Claude Code's `.claude/rules/` for modular instructions, clank automatically generates AGENTS.md and GEMINI.md by consolidating `agents.md` with all your rules. Claude reads rules natively via symlinks; other agents get everything in one file.
|
|
307
|
+
|
|
308
|
+
Store rules in `claude/rules/` in your overlay, then run `clank link`. Consolidation activates automatically when rules files exist -- when there are no rules, all agent files remain symlinks as before.
|
|
309
|
+
|
|
303
310
|
## Design Principles
|
|
304
311
|
|
|
305
312
|
1. **Everything is linked, nothing is copied** - Single source of truth in overlay
|
|
@@ -336,7 +343,8 @@ Available placeholders:
|
|
|
336
343
|
├── claude/ # Claude Code specific
|
|
337
344
|
│ ├── settings.json # -> .claude/settings.json
|
|
338
345
|
│ ├── commands/ # -> .claude/commands/
|
|
339
|
-
│
|
|
346
|
+
│ ├── agents/ # -> .claude/agents/
|
|
347
|
+
│ └── rules/ # -> .claude/rules/ (also consolidated into AGENTS.md/GEMINI.md)
|
|
340
348
|
├── gemini/ # Gemini specific
|
|
341
349
|
│ └── commands/ # -> .gemini/commands/
|
|
342
350
|
└── worktrees/
|
package/package.json
CHANGED
package/src/AgentFiles.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
3
|
/** Base agent directory names (without leading dot) */
|
|
4
|
-
export const agentDirNames = ["claude", "gemini"];
|
|
4
|
+
export const agentDirNames = ["claude", "gemini", "codex"];
|
|
5
5
|
|
|
6
6
|
/** Agent directories as they appear in target (with leading dot) */
|
|
7
7
|
export const managedAgentDirs = agentDirNames.map((d) => `.${d}`);
|
package/src/ClassifyFiles.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { lstat } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { agentFiles } from "./AgentFiles.ts";
|
|
4
|
+
import { isGeneratedByClank } from "./Consolidate.ts";
|
|
4
5
|
import {
|
|
5
6
|
isTrackedByGit,
|
|
6
7
|
relativePath,
|
|
7
8
|
resolveSymlinkTarget,
|
|
9
|
+
toSlash,
|
|
8
10
|
walkDirectory,
|
|
9
11
|
} from "./FsUtil.ts";
|
|
10
12
|
import type { GitContext } from "./Git.ts";
|
|
@@ -90,11 +92,14 @@ ${commands.join("\n")}`);
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
if (classified.untracked.length > 0) {
|
|
93
|
-
const
|
|
95
|
+
const files = classified.untracked.map((p) => ` ${rel(p)}`);
|
|
94
96
|
sections.push(`Found untracked agent files.
|
|
95
97
|
|
|
96
98
|
Add them to clank:
|
|
97
|
-
${
|
|
99
|
+
${files.join("\n")}
|
|
100
|
+
|
|
101
|
+
clank add -i # add interactively
|
|
102
|
+
clank add <file> [<file>...] # add specific files`);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
if (classified.staleSymlinks.length > 0) {
|
|
@@ -153,6 +158,7 @@ async function classifySingleAgentFile(
|
|
|
153
158
|
return classifyAgentSymlink(filePath, overlayRoot, mapperCtx);
|
|
154
159
|
}
|
|
155
160
|
if (stat.isFile()) {
|
|
161
|
+
if (await isGeneratedByClank(filePath)) return {};
|
|
156
162
|
const isTracked = await isTrackedByGit(filePath, targetRoot);
|
|
157
163
|
return isTracked ? { tracked: [filePath] } : { untracked: [filePath] };
|
|
158
164
|
}
|
|
@@ -180,7 +186,7 @@ async function classifyAgentSymlink(
|
|
|
180
186
|
const absoluteTarget = await resolveSymlinkTarget(filePath);
|
|
181
187
|
|
|
182
188
|
// Symlink doesn't point to overlay at all
|
|
183
|
-
if (!absoluteTarget.startsWith(overlayRoot)) {
|
|
189
|
+
if (!toSlash(absoluteTarget).startsWith(toSlash(overlayRoot))) {
|
|
184
190
|
return { staleSymlinks: [filePath] };
|
|
185
191
|
}
|
|
186
192
|
|
package/src/Cli.ts
CHANGED
|
@@ -36,6 +36,8 @@ Clank Overlay Directory Structure
|
|
|
36
36
|
│ ├── settings.json # -> .claude/settings.json
|
|
37
37
|
│ ├── commands/ # -> .claude/commands/
|
|
38
38
|
│ └── agents/ # -> .claude/agents/
|
|
39
|
+
├── codex/ # Codex specific
|
|
40
|
+
│ └── config.toml # -> .codex/config.toml
|
|
39
41
|
├── <subdir>/clank/ # Subdirectory files (monorepo support)
|
|
40
42
|
│ └── notes.md # -> <subdir>/clank/notes.md
|
|
41
43
|
└── worktrees/
|
|
@@ -51,6 +53,7 @@ Mapping Rules
|
|
|
51
53
|
global/claude/commands/<file> -> .claude/commands/<file>
|
|
52
54
|
targets/<proj>/clank/<file> -> clank/<file>
|
|
53
55
|
targets/<proj>/claude/commands/ -> .claude/commands/
|
|
56
|
+
targets/<proj>/codex/config.toml -> .codex/config.toml
|
|
54
57
|
targets/<proj>/agents.md -> CLAUDE.md, AGENTS.md, GEMINI.md
|
|
55
58
|
targets/<proj>/<sub>/clank/<file> -> <sub>/clank/<file>
|
|
56
59
|
targets/<proj>/worktrees/<br>/clank -> clank/
|
|
@@ -84,31 +87,9 @@ export async function runCLI(): Promise<void> {
|
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
function registerCommands(program: Command): void {
|
|
87
|
-
registerCoreCommands(program);
|
|
88
|
-
registerHelpCommands(program);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function registerCoreCommands(program: Command): void {
|
|
92
90
|
registerOverlayCommands(program);
|
|
93
91
|
registerUtilityCommands(program);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
function registerHelpCommands(program: Command): void {
|
|
97
|
-
const help = program
|
|
98
|
-
.command("help")
|
|
99
|
-
.description("Show help information")
|
|
100
|
-
.argument("[command]", "Command to show help for")
|
|
101
|
-
.action((commandName?: string) => {
|
|
102
|
-
if (!commandName) return program.help();
|
|
103
|
-
const subcommand = program.commands.find((c) => c.name() === commandName);
|
|
104
|
-
if (subcommand) return subcommand.help();
|
|
105
|
-
console.error(`Unknown command: ${commandName}`);
|
|
106
|
-
process.exit(1);
|
|
107
|
-
});
|
|
108
|
-
help
|
|
109
|
-
.command("structure")
|
|
110
|
-
.description("Show overlay directory structure")
|
|
111
|
-
.action(() => console.log(structureHelp));
|
|
92
|
+
registerHelpCommands(program);
|
|
112
93
|
}
|
|
113
94
|
|
|
114
95
|
function registerOverlayCommands(program: Command): void {
|
|
@@ -163,6 +144,7 @@ function registerUtilityCommands(program: Command): void {
|
|
|
163
144
|
.command("check")
|
|
164
145
|
.alias("status")
|
|
165
146
|
.description("Show overlay status and check for issues")
|
|
147
|
+
.option("--prompt", "Print the agent fix prompt for orphaned paths")
|
|
166
148
|
.action(withErrorHandling(checkCommand));
|
|
167
149
|
|
|
168
150
|
registerFilesCommand(program);
|
|
@@ -175,6 +157,24 @@ function registerUtilityCommands(program: Command): void {
|
|
|
175
157
|
.action(withErrorHandling(vscodeCommand));
|
|
176
158
|
}
|
|
177
159
|
|
|
160
|
+
function registerHelpCommands(program: Command): void {
|
|
161
|
+
const help = program
|
|
162
|
+
.command("help")
|
|
163
|
+
.description("Show help information")
|
|
164
|
+
.argument("[command]", "Command to show help for")
|
|
165
|
+
.action((commandName?: string) => {
|
|
166
|
+
if (!commandName) return program.help();
|
|
167
|
+
const subcommand = program.commands.find((c) => c.name() === commandName);
|
|
168
|
+
if (subcommand) return subcommand.help();
|
|
169
|
+
console.error(`Unknown command: ${commandName}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
});
|
|
172
|
+
help
|
|
173
|
+
.command("structure")
|
|
174
|
+
.description("Show overlay directory structure")
|
|
175
|
+
.action(() => console.log(structureHelp));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
178
|
function withErrorHandling<T extends unknown[]>(
|
|
179
179
|
fn: (...args: T) => Promise<void>,
|
|
180
180
|
): (...args: T) => Promise<void> {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { agentFiles, forEachAgentPath } from "./AgentFiles.ts";
|
|
4
|
+
import { fileExists, isSymlink, walkDirectory } from "./FsUtil.ts";
|
|
5
|
+
import type { GitContext } from "./Git.ts";
|
|
6
|
+
import { overlayProjectDir, overlayWorktreeDir } from "./Mapper.ts";
|
|
7
|
+
|
|
8
|
+
/** Params for the consolidation entry point */
|
|
9
|
+
export interface ConsolidateParams {
|
|
10
|
+
overlayRoot: string;
|
|
11
|
+
targetRoot: string;
|
|
12
|
+
gitContext: GitContext;
|
|
13
|
+
agents: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A rules file with its parsed metadata and content */
|
|
17
|
+
interface RuleFile {
|
|
18
|
+
filename: string;
|
|
19
|
+
content: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
paths?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** First-line marker for generated agent files */
|
|
25
|
+
export const generatedMarker = "<!-- Generated by clank";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Consolidate agents.md + .claude/rules/ into generated AGENTS.md/GEMINI.md.
|
|
29
|
+
*
|
|
30
|
+
* Only runs when rules files exist in the overlay. Root-level only --
|
|
31
|
+
* subdirectory agents.md files continue as pure symlinks.
|
|
32
|
+
*/
|
|
33
|
+
export async function consolidateRulesIntoAgentFiles(
|
|
34
|
+
params: ConsolidateParams,
|
|
35
|
+
): Promise<string[]> {
|
|
36
|
+
const { overlayRoot, targetRoot, gitContext, agents } = params;
|
|
37
|
+
|
|
38
|
+
// Collect rules from all applicable overlay scopes
|
|
39
|
+
const rulesFiles = await collectRulesFiles(overlayRoot, gitContext);
|
|
40
|
+
if (rulesFiles.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
// Read root-level agents.md from overlay (if exists)
|
|
43
|
+
const agentsMdContent = await readRootAgentsMd(overlayRoot, gitContext);
|
|
44
|
+
|
|
45
|
+
// Build the consolidated content
|
|
46
|
+
const overlayPaths = getOverlayPaths(overlayRoot, gitContext);
|
|
47
|
+
const merged = buildConsolidatedContent(
|
|
48
|
+
agentsMdContent,
|
|
49
|
+
rulesFiles,
|
|
50
|
+
overlayPaths,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// For each non-claude agent, replace the symlink with a generated file
|
|
54
|
+
const generated: string[] = [];
|
|
55
|
+
await forEachAgentPath(targetRoot, agents, async (agentPath, agentName) => {
|
|
56
|
+
if (agentName.toLowerCase() === "claude") return;
|
|
57
|
+
// Remove existing symlink if present
|
|
58
|
+
if (await isSymlink(agentPath)) {
|
|
59
|
+
await unlink(agentPath);
|
|
60
|
+
}
|
|
61
|
+
await writeFile(agentPath, merged, "utf-8");
|
|
62
|
+
generated.push(basename(agentPath));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return generated;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Check if a file is generated by clank (first line starts with marker) */
|
|
69
|
+
export async function isGeneratedByClank(filePath: string): Promise<boolean> {
|
|
70
|
+
if (!(await fileExists(filePath))) return false;
|
|
71
|
+
if (await isSymlink(filePath)) return false;
|
|
72
|
+
try {
|
|
73
|
+
const content = await readFile(filePath, "utf-8");
|
|
74
|
+
return content.startsWith(generatedMarker);
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Strip YAML frontmatter and extract description/paths */
|
|
81
|
+
export function stripFrontmatter(raw: string): {
|
|
82
|
+
content: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
paths?: string;
|
|
85
|
+
} {
|
|
86
|
+
const fence = "---";
|
|
87
|
+
const fenceNewline = `\n${fence}`;
|
|
88
|
+
|
|
89
|
+
if (!raw.startsWith(fence)) {
|
|
90
|
+
return { content: raw };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const endIndex = raw.indexOf(fenceNewline, fence.length);
|
|
94
|
+
if (endIndex === -1) {
|
|
95
|
+
return { content: raw };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const frontmatter = raw.slice(fence.length, endIndex).trim();
|
|
99
|
+
const content = raw.slice(endIndex + fenceNewline.length).replace(/^\n+/, "");
|
|
100
|
+
|
|
101
|
+
let description: string | undefined;
|
|
102
|
+
let paths: string | undefined;
|
|
103
|
+
|
|
104
|
+
for (const line of frontmatter.split("\n")) {
|
|
105
|
+
const descMatch = line.match(/^description:\s*(.+)/);
|
|
106
|
+
if (descMatch) {
|
|
107
|
+
description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
108
|
+
}
|
|
109
|
+
const pathsMatch = line.match(/^paths:\s*(.+)/);
|
|
110
|
+
if (pathsMatch) {
|
|
111
|
+
// paths can be inline array: ["src/**/*.ts"] or plain string
|
|
112
|
+
paths = pathsMatch[1]
|
|
113
|
+
.trim()
|
|
114
|
+
.replace(/^\[|\]$/g, "")
|
|
115
|
+
.replace(/["']/g, "");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { content, description, paths };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Convert a filename to a human-readable heading */
|
|
123
|
+
export function humanizeFilename(filename: string): string {
|
|
124
|
+
return filename.replace(/\.md$/, "").replace(/[-_]/g, " ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Remove generated agent files from target (for unlink) */
|
|
128
|
+
export async function removeGeneratedAgentFiles(
|
|
129
|
+
targetRoot: string,
|
|
130
|
+
): Promise<string[]> {
|
|
131
|
+
const removed: string[] = [];
|
|
132
|
+
for (const name of agentFiles) {
|
|
133
|
+
const path = join(targetRoot, name);
|
|
134
|
+
if (await isGeneratedByClank(path)) {
|
|
135
|
+
await unlink(path);
|
|
136
|
+
removed.push(name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return removed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Collect all .md files from claude/rules/ across overlay scopes */
|
|
143
|
+
async function collectRulesFiles(
|
|
144
|
+
overlayRoot: string,
|
|
145
|
+
gitContext: GitContext,
|
|
146
|
+
): Promise<RuleFile[]> {
|
|
147
|
+
const dirs = [
|
|
148
|
+
join(overlayRoot, "global/claude/rules"),
|
|
149
|
+
join(
|
|
150
|
+
overlayProjectDir(overlayRoot, gitContext.projectName),
|
|
151
|
+
"claude/rules",
|
|
152
|
+
),
|
|
153
|
+
join(overlayWorktreeDir(overlayRoot, gitContext), "claude/rules"),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
const allFiles: RuleFile[] = [];
|
|
157
|
+
for (const dir of dirs) {
|
|
158
|
+
if (!(await fileExists(dir))) continue;
|
|
159
|
+
for await (const { path, isDirectory } of walkDirectory(dir)) {
|
|
160
|
+
if (isDirectory) continue;
|
|
161
|
+
if (!path.endsWith(".md")) continue;
|
|
162
|
+
const raw = await readFile(path, "utf-8");
|
|
163
|
+
const parsed = stripFrontmatter(raw);
|
|
164
|
+
allFiles.push({
|
|
165
|
+
filename: basename(path),
|
|
166
|
+
...parsed,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Sort alphabetically by filename for deterministic output
|
|
172
|
+
allFiles.sort((a, b) => a.filename.localeCompare(b.filename));
|
|
173
|
+
return allFiles;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Read root-level agents.md from the overlay (project scope, then global) */
|
|
177
|
+
async function readRootAgentsMd(
|
|
178
|
+
overlayRoot: string,
|
|
179
|
+
gitContext: GitContext,
|
|
180
|
+
): Promise<string | null> {
|
|
181
|
+
const projectPath = join(
|
|
182
|
+
overlayProjectDir(overlayRoot, gitContext.projectName),
|
|
183
|
+
"agents.md",
|
|
184
|
+
);
|
|
185
|
+
if (await fileExists(projectPath)) {
|
|
186
|
+
return readFile(projectPath, "utf-8");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Fall back to global agents.md
|
|
190
|
+
const globalPath = join(overlayRoot, "global/agents.md");
|
|
191
|
+
if (await fileExists(globalPath)) {
|
|
192
|
+
return readFile(globalPath, "utf-8");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Get overlay paths for the header comments */
|
|
199
|
+
function getOverlayPaths(
|
|
200
|
+
overlayRoot: string,
|
|
201
|
+
gitContext: GitContext,
|
|
202
|
+
): { agentsMdPath: string; rulesDir: string } {
|
|
203
|
+
const projectDir = overlayProjectDir(overlayRoot, gitContext.projectName);
|
|
204
|
+
return {
|
|
205
|
+
agentsMdPath: join(projectDir, "agents.md"),
|
|
206
|
+
rulesDir: join(projectDir, "claude/rules"),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Build the consolidated markdown content */
|
|
211
|
+
function buildConsolidatedContent(
|
|
212
|
+
agentsMdContent: string | null,
|
|
213
|
+
rules: RuleFile[],
|
|
214
|
+
overlayPaths: { agentsMdPath: string; rulesDir: string },
|
|
215
|
+
): string {
|
|
216
|
+
const lines: string[] = [];
|
|
217
|
+
|
|
218
|
+
// Header comments
|
|
219
|
+
lines.push(`${generatedMarker} - do not edit -->`);
|
|
220
|
+
if (agentsMdContent) {
|
|
221
|
+
lines.push(`<!-- Source: ${overlayPaths.agentsMdPath} -->`);
|
|
222
|
+
}
|
|
223
|
+
lines.push(`<!-- Rules: ${overlayPaths.rulesDir} -->`);
|
|
224
|
+
lines.push("");
|
|
225
|
+
|
|
226
|
+
// Base agents.md content
|
|
227
|
+
if (agentsMdContent) {
|
|
228
|
+
lines.push(agentsMdContent.trimEnd());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Append each rule as a section
|
|
232
|
+
for (const rule of rules) {
|
|
233
|
+
lines.push("");
|
|
234
|
+
lines.push("---");
|
|
235
|
+
lines.push("");
|
|
236
|
+
|
|
237
|
+
const heading = rule.description || humanizeFilename(rule.filename);
|
|
238
|
+
lines.push(`## ${heading}`);
|
|
239
|
+
|
|
240
|
+
if (rule.paths) {
|
|
241
|
+
lines.push(`Applies to: ${rule.paths}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
lines.push("");
|
|
245
|
+
lines.push(rule.content.trimEnd());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
lines.push("");
|
|
249
|
+
return lines.join("\n");
|
|
250
|
+
}
|
package/src/FsUtil.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
readdir,
|
|
6
6
|
readFile,
|
|
7
7
|
readlink,
|
|
8
|
+
realpath,
|
|
8
9
|
symlink,
|
|
9
10
|
unlink,
|
|
10
11
|
writeFile,
|
|
@@ -28,6 +29,27 @@ interface WalkContext {
|
|
|
28
29
|
skip?: (relPath: string, isDirectory: boolean) => boolean;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** Normalize path separators to forward slashes (for cross-platform string comparisons) */
|
|
33
|
+
export function toSlash(p: string): string {
|
|
34
|
+
return p.replaceAll("\\", "/");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve Windows 8.3 short names to canonical long paths.
|
|
38
|
+
* On non-Windows, returns the path unchanged. */
|
|
39
|
+
export async function resolveWindowsPath(p: string): Promise<string> {
|
|
40
|
+
if (process.platform !== "win32") return p;
|
|
41
|
+
try {
|
|
42
|
+
return await realpath(p);
|
|
43
|
+
} catch {
|
|
44
|
+
return p;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Get the current working directory, resolving Windows 8.3 short names */
|
|
49
|
+
export async function getCwd(): Promise<string> {
|
|
50
|
+
return resolveWindowsPath(process.cwd());
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
/**
|
|
32
54
|
* Create a symbolic link, removing existing link/file first
|
|
33
55
|
* @param target - The path the symlink should point to (absolute)
|
|
@@ -45,7 +67,24 @@ export async function createSymlink(
|
|
|
45
67
|
// File doesn't exist, which is fine
|
|
46
68
|
}
|
|
47
69
|
|
|
48
|
-
|
|
70
|
+
try {
|
|
71
|
+
await symlink(target, linkPath);
|
|
72
|
+
} catch (error: unknown) {
|
|
73
|
+
if (
|
|
74
|
+
error instanceof Error &&
|
|
75
|
+
"code" in error &&
|
|
76
|
+
error.code === "EPERM" &&
|
|
77
|
+
process.platform === "win32"
|
|
78
|
+
) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Permission denied creating symlink.\n` +
|
|
81
|
+
`On Windows, symlinks require Developer Mode or administrator privileges.\n` +
|
|
82
|
+
`Enable Developer Mode: Settings > Update & Security > For Developers\n` +
|
|
83
|
+
`Or use clank from WSL.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
49
88
|
}
|
|
50
89
|
|
|
51
90
|
/** Remove a symlink if it exists */
|
|
@@ -62,12 +101,13 @@ export async function removeSymlink(linkPath: string): Promise<void> {
|
|
|
62
101
|
|
|
63
102
|
/**
|
|
64
103
|
* Get the symlink target path (absolute)
|
|
104
|
+
* Uses forward slashes so readlink returns consistent paths across platforms.
|
|
65
105
|
* @param _from - The location of the symlink (unused, kept for API compatibility)
|
|
66
106
|
* @param to - The target of the symlink (absolute)
|
|
67
107
|
* @returns Absolute path to the target
|
|
68
108
|
*/
|
|
69
109
|
export function getLinkTarget(_from: string, to: string): string {
|
|
70
|
-
return to;
|
|
110
|
+
return toSlash(to);
|
|
71
111
|
}
|
|
72
112
|
|
|
73
113
|
/** Recursively walk a directory, yielding all files and directories */
|
|
@@ -118,7 +158,7 @@ export async function isTrackedByGit(
|
|
|
118
158
|
repoRoot: string,
|
|
119
159
|
): Promise<boolean> {
|
|
120
160
|
try {
|
|
121
|
-
const relPath = relative(repoRoot, filePath);
|
|
161
|
+
const relPath = toSlash(relative(repoRoot, filePath));
|
|
122
162
|
await execFileAsync("git", ["ls-files", "--error-unmatch", relPath], {
|
|
123
163
|
cwd: repoRoot,
|
|
124
164
|
});
|
|
@@ -138,6 +178,16 @@ export async function isSymlink(filePath: string): Promise<boolean> {
|
|
|
138
178
|
}
|
|
139
179
|
}
|
|
140
180
|
|
|
181
|
+
/** Check if a path is a real (non-symlink) file tracked by git */
|
|
182
|
+
export async function isTrackedRealFile(
|
|
183
|
+
filePath: string,
|
|
184
|
+
repoRoot: string,
|
|
185
|
+
): Promise<boolean> {
|
|
186
|
+
if (!(await fileExists(filePath))) return false;
|
|
187
|
+
if (await isSymlink(filePath)) return false;
|
|
188
|
+
return isTrackedByGit(filePath, repoRoot);
|
|
189
|
+
}
|
|
190
|
+
|
|
141
191
|
/** Get path relative to cwd, or "." if same directory */
|
|
142
192
|
export function relativePath(cwd: string, path: string): string {
|
|
143
193
|
return relative(cwd, path) || ".";
|
|
@@ -195,7 +245,7 @@ function* processEntry(
|
|
|
195
245
|
| { recurse: string; options: WalkOptions; root: string }
|
|
196
246
|
> {
|
|
197
247
|
const fullPath = join(dir, entry.name);
|
|
198
|
-
const relPath = relative(ctx.root, fullPath);
|
|
248
|
+
const relPath = toSlash(relative(ctx.root, fullPath));
|
|
199
249
|
|
|
200
250
|
if (entry.isDirectory()) {
|
|
201
251
|
if (shouldSkipDir(entry.name, ctx.skipDirs, ctx.includeHidden)) return;
|
package/src/Git.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename, isAbsolute, join } from "node:path";
|
|
2
2
|
import { exec } from "./Exec.ts";
|
|
3
|
+
import { resolveWindowsPath } from "./FsUtil.ts";
|
|
3
4
|
|
|
4
5
|
/** Selected git project/worktree metadata */
|
|
5
6
|
export interface GitContext {
|
|
@@ -34,7 +35,9 @@ export async function detectGitRoot(
|
|
|
34
35
|
): Promise<string> {
|
|
35
36
|
const toplevel = await gitCommand("rev-parse --show-toplevel", cwd);
|
|
36
37
|
if (toplevel) {
|
|
37
|
-
|
|
38
|
+
// On Windows, git returns forward-slash paths that may use long names
|
|
39
|
+
// while cwd uses 8.3 short names. Resolve to canonical form.
|
|
40
|
+
return await resolveWindowsPath(toplevel);
|
|
38
41
|
}
|
|
39
42
|
throw new Error("Not in a git repository");
|
|
40
43
|
}
|
|
@@ -89,7 +92,7 @@ export async function isGitWorktree(
|
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
// Worktrees have .git/worktrees/* in their git-dir path
|
|
92
|
-
return gitDir.includes("/worktrees/");
|
|
95
|
+
return gitDir.replaceAll("\\", "/").includes("/worktrees/");
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
/** Parse repository name from git remote URL (handles HTTPS and SSH formats) */
|
package/src/Gitignore.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import picomatch from "picomatch";
|
|
3
4
|
import { agentFiles, targetManagedDirs } from "./AgentFiles.ts";
|
|
4
5
|
import { filterClankLines } from "./Exclude.ts";
|
|
5
|
-
import { fileExists, walkDirectory } from "./FsUtil.ts";
|
|
6
|
+
import { fileExists, toSlash, walkDirectory } from "./FsUtil.ts";
|
|
6
7
|
import { getGitDir } from "./Git.ts";
|
|
7
8
|
import { partition } from "./Util.ts";
|
|
8
9
|
|
|
@@ -52,7 +53,7 @@ export async function collectGitignorePatterns(
|
|
|
52
53
|
|
|
53
54
|
// 3. Find nested .gitignore files
|
|
54
55
|
for (const path of await findNestedGitignores(targetRoot)) {
|
|
55
|
-
const basePath = relative(targetRoot, dirname(path));
|
|
56
|
+
const basePath = toSlash(relative(targetRoot, dirname(path)));
|
|
56
57
|
await parseGitignoreFile(path, result, { basePath });
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -144,6 +145,23 @@ export function deduplicateGlobs(globs: string[]): string[] {
|
|
|
144
145
|
return [...universal, ...uncovered];
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
/** Load a repo's .gitignore and return a matcher for ignored filenames */
|
|
149
|
+
export async function loadGitignore(
|
|
150
|
+
repoRoot: string,
|
|
151
|
+
): Promise<(name: string) => boolean> {
|
|
152
|
+
const gitignorePath = join(repoRoot, ".gitignore");
|
|
153
|
+
if (!(await fileExists(gitignorePath))) return () => false;
|
|
154
|
+
|
|
155
|
+
const content = await readFile(gitignorePath, "utf-8");
|
|
156
|
+
const patterns = content
|
|
157
|
+
.split("\n")
|
|
158
|
+
.map((line) => line.trim())
|
|
159
|
+
.filter((line) => line && !line.startsWith("#") && !line.startsWith("!"));
|
|
160
|
+
|
|
161
|
+
if (patterns.length === 0) return () => false;
|
|
162
|
+
return picomatch(patterns);
|
|
163
|
+
}
|
|
164
|
+
|
|
147
165
|
/** Parse a gitignore file and accumulate results */
|
|
148
166
|
async function parseGitignoreFile(
|
|
149
167
|
source: string,
|