@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.
Files changed (43) hide show
  1. package/README.md +55 -19
  2. package/bin/ace.js +1 -0
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/skills/auto-goal/SKILL.md +131 -87
  6. package/plugin/skills/auto-goal/references/recovery.md +29 -0
  7. package/plugin/skills/auto-goal/references/state-template.md +97 -0
  8. package/src/commands/doctor.js +12 -5
  9. package/src/commands/init.js +9 -5
  10. package/src/commands/list.js +26 -0
  11. package/src/commands/spec.js +57 -0
  12. package/src/commands/uninstall.js +65 -31
  13. package/src/core/constants.js +23 -20
  14. package/src/core/installer.js +84 -4
  15. package/src/core/merger.js +15 -3
  16. package/src/core/team-installer.js +96 -0
  17. package/src/core/ui.js +0 -1
  18. package/templates/CLAUDE.md +28 -14
  19. package/templates/ace/rules/git.md +50 -0
  20. package/templates/ace/rules/gitflow.md +109 -0
  21. package/templates/{rules/ace → ace/rules}/thinking.md +5 -0
  22. package/templates/hooks/ace.bash-guard.sh +71 -0
  23. package/templates/hooks/ace.content-guard.sh +68 -0
  24. package/templates/hooks/ace.file-guard.sh +29 -0
  25. package/templates/hooks/ace.java-compile-check.sh +4 -2
  26. package/templates/hooks/ace.stop-verify.sh +28 -0
  27. package/templates/scripts/statusline-command.sh +2 -0
  28. package/templates/scripts/statusline.py +32 -0
  29. package/templates/settings.json +58 -2
  30. package/templates/hookify/hookify.ace.block-dangerous-ops.local.md +0 -16
  31. package/templates/hookify/hookify.ace.code-quality-gate.local.md +0 -45
  32. package/templates/hookify/hookify.ace.dangerous-commands.local.md +0 -20
  33. package/templates/hookify/hookify.ace.protect-secrets.local.md +0 -17
  34. package/templates/hookify/hookify.ace.require-verification.local.md +0 -13
  35. package/templates/hookify/hookify.ace.safe-git-commands.local.md +0 -38
  36. package/templates/hookify/hookify.ace.sensitive-data.local.md +0 -22
  37. /package/templates/{rules/ace → ace/rules}/clean-code.md +0 -0
  38. /package/templates/{rules/ace → ace/rules}/code-quality.md +0 -0
  39. /package/templates/{rules/ace → ace/rules}/context-hygiene.md +0 -0
  40. /package/templates/{rules/ace → ace/rules}/interactive-clarify.md +0 -0
  41. /package/templates/{rules/ace → ace/rules}/memory-policy.md +0 -0
  42. /package/templates/{rules/ace → ace/rules}/reporting.md +0 -0
  43. /package/templates/{rules/ace → ace/rules}/task-recovery.md +0 -0
@@ -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
+ }
@@ -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, hookify rules). Continue?',
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/ directory
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 rulesDir = path.join(CLAUDE_DIR, 'rules', 'ace');
37
- if (await fs.pathExists(rulesDir)) {
38
- await fs.remove(rulesDir);
39
- removed.push('rules/ace/');
40
- } else {
41
- skipped.push('rules/ace/ (not found)');
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 hookify rules
86
- const spinner3 = ora('Removing hookify rules...').start();
99
+ // 3. Remove hook scripts
100
+ const spinner3 = ora('Removing hooks...').start();
87
101
  try {
88
- const hookifyFiles = COMPONENTS.hookify.files;
89
- for (const file of hookifyFiles) {
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('hookify rules removed');
110
+ spinner3.succeed('hooks removed');
97
111
  } catch (err) {
98
- spinner3.fail('hookify rules removal failed');
99
- errors.push({ component: 'hookify', error: err.message });
112
+ spinner3.fail('hooks removal failed');
113
+ errors.push({ component: 'hooks', error: err.message });
100
114
  }
101
115
 
102
- // 4. Remove hook scripts
103
- const spinner4 = ora('Removing hooks...').start();
116
+ // 4. Remove legacy hookify rules (cleanup from older versions)
117
+ const spinner4 = ora('Removing legacy hookify rules...').start();
104
118
  try {
105
- const hookFiles = COMPONENTS.hooks.conditional;
106
- for (const file of hookFiles) {
107
- const destPath = path.join(CLAUDE_DIR, file.dest);
108
- if (await fs.pathExists(destPath)) {
109
- await fs.remove(destPath);
110
- removed.push(file.dest);
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
- spinner4.succeed('hooks removed');
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('hooks removal failed');
116
- errors.push({ component: 'hooks', error: err.message });
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
- const lines = content.split('\n');
132
- const filtered = lines.filter(line => !line.includes('@~/.claude/rules/ace/'));
133
- const cleaned = filtered.join('\n')
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 @references (surgical)');
173
+ removed.push('CLAUDE.md ace content (surgical)');
140
174
  }
141
175
  }
142
176
 
@@ -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', 'hookify', 'memory'],
20
+ full: ['core', 'rules', 'plugin', 'hooks', 'scripts', 'memory'],
21
21
  minimal: ['core', 'rules', 'plugin'],
22
- safe: ['core', 'rules', 'plugin', 'hookify', 'memory'],
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 rules/, hooks/, and hookify/.
61
+ * Used to identify ACE-owned content in ace/ and hooks/.
62
62
  */
63
63
  export const ACE_OWNED_PATTERNS = [
64
- /^rules\/ace\//, // rules/ace/*.md
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
- /^hookify\.ace\./, // hookify.ace.*.local.md
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
- return ACE_OWNED_PATTERNS.some(pattern => pattern.test(relativePath));
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 '~/.claude/rules/ace/thinking.md' or '~/.claude/hooks/ace.java-compile-check.sh'
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(/^~\/\.claude\//, '');
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/ace',
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 (optional, role-dependent)',
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
- hookify: {
116
- description: 'Safety guard rules (block dangerous ops, protect secrets, safe git, code quality, require verification)',
123
+ scripts: {
124
+ description: 'Utility scripts (status line)',
117
125
  required: false,
118
126
  files: [
119
- { src: 'hookify/hookify.ace.block-dangerous-ops.local.md', dest: 'hookify.ace.block-dangerous-ops.local.md' },
120
- { src: 'hookify/hookify.ace.protect-secrets.local.md', dest: 'hookify.ace.protect-secrets.local.md' },
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: {
@@ -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.length === 0) {
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, adding ${added.length} references`));
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) {
@@ -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; // This line has an obsolete ref
73
+ return true;
74
74
  }
75
75
  }
76
76
  return false;
77
77
  });
78
78
 
79
- // Return null to mark for removal, otherwise keep line
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
+ }
package/src/core/ui.js CHANGED
@@ -92,6 +92,5 @@ export const componentLabels = {
92
92
  rules: 'Rules',
93
93
  plugin: 'Plugin',
94
94
  hooks: 'Hooks',
95
- hookify: 'Safety Guards',
96
95
  memory: 'Memory',
97
96
  };