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 +312 -0
- package/bin/clank.ts +8 -0
- package/package.json +50 -0
- package/src/AgentFiles.ts +40 -0
- package/src/ClassifyFiles.ts +203 -0
- package/src/Cli.ts +229 -0
- package/src/Config.ts +98 -0
- package/src/Exclude.ts +154 -0
- package/src/Exec.ts +5 -0
- package/src/FsUtil.ts +154 -0
- package/src/Git.ts +140 -0
- package/src/Gitignore.ts +226 -0
- package/src/Mapper.ts +330 -0
- package/src/OverlayGit.ts +78 -0
- package/src/OverlayLinks.ts +125 -0
- package/src/ScopeFromSymlink.ts +22 -0
- package/src/Templates.ts +87 -0
- package/src/Util.ts +13 -0
- package/src/commands/Add.ts +301 -0
- package/src/commands/Check.ts +314 -0
- package/src/commands/Commit.ts +35 -0
- package/src/commands/Init.ts +62 -0
- package/src/commands/Link.ts +415 -0
- package/src/commands/Move.ts +172 -0
- package/src/commands/Rm.ts +161 -0
- package/src/commands/Unlink.ts +45 -0
- package/src/commands/VsCode.ts +195 -0
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
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
|
+
}
|