@shirayner/ace 0.1.7 → 0.1.8-SNAPSHOT.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -19
- package/bin/ace.js +1 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/auto-goal/SKILL.md +131 -87
- package/plugin/skills/auto-goal/references/recovery.md +29 -0
- package/plugin/skills/auto-goal/references/state-template.md +97 -0
- package/src/commands/doctor.js +12 -5
- package/src/commands/init.js +9 -5
- package/src/commands/list.js +26 -0
- package/src/commands/spec.js +57 -0
- package/src/commands/uninstall.js +65 -31
- package/src/core/constants.js +23 -20
- package/src/core/installer.js +84 -4
- package/src/core/merger.js +15 -3
- package/src/core/team-installer.js +96 -0
- package/src/core/ui.js +0 -1
- package/templates/CLAUDE.md +28 -14
- package/templates/ace/rules/git.md +50 -0
- package/templates/ace/rules/gitflow.md +109 -0
- package/templates/{rules/ace → ace/rules}/thinking.md +5 -0
- package/templates/hooks/ace.bash-guard.sh +71 -0
- package/templates/hooks/ace.content-guard.sh +68 -0
- package/templates/hooks/ace.file-guard.sh +29 -0
- package/templates/hooks/ace.java-compile-check.sh +4 -2
- package/templates/hooks/ace.stop-verify.sh +28 -0
- package/templates/scripts/statusline-command.sh +2 -0
- package/templates/scripts/statusline.py +32 -0
- package/templates/settings.json +58 -2
- package/templates/hookify/hookify.ace.block-dangerous-ops.local.md +0 -16
- package/templates/hookify/hookify.ace.code-quality-gate.local.md +0 -45
- package/templates/hookify/hookify.ace.dangerous-commands.local.md +0 -20
- package/templates/hookify/hookify.ace.protect-secrets.local.md +0 -17
- package/templates/hookify/hookify.ace.require-verification.local.md +0 -13
- package/templates/hookify/hookify.ace.safe-git-commands.local.md +0 -38
- package/templates/hookify/hookify.ace.sensitive-data.local.md +0 -22
- /package/templates/{rules/ace → ace/rules}/clean-code.md +0 -0
- /package/templates/{rules/ace → ace/rules}/code-quality.md +0 -0
- /package/templates/{rules/ace → ace/rules}/context-hygiene.md +0 -0
- /package/templates/{rules/ace → ace/rules}/interactive-clarify.md +0 -0
- /package/templates/{rules/ace → ace/rules}/memory-policy.md +0 -0
- /package/templates/{rules/ace → ace/rules}/reporting.md +0 -0
- /package/templates/{rules/ace → ace/rules}/task-recovery.md +0 -0
package/src/commands/list.js
CHANGED
|
@@ -67,6 +67,11 @@ async function getComponentStatus(component) {
|
|
|
67
67
|
} catch { /* ignore */ }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
if (component.recursiveDir) {
|
|
71
|
+
const collected = await collectMdFiles(path.join(TEMPLATES_DIR, component.recursiveDir), component.recursiveDir);
|
|
72
|
+
allPaths.push(...collected.map(f => path.join(CLAUDE_DIR, f)));
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
if (component.files) {
|
|
71
76
|
allPaths.push(...component.files.map(f => path.join(CLAUDE_DIR, f.dest)));
|
|
72
77
|
}
|
|
@@ -101,6 +106,11 @@ async function getComponentDetails(component) {
|
|
|
101
106
|
} catch { /* ignore */ }
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
if (component.recursiveDir) {
|
|
110
|
+
const collected = await collectMdFiles(path.join(TEMPLATES_DIR, component.recursiveDir), component.recursiveDir);
|
|
111
|
+
allFiles.push(...collected);
|
|
112
|
+
}
|
|
113
|
+
|
|
104
114
|
allFiles.push(
|
|
105
115
|
...(component.files || []).map(f => f.dest),
|
|
106
116
|
...(component.directories || []),
|
|
@@ -115,3 +125,19 @@ async function getComponentDetails(component) {
|
|
|
115
125
|
|
|
116
126
|
return { missing, installed };
|
|
117
127
|
}
|
|
128
|
+
|
|
129
|
+
async function collectMdFiles(dir, relBase) {
|
|
130
|
+
const results = [];
|
|
131
|
+
try {
|
|
132
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const rel = path.join(relBase, entry.name);
|
|
135
|
+
if (entry.isDirectory()) {
|
|
136
|
+
results.push(...await collectMdFiles(path.join(dir, entry.name), rel));
|
|
137
|
+
} else if (entry.name.endsWith('.md')) {
|
|
138
|
+
results.push(rel);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch { /* ignore */ }
|
|
142
|
+
return results;
|
|
143
|
+
}
|
package/src/commands/spec.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import ora from 'ora';
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
4
5
|
import { SpecInstaller } from '../core/spec-installer.js';
|
|
6
|
+
import { TeamInstaller } from '../core/team-installer.js';
|
|
5
7
|
|
|
6
8
|
export async function specInitCommand(targetPath, options) {
|
|
7
9
|
const targetDir = targetPath ? path.resolve(targetPath) : process.cwd();
|
|
@@ -31,6 +33,61 @@ export async function specInitCommand(targetPath, options) {
|
|
|
31
33
|
spinner.fail(`Unexpected error: ${err.message}`);
|
|
32
34
|
process.exit(1);
|
|
33
35
|
}
|
|
36
|
+
|
|
37
|
+
// ─── Team conventions initialization ───────────────
|
|
38
|
+
await initTeamConventions(targetDir, options);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function initTeamConventions(targetDir, options) {
|
|
42
|
+
let repoUrl = options.teamRepo;
|
|
43
|
+
|
|
44
|
+
if (!repoUrl) {
|
|
45
|
+
const shouldInit = await p.confirm({
|
|
46
|
+
message: 'Initialize team conventions from a Git repository?',
|
|
47
|
+
initialValue: false,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (p.isCancel(shouldInit) || !shouldInit) return;
|
|
51
|
+
|
|
52
|
+
const urlInput = await p.text({
|
|
53
|
+
message: 'Enter the Git repository URL for team conventions:',
|
|
54
|
+
placeholder: 'https://gitlab.example.com/team/conventions.git',
|
|
55
|
+
validate: (value) => {
|
|
56
|
+
if (!value || value.trim().length === 0) return 'URL is required';
|
|
57
|
+
if (!value.match(/^(https?:\/\/|git@)/)) return 'Must be a valid git URL (https:// or git@)';
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (p.isCancel(urlInput)) return;
|
|
62
|
+
repoUrl = urlInput.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const spinner = ora('Cloning team conventions...').start();
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const teamInstaller = new TeamInstaller({
|
|
69
|
+
targetDir,
|
|
70
|
+
repoUrl,
|
|
71
|
+
force: options.force,
|
|
72
|
+
dryRun: options.dryRun,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const results = await teamInstaller.run();
|
|
76
|
+
spinner.stop();
|
|
77
|
+
|
|
78
|
+
if (results.errors.length > 0) {
|
|
79
|
+
console.log(chalk.red('\n Team conventions errors:'));
|
|
80
|
+
results.errors.forEach(e => console.log(chalk.red(` ! ${e.error}`)));
|
|
81
|
+
} else if (results.skipped.length > 0) {
|
|
82
|
+
console.log(chalk.yellow('\n Team conventions:'));
|
|
83
|
+
results.skipped.forEach(s => console.log(chalk.yellow(` - ${s}`)));
|
|
84
|
+
} else if (results.installed.length > 0) {
|
|
85
|
+
console.log(chalk.green('\n Team conventions installed:'));
|
|
86
|
+
results.installed.forEach(f => console.log(chalk.green(` + ${f}`)));
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
spinner.fail(`Failed to initialize team conventions: ${err.message}`);
|
|
90
|
+
}
|
|
34
91
|
}
|
|
35
92
|
|
|
36
93
|
export async function specDoctorCommand(targetPath) {
|
|
@@ -17,7 +17,7 @@ export async function uninstallCommand(options) {
|
|
|
17
17
|
const { confirm } = await inquirer.prompt([{
|
|
18
18
|
type: 'confirm',
|
|
19
19
|
name: 'confirm',
|
|
20
|
-
message: 'This will remove all ace-managed files (rules, plugin, hooks
|
|
20
|
+
message: 'This will remove all ace-managed files (rules, plugin, hooks). Continue?',
|
|
21
21
|
default: false,
|
|
22
22
|
}]);
|
|
23
23
|
if (!confirm) {
|
|
@@ -30,16 +30,30 @@ export async function uninstallCommand(options) {
|
|
|
30
30
|
const skipped = [];
|
|
31
31
|
const errors = [];
|
|
32
32
|
|
|
33
|
-
// 1. Remove rules/ace/
|
|
33
|
+
// 1. Remove ace/rules/ directory (and legacy rules/ace/ if still present)
|
|
34
34
|
const spinner1 = ora('Removing rules...').start();
|
|
35
35
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
const newRulesDir = path.join(CLAUDE_DIR, 'ace', 'rules');
|
|
37
|
+
const aceDir = path.join(CLAUDE_DIR, 'ace');
|
|
38
|
+
const legacyRulesDir = path.join(CLAUDE_DIR, 'rules', 'ace');
|
|
39
|
+
|
|
40
|
+
if (await fs.pathExists(newRulesDir)) {
|
|
41
|
+
await fs.remove(newRulesDir);
|
|
42
|
+
removed.push('ace/rules/');
|
|
43
|
+
}
|
|
44
|
+
// Remove ace/ parent if empty
|
|
45
|
+
if (await fs.pathExists(aceDir)) {
|
|
46
|
+
const remaining = await fs.readdir(aceDir);
|
|
47
|
+
if (remaining.length === 0) {
|
|
48
|
+
await fs.remove(aceDir);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Also clean legacy directory if still exists
|
|
52
|
+
if (await fs.pathExists(legacyRulesDir)) {
|
|
53
|
+
await fs.remove(legacyRulesDir);
|
|
54
|
+
removed.push('rules/ace/ (legacy)');
|
|
42
55
|
}
|
|
56
|
+
|
|
43
57
|
spinner1.succeed('rules removed');
|
|
44
58
|
} catch (err) {
|
|
45
59
|
spinner1.fail('rules removal failed');
|
|
@@ -82,38 +96,44 @@ export async function uninstallCommand(options) {
|
|
|
82
96
|
errors.push({ component: 'plugin', error: err.message });
|
|
83
97
|
}
|
|
84
98
|
|
|
85
|
-
// 3. Remove
|
|
86
|
-
const spinner3 = ora('Removing
|
|
99
|
+
// 3. Remove hook scripts
|
|
100
|
+
const spinner3 = ora('Removing hooks...').start();
|
|
87
101
|
try {
|
|
88
|
-
const
|
|
89
|
-
for (const file of
|
|
102
|
+
const hookFiles = [...(COMPONENTS.hooks.files || []), ...(COMPONENTS.hooks.conditional || [])];
|
|
103
|
+
for (const file of hookFiles) {
|
|
90
104
|
const destPath = path.join(CLAUDE_DIR, file.dest);
|
|
91
105
|
if (await fs.pathExists(destPath)) {
|
|
92
106
|
await fs.remove(destPath);
|
|
93
107
|
removed.push(file.dest);
|
|
94
108
|
}
|
|
95
109
|
}
|
|
96
|
-
spinner3.succeed('
|
|
110
|
+
spinner3.succeed('hooks removed');
|
|
97
111
|
} catch (err) {
|
|
98
|
-
spinner3.fail('
|
|
99
|
-
errors.push({ component: '
|
|
112
|
+
spinner3.fail('hooks removal failed');
|
|
113
|
+
errors.push({ component: 'hooks', error: err.message });
|
|
100
114
|
}
|
|
101
115
|
|
|
102
|
-
// 4. Remove
|
|
103
|
-
const spinner4 = ora('Removing
|
|
116
|
+
// 4. Remove legacy hookify rules (cleanup from older versions)
|
|
117
|
+
const spinner4 = ora('Removing legacy hookify rules...').start();
|
|
104
118
|
try {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
const hookifyPattern = /^hookify\.ace\..+\.local\.md$/;
|
|
120
|
+
const claudeFiles = await fs.readdir(CLAUDE_DIR);
|
|
121
|
+
let hookifyRemoved = 0;
|
|
122
|
+
for (const file of claudeFiles) {
|
|
123
|
+
if (hookifyPattern.test(file)) {
|
|
124
|
+
await fs.remove(path.join(CLAUDE_DIR, file));
|
|
125
|
+
removed.push(file);
|
|
126
|
+
hookifyRemoved++;
|
|
111
127
|
}
|
|
112
128
|
}
|
|
113
|
-
|
|
129
|
+
if (hookifyRemoved > 0) {
|
|
130
|
+
spinner4.succeed(`legacy hookify rules removed (${hookifyRemoved})`);
|
|
131
|
+
} else {
|
|
132
|
+
spinner4.succeed('no legacy hookify rules found');
|
|
133
|
+
}
|
|
114
134
|
} catch (err) {
|
|
115
|
-
spinner4.fail('
|
|
116
|
-
errors.push({ component: '
|
|
135
|
+
spinner4.fail('legacy hookify removal failed');
|
|
136
|
+
errors.push({ component: 'hookify-legacy', error: err.message });
|
|
117
137
|
}
|
|
118
138
|
|
|
119
139
|
// 5. Restore CLAUDE.md and settings.json from pre-install backups
|
|
@@ -126,17 +146,31 @@ export async function uninstallCommand(options) {
|
|
|
126
146
|
await fs.remove(claudeBackup);
|
|
127
147
|
removed.push('CLAUDE.md (restored pre-ace backup)');
|
|
128
148
|
} else if (await fs.pathExists(claudeMdPath)) {
|
|
129
|
-
// Fallback: surgically remove ace @references
|
|
149
|
+
// Fallback: surgically remove ace @references and managed section
|
|
130
150
|
const content = await fs.readFile(claudeMdPath, 'utf-8');
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
151
|
+
let cleaned = content;
|
|
152
|
+
// Remove the entire managed section if present
|
|
153
|
+
const managedStart = '<!-- ace:managed:start -->';
|
|
154
|
+
const managedEnd = '<!-- ace:managed:end -->';
|
|
155
|
+
const startIdx = cleaned.indexOf(managedStart);
|
|
156
|
+
const endIdx = cleaned.indexOf(managedEnd);
|
|
157
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
158
|
+
cleaned = cleaned.slice(0, startIdx) + cleaned.slice(endIdx + managedEnd.length);
|
|
159
|
+
}
|
|
160
|
+
// Remove any remaining ace @references (legacy or new format)
|
|
161
|
+
const lines = cleaned.split('\n');
|
|
162
|
+
const filtered = lines.filter(line =>
|
|
163
|
+
!line.includes('@~/.claude/rules/ace/') &&
|
|
164
|
+
!line.includes('@~/.claude/ace/') &&
|
|
165
|
+
!line.includes('hookify.ace.')
|
|
166
|
+
);
|
|
167
|
+
cleaned = filtered.join('\n')
|
|
134
168
|
.replace(/\n## Added by ace\n*/g, '\n')
|
|
135
169
|
.replace(/\n{3,}/g, '\n\n')
|
|
136
170
|
.trim() + '\n';
|
|
137
171
|
if (cleaned !== content) {
|
|
138
172
|
await fs.writeFile(claudeMdPath, cleaned, 'utf-8');
|
|
139
|
-
removed.push('CLAUDE.md ace
|
|
173
|
+
removed.push('CLAUDE.md ace content (surgical)');
|
|
140
174
|
}
|
|
141
175
|
}
|
|
142
176
|
|
package/src/core/constants.js
CHANGED
|
@@ -17,9 +17,9 @@ export const MARKETPLACE_DIR = path.join(CLAUDE_DIR, 'plugins', 'marketplaces',
|
|
|
17
17
|
export const PLUGIN_KEY = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
|
|
18
18
|
|
|
19
19
|
export const PRESETS = {
|
|
20
|
-
full: ['core', 'rules', 'plugin', 'hooks', '
|
|
20
|
+
full: ['core', 'rules', 'plugin', 'hooks', 'scripts', 'memory'],
|
|
21
21
|
minimal: ['core', 'rules', 'plugin'],
|
|
22
|
-
safe: ['core', 'rules', 'plugin', '
|
|
22
|
+
safe: ['core', 'rules', 'plugin', 'memory'],
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
export const ROLES = {
|
|
@@ -58,12 +58,13 @@ export const SPEC_TEMPLATE_FILES = [
|
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Patterns for files owned by ACE - these are overwritten directly on init without prompting.
|
|
61
|
-
* Used to identify ACE-owned content in
|
|
61
|
+
* Used to identify ACE-owned content in ace/ and hooks/.
|
|
62
62
|
*/
|
|
63
63
|
export const ACE_OWNED_PATTERNS = [
|
|
64
|
-
/^rules
|
|
64
|
+
/^ace\/rules\//, // ace/rules/*.md (v2.0+)
|
|
65
|
+
/^rules\/ace\//, // rules/ace/*.md (legacy, for migration detection)
|
|
65
66
|
/^hooks\/ace\./, // hooks/ace.*.sh
|
|
66
|
-
/^
|
|
67
|
+
/^scripts\/statusline/, // scripts/statusline*
|
|
67
68
|
];
|
|
68
69
|
|
|
69
70
|
/**
|
|
@@ -72,17 +73,18 @@ export const ACE_OWNED_PATTERNS = [
|
|
|
72
73
|
* @returns {boolean}
|
|
73
74
|
*/
|
|
74
75
|
export function isAceOwnedFile(relativePath) {
|
|
75
|
-
|
|
76
|
+
const normalized = relativePath.replace(/\\/g, '/');
|
|
77
|
+
return ACE_OWNED_PATTERNS.some(pattern => pattern.test(normalized));
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
/**
|
|
79
81
|
* Check if an @reference path is owned by ACE.
|
|
80
|
-
* @param {string} refPath - Reference path like '
|
|
82
|
+
* @param {string} refPath - Reference path like '@~/.claude/rules/ace/thinking.md' or '~/.claude/hooks/ace.java-compile-check.sh'
|
|
81
83
|
* @returns {boolean}
|
|
82
84
|
*/
|
|
83
85
|
export function isAceOwnedRef(refPath) {
|
|
84
|
-
// Remove the ~/.claude/ prefix if present
|
|
85
|
-
const relativePath = refPath.replace(
|
|
86
|
+
// Remove the @~/.claude/ or ~/.claude/ prefix if present
|
|
87
|
+
const relativePath = refPath.replace(/^@?~\/\.claude\//, '');
|
|
86
88
|
return isAceOwnedFile(relativePath);
|
|
87
89
|
}
|
|
88
90
|
|
|
@@ -98,7 +100,7 @@ export const COMPONENTS = {
|
|
|
98
100
|
rules: {
|
|
99
101
|
description: 'Cognitive & code quality rules',
|
|
100
102
|
required: true,
|
|
101
|
-
rulesDir: 'rules
|
|
103
|
+
rulesDir: 'ace/rules',
|
|
102
104
|
},
|
|
103
105
|
plugin: {
|
|
104
106
|
description: 'Ace plugin (skills: auto-goal, coding, skill-creator, skill-optimize; commands: report)',
|
|
@@ -106,23 +108,24 @@ export const COMPONENTS = {
|
|
|
106
108
|
isPlugin: true,
|
|
107
109
|
},
|
|
108
110
|
hooks: {
|
|
109
|
-
description: 'Hook scripts (
|
|
111
|
+
description: 'Hook scripts (safety guards + compile checks)',
|
|
110
112
|
required: false,
|
|
113
|
+
files: [
|
|
114
|
+
{ src: 'hooks/ace.bash-guard.sh', dest: 'hooks/ace.bash-guard.sh' },
|
|
115
|
+
{ src: 'hooks/ace.content-guard.sh', dest: 'hooks/ace.content-guard.sh' },
|
|
116
|
+
{ src: 'hooks/ace.file-guard.sh', dest: 'hooks/ace.file-guard.sh' },
|
|
117
|
+
{ src: 'hooks/ace.stop-verify.sh', dest: 'hooks/ace.stop-verify.sh' },
|
|
118
|
+
],
|
|
111
119
|
conditional: [
|
|
112
120
|
{ src: 'hooks/ace.java-compile-check.sh', dest: 'hooks/ace.java-compile-check.sh', roles: ['backend', 'fullstack'] },
|
|
113
121
|
],
|
|
114
122
|
},
|
|
115
|
-
|
|
116
|
-
description: '
|
|
123
|
+
scripts: {
|
|
124
|
+
description: 'Utility scripts (status line)',
|
|
117
125
|
required: false,
|
|
118
126
|
files: [
|
|
119
|
-
{ src: '
|
|
120
|
-
{ src: '
|
|
121
|
-
{ src: 'hookify/hookify.ace.safe-git-commands.local.md', dest: 'hookify.ace.safe-git-commands.local.md' },
|
|
122
|
-
{ src: 'hookify/hookify.ace.code-quality-gate.local.md', dest: 'hookify.ace.code-quality-gate.local.md' },
|
|
123
|
-
{ src: 'hookify/hookify.ace.require-verification.local.md', dest: 'hookify.ace.require-verification.local.md' },
|
|
124
|
-
{ src: 'hookify/hookify.ace.dangerous-commands.local.md', dest: 'hookify.ace.dangerous-commands.local.md' },
|
|
125
|
-
{ src: 'hookify/hookify.ace.sensitive-data.local.md', dest: 'hookify.ace.sensitive-data.local.md' },
|
|
127
|
+
{ src: 'scripts/statusline-command.sh', dest: 'scripts/statusline-command.sh' },
|
|
128
|
+
{ src: 'scripts/statusline.py', dest: 'scripts/statusline.py' },
|
|
126
129
|
],
|
|
127
130
|
},
|
|
128
131
|
memory: {
|
package/src/core/installer.js
CHANGED
|
@@ -57,6 +57,9 @@ export class Installer {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Check recursiveDir files (ACE-owned, no conflict prompt)
|
|
61
|
+
// Skip conflict detection — these are always overwritten via ACE_OWNED_PATTERNS
|
|
62
|
+
|
|
60
63
|
// Check regular files
|
|
61
64
|
if (component.files) {
|
|
62
65
|
for (const file of component.files) {
|
|
@@ -94,6 +97,7 @@ export class Installer {
|
|
|
94
97
|
async run() {
|
|
95
98
|
if (!this.dryRun) {
|
|
96
99
|
await fs.ensureDir(this.targetDir);
|
|
100
|
+
await this.prepare();
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
for (const componentName of this.components) {
|
|
@@ -114,6 +118,56 @@ export class Installer {
|
|
|
114
118
|
return this.results;
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Prepare the target directory: migrate legacy structure and ensure new layout.
|
|
123
|
+
* Call this before installComponent() if not using run().
|
|
124
|
+
*/
|
|
125
|
+
async prepare() {
|
|
126
|
+
await fs.ensureDir(this.targetDir);
|
|
127
|
+
await this.migrateFromLegacy();
|
|
128
|
+
await this.ensureAceStructure();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Migrate from legacy directory structure (rules/ace/) to new (ace/rules/).
|
|
133
|
+
* Only runs if old directory exists.
|
|
134
|
+
*/
|
|
135
|
+
async migrateFromLegacy() {
|
|
136
|
+
const legacyDir = path.join(this.targetDir, 'rules', 'ace');
|
|
137
|
+
const newDir = path.join(this.targetDir, 'ace', 'rules');
|
|
138
|
+
|
|
139
|
+
if (!await fs.pathExists(legacyDir)) return;
|
|
140
|
+
|
|
141
|
+
// Move contents from legacy to new location
|
|
142
|
+
await fs.ensureDir(newDir);
|
|
143
|
+
const files = await fs.readdir(legacyDir);
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const src = path.join(legacyDir, file);
|
|
146
|
+
const dest = path.join(newDir, file);
|
|
147
|
+
await fs.move(src, dest, { overwrite: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Remove empty legacy directory
|
|
151
|
+
await fs.remove(legacyDir);
|
|
152
|
+
// Clean up parent if empty
|
|
153
|
+
const rulesParent = path.join(this.targetDir, 'rules');
|
|
154
|
+
if (await fs.pathExists(rulesParent)) {
|
|
155
|
+
const remaining = await fs.readdir(rulesParent);
|
|
156
|
+
if (remaining.length === 0) {
|
|
157
|
+
await fs.remove(rulesParent);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.results.merged.push({ file: 'ace/rules/ (migrated from rules/ace/)' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Ensure the ace/ namespace directory structure exists.
|
|
166
|
+
*/
|
|
167
|
+
async ensureAceStructure() {
|
|
168
|
+
await fs.ensureDir(path.join(this.targetDir, 'ace', 'rules'));
|
|
169
|
+
}
|
|
170
|
+
|
|
117
171
|
async installComponent(name, component) {
|
|
118
172
|
if (component.isPlugin) {
|
|
119
173
|
await this.installPlugin();
|
|
@@ -124,6 +178,10 @@ export class Installer {
|
|
|
124
178
|
await this.installRulesDir(component.rulesDir, name);
|
|
125
179
|
}
|
|
126
180
|
|
|
181
|
+
if (component.recursiveDir) {
|
|
182
|
+
await this.installRecursiveDir(component.recursiveDir, name);
|
|
183
|
+
}
|
|
184
|
+
|
|
127
185
|
if (component.files) {
|
|
128
186
|
for (const file of component.files) {
|
|
129
187
|
await this.installFile(file, name);
|
|
@@ -247,6 +305,27 @@ export class Installer {
|
|
|
247
305
|
}
|
|
248
306
|
}
|
|
249
307
|
|
|
308
|
+
async installRecursiveDir(dir, componentName) {
|
|
309
|
+
const srcDir = path.join(this.templatesDir, dir);
|
|
310
|
+
if (!await fs.pathExists(srcDir)) {
|
|
311
|
+
this.results.errors.push({ file: dir, error: 'Directory not found in templates' });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
await this._walkAndInstall(srcDir, dir, componentName);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async _walkAndInstall(baseDir, relativeBase, componentName) {
|
|
318
|
+
const entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
const srcRel = path.join(relativeBase, entry.name);
|
|
321
|
+
if (entry.isDirectory()) {
|
|
322
|
+
await this._walkAndInstall(path.join(baseDir, entry.name), srcRel, componentName);
|
|
323
|
+
} else if (entry.name.endsWith('.md')) {
|
|
324
|
+
await this.installFile({ src: srcRel, dest: srcRel }, componentName);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
250
329
|
async installFile(fileSpec, componentName) {
|
|
251
330
|
const srcPath = path.join(this.templatesDir, fileSpec.src);
|
|
252
331
|
const destPath = path.join(this.targetDir, fileSpec.dest);
|
|
@@ -330,15 +409,16 @@ export class Installer {
|
|
|
330
409
|
async mergeClaudeMdFile(srcPath, destPath, fileSpec) {
|
|
331
410
|
const existing = await fs.readFile(destPath, 'utf-8');
|
|
332
411
|
const template = await fs.readFile(srcPath, 'utf-8');
|
|
333
|
-
const { content, added } = mergeClaudeMd(existing, template);
|
|
412
|
+
const { content, added, removed } = mergeClaudeMd(existing, template);
|
|
334
413
|
|
|
335
|
-
if (added
|
|
414
|
+
// Skip if content is unchanged (no refs added/removed, managed section identical)
|
|
415
|
+
if (content === existing) {
|
|
336
416
|
this.results.skipped.push(fileSpec.dest);
|
|
337
417
|
return;
|
|
338
418
|
}
|
|
339
419
|
|
|
340
420
|
if (this.dryRun) {
|
|
341
|
-
!this.quiet && console.log(chalk.cyan(` [dry-run] Would merge CLAUDE.md
|
|
421
|
+
!this.quiet && console.log(chalk.cyan(` [dry-run] Would merge CLAUDE.md`));
|
|
342
422
|
this.results.merged.push({ file: fileSpec.dest, added });
|
|
343
423
|
return;
|
|
344
424
|
}
|
|
@@ -346,7 +426,7 @@ export class Installer {
|
|
|
346
426
|
await backupPreInstall(destPath);
|
|
347
427
|
await backupFile(destPath);
|
|
348
428
|
await fs.writeFile(destPath, content, 'utf-8');
|
|
349
|
-
this.results.merged.push({ file: fileSpec.dest, added });
|
|
429
|
+
this.results.merged.push({ file: fileSpec.dest, added, removed });
|
|
350
430
|
}
|
|
351
431
|
|
|
352
432
|
async mergeSettingsJsonFile(srcPath, destPath, fileSpec) {
|
package/src/core/merger.js
CHANGED
|
@@ -60,28 +60,40 @@ function mergeWithMarkers(existingContent, templateContent) {
|
|
|
60
60
|
let result = replaceManagedSection(existingContent, templateManaged);
|
|
61
61
|
|
|
62
62
|
// Clean up any obsolete ACE refs outside the managed section
|
|
63
|
+
// This includes old @~/.claude/rules/ace/ refs AND hookify @refs
|
|
63
64
|
const removed = [];
|
|
64
65
|
const lines = result.split('\n');
|
|
65
66
|
const cleanedLines = lines.map(line => {
|
|
66
67
|
const refs = extractRefs(line);
|
|
67
68
|
const hasObsoleteAceRef = refs.some(ref => {
|
|
68
|
-
// Check if this is an ACE-owned ref that's NOT in the new template
|
|
69
69
|
if (isAceOwnedRef(ref)) {
|
|
70
70
|
const refWithAt = `@${ref}`;
|
|
71
71
|
if (!templateRefs.includes(refWithAt)) {
|
|
72
72
|
removed.push(ref);
|
|
73
|
-
return true;
|
|
73
|
+
return true;
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
return false;
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
//
|
|
79
|
+
// Also remove lines with hookify @ references (these should not be in CLAUDE.md)
|
|
80
|
+
const hasHookifyRef = refs.some(ref => /hookify\.ace\./.test(ref));
|
|
81
|
+
if (hasHookifyRef) {
|
|
82
|
+
const refBare = refs.find(ref => /hookify\.ace\./.test(ref));
|
|
83
|
+
if (refBare) removed.push(refBare);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
return hasObsoleteAceRef ? null : line;
|
|
81
88
|
}).filter(line => line !== null);
|
|
82
89
|
|
|
83
90
|
result = cleanedLines.join('\n');
|
|
84
91
|
|
|
92
|
+
// Clean up empty "## Added by ace" section if all its refs were removed
|
|
93
|
+
result = result.replace(/\n## Added by ace\n*(?=\n|$)/g, '\n');
|
|
94
|
+
// Normalize multiple blank lines
|
|
95
|
+
result = result.replace(/\n{3,}/g, '\n\n');
|
|
96
|
+
|
|
85
97
|
// Get the new refs that were added (in the managed section)
|
|
86
98
|
const existingRefs = extractRefs(existingContent);
|
|
87
99
|
const added = templateRefs
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
export class TeamInstaller {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.targetDir = options.targetDir || process.cwd();
|
|
9
|
+
this.repoUrl = options.repoUrl;
|
|
10
|
+
this.force = options.force || false;
|
|
11
|
+
this.dryRun = options.dryRun || false;
|
|
12
|
+
this.results = { installed: [], skipped: [], errors: [] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get teamDir() {
|
|
16
|
+
return path.join(this.targetDir, '.claude', 'rules', 'team');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async run() {
|
|
20
|
+
if (!this.repoUrl) return this.results;
|
|
21
|
+
|
|
22
|
+
const tempDir = path.join(os.tmpdir(), `ace-team-${Date.now()}`);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await this.cloneRepo(tempDir);
|
|
26
|
+
await this.copyFiles(tempDir);
|
|
27
|
+
} finally {
|
|
28
|
+
await fs.remove(tempDir);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return this.results;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async cloneRepo(tempDir) {
|
|
35
|
+
if (this.dryRun) {
|
|
36
|
+
this.results.installed.push(`git clone ${this.repoUrl} (dry-run)`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
execSync(`git clone --depth 1 "${this.repoUrl}" "${tempDir}"`, {
|
|
42
|
+
stdio: 'pipe',
|
|
43
|
+
timeout: 60000,
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const msg = err.stderr ? err.stderr.toString().trim() : err.message;
|
|
47
|
+
this.results.errors.push({
|
|
48
|
+
component: 'team-clone',
|
|
49
|
+
error: `Failed to clone ${this.repoUrl}: ${msg}`,
|
|
50
|
+
});
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async copyFiles(tempDir) {
|
|
56
|
+
if (this.dryRun) {
|
|
57
|
+
this.results.installed.push(`.claude/rules/team/ (from ${this.repoUrl})`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!await fs.pathExists(tempDir)) return;
|
|
62
|
+
|
|
63
|
+
const exists = await fs.pathExists(this.teamDir);
|
|
64
|
+
if (exists && !this.force) {
|
|
65
|
+
this.results.skipped.push('.claude/rules/team/ (already exists, use --force to overwrite)');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (exists) {
|
|
70
|
+
await fs.remove(this.teamDir);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await fs.ensureDir(this.teamDir);
|
|
74
|
+
await this._copyRecursive(tempDir, this.teamDir);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async _copyRecursive(srcDir, destDir) {
|
|
78
|
+
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
|
79
|
+
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.name === '.git') continue;
|
|
82
|
+
|
|
83
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
84
|
+
const destPath = path.join(destDir, entry.name);
|
|
85
|
+
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
await fs.ensureDir(destPath);
|
|
88
|
+
await this._copyRecursive(srcPath, destPath);
|
|
89
|
+
} else {
|
|
90
|
+
await fs.copy(srcPath, destPath);
|
|
91
|
+
const relative = path.relative(this.teamDir, destPath).replace(/\\/g, '/');
|
|
92
|
+
this.results.installed.push(`.claude/rules/team/${relative}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|