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