claudeup 6.2.1 → 6.3.0
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 +22 -0
- package/bin/claudeup.js +6 -1
- package/package.json +6 -6
- package/src/__tests__/alias-adopt.test.ts +331 -329
- package/src/__tests__/alias-parser.test.ts +384 -376
- package/src/__tests__/alias-shell-writer.test.ts +638 -625
- package/src/__tests__/alias-store.test.ts +143 -143
- package/src/__tests__/catalog-cache-store.test.ts +265 -265
- package/src/__tests__/catalog-notice.test.ts +123 -123
- package/src/__tests__/cli-apply-seams.test.ts +12 -8
- package/src/__tests__/cli-router.test.ts +132 -3
- package/src/__tests__/cli-update-view.test.ts +27 -8
- package/src/__tests__/community-staleness.test.ts +0 -2
- package/src/__tests__/component-badge.test.ts +4 -4
- package/src/__tests__/content-drift.test.ts +5 -1
- package/src/__tests__/conventions-integration.test.ts +774 -689
- package/src/__tests__/conventions-manager.test.ts +1216 -1152
- package/src/__tests__/doctor-bins.test.ts +14 -6
- package/src/__tests__/doctor.test.ts +438 -425
- package/src/__tests__/dual-write-prevention.test.ts +277 -282
- package/src/__tests__/enabled-not-installed.test.ts +71 -63
- package/src/__tests__/file-locking.test.ts +196 -196
- package/src/__tests__/gap-fill-versions.test.ts +318 -311
- package/src/__tests__/github-budget.test.ts +170 -164
- package/src/__tests__/gitignore-detector.test.ts +160 -152
- package/src/__tests__/gitignore-fixer.test.ts +285 -247
- package/src/__tests__/gitignore-prerun.test.ts +57 -57
- package/src/__tests__/gitignore-resolver.test.ts +173 -146
- package/src/__tests__/gitignore-service.test.ts +121 -119
- package/src/__tests__/install-command.test.ts +3 -3
- package/src/__tests__/install-plan.test.ts +13 -7
- package/src/__tests__/manifest.test.ts +16 -5
- package/src/__tests__/markdown-renderer.test.ts +0 -1
- package/src/__tests__/marketplace-badge.test.ts +111 -90
- package/src/__tests__/marketplace-version.test.ts +64 -61
- package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
- package/src/__tests__/plugin-requires.test.ts +18 -4
- package/src/__tests__/plugin-setup.test.ts +12 -12
- package/src/__tests__/profile-command.test.ts +1 -7
- package/src/__tests__/profile-materializer.test.ts +31 -8
- package/src/__tests__/profile-sync.test.ts +23 -5
- package/src/__tests__/registry-version-resolution.test.ts +61 -47
- package/src/__tests__/resolve-executable.test.ts +1 -1
- package/src/__tests__/resolver.test.ts +38 -13
- package/src/__tests__/scope-action.test.ts +52 -50
- package/src/__tests__/scope-squares.test.tsx +4 -5
- package/src/__tests__/skill-install-files.test.ts +111 -103
- package/src/__tests__/symlink-manager.test.ts +27 -10
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/update-apply.test.ts +1 -3
- package/src/__tests__/useGitignoreModal.test.ts +69 -61
- package/src/__tests__/version-snapshot.test.ts +183 -165
- package/src/cli/ansi.ts +23 -15
- package/src/cli/doctor.ts +10 -9
- package/src/cli/install.ts +3 -3
- package/src/cli/profile.ts +5 -4
- package/src/cli/prompt.ts +0 -1
- package/src/cli/router.ts +69 -2
- package/src/cli/update-view.ts +11 -6
- package/src/cli/update.ts +9 -4
- package/src/cli/upgrade.ts +6 -4
- package/src/data/alias-flags.ts +260 -260
- package/src/data/gitignore-defaults.ts +20 -20
- package/src/data/gitignore-reasons.ts +86 -92
- package/src/data/gitignore-templates.ts +14 -14
- package/src/data/predefined-profiles.ts +1 -2
- package/src/data/skill-repos.ts +43 -35
- package/src/main.tsx +29 -6
- package/src/opentui.d.ts +1 -3
- package/src/prerunner/index.ts +7 -8
- package/src/services/alias-settings.ts +20 -23
- package/src/services/alias-shell-writer.ts +800 -803
- package/src/services/alias-store.ts +77 -77
- package/src/services/claude-settings.ts +27 -18
- package/src/services/content-drift.ts +13 -3
- package/src/services/conventions-manager.ts +692 -617
- package/src/services/doctor.ts +464 -403
- package/src/services/gitignore-detector.ts +113 -103
- package/src/services/gitignore-fixer.ts +204 -189
- package/src/services/gitignore-prerun.ts +26 -26
- package/src/services/gitignore-resolver.ts +162 -144
- package/src/services/gitignore-service.ts +120 -117
- package/src/services/install-plan.ts +4 -4
- package/src/services/manifest.ts +1 -1
- package/src/services/marketplace-catalog-git.ts +5 -2
- package/src/services/marketplace-fetcher.ts +241 -235
- package/src/services/plugin-manager.ts +17 -20
- package/src/services/plugin-mcp-config.ts +2 -2
- package/src/services/plugin-requires.ts +9 -6
- package/src/services/plugin-setup.ts +7 -9
- package/src/services/plugin-version-check.ts +4 -4
- package/src/services/profile-materializer.ts +9 -5
- package/src/services/profile-sync.ts +12 -4
- package/src/services/profiles.ts +3 -3
- package/src/services/resolver.ts +10 -6
- package/src/services/settings-manager.ts +82 -46
- package/src/services/skills-manager.ts +11 -9
- package/src/services/symlink-manager.ts +2 -5
- package/src/services/toolchain.ts +3 -3
- package/src/services/update-cache.ts +1 -1
- package/src/services/update-engine.ts +4 -4
- package/src/services/update-plan.ts +9 -11
- package/src/services/version-snapshot.ts +88 -88
- package/src/types/gitignore.ts +28 -28
- package/src/ui/App.tsx +36 -36
- package/src/ui/adapters/settingsAdapter.ts +2 -2
- package/src/ui/components/CategoryHeader.tsx +1 -1
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +3 -1
- package/src/ui/components/ScrollableList.tsx +2 -1
- package/src/ui/components/StyledText.tsx +1 -1
- package/src/ui/components/layout/FooterHints.tsx +5 -3
- package/src/ui/components/layout/Panel.tsx +2 -2
- package/src/ui/components/layout/ScreenLayout.tsx +5 -4
- package/src/ui/components/modals/InputModal.tsx +7 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -3
- package/src/ui/components/modals/SelectModal.tsx +1 -1
- package/src/ui/components/primitives/DetailSection.tsx +1 -1
- package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +1 -5
- package/src/ui/hooks/useMismatchModal.ts +4 -10
- package/src/ui/renderers/cliToolRenderers.tsx +2 -2
- package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +3 -9
- package/src/ui/renderers/profileRenderers.tsx +9 -8
- package/src/ui/renderers/settingsRenderers.tsx +4 -4
- package/src/ui/renderers/skillRenderers.tsx +14 -14
- package/src/ui/screens/AliasScreen.tsx +31 -34
- package/src/ui/screens/CliToolsScreen.tsx +14 -13
- package/src/ui/screens/EnvVarsScreen.tsx +8 -8
- package/src/ui/screens/GitignoreScreen.tsx +15 -14
- package/src/ui/screens/McpRegistryScreen.tsx +10 -7
- package/src/ui/screens/McpScreen.tsx +10 -10
- package/src/ui/screens/PluginsScreen.tsx +17 -21
- package/src/ui/screens/ProfilesScreen.tsx +25 -24
- package/src/ui/screens/SkillsScreen.tsx +16 -14
- package/src/ui/state/AppContext.tsx +4 -8
- package/src/ui/state/DimensionsContext.tsx +3 -2
- package/src/ui/theme-mode.ts +20 -38
- package/src/ui/theme-resolve.ts +148 -0
- package/src/utils/clipboard.ts +5 -5
- package/src/utils/command-utils.ts +2 -2
- package/src/utils/config-dir.ts +1 -3
- package/src/utils/file-locking.ts +88 -86
- package/src/ui/hooks/useAsyncData.ts +0 -127
- package/src/ui/state/AnimationContext.tsx +0 -76
|
@@ -13,13 +13,20 @@
|
|
|
13
13
|
* - Line-oriented state machine for CLAUDE.md marker parsing
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import { existsSync } from
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import {
|
|
19
|
+
open,
|
|
20
|
+
readFile,
|
|
21
|
+
rename,
|
|
22
|
+
stat,
|
|
23
|
+
unlink,
|
|
24
|
+
writeFile,
|
|
25
|
+
} from "node:fs/promises";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
28
|
+
import type { PluginConventions } from "../types/index.js";
|
|
29
|
+
import { withFileLock } from "../utils/file-locking.js";
|
|
23
30
|
|
|
24
31
|
// ============================================================
|
|
25
32
|
// TYPES
|
|
@@ -27,29 +34,32 @@ import type { PluginConventions } from '../types/index.js';
|
|
|
27
34
|
|
|
28
35
|
/** A successfully parsed managed section from CLAUDE.md */
|
|
29
36
|
export interface ParsedSection {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
section: string;
|
|
38
|
+
version: string | null;
|
|
39
|
+
content: string;
|
|
40
|
+
startLine: number;
|
|
41
|
+
endLine: number;
|
|
42
|
+
insideCodeBlock: boolean;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/** Parse error types for CLAUDE.md markers */
|
|
39
46
|
export type ParseError =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
| { type: "missing_end"; section: string; startLine: number }
|
|
48
|
+
| { type: "mismatched_end"; section: string; expected: string; line: number }
|
|
49
|
+
| { type: "duplicate_section"; section: string; lines: number[] }
|
|
50
|
+
| { type: "marker_in_code_block"; section: string; line: number };
|
|
44
51
|
|
|
45
52
|
/** Result from parseClaudeMdSections */
|
|
46
53
|
export interface ParseResult {
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
sections: ParsedSection[];
|
|
55
|
+
errors: ParseError[];
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/** Logger for convention operations */
|
|
52
|
-
export type ConventionLogger = (
|
|
59
|
+
export type ConventionLogger = (
|
|
60
|
+
level: "info" | "warn" | "error",
|
|
61
|
+
message: string,
|
|
62
|
+
) => void;
|
|
53
63
|
|
|
54
64
|
// ============================================================
|
|
55
65
|
// MARKER PATTERNS
|
|
@@ -72,52 +82,52 @@ const CODE_FENCE_RE = /^(`{3,}|~{3,})/;
|
|
|
72
82
|
* @param content - Content to write
|
|
73
83
|
*/
|
|
74
84
|
async function atomicWrite(targetPath: string, content: string): Promise<void> {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
85
|
+
const dir = dirname(targetPath);
|
|
86
|
+
const tmpPath = join(dir, `.${basename(targetPath)}.tmp.${randomUUID()}`);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Preserve original file permissions if file exists
|
|
90
|
+
let mode: number | undefined;
|
|
91
|
+
try {
|
|
92
|
+
const fileStat = await stat(targetPath);
|
|
93
|
+
mode = fileStat.mode;
|
|
94
|
+
} catch {
|
|
95
|
+
// File doesn't exist yet, use default permissions
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Write to temp file
|
|
99
|
+
await writeFile(tmpPath, content, { mode: mode ?? 0o644 });
|
|
100
|
+
|
|
101
|
+
// fsync to ensure content is on disk before rename
|
|
102
|
+
const fd = await open(tmpPath, "r");
|
|
103
|
+
await fd.sync();
|
|
104
|
+
await fd.close();
|
|
105
|
+
|
|
106
|
+
// Atomic rename (same filesystem guaranteed since same directory)
|
|
107
|
+
await rename(tmpPath, targetPath);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// Clean up temp file on failure
|
|
110
|
+
try {
|
|
111
|
+
await unlink(tmpPath);
|
|
112
|
+
} catch {
|
|
113
|
+
/* ignore */
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
107
117
|
}
|
|
108
118
|
|
|
109
119
|
/**
|
|
110
120
|
* Read file contents, returning empty string if file does not exist.
|
|
111
121
|
*/
|
|
112
122
|
async function readFileOrEmpty(filePath: string): Promise<string> {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
try {
|
|
124
|
+
return await readFile(filePath, "utf-8");
|
|
125
|
+
} catch (err: unknown) {
|
|
126
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
127
|
+
return "";
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
// ============================================================
|
|
@@ -136,66 +146,70 @@ async function readFileOrEmpty(filePath: string): Promise<string> {
|
|
|
136
146
|
* @returns Object with added entries and whether file was modified
|
|
137
147
|
*/
|
|
138
148
|
export async function ensureGitignoreEntries(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
149
|
+
gitignorePath: string,
|
|
150
|
+
entries: string[],
|
|
151
|
+
comment?: string,
|
|
142
152
|
): Promise<{ added: string[]; modified: boolean }> {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
153
|
+
if (entries.length === 0) {
|
|
154
|
+
return { added: [], modified: false };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return withFileLock(gitignorePath, async () => {
|
|
158
|
+
const content = await readFileOrEmpty(gitignorePath);
|
|
159
|
+
const lines = content.split("\n");
|
|
160
|
+
|
|
161
|
+
// Build a Set of existing patterns (trimmed, ignoring comments and blank lines)
|
|
162
|
+
const existingPatterns = new Set<string>();
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const trimmed = line.trim();
|
|
165
|
+
if (trimmed && !trimmed.startsWith("#")) {
|
|
166
|
+
existingPatterns.add(trimmed);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Find entries that need to be added
|
|
171
|
+
const toAdd = entries.filter(
|
|
172
|
+
(entry) => !existingPatterns.has(entry.trim()),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (toAdd.length === 0) {
|
|
176
|
+
return { added: [], modified: false };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build the append block
|
|
180
|
+
const appendLines: string[] = [];
|
|
181
|
+
|
|
182
|
+
// Add comment if provided and not already in the file
|
|
183
|
+
if (comment) {
|
|
184
|
+
const commentExists = lines.some(
|
|
185
|
+
(line) => line.trim() === comment.trim(),
|
|
186
|
+
);
|
|
187
|
+
if (!commentExists) {
|
|
188
|
+
appendLines.push(comment);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const entry of toAdd) {
|
|
193
|
+
appendLines.push(entry);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Ensure existing content ends with a newline before appending
|
|
197
|
+
let newContent = content;
|
|
198
|
+
if (newContent.length > 0 && !newContent.endsWith("\n")) {
|
|
199
|
+
newContent += "\n";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Add blank line separator if file has content
|
|
203
|
+
if (newContent.trim().length > 0) {
|
|
204
|
+
newContent += "\n";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
newContent += `${appendLines.join("\n")}\n`;
|
|
208
|
+
|
|
209
|
+
await atomicWrite(gitignorePath, newContent);
|
|
210
|
+
|
|
211
|
+
return { added: toAdd, modified: true };
|
|
212
|
+
});
|
|
199
213
|
}
|
|
200
214
|
|
|
201
215
|
/**
|
|
@@ -207,41 +221,41 @@ export async function ensureGitignoreEntries(
|
|
|
207
221
|
* @returns Object with removed entries and whether file was modified
|
|
208
222
|
*/
|
|
209
223
|
export async function removeGitignoreEntries(
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
gitignorePath: string,
|
|
225
|
+
entries: string[],
|
|
212
226
|
): Promise<{ removed: string[]; modified: boolean }> {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
227
|
+
if (entries.length === 0) {
|
|
228
|
+
return { removed: [], modified: false };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return withFileLock(gitignorePath, async () => {
|
|
232
|
+
const content = await readFileOrEmpty(gitignorePath);
|
|
233
|
+
if (!content) {
|
|
234
|
+
return { removed: [], modified: false };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const entriesToRemove = new Set(entries.map((e) => e.trim()));
|
|
238
|
+
const lines = content.split("\n");
|
|
239
|
+
const removed: string[] = [];
|
|
240
|
+
const newLines: string[] = [];
|
|
241
|
+
|
|
242
|
+
for (const line of lines) {
|
|
243
|
+
const trimmed = line.trim();
|
|
244
|
+
if (entriesToRemove.has(trimmed)) {
|
|
245
|
+
removed.push(trimmed);
|
|
246
|
+
} else {
|
|
247
|
+
newLines.push(line);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (removed.length === 0) {
|
|
252
|
+
return { removed: [], modified: false };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await atomicWrite(gitignorePath, newLines.join("\n"));
|
|
256
|
+
|
|
257
|
+
return { removed, modified: true };
|
|
258
|
+
});
|
|
245
259
|
}
|
|
246
260
|
|
|
247
261
|
/**
|
|
@@ -252,39 +266,39 @@ export async function removeGitignoreEntries(
|
|
|
252
266
|
* @returns Object with present and missing entries
|
|
253
267
|
*/
|
|
254
268
|
export async function checkGitignoreEntries(
|
|
255
|
-
|
|
256
|
-
|
|
269
|
+
gitignorePath: string,
|
|
270
|
+
entries: string[],
|
|
257
271
|
): Promise<{ present: string[]; missing: string[] }> {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
272
|
+
const content = await readFileOrEmpty(gitignorePath);
|
|
273
|
+
const lines = content.split("\n");
|
|
274
|
+
|
|
275
|
+
const existingPatterns = new Set<string>();
|
|
276
|
+
for (const line of lines) {
|
|
277
|
+
const trimmed = line.trim();
|
|
278
|
+
if (trimmed && !trimmed.startsWith("#")) {
|
|
279
|
+
existingPatterns.add(trimmed);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const present: string[] = [];
|
|
284
|
+
const missing: string[] = [];
|
|
285
|
+
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (existingPatterns.has(entry.trim())) {
|
|
288
|
+
present.push(entry);
|
|
289
|
+
} else {
|
|
290
|
+
missing.push(entry);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { present, missing };
|
|
281
295
|
}
|
|
282
296
|
|
|
283
297
|
// ============================================================
|
|
284
298
|
// CLAUDE.MD PARSING — LINE-ORIENTED STATE MACHINE
|
|
285
299
|
// ============================================================
|
|
286
300
|
|
|
287
|
-
type ParserState =
|
|
301
|
+
type ParserState = "NORMAL" | "IN_SECTION";
|
|
288
302
|
|
|
289
303
|
/**
|
|
290
304
|
* Parse a CLAUDE.md file content using a line-oriented state machine.
|
|
@@ -295,161 +309,176 @@ type ParserState = 'NORMAL' | 'IN_SECTION';
|
|
|
295
309
|
* @returns ParseResult with sections and errors
|
|
296
310
|
*/
|
|
297
311
|
export function parseClaudeMdContent(content: string): ParseResult {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
312
|
+
const lines = content.split("\n");
|
|
313
|
+
const sections: ParsedSection[] = [];
|
|
314
|
+
const errors: ParseError[] = [];
|
|
315
|
+
|
|
316
|
+
let state: ParserState = "NORMAL";
|
|
317
|
+
let inCodeBlock = false;
|
|
318
|
+
// Track active fence: delimiter char ('`' or '~') and minimum length to close
|
|
319
|
+
let activeFenceChar = "";
|
|
320
|
+
let activeFenceLen = 0;
|
|
321
|
+
|
|
322
|
+
// Current section being accumulated
|
|
323
|
+
let currentSection = "";
|
|
324
|
+
let currentVersion: string | null = null;
|
|
325
|
+
let currentStartLine = 0;
|
|
326
|
+
let currentContentLines: string[] = [];
|
|
327
|
+
|
|
328
|
+
// Track section names for duplicate detection
|
|
329
|
+
const sectionLineMap = new Map<string, number[]>();
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < lines.length; i++) {
|
|
332
|
+
const line = lines[i];
|
|
333
|
+
const lineNum = i + 1; // 1-based line numbers
|
|
334
|
+
|
|
335
|
+
// Check for code fence open/close (CommonMark: matching delimiter family + sufficient length)
|
|
336
|
+
const fenceMatch = line.trim().match(CODE_FENCE_RE);
|
|
337
|
+
if (fenceMatch) {
|
|
338
|
+
const fenceStr = fenceMatch[1]; // e.g. '```' or '~~~~'
|
|
339
|
+
const fenceChar = fenceStr[0]; // '`' or '~'
|
|
340
|
+
const fenceLen = fenceStr.length;
|
|
341
|
+
|
|
342
|
+
if (!inCodeBlock) {
|
|
343
|
+
// Opening a new code block
|
|
344
|
+
inCodeBlock = true;
|
|
345
|
+
activeFenceChar = fenceChar;
|
|
346
|
+
activeFenceLen = fenceLen;
|
|
347
|
+
} else if (fenceChar === activeFenceChar && fenceLen >= activeFenceLen) {
|
|
348
|
+
// Closing: must be same delimiter family with at least the same length
|
|
349
|
+
inCodeBlock = false;
|
|
350
|
+
activeFenceChar = "";
|
|
351
|
+
activeFenceLen = 0;
|
|
352
|
+
}
|
|
353
|
+
// Otherwise: different delimiter family or shorter length — ignore (stays in code block)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check for BEGIN marker
|
|
357
|
+
const beginMatch = line.trim().match(BEGIN_MARKER_RE);
|
|
358
|
+
if (beginMatch) {
|
|
359
|
+
const sectionName = beginMatch[1];
|
|
360
|
+
const version = beginMatch[2];
|
|
361
|
+
|
|
362
|
+
if (inCodeBlock) {
|
|
363
|
+
errors.push({
|
|
364
|
+
type: "marker_in_code_block",
|
|
365
|
+
section: sectionName,
|
|
366
|
+
line: lineNum,
|
|
367
|
+
});
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// If we're already in a section, the previous one had no END marker
|
|
372
|
+
if (state === "IN_SECTION") {
|
|
373
|
+
errors.push({
|
|
374
|
+
type: "missing_end",
|
|
375
|
+
section: currentSection,
|
|
376
|
+
startLine: currentStartLine,
|
|
377
|
+
});
|
|
378
|
+
// Close the previous section with an error
|
|
379
|
+
sections.push({
|
|
380
|
+
section: currentSection,
|
|
381
|
+
version: currentVersion,
|
|
382
|
+
content: currentContentLines.join("\n"),
|
|
383
|
+
startLine: currentStartLine,
|
|
384
|
+
endLine: lineNum - 1,
|
|
385
|
+
insideCodeBlock: false,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Track for duplicate detection
|
|
390
|
+
const seenLines = sectionLineMap.get(sectionName) ?? [];
|
|
391
|
+
seenLines.push(lineNum);
|
|
392
|
+
sectionLineMap.set(sectionName, seenLines);
|
|
393
|
+
|
|
394
|
+
// Start new section
|
|
395
|
+
state = "IN_SECTION";
|
|
396
|
+
currentSection = sectionName;
|
|
397
|
+
currentVersion = version;
|
|
398
|
+
currentStartLine = lineNum;
|
|
399
|
+
currentContentLines = [];
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Check for END marker
|
|
404
|
+
const endMatch = line.trim().match(END_MARKER_RE);
|
|
405
|
+
if (endMatch) {
|
|
406
|
+
const sectionName = endMatch[1];
|
|
407
|
+
|
|
408
|
+
if (inCodeBlock) {
|
|
409
|
+
// Inside code block, skip
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (state !== "IN_SECTION") {
|
|
414
|
+
// Orphan END marker, ignore silently
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Verify END marker matches the currently open section
|
|
419
|
+
if (sectionName !== currentSection) {
|
|
420
|
+
errors.push({
|
|
421
|
+
type: "mismatched_end",
|
|
422
|
+
section: sectionName,
|
|
423
|
+
expected: currentSection,
|
|
424
|
+
line: lineNum,
|
|
425
|
+
});
|
|
426
|
+
// Don't close the section — keep accumulating content
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Close section
|
|
431
|
+
sections.push({
|
|
432
|
+
section: currentSection,
|
|
433
|
+
version: currentVersion,
|
|
434
|
+
content: currentContentLines.join("\n"),
|
|
435
|
+
startLine: currentStartLine,
|
|
436
|
+
endLine: lineNum,
|
|
437
|
+
insideCodeBlock: false,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
state = "NORMAL";
|
|
441
|
+
currentSection = "";
|
|
442
|
+
currentVersion = null;
|
|
443
|
+
currentContentLines = [];
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Accumulate content lines if inside a section
|
|
448
|
+
if (state === "IN_SECTION") {
|
|
449
|
+
currentContentLines.push(line);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// After all lines: if still in a section, it's unclosed
|
|
454
|
+
if (state === "IN_SECTION") {
|
|
455
|
+
errors.push({
|
|
456
|
+
type: "missing_end",
|
|
457
|
+
section: currentSection,
|
|
458
|
+
startLine: currentStartLine,
|
|
459
|
+
});
|
|
460
|
+
sections.push({
|
|
461
|
+
section: currentSection,
|
|
462
|
+
version: currentVersion,
|
|
463
|
+
content: currentContentLines.join("\n"),
|
|
464
|
+
startLine: currentStartLine,
|
|
465
|
+
endLine: lines.length,
|
|
466
|
+
insideCodeBlock: false,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Check for duplicate sections
|
|
471
|
+
for (const [sectionName, lineNums] of sectionLineMap) {
|
|
472
|
+
if (lineNums.length > 1) {
|
|
473
|
+
errors.push({
|
|
474
|
+
type: "duplicate_section",
|
|
475
|
+
section: sectionName,
|
|
476
|
+
lines: lineNums,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return { sections, errors };
|
|
453
482
|
}
|
|
454
483
|
|
|
455
484
|
/**
|
|
@@ -459,12 +488,14 @@ export function parseClaudeMdContent(content: string): ParseResult {
|
|
|
459
488
|
* @param claudeMdPath - Absolute path to CLAUDE.md file
|
|
460
489
|
* @returns ParseResult with sections and errors
|
|
461
490
|
*/
|
|
462
|
-
export async function parseClaudeMdSections(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
491
|
+
export async function parseClaudeMdSections(
|
|
492
|
+
claudeMdPath: string,
|
|
493
|
+
): Promise<ParseResult> {
|
|
494
|
+
const content = await readFileOrEmpty(claudeMdPath);
|
|
495
|
+
if (!content) {
|
|
496
|
+
return { sections: [], errors: [] };
|
|
497
|
+
}
|
|
498
|
+
return parseClaudeMdContent(content);
|
|
468
499
|
}
|
|
469
500
|
|
|
470
501
|
// ============================================================
|
|
@@ -474,8 +505,12 @@ export async function parseClaudeMdSections(claudeMdPath: string): Promise<Parse
|
|
|
474
505
|
/**
|
|
475
506
|
* Build a complete marker block for a section.
|
|
476
507
|
*/
|
|
477
|
-
function buildMarkerBlock(
|
|
478
|
-
|
|
508
|
+
function buildMarkerBlock(
|
|
509
|
+
section: string,
|
|
510
|
+
version: string,
|
|
511
|
+
content: string,
|
|
512
|
+
): string {
|
|
513
|
+
return `<!-- BEGIN magus:${section} v${version} -->\n${content}\n<!-- END magus:${section} -->`;
|
|
479
514
|
}
|
|
480
515
|
|
|
481
516
|
/**
|
|
@@ -493,74 +528,78 @@ function buildMarkerBlock(section: string, version: string, content: string): st
|
|
|
493
528
|
* @returns Object indicating action taken, or error if file is malformed
|
|
494
529
|
*/
|
|
495
530
|
export async function injectClaudeMdSection(
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
531
|
+
claudeMdPath: string,
|
|
532
|
+
section: string,
|
|
533
|
+
version: string,
|
|
534
|
+
content: string,
|
|
500
535
|
): Promise<{
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
536
|
+
action: "inserted" | "updated" | "unchanged" | "error";
|
|
537
|
+
previousVersion?: string;
|
|
538
|
+
error?: string;
|
|
504
539
|
}> {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
540
|
+
return withFileLock(claudeMdPath, async () => {
|
|
541
|
+
const fileContent = await readFileOrEmpty(claudeMdPath);
|
|
542
|
+
const parseResult = parseClaudeMdContent(fileContent);
|
|
543
|
+
|
|
544
|
+
// Refuse if parse errors detected
|
|
545
|
+
if (parseResult.errors.length > 0) {
|
|
546
|
+
const errorSummary = parseResult.errors
|
|
547
|
+
.map((e) => {
|
|
548
|
+
if (e.type === "missing_end")
|
|
549
|
+
return `missing END for "${e.section}" at line ${e.startLine}`;
|
|
550
|
+
if (e.type === "mismatched_end")
|
|
551
|
+
return `mismatched END for "${e.section}" (expected "${e.expected}") at line ${e.line}`;
|
|
552
|
+
if (e.type === "duplicate_section")
|
|
553
|
+
return `duplicate section "${e.section}" at lines ${e.lines.join(", ")}`;
|
|
554
|
+
if (e.type === "marker_in_code_block")
|
|
555
|
+
return `marker for "${e.section}" inside code block at line ${e.line}`;
|
|
556
|
+
return "unknown error";
|
|
557
|
+
})
|
|
558
|
+
.join("; ");
|
|
559
|
+
return {
|
|
560
|
+
action: "error" as const,
|
|
561
|
+
error: `File has malformed markers: ${errorSummary}. Use 'claudeup doctor --fix' to repair.`,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Find existing section
|
|
566
|
+
const existing = parseResult.sections.find((s) => s.section === section);
|
|
567
|
+
|
|
568
|
+
if (existing) {
|
|
569
|
+
// Section exists -- check version
|
|
570
|
+
if (existing.version === version) {
|
|
571
|
+
return { action: "unchanged" as const };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Version changed -- replace the section
|
|
575
|
+
const lines = fileContent.split("\n");
|
|
576
|
+
const before = lines.slice(0, existing.startLine - 1);
|
|
577
|
+
const after = lines.slice(existing.endLine);
|
|
578
|
+
const newBlock = buildMarkerBlock(section, version, content);
|
|
579
|
+
|
|
580
|
+
const newContent = [...before, newBlock, ...after].join("\n");
|
|
581
|
+
await atomicWrite(claudeMdPath, newContent);
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
action: "updated" as const,
|
|
585
|
+
previousVersion: existing.version ?? undefined,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Section doesn't exist -- append
|
|
590
|
+
let newContent = fileContent;
|
|
591
|
+
if (newContent.length > 0 && !newContent.endsWith("\n")) {
|
|
592
|
+
newContent += "\n";
|
|
593
|
+
}
|
|
594
|
+
if (newContent.length > 0) {
|
|
595
|
+
newContent += "\n";
|
|
596
|
+
}
|
|
597
|
+
newContent += `${buildMarkerBlock(section, version, content)}\n`;
|
|
598
|
+
|
|
599
|
+
await atomicWrite(claudeMdPath, newContent);
|
|
600
|
+
|
|
601
|
+
return { action: "inserted" as const };
|
|
602
|
+
});
|
|
564
603
|
}
|
|
565
604
|
|
|
566
605
|
/**
|
|
@@ -574,56 +613,65 @@ export async function injectClaudeMdSection(
|
|
|
574
613
|
* @returns Whether a section was removed, or error if file is malformed
|
|
575
614
|
*/
|
|
576
615
|
export async function removeClaudeMdSection(
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
616
|
+
claudeMdPath: string,
|
|
617
|
+
section: string,
|
|
618
|
+
options?: { force?: boolean },
|
|
580
619
|
): Promise<{ removed: boolean; error?: string }> {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
620
|
+
return withFileLock(claudeMdPath, async () => {
|
|
621
|
+
const fileContent = await readFileOrEmpty(claudeMdPath);
|
|
622
|
+
if (!fileContent) {
|
|
623
|
+
return { removed: false };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const parseResult = parseClaudeMdContent(fileContent);
|
|
627
|
+
|
|
628
|
+
// Refuse if parse errors detected (same guard as injectClaudeMdSection)
|
|
629
|
+
// Unless force=true (used by doctor repair-markers to fix broken files)
|
|
630
|
+
if (parseResult.errors.length > 0 && !options?.force) {
|
|
631
|
+
const errorSummary = parseResult.errors
|
|
632
|
+
.map((e) => {
|
|
633
|
+
if (e.type === "missing_end")
|
|
634
|
+
return `missing END for "${e.section}" at line ${e.startLine}`;
|
|
635
|
+
if (e.type === "mismatched_end")
|
|
636
|
+
return `mismatched END for "${e.section}" (expected "${e.expected}") at line ${e.line}`;
|
|
637
|
+
if (e.type === "duplicate_section")
|
|
638
|
+
return `duplicate section "${e.section}" at lines ${e.lines.join(", ")}`;
|
|
639
|
+
if (e.type === "marker_in_code_block")
|
|
640
|
+
return `marker for "${e.section}" inside code block at line ${e.line}`;
|
|
641
|
+
return "unknown error";
|
|
642
|
+
})
|
|
643
|
+
.join("; ");
|
|
644
|
+
return {
|
|
645
|
+
removed: false,
|
|
646
|
+
error: `File has malformed markers: ${errorSummary}. Use 'claudeup doctor --fix' to repair.`,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const existing = parseResult.sections.find((s) => s.section === section);
|
|
651
|
+
|
|
652
|
+
if (!existing) {
|
|
653
|
+
return { removed: false };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const lines = fileContent.split("\n");
|
|
657
|
+
const before = lines.slice(0, existing.startLine - 1);
|
|
658
|
+
const after = lines.slice(existing.endLine);
|
|
659
|
+
|
|
660
|
+
// Clean up blank lines at the join point
|
|
661
|
+
while (
|
|
662
|
+
before.length > 0 &&
|
|
663
|
+
before[before.length - 1].trim() === "" &&
|
|
664
|
+
after.length > 0 &&
|
|
665
|
+
after[0].trim() === ""
|
|
666
|
+
) {
|
|
667
|
+
after.shift();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const newContent = [...before, ...after].join("\n");
|
|
671
|
+
await atomicWrite(claudeMdPath, newContent);
|
|
672
|
+
|
|
673
|
+
return { removed: true };
|
|
674
|
+
});
|
|
627
675
|
}
|
|
628
676
|
|
|
629
677
|
/**
|
|
@@ -632,16 +680,21 @@ export async function removeClaudeMdSection(
|
|
|
632
680
|
* @param claudeMdPath - Absolute path to CLAUDE.md file
|
|
633
681
|
* @returns Array of section info (name, version, line range)
|
|
634
682
|
*/
|
|
635
|
-
export async function listClaudeMdSections(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
683
|
+
export async function listClaudeMdSections(claudeMdPath: string): Promise<
|
|
684
|
+
Array<{
|
|
685
|
+
section: string;
|
|
686
|
+
version: string | null;
|
|
687
|
+
startLine: number;
|
|
688
|
+
endLine: number;
|
|
689
|
+
}>
|
|
690
|
+
> {
|
|
691
|
+
const parseResult = await parseClaudeMdSections(claudeMdPath);
|
|
692
|
+
return parseResult.sections.map((s) => ({
|
|
693
|
+
section: s.section,
|
|
694
|
+
version: s.version,
|
|
695
|
+
startLine: s.startLine,
|
|
696
|
+
endLine: s.endLine,
|
|
697
|
+
}));
|
|
645
698
|
}
|
|
646
699
|
|
|
647
700
|
// ============================================================
|
|
@@ -656,27 +709,27 @@ export async function listClaudeMdSections(
|
|
|
656
709
|
* @returns Absolute path to global gitignore, or null if unresolvable
|
|
657
710
|
*/
|
|
658
711
|
export async function resolveGlobalGitignorePath(): Promise<string | null> {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
712
|
+
try {
|
|
713
|
+
const { execSync } = await import("node:child_process");
|
|
714
|
+
const configPath = execSync("git config --global core.excludesfile", {
|
|
715
|
+
encoding: "utf-8",
|
|
716
|
+
timeout: 5000,
|
|
717
|
+
}).trim();
|
|
718
|
+
|
|
719
|
+
if (configPath) {
|
|
720
|
+
// Expand ~ to home directory
|
|
721
|
+
const expanded = configPath.startsWith("~")
|
|
722
|
+
? join(homedir(), configPath.slice(1))
|
|
723
|
+
: configPath;
|
|
724
|
+
return resolve(expanded);
|
|
725
|
+
}
|
|
726
|
+
} catch {
|
|
727
|
+
// git config returned empty or git not found
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Fallback: XDG_CONFIG_HOME/git/ignore or ~/.config/git/ignore
|
|
731
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
732
|
+
return join(xdgConfig, "git", "ignore");
|
|
680
733
|
}
|
|
681
734
|
|
|
682
735
|
// ============================================================
|
|
@@ -691,26 +744,28 @@ export async function resolveGlobalGitignorePath(): Promise<string | null> {
|
|
|
691
744
|
* @returns Parsed conventions or null
|
|
692
745
|
*/
|
|
693
746
|
export async function resolvePluginConventions(
|
|
694
|
-
|
|
747
|
+
pluginPath: string,
|
|
695
748
|
): Promise<PluginConventions | null> {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
749
|
+
// `.claude-plugin/plugin.json` is the location Claude Code reads; a root-level
|
|
750
|
+
// manifest is inert to the runtime but still worth reading for a third-party plugin
|
|
751
|
+
// whose author put it there.
|
|
752
|
+
const nested = join(pluginPath, ".claude-plugin", "plugin.json");
|
|
753
|
+
const manifestPath = existsSync(nested)
|
|
754
|
+
? nested
|
|
755
|
+
: join(pluginPath, "plugin.json");
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
const raw = await readFile(manifestPath, "utf-8");
|
|
759
|
+
const manifest = JSON.parse(raw);
|
|
760
|
+
|
|
761
|
+
if (!manifest.conventions || typeof manifest.conventions !== "object") {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return manifest.conventions as PluginConventions;
|
|
766
|
+
} catch {
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
714
769
|
}
|
|
715
770
|
|
|
716
771
|
/**
|
|
@@ -722,20 +777,23 @@ export async function resolvePluginConventions(
|
|
|
722
777
|
* @throws Error if template file cannot be read
|
|
723
778
|
*/
|
|
724
779
|
export async function readConventionTemplate(
|
|
725
|
-
|
|
726
|
-
|
|
780
|
+
pluginPath: string,
|
|
781
|
+
templateRelPath: string,
|
|
727
782
|
): Promise<string> {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
783
|
+
const templatePath = resolve(join(pluginPath, templateRelPath));
|
|
784
|
+
const resolvedPluginPath = resolve(pluginPath);
|
|
785
|
+
|
|
786
|
+
// Prevent path traversal: template must stay within plugin directory
|
|
787
|
+
if (
|
|
788
|
+
!templatePath.startsWith(`${resolvedPluginPath}/`) &&
|
|
789
|
+
templatePath !== resolvedPluginPath
|
|
790
|
+
) {
|
|
791
|
+
throw new Error(
|
|
792
|
+
`Template path escapes plugin directory: ${templateRelPath}`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return readFile(templatePath, "utf-8");
|
|
739
797
|
}
|
|
740
798
|
|
|
741
799
|
// ============================================================
|
|
@@ -753,84 +811,101 @@ export async function readConventionTemplate(
|
|
|
753
811
|
* @returns Summary of changes made
|
|
754
812
|
*/
|
|
755
813
|
export async function applyConventions(
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
814
|
+
pluginPath: string,
|
|
815
|
+
projectPath: string,
|
|
816
|
+
logger?: ConventionLogger,
|
|
759
817
|
): Promise<{
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
818
|
+
gitignore: { added: string[]; modified: boolean };
|
|
819
|
+
claudemd: {
|
|
820
|
+
action: "inserted" | "updated" | "unchanged" | "skipped" | "error";
|
|
821
|
+
section?: string;
|
|
822
|
+
error?: string;
|
|
823
|
+
};
|
|
766
824
|
}> {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
825
|
+
// Resolve conventions from plugin.json
|
|
826
|
+
const conventions = await resolvePluginConventions(pluginPath);
|
|
827
|
+
|
|
828
|
+
// Null conventions = no-op
|
|
829
|
+
if (!conventions) {
|
|
830
|
+
return {
|
|
831
|
+
gitignore: { added: [], modified: false },
|
|
832
|
+
claudemd: { action: "skipped" },
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Apply gitignore entries
|
|
837
|
+
let gitignoreResult = { added: [] as string[], modified: false };
|
|
838
|
+
|
|
839
|
+
if (
|
|
840
|
+
conventions.gitignore?.project &&
|
|
841
|
+
conventions.gitignore.project.length > 0
|
|
842
|
+
) {
|
|
843
|
+
const gitignorePath = join(projectPath, ".gitignore");
|
|
844
|
+
gitignoreResult = await ensureGitignoreEntries(
|
|
845
|
+
gitignorePath,
|
|
846
|
+
conventions.gitignore.project,
|
|
847
|
+
"# Managed by magus plugin conventions",
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
if (gitignoreResult.modified) {
|
|
851
|
+
logger?.(
|
|
852
|
+
"info",
|
|
853
|
+
`Added ${gitignoreResult.added.length} entries to .gitignore`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Apply CLAUDE.md section
|
|
859
|
+
let claudemdResult: {
|
|
860
|
+
action: "inserted" | "updated" | "unchanged" | "skipped" | "error";
|
|
861
|
+
section?: string;
|
|
862
|
+
error?: string;
|
|
863
|
+
} = { action: "skipped" };
|
|
864
|
+
|
|
865
|
+
if (conventions.claudemd) {
|
|
866
|
+
const claudeMdPath = join(projectPath, "CLAUDE.md");
|
|
867
|
+
const { section, template, version } = conventions.claudemd;
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
const templateContent = await readConventionTemplate(
|
|
871
|
+
pluginPath,
|
|
872
|
+
template,
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
const result = await injectClaudeMdSection(
|
|
876
|
+
claudeMdPath,
|
|
877
|
+
section,
|
|
878
|
+
version,
|
|
879
|
+
templateContent,
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
claudemdResult = {
|
|
883
|
+
action: result.action,
|
|
884
|
+
section,
|
|
885
|
+
error: result.error,
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
if (result.action === "inserted") {
|
|
889
|
+
logger?.("info", `Injected CLAUDE.md section "${section}" v${version}`);
|
|
890
|
+
} else if (result.action === "updated") {
|
|
891
|
+
logger?.(
|
|
892
|
+
"info",
|
|
893
|
+
`Updated CLAUDE.md section "${section}" from v${result.previousVersion} to v${version}`,
|
|
894
|
+
);
|
|
895
|
+
} else if (result.action === "error") {
|
|
896
|
+
logger?.("warn", `Could not update CLAUDE.md: ${result.error}`);
|
|
897
|
+
}
|
|
898
|
+
} catch (err: unknown) {
|
|
899
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
900
|
+
claudemdResult = { action: "error", section, error: message };
|
|
901
|
+
logger?.("error", `Failed to apply CLAUDE.md conventions: ${message}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return {
|
|
906
|
+
gitignore: gitignoreResult,
|
|
907
|
+
claudemd: claudemdResult,
|
|
908
|
+
};
|
|
834
909
|
}
|
|
835
910
|
|
|
836
911
|
/**
|
|
@@ -844,27 +919,27 @@ export async function applyConventions(
|
|
|
844
919
|
* @returns Summary of changes made
|
|
845
920
|
*/
|
|
846
921
|
export async function removeConventions(
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
922
|
+
pluginName: string,
|
|
923
|
+
projectPath: string,
|
|
924
|
+
logger?: ConventionLogger,
|
|
850
925
|
): Promise<{
|
|
851
|
-
|
|
926
|
+
claudemd: { removed: boolean };
|
|
852
927
|
}> {
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
928
|
+
// Even without conventions declaration, try to remove the section
|
|
929
|
+
// in case it was applied by a previous version
|
|
930
|
+
const claudeMdPath = join(projectPath, "CLAUDE.md");
|
|
931
|
+
|
|
932
|
+
try {
|
|
933
|
+
const result = await removeClaudeMdSection(claudeMdPath, pluginName);
|
|
934
|
+
|
|
935
|
+
if (result.removed) {
|
|
936
|
+
logger?.("info", `Removed CLAUDE.md section "${pluginName}"`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return { claudemd: result };
|
|
940
|
+
} catch (err: unknown) {
|
|
941
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
942
|
+
logger?.("warn", `Failed to remove CLAUDE.md section: ${message}`);
|
|
943
|
+
return { claudemd: { removed: false } };
|
|
944
|
+
}
|
|
870
945
|
}
|