claudeup 6.2.1 → 6.3.2

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