@shirayner/ace 0.1.1-snapshot.5 → 0.1.1-snapshot.7
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/package.json +1 -1
- package/src/commands/init.js +157 -57
- package/src/core/constants.js +37 -7
- package/src/core/installer.js +15 -12
- package/src/core/merger.js +139 -5
- package/src/core/spec-installer.js +0 -5
- package/templates/CLAUDE.md +2 -6
- /package/templates/hookify/{ace.hookify.block-dangerous-ops.local.md → hookify.ace.block-dangerous-ops.local.md} +0 -0
- /package/templates/hookify/{ace.hookify.code-quality-gate.local.md → hookify.ace.code-quality-gate.local.md} +0 -0
- /package/templates/hookify/{hookify.dangerous-commands.local.md → hookify.ace.dangerous-commands.local.md} +0 -0
- /package/templates/hookify/{ace.hookify.protect-secrets.local.md → hookify.ace.protect-secrets.local.md} +0 -0
- /package/templates/hookify/{ace.hookify.require-verification.local.md → hookify.ace.require-verification.local.md} +0 -0
- /package/templates/hookify/{ace.hookify.safe-git-commands.local.md → hookify.ace.safe-git-commands.local.md} +0 -0
- /package/templates/hookify/{hookify.sensitive-data.local.md → hookify.ace.sensitive-data.local.md} +0 -0
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
2
4
|
import { createRequire } from 'module';
|
|
3
|
-
import { PRESETS, COMPONENTS } from '../core/constants.js';
|
|
5
|
+
import { PRESETS, COMPONENTS, CLAUDE_DIR, TEMPLATES_DIR, isAceOwnedFile } from '../core/constants.js';
|
|
4
6
|
import { Installer } from '../core/installer.js';
|
|
7
|
+
import { mergeClaudeMd } from '../core/merger.js';
|
|
5
8
|
|
|
6
9
|
const require = createRequire(import.meta.url);
|
|
7
10
|
const pkg = require('../../package.json');
|
|
@@ -19,10 +22,8 @@ export async function initCommand(options) {
|
|
|
19
22
|
const version = pkg.version;
|
|
20
23
|
const components = PRESETS['full'];
|
|
21
24
|
|
|
22
|
-
// ─── Intro ──────────────────────────────────────────────
|
|
23
25
|
p.intro(`ace v${version}`);
|
|
24
26
|
|
|
25
|
-
// ─── Conflict detection ─────────────────────────────────
|
|
26
27
|
const installer = new Installer({
|
|
27
28
|
force: options.force,
|
|
28
29
|
dryRun: options.dryRun,
|
|
@@ -33,89 +34,100 @@ export async function initCommand(options) {
|
|
|
33
34
|
|
|
34
35
|
let resolutions = {};
|
|
35
36
|
|
|
37
|
+
// ─── Conflict detection with categorized preview ───
|
|
36
38
|
if (!options.force) {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
p.log.info('Safe merge: CLAUDE.md, settings.json (preserves your changes)');
|
|
46
|
-
}
|
|
47
|
-
if (totalFiles > 0) {
|
|
48
|
-
p.log.warn(`${totalFiles} existing file(s) found`);
|
|
39
|
+
const preview = await buildInstallPreview(installer, components);
|
|
40
|
+
const hasExisting = preview.merge.length > 0 || preview.skip.length > 0 || preview.conflict.length > 0;
|
|
41
|
+
|
|
42
|
+
if (hasExisting) {
|
|
43
|
+
// Show safe merge section
|
|
44
|
+
if (preview.merge.length > 0) {
|
|
45
|
+
const mergeLines = preview.merge.map(m => ` ${m.dest} — ${m.detail}`);
|
|
46
|
+
p.log.info(['Safe merge:', ...mergeLines].join('\n'));
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{ value: 'overwrite', label: 'Overwrite all', hint: 'replace with latest' },
|
|
56
|
-
{ value: 'cancel', label: 'Cancel' },
|
|
57
|
-
],
|
|
58
|
-
initialValue: 'skip',
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
if (p.isCancel(action) || action === 'cancel') {
|
|
62
|
-
p.cancel('Setup cancelled.');
|
|
63
|
-
process.exit(0);
|
|
49
|
+
// Show auto-skip section
|
|
50
|
+
if (preview.skip.length > 0) {
|
|
51
|
+
const skipLines = preview.skip.map(s => ` ${s} — preserves your data`);
|
|
52
|
+
p.log.message(['Auto-skip:', ...skipLines].join('\n'));
|
|
64
53
|
}
|
|
65
54
|
|
|
66
|
-
|
|
67
|
-
|
|
55
|
+
// Show conflict section + prompt
|
|
56
|
+
if (preview.conflict.length > 0) {
|
|
57
|
+
const conflictLines = preview.conflict.map(f => ` ${f}`);
|
|
58
|
+
p.log.warn([`${preview.conflict.length} existing file(s):`, ...conflictLines].join('\n'));
|
|
59
|
+
|
|
60
|
+
const action = await p.select({
|
|
61
|
+
message: `How to handle ${preview.conflict.length} existing files?`,
|
|
62
|
+
options: [
|
|
63
|
+
{ value: 'skip', label: 'Keep existing', hint: 'recommended' },
|
|
64
|
+
{ value: 'overwrite', label: 'Overwrite with latest' },
|
|
65
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
66
|
+
],
|
|
67
|
+
initialValue: 'skip',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (p.isCancel(action) || action === 'cancel') {
|
|
71
|
+
p.cancel('Setup cancelled.');
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const componentName of components) {
|
|
76
|
+
resolutions[componentName] = action;
|
|
77
|
+
}
|
|
68
78
|
}
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
installer.resolutions = resolutions;
|
|
73
83
|
|
|
74
|
-
// ─── Dry-run notice ─────────────────────────────────────
|
|
75
84
|
if (options.dryRun) {
|
|
76
85
|
p.log.warn('dry-run — no changes will be made');
|
|
77
86
|
}
|
|
78
87
|
|
|
79
|
-
// ─── Install
|
|
80
|
-
p.
|
|
88
|
+
// ─── Install with single spinner ───────────────────
|
|
89
|
+
const s = p.spinner();
|
|
90
|
+
const componentResults = [];
|
|
91
|
+
|
|
92
|
+
s.start('Installing...');
|
|
81
93
|
|
|
82
94
|
for (const componentName of components) {
|
|
83
95
|
const component = COMPONENTS[componentName];
|
|
84
96
|
if (!component) continue;
|
|
85
97
|
|
|
86
98
|
const label = componentLabels[componentName] || componentName;
|
|
99
|
+
s.message(`Installing ${label}...`);
|
|
100
|
+
|
|
87
101
|
const beforeInstalled = installer.results.installed.length;
|
|
88
102
|
const beforeMerged = installer.results.merged.length;
|
|
89
103
|
const beforeSkipped = installer.results.skipped.length;
|
|
90
104
|
|
|
91
|
-
const s = p.spinner();
|
|
92
|
-
s.start(label);
|
|
93
|
-
|
|
94
105
|
try {
|
|
95
106
|
await installer.installComponent(componentName, component);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
p.log.info(`${label} — merged`);
|
|
104
|
-
} else if (newSkipped > 0 && newInstalled === 0 && newMerged === 0) {
|
|
105
|
-
p.log.message(`${label} — unchanged`);
|
|
106
|
-
} else {
|
|
107
|
-
const count = newInstalled + newMerged;
|
|
108
|
-
const detail = count > 0 ? `${count} file${count > 1 ? 's' : ''}` : '';
|
|
109
|
-
p.log.success(`${label} — ${detail}`);
|
|
110
|
-
}
|
|
107
|
+
componentResults.push({
|
|
108
|
+
label,
|
|
109
|
+
installed: installer.results.installed.length - beforeInstalled,
|
|
110
|
+
merged: installer.results.merged.length - beforeMerged,
|
|
111
|
+
skipped: installer.results.skipped.length - beforeSkipped,
|
|
112
|
+
error: null,
|
|
113
|
+
});
|
|
111
114
|
} catch (err) {
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
componentResults.push({
|
|
116
|
+
label,
|
|
117
|
+
installed: installer.results.installed.length - beforeInstalled,
|
|
118
|
+
merged: installer.results.merged.length - beforeMerged,
|
|
119
|
+
skipped: installer.results.skipped.length - beforeSkipped,
|
|
120
|
+
error: err.message,
|
|
121
|
+
});
|
|
114
122
|
installer.results.errors.push({ component: componentName, error: err.message });
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
|
|
118
|
-
|
|
126
|
+
s.stop('Installed to ~/.claude/');
|
|
127
|
+
|
|
128
|
+
// ─── Summary table ─────────────────────────────────
|
|
129
|
+
p.log.message(formatSummaryTable(componentResults));
|
|
130
|
+
|
|
119
131
|
const { installed, merged, skipped, errors } = installer.results;
|
|
120
132
|
const parts = [];
|
|
121
133
|
if (installed.length > 0) parts.push(`${installed.length} installed`);
|
|
@@ -128,12 +140,12 @@ export async function initCommand(options) {
|
|
|
128
140
|
p.log.warn(`${parts.join(', ')}, ${errors.length} failed`);
|
|
129
141
|
}
|
|
130
142
|
|
|
131
|
-
// ─── Next Steps
|
|
143
|
+
// ─── Next Steps ────────────────────────────────────
|
|
132
144
|
p.note(
|
|
133
145
|
[
|
|
134
146
|
'Get started',
|
|
135
147
|
' 1. cd <your-project> && ace spec init',
|
|
136
|
-
' 2. Open Claude Code, type: /opsx:propose
|
|
148
|
+
' 2. Open Claude Code, type: /opsx:propose',
|
|
137
149
|
'',
|
|
138
150
|
'Customize',
|
|
139
151
|
' Change role edit ~/.claude/memory/user_profile.md',
|
|
@@ -144,10 +156,98 @@ export async function initCommand(options) {
|
|
|
144
156
|
'Next steps'
|
|
145
157
|
);
|
|
146
158
|
|
|
147
|
-
// ─── Outro ──────────────────────────────────────────────
|
|
148
159
|
if (errors.length === 0) {
|
|
149
160
|
p.outro('Done. Go to your project and run ace spec init.');
|
|
150
161
|
} else {
|
|
151
162
|
p.outro('Done with errors. Run ace doctor to diagnose.');
|
|
152
163
|
}
|
|
153
164
|
}
|
|
165
|
+
|
|
166
|
+
// ─── Helpers ───────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Scan target directory and categorize existing files by handling strategy.
|
|
170
|
+
*/
|
|
171
|
+
async function buildInstallPreview(installer, components) {
|
|
172
|
+
const preview = { merge: [], skip: [], conflict: [] };
|
|
173
|
+
|
|
174
|
+
for (const componentName of components) {
|
|
175
|
+
const component = COMPONENTS[componentName];
|
|
176
|
+
if (!component || component.isPlugin) continue;
|
|
177
|
+
|
|
178
|
+
if (component.files) {
|
|
179
|
+
for (const file of component.files) {
|
|
180
|
+
const destPath = path.join(installer.targetDir, file.dest);
|
|
181
|
+
if (await fs.pathExists(destPath)) {
|
|
182
|
+
if (file.merge === 'claude-md' || file.merge === 'settings-json') {
|
|
183
|
+
preview.merge.push({ src: file.src, dest: file.dest, strategy: file.merge });
|
|
184
|
+
} else if (file.merge === 'skip-existing') {
|
|
185
|
+
preview.skip.push(file.dest);
|
|
186
|
+
} else {
|
|
187
|
+
preview.conflict.push(file.dest);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (component.rulesDir) {
|
|
194
|
+
const srcDir = path.join(installer.templatesDir, component.rulesDir);
|
|
195
|
+
const destDir = path.join(installer.targetDir, component.rulesDir);
|
|
196
|
+
if (await fs.pathExists(srcDir)) {
|
|
197
|
+
const files = (await fs.readdir(srcDir)).filter(f => f.endsWith('.md'));
|
|
198
|
+
for (const f of files) {
|
|
199
|
+
const relativePath = path.join(component.rulesDir, f).replace(/\\/g, '/');
|
|
200
|
+
// ACE-owned files are overwritten directly, not shown as conflicts
|
|
201
|
+
if (await fs.pathExists(path.join(destDir, f)) && !isAceOwnedFile(relativePath)) {
|
|
202
|
+
preview.conflict.push(relativePath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (component.conditional) {
|
|
209
|
+
for (const file of component.conditional) {
|
|
210
|
+
if (file.roles?.includes(installer.role)) {
|
|
211
|
+
const destPath = path.join(installer.targetDir, file.dest);
|
|
212
|
+
// ACE-owned files are overwritten directly, not shown as conflicts
|
|
213
|
+
if (await fs.pathExists(destPath) && !isAceOwnedFile(file.dest)) {
|
|
214
|
+
preview.conflict.push(file.dest);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Enrich merge files with detail
|
|
222
|
+
for (const item of preview.merge) {
|
|
223
|
+
if (item.strategy === 'claude-md') {
|
|
224
|
+
try {
|
|
225
|
+
const existing = await fs.readFile(path.join(installer.targetDir, item.dest), 'utf-8');
|
|
226
|
+
const template = await fs.readFile(path.join(installer.templatesDir, item.src), 'utf-8');
|
|
227
|
+
const { added } = mergeClaudeMd(existing, template);
|
|
228
|
+
item.detail = added.length > 0 ? `adds ${added.length} new @references` : 'up to date';
|
|
229
|
+
} catch {
|
|
230
|
+
item.detail = 'will merge';
|
|
231
|
+
}
|
|
232
|
+
} else if (item.strategy === 'settings-json') {
|
|
233
|
+
item.detail = 'merges permissions & plugins';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return preview;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Format component results into an aligned summary table.
|
|
242
|
+
*/
|
|
243
|
+
function formatSummaryTable(results) {
|
|
244
|
+
const maxLen = Math.max(...results.map(r => r.label.length));
|
|
245
|
+
return results.map(r => {
|
|
246
|
+
const padded = r.label.padEnd(maxLen);
|
|
247
|
+
if (r.error) return `\u25A0 ${padded} ${r.error}`;
|
|
248
|
+
if (r.merged > 0 && r.installed === 0 && r.skipped === 0) return `\u25C6 ${padded} merged`;
|
|
249
|
+
if (r.skipped > 0 && r.installed === 0 && r.merged === 0) return `\u2502 ${padded} unchanged`;
|
|
250
|
+
const count = r.installed + r.merged;
|
|
251
|
+
return `\u25C6 ${padded} ${count} file${count > 1 ? 's' : ''}`;
|
|
252
|
+
}).join('\n');
|
|
253
|
+
}
|
package/src/core/constants.js
CHANGED
|
@@ -67,6 +67,36 @@ export const SPEC_TEMPLATE_FILES = [
|
|
|
67
67
|
'retrospective-template.md',
|
|
68
68
|
];
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Patterns for files owned by ACE - these are overwritten directly on init without prompting.
|
|
72
|
+
* Used to identify ACE-owned content in rules/, hooks/, and hookify/.
|
|
73
|
+
*/
|
|
74
|
+
export const ACE_OWNED_PATTERNS = [
|
|
75
|
+
/^rules\/ace\//, // rules/ace/*.md
|
|
76
|
+
/^hooks\/ace\./, // hooks/ace.*.sh
|
|
77
|
+
/^hookify\.ace\./, // hookify.ace.*.local.md
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a file path (relative to ~/.claude/) is owned by ACE.
|
|
82
|
+
* @param {string} relativePath - Path like 'rules/ace/thinking.md' or 'hooks/ace.java-compile-check.sh'
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
export function isAceOwnedFile(relativePath) {
|
|
86
|
+
return ACE_OWNED_PATTERNS.some(pattern => pattern.test(relativePath));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if an @reference path is owned by ACE.
|
|
91
|
+
* @param {string} refPath - Reference path like '~/.claude/rules/ace/thinking.md' or '~/.claude/hooks/ace.java-compile-check.sh'
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function isAceOwnedRef(refPath) {
|
|
95
|
+
// Remove the ~/.claude/ prefix if present
|
|
96
|
+
const relativePath = refPath.replace(/^~\/\.claude\//, '');
|
|
97
|
+
return isAceOwnedFile(relativePath);
|
|
98
|
+
}
|
|
99
|
+
|
|
70
100
|
export const COMPONENTS = {
|
|
71
101
|
core: {
|
|
72
102
|
description: 'Core config (CLAUDE.md + settings.json)',
|
|
@@ -97,13 +127,13 @@ export const COMPONENTS = {
|
|
|
97
127
|
description: 'Safety guard rules (block dangerous ops, protect secrets, safe git, code quality, require verification)',
|
|
98
128
|
required: false,
|
|
99
129
|
files: [
|
|
100
|
-
{ src: 'hookify/ace.
|
|
101
|
-
{ src: 'hookify/ace.
|
|
102
|
-
{ src: 'hookify/ace.
|
|
103
|
-
{ src: 'hookify/ace.
|
|
104
|
-
{ src: 'hookify/ace.
|
|
105
|
-
{ src: 'hookify/hookify.dangerous-commands.local.md', dest: '
|
|
106
|
-
{ src: 'hookify/hookify.sensitive-data.local.md', dest: '
|
|
130
|
+
{ src: 'hookify/hookify.ace.block-dangerous-ops.local.md', dest: 'hookify.ace.block-dangerous-ops.local.md' },
|
|
131
|
+
{ src: 'hookify/hookify.ace.protect-secrets.local.md', dest: 'hookify.ace.protect-secrets.local.md' },
|
|
132
|
+
{ src: 'hookify/hookify.ace.safe-git-commands.local.md', dest: 'hookify.ace.safe-git-commands.local.md' },
|
|
133
|
+
{ src: 'hookify/hookify.ace.code-quality-gate.local.md', dest: 'hookify.ace.code-quality-gate.local.md' },
|
|
134
|
+
{ src: 'hookify/hookify.ace.require-verification.local.md', dest: 'hookify.ace.require-verification.local.md' },
|
|
135
|
+
{ src: 'hookify/hookify.ace.dangerous-commands.local.md', dest: 'hookify.ace.dangerous-commands.local.md' },
|
|
136
|
+
{ src: 'hookify/hookify.ace.sensitive-data.local.md', dest: 'hookify.ace.sensitive-data.local.md' },
|
|
107
137
|
],
|
|
108
138
|
},
|
|
109
139
|
memory: {
|
package/src/core/installer.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import {
|
|
6
|
-
CLAUDE_DIR, TEMPLATES_DIR, COMPONENTS,
|
|
6
|
+
CLAUDE_DIR, TEMPLATES_DIR, COMPONENTS, isAceOwnedFile,
|
|
7
7
|
PLUGIN_SRC_DIR, PLUGIN_CACHE_DIR, INSTALLED_PLUGINS_FILE,
|
|
8
8
|
KNOWN_MARKETPLACES_FILE, MARKETPLACE_DIR, MARKETPLACE_NAME,
|
|
9
9
|
PLUGIN_KEY, PLUGIN_NAME,
|
|
@@ -255,22 +255,25 @@ export class Installer {
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
if (exists && !this.force) {
|
|
258
|
-
|
|
258
|
+
// ACE-owned files are always overwritten (no user prompt needed)
|
|
259
|
+
if (isAceOwnedFile(fileSpec.dest)) {
|
|
260
|
+
// fall through to install (overwrite)
|
|
261
|
+
} else if (fileSpec.merge === 'claude-md') {
|
|
259
262
|
await this.mergeClaudeMdFile(srcPath, destPath, fileSpec);
|
|
260
263
|
return;
|
|
261
|
-
}
|
|
262
|
-
if (fileSpec.merge === 'settings-json') {
|
|
264
|
+
} else if (fileSpec.merge === 'settings-json') {
|
|
263
265
|
await this.mergeSettingsJsonFile(srcPath, destPath, fileSpec);
|
|
264
266
|
return;
|
|
265
|
-
}
|
|
266
|
-
// Check per-component resolution
|
|
267
|
-
const resolution = this.resolutions[componentName];
|
|
268
|
-
if (resolution === 'overwrite') {
|
|
269
|
-
// fall through to install
|
|
270
267
|
} else {
|
|
271
|
-
//
|
|
272
|
-
this.
|
|
273
|
-
|
|
268
|
+
// Check per-component resolution
|
|
269
|
+
const resolution = this.resolutions[componentName];
|
|
270
|
+
if (resolution === 'overwrite') {
|
|
271
|
+
// fall through to install
|
|
272
|
+
} else {
|
|
273
|
+
// default: skip
|
|
274
|
+
this.results.skipped.push(fileSpec.dest);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
274
277
|
}
|
|
275
278
|
}
|
|
276
279
|
|
package/src/core/merger.js
CHANGED
|
@@ -1,19 +1,146 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import deepmerge from 'deepmerge';
|
|
4
|
+
import { isAceOwnedRef } from './constants.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
7
|
+
* Marker constants for ACE-managed sections in CLAUDE.md
|
|
8
|
+
*/
|
|
9
|
+
const ACE_MANAGED_START = '<!-- ace:managed:start -->';
|
|
10
|
+
const ACE_MANAGED_END = '<!-- ace:managed:end -->';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Merge CLAUDE.md using marker-based section replacement.
|
|
14
|
+
*
|
|
15
|
+
* Strategy:
|
|
16
|
+
* 1. If both existing and template have ace:managed markers, replace the section
|
|
17
|
+
* between markers with template content, while preserving content outside markers.
|
|
18
|
+
* 2. Clean up any ACE-owned references outside the managed section (obsolete refs).
|
|
19
|
+
* 3. If markers are missing or incomplete, fall back to append-only strategy for
|
|
20
|
+
* backward compatibility.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} existingContent - Current CLAUDE.md content
|
|
23
|
+
* @param {string} templateContent - Template CLAUDE.md content
|
|
24
|
+
* @returns {{content: string, added: string[], removed: string[]}} Merged content and change list
|
|
8
25
|
*/
|
|
9
26
|
export function mergeClaudeMd(existingContent, templateContent) {
|
|
27
|
+
// Check if both files have complete marker sections
|
|
28
|
+
const existingHasMarkers = hasCompleteMarkers(existingContent);
|
|
29
|
+
const templateHasMarkers = hasCompleteMarkers(templateContent);
|
|
30
|
+
|
|
31
|
+
if (existingHasMarkers && templateHasMarkers) {
|
|
32
|
+
return mergeWithMarkers(existingContent, templateContent);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fall back to legacy append strategy
|
|
36
|
+
return mergeWithAppend(existingContent, templateContent);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if content has both start and end markers.
|
|
41
|
+
*/
|
|
42
|
+
function hasCompleteMarkers(content) {
|
|
43
|
+
const hasStart = content.includes(ACE_MANAGED_START);
|
|
44
|
+
const hasEnd = content.includes(ACE_MANAGED_END);
|
|
45
|
+
return hasStart && hasEnd;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Merge using marker-based section replacement.
|
|
50
|
+
* Replaces content between ace:managed markers and cleans up obsolete ACE refs.
|
|
51
|
+
*/
|
|
52
|
+
function mergeWithMarkers(existingContent, templateContent) {
|
|
53
|
+
// Extract the managed section from template
|
|
54
|
+
const templateManaged = extractManagedSection(templateContent);
|
|
55
|
+
|
|
56
|
+
// Get all ACE-owned refs from template (these are the current/active ones)
|
|
57
|
+
const templateRefs = extractRefs(templateManaged);
|
|
58
|
+
|
|
59
|
+
// Replace the managed section in existing content
|
|
60
|
+
let result = replaceManagedSection(existingContent, templateManaged);
|
|
61
|
+
|
|
62
|
+
// Clean up any obsolete ACE refs outside the managed section
|
|
63
|
+
const removed = [];
|
|
64
|
+
const lines = result.split('\n');
|
|
65
|
+
const cleanedLines = lines.map(line => {
|
|
66
|
+
const refs = extractRefs(line);
|
|
67
|
+
const hasObsoleteAceRef = refs.some(ref => {
|
|
68
|
+
// Check if this is an ACE-owned ref that's NOT in the new template
|
|
69
|
+
if (isAceOwnedRef(ref)) {
|
|
70
|
+
const refWithAt = `@${ref}`;
|
|
71
|
+
if (!templateRefs.includes(refWithAt)) {
|
|
72
|
+
removed.push(ref);
|
|
73
|
+
return true; // This line has an obsolete ref
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Return null to mark for removal, otherwise keep line
|
|
80
|
+
return hasObsoleteAceRef ? null : line;
|
|
81
|
+
}).filter(line => line !== null);
|
|
82
|
+
|
|
83
|
+
result = cleanedLines.join('\n');
|
|
84
|
+
|
|
85
|
+
// Get the new refs that were added (in the managed section)
|
|
86
|
+
const existingRefs = extractRefs(existingContent);
|
|
87
|
+
const added = templateRefs
|
|
88
|
+
.map(ref => ref.replace(/^@/, ''))
|
|
89
|
+
.filter(ref => !existingRefs.includes(ref));
|
|
90
|
+
|
|
91
|
+
return { content: result, added, removed };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract content between ace:managed markers.
|
|
96
|
+
*/
|
|
97
|
+
function extractManagedSection(content) {
|
|
98
|
+
const startIdx = content.indexOf(ACE_MANAGED_START);
|
|
99
|
+
const endIdx = content.indexOf(ACE_MANAGED_END);
|
|
100
|
+
|
|
101
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
|
|
102
|
+
return content;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return content.slice(startIdx, endIdx + ACE_MANAGED_END.length);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Replace the managed section in existing content with new managed content.
|
|
110
|
+
*/
|
|
111
|
+
function replaceManagedSection(existingContent, newManagedContent) {
|
|
112
|
+
const startIdx = existingContent.indexOf(ACE_MANAGED_START);
|
|
113
|
+
const endIdx = existingContent.indexOf(ACE_MANAGED_END);
|
|
114
|
+
|
|
115
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
116
|
+
return existingContent;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const before = existingContent.slice(0, startIdx);
|
|
120
|
+
const after = existingContent.slice(endIdx + ACE_MANAGED_END.length);
|
|
121
|
+
|
|
122
|
+
// Normalize whitespace: ensure single newline separation
|
|
123
|
+
return before.trimEnd() + '\n' + newManagedContent + '\n' + after.trimStart();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Legacy merge strategy: append missing @references from template.
|
|
128
|
+
* Used for backward compatibility when markers are not present.
|
|
129
|
+
*/
|
|
130
|
+
function mergeWithAppend(existingContent, templateContent) {
|
|
10
131
|
const existingRefs = extractRefs(existingContent);
|
|
11
132
|
const templateRefs = extractRefs(templateContent);
|
|
12
133
|
|
|
13
|
-
|
|
134
|
+
// Filter out ACE-owned refs from existing (we'll add current ones from template)
|
|
135
|
+
// This provides some cleanup even in legacy mode
|
|
136
|
+
const userRefs = existingRefs.filter(ref => !isAceOwnedRef(ref));
|
|
137
|
+
const templateRefsBare = templateRefs.map(ref => ref.replace(/^@/, ''));
|
|
138
|
+
|
|
139
|
+
// Find refs in template but not in user's refs
|
|
140
|
+
const missingRefs = templateRefsBare.filter(ref => !userRefs.includes(ref));
|
|
14
141
|
|
|
15
142
|
if (missingRefs.length === 0) {
|
|
16
|
-
return { content: existingContent, added: [] };
|
|
143
|
+
return { content: existingContent, added: [], removed: [] };
|
|
17
144
|
}
|
|
18
145
|
|
|
19
146
|
// Append missing references at the end with a section marker
|
|
@@ -32,15 +159,22 @@ export function mergeClaudeMd(existingContent, templateContent) {
|
|
|
32
159
|
return {
|
|
33
160
|
content: existingContent.trimEnd() + '\n' + appendSection,
|
|
34
161
|
added: missingRefs,
|
|
162
|
+
removed: [],
|
|
35
163
|
};
|
|
36
164
|
}
|
|
37
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Extract @reference paths from content.
|
|
168
|
+
*/
|
|
38
169
|
function extractRefs(content) {
|
|
39
170
|
const refPattern = /@~?\/?\.?claude\/[^\s)]+/g;
|
|
40
171
|
const matches = content.match(refPattern) || [];
|
|
41
|
-
return matches
|
|
172
|
+
return matches;
|
|
42
173
|
}
|
|
43
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Find the full line containing a reference.
|
|
177
|
+
*/
|
|
44
178
|
function findRefLine(content, ref) {
|
|
45
179
|
const lines = content.split('\n');
|
|
46
180
|
return lines.find(line => line.includes(ref));
|
|
@@ -64,11 +64,6 @@ export class SpecInstaller {
|
|
|
64
64
|
async runOpenspecInit() {
|
|
65
65
|
if (this.skipOpenspec) return;
|
|
66
66
|
|
|
67
|
-
if (await fs.pathExists(this.openspecDir) && !this.force) {
|
|
68
|
-
this.results.skipped.push('openspec/ (already exists)');
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
67
|
if (this.dryRun) {
|
|
73
68
|
this.results.installed.push('openspec/ (via openspec init)');
|
|
74
69
|
return;
|
package/templates/CLAUDE.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# 全局配置索引
|
|
2
2
|
|
|
3
|
+
<!-- ace:managed:start -->
|
|
3
4
|
## 核心原则
|
|
4
5
|
- @~/.claude/rules/ace/thinking.md - 深度思考原则(序验深广辨简)
|
|
5
6
|
|
|
@@ -15,11 +16,6 @@
|
|
|
15
16
|
## 质量控制
|
|
16
17
|
- @~/.claude/rules/ace/memory-policy.md - Memory 质量策略
|
|
17
18
|
|
|
18
|
-
## Hookify 规则
|
|
19
|
-
- @~/.claude/hooks/ace.hookify.block-dangerous-ops.local.md - 阻止危险操作
|
|
20
|
-
- @~/.claude/hooks/ace.hookify.protect-secrets.local.md - 保护敏感信息
|
|
21
|
-
- @~/.claude/hooks/ace.hookify.safe-git-commands.local.md - Git 安全命令
|
|
22
|
-
- @~/.claude/hooks/ace.hookify.code-quality-gate.local.md - 代码质量门禁
|
|
23
|
-
|
|
24
19
|
## 交互规则
|
|
25
20
|
- @~/.claude/rules/ace/interactive-clarify.md - 交互式澄清规则
|
|
21
|
+
<!-- ace:managed:end -->
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/templates/hookify/{hookify.sensitive-data.local.md → hookify.ace.sensitive-data.local.md}
RENAMED
|
File without changes
|