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.
- package/bin/claudeup.js +43 -19
- package/package.json +14 -19
- package/scripts/build-binaries.ts +77 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +43 -0
- package/src/__tests__/cli-router.test.ts +65 -0
- package/src/__tests__/conventions-integration.test.ts +745 -0
- package/src/__tests__/conventions-manager.test.ts +1172 -0
- package/src/__tests__/doctor-bins.test.ts +35 -0
- package/src/__tests__/doctor.test.ts +450 -0
- package/src/__tests__/dual-write-prevention.test.ts +14 -4
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/gitignore-prerun.test.ts +1 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +98 -0
- package/src/__tests__/manifest.test.ts +136 -0
- package/src/__tests__/marketplace-refresh.test.ts +267 -0
- package/src/__tests__/plugin-requires.test.ts +133 -0
- package/src/__tests__/plugin-setup.test.ts +7 -0
- package/src/__tests__/profile-command.test.ts +116 -0
- package/src/__tests__/profile-materializer.test.ts +82 -0
- package/src/__tests__/profile-sync.test.ts +136 -0
- package/src/__tests__/registry-version-resolution.test.ts +74 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +218 -0
- package/src/__tests__/symlink-manager.test.ts +99 -0
- package/src/__tests__/toolchain.test.ts +56 -0
- package/src/cli/claude.ts +21 -0
- package/src/cli/doctor.ts +132 -0
- package/src/cli/install.ts +360 -0
- package/src/cli/profile.ts +166 -0
- package/src/cli/router.ts +107 -0
- package/src/cli/update.ts +85 -0
- package/src/data/cli-tools.ts +7 -7
- package/src/data/gitignore-defaults.ts +4 -0
- package/src/data/marketplaces.ts +12 -0
- package/src/data/predefined-profiles.ts +3 -4
- package/src/main.tsx +11 -154
- package/src/prerunner/index.ts +62 -27
- package/src/services/claude-cli.ts +19 -22
- package/src/services/claude-settings.ts +99 -26
- package/src/services/conventions-manager.ts +865 -0
- package/src/services/doctor-bins.ts +49 -0
- package/src/services/doctor.ts +509 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/manifest.ts +166 -0
- package/src/services/marketplace-refresh.ts +173 -0
- package/src/services/plugin-manager.ts +7 -1
- package/src/services/plugin-requires.ts +215 -0
- package/src/services/plugin-version-check.ts +9 -4
- package/src/services/profile-materializer.ts +61 -0
- package/src/services/profile-sync.ts +150 -0
- package/src/services/resolver.ts +293 -0
- package/src/services/symlink-manager.ts +146 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/version-check.ts +10 -11
- package/src/types/index.ts +138 -31
- package/src/ui/App.tsx +0 -4
- package/src/ui/screens/CliToolsScreen.tsx +11 -3
- package/src/ui/screens/index.ts +0 -1
- package/src/ui/state/reducer.ts +0 -101
- package/src/ui/state/types.ts +0 -35
- package/src/utils/command-utils.ts +23 -0
- package/src/utils/file-locking.ts +144 -0
- package/src/data/alias-flags.js +0 -205
- package/src/data/cli-tools.js +0 -123
- package/src/data/gitignore-defaults.js +0 -24
- package/src/data/gitignore-reasons.js +0 -97
- package/src/data/gitignore-templates.js +0 -21
- package/src/data/marketplaces.js +0 -138
- package/src/data/mcp-servers.js +0 -509
- package/src/data/predefined-profiles.js +0 -248
- package/src/data/settings-catalog.js +0 -625
- package/src/data/skill-repos.js +0 -160
- package/src/data/statuslines.js +0 -159
- package/src/data/statuslines.ts +0 -188
- package/src/index.js +0 -4
- package/src/index.ts +0 -5
- package/src/main.js +0 -170
- package/src/prerunner/index.js +0 -252
- package/src/services/alias-settings.js +0 -51
- package/src/services/alias-shell-writer.js +0 -1018
- package/src/services/alias-store.js +0 -129
- package/src/services/claude-cli.js +0 -189
- package/src/services/claude-runner.js +0 -28
- package/src/services/claude-settings.js +0 -1223
- package/src/services/gitignore-detector.js +0 -155
- package/src/services/gitignore-fixer.js +0 -231
- package/src/services/gitignore-prerun.js +0 -46
- package/src/services/gitignore-resolver.js +0 -143
- package/src/services/gitignore-service.js +0 -117
- package/src/services/local-marketplace.js +0 -339
- package/src/services/marketplace-fetcher.js +0 -96
- package/src/services/marketplace-sync.js +0 -94
- package/src/services/mcp-registry.js +0 -87
- package/src/services/plugin-manager.js +0 -473
- package/src/services/plugin-mcp-config.js +0 -177
- package/src/services/plugin-setup.js +0 -499
- package/src/services/plugin-version-check.js +0 -262
- package/src/services/profiles.js +0 -161
- package/src/services/settings-manager.js +0 -243
- package/src/services/skills-manager.js +0 -393
- package/src/services/skillsmp-client.js +0 -87
- package/src/services/update-cache.js +0 -52
- package/src/services/version-check.js +0 -99
- package/src/types/gitignore.js +0 -6
- package/src/types/index.js +0 -1
- package/src/ui/App.js +0 -355
- package/src/ui/adapters/pluginsAdapter.js +0 -139
- package/src/ui/adapters/settingsAdapter.js +0 -111
- package/src/ui/adapters/skillsAdapter.js +0 -154
- package/src/ui/components/CategoryHeader.js +0 -9
- package/src/ui/components/EmptyFilterState.js +0 -4
- package/src/ui/components/FlagDetailEditor.js +0 -0
- package/src/ui/components/ScopeIndicator.js +0 -30
- package/src/ui/components/ScrollableList.js +0 -36
- package/src/ui/components/SearchInput.js +0 -19
- package/src/ui/components/StyledText.js +0 -39
- package/src/ui/components/TabBar.js +0 -19
- package/src/ui/components/layout/FooterHints.js +0 -29
- package/src/ui/components/layout/Panel.js +0 -6
- package/src/ui/components/layout/ProgressBar.js +0 -14
- package/src/ui/components/layout/ScopeTabs.js +0 -6
- package/src/ui/components/layout/ScreenLayout.js +0 -16
- package/src/ui/components/layout/index.js +0 -5
- package/src/ui/components/modals/ConfirmModal.js +0 -14
- package/src/ui/components/modals/InputModal.js +0 -5
- package/src/ui/components/modals/LoadingModal.js +0 -17
- package/src/ui/components/modals/MessageModal.js +0 -16
- package/src/ui/components/modals/ModalContainer.js +0 -137
- package/src/ui/components/modals/SelectModal.js +0 -18
- package/src/ui/components/modals/VersionMismatchModal.js +0 -36
- package/src/ui/components/modals/index.js +0 -6
- package/src/ui/components/primitives/ActionHints.js +0 -13
- package/src/ui/components/primitives/DetailSection.js +0 -7
- package/src/ui/components/primitives/KeyValueLine.js +0 -8
- package/src/ui/components/primitives/ListCategoryRow.js +0 -8
- package/src/ui/components/primitives/MetaText.js +0 -8
- package/src/ui/components/primitives/ScopeDetail.js +0 -32
- package/src/ui/components/primitives/ScopeSquares.js +0 -11
- package/src/ui/components/primitives/SelectableRow.js +0 -5
- package/src/ui/components/primitives/index.js +0 -8
- package/src/ui/hooks/index.js +0 -4
- package/src/ui/hooks/useAsyncData.js +0 -77
- package/src/ui/hooks/useGitignoreModal.js +0 -74
- package/src/ui/hooks/useKeyboard.js +0 -13
- package/src/ui/hooks/useKeyboardHandler.js +0 -39
- package/src/ui/hooks/useMismatchModal.js +0 -77
- package/src/ui/registry.js +0 -1
- package/src/ui/renderers/cliToolRenderers.js +0 -54
- package/src/ui/renderers/gitignoreRenderers.js +0 -46
- package/src/ui/renderers/mcpRenderers.js +0 -26
- package/src/ui/renderers/pluginRenderers.js +0 -124
- package/src/ui/renderers/profileRenderers.js +0 -177
- package/src/ui/renderers/settingsRenderers.js +0 -73
- package/src/ui/renderers/skillRenderers.js +0 -138
- package/src/ui/screens/AliasScreen.js +0 -1111
- package/src/ui/screens/CliToolsScreen.js +0 -338
- package/src/ui/screens/EnvVarsScreen.js +0 -152
- package/src/ui/screens/GitignoreScreen.js +0 -328
- package/src/ui/screens/McpRegistryScreen.js +0 -238
- package/src/ui/screens/McpScreen.js +0 -176
- package/src/ui/screens/ModelSelectorScreen.js +0 -296
- package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
- package/src/ui/screens/PluginsScreen.js +0 -769
- package/src/ui/screens/ProfilesScreen.js +0 -298
- package/src/ui/screens/SkillsScreen.js +0 -549
- package/src/ui/screens/StatusLineScreen.js +0 -206
- package/src/ui/screens/StatusLineScreen.tsx +0 -416
- package/src/ui/screens/index.js +0 -10
- package/src/ui/state/AnimationContext.js +0 -34
- package/src/ui/state/AppContext.js +0 -162
- package/src/ui/state/DimensionsContext.js +0 -71
- package/src/ui/state/reducer.js +0 -547
- package/src/ui/state/types.js +0 -1
- package/src/ui/theme.js +0 -47
- package/src/utils/clipboard.js +0 -56
- package/src/utils/command-utils.js +0 -19
- package/src/utils/fuzzy-search.js +0 -101
- 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
|
+
}
|