@sallmarta/eye-hate-agent 1.0.8 → 1.0.10

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 CHANGED
@@ -82,10 +82,10 @@ The EHA CLI provides a lightweight, frictionless setup and maintenance toolbelt:
82
82
 
83
83
  | Command | Primary Purpose |
84
84
  | :--- | :--- |
85
- | `eha init` | Lets you choose your target AI agent, and projects standard rules/skills. |
86
- | `eha init <agent>` | Directly initiates the EHA project setup for a specific agent (e.g. `copilot`, `claude`, `antigravity`). |
85
+ | `eha` | **Unified wizard**: banner agent selection scope selection (project/device) → install. |
87
86
  | `eha doctor` | Performs a health check verifying that all generated files are present and intact. |
88
- | `eha remove [agent]` | Safely deletes EHA's generated contract files for the specified agent (or all agents if omitted), along with configuration files. |
87
+ | `eha remove [agent]` | Safely deletes EHA's generated project-level contract files for the specified agent (or all agents if omitted), along with configuration files. |
88
+ | `eha uninstall` | Safely deletes EHA's generated device-level contract files from your machine. |
89
89
 
90
90
  ---
91
91
 
@@ -95,22 +95,27 @@ When a new version of EHA is released, simply run:
95
95
  ```bash
96
96
  $ eha
97
97
  ```
98
- in your repository. The engine will detect the version mismatch automatically, prompt you to regenerate the files **(if needed)**, and update them to the latest standards seamlessly.
98
+ in your repository or from anywhere on your machine. The engine will detect the version mismatch automatically, prompt you to update the files, and update them to the latest standards seamlessly.
99
99
 
100
100
  ---
101
101
 
102
102
  ## Uninstallation
103
103
 
104
- To completely remove EHA from your project and device:
104
+ To completely remove EHA:
105
105
 
106
- ### 1. Remove project files
107
- To clean up projected AI files, run the following command in your project root. You can optionally specify a target agent (e.g. `claude`, `copilot`, `antigravity`) to remove only that agent's files while preserving other active installations:
106
+ ### 1. Remove project-level (Local) files
107
+ To clean up locally run the following command in your project root. Possible to specify a target agent (e.g. `claude`):
108
108
  ```bash
109
109
  $ eha remove [agent]
110
110
  ```
111
111
 
112
- ### 2. Uninstall the CLI globally
113
- To completely remove the CLI from your device, run:
112
+ ### 2. Remove device-level (Global) files
113
+ To clean up globally from your machine, run:
114
+ ```bash
115
+ $ eha uninstall
116
+ ```
117
+
118
+ ### 3. Remove the CLI Completely
114
119
  ```bash
115
120
  $ npm uninstall -g @sallmarta/eye-hate-agent
116
121
  ```
package/bin/eha.js CHANGED
@@ -7,57 +7,24 @@ const chalk = require('chalk');
7
7
  const readline = require('node:readline/promises');
8
8
  const {
9
9
  SUPPORTED_AGENT_IDS,
10
+ deviceManifestExists,
10
11
  doctor,
11
12
  findRepoRoot,
12
13
  initProject,
14
+ installDevice,
13
15
  listSupportedRuntimes,
14
16
  listWorkflows,
15
17
  readConfig,
18
+ readDeviceManifest,
16
19
  readProjectManifest,
17
20
  removeProject,
21
+ uninstallDevice,
18
22
  } = require('../src/engine');
19
23
 
20
24
  const pkg = require('../package.json');
21
25
 
22
26
  // ─── Helpers ──────────────────────────────────────────────────────────────────
23
27
 
24
- async function promptAgentChoice(currentAgent) {
25
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
26
- try {
27
- const runtimes = listSupportedRuntimes();
28
- const defaultIndex = 1;
29
-
30
- console.log('');
31
- console.log('Which agent?');
32
- for (let i = 0; i < runtimes.length; i++) {
33
- console.log(` ${i + 1}. ${runtimes[i].name}`);
34
- }
35
- console.log(` ${runtimes.length + 1}. All Agents`);
36
-
37
- const maxChoice = runtimes.length + 1;
38
- const answer = await rl.question(
39
- `Choose [1-${maxChoice}] (default: ${defaultIndex}): `,
40
- );
41
- const trimmed = answer.trim();
42
-
43
- if (!trimmed) return runtimes[defaultIndex - 1].id;
44
-
45
- const num = parseInt(trimmed, 10);
46
- if (num >= 1 && num <= runtimes.length) return runtimes[num - 1].id;
47
- if (num === maxChoice) return 'all';
48
-
49
- const normalized = trimmed.toLowerCase();
50
- if (normalized === 'all') return 'all';
51
-
52
- const match = runtimes.find(r => r.id === normalized);
53
- if (match) return match.id;
54
-
55
- return trimmed.toLowerCase();
56
- } finally {
57
- rl.close();
58
- }
59
- }
60
-
61
28
  async function promptConfirm(message, defaultYes = false) {
62
29
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
63
30
  try {
@@ -138,116 +105,281 @@ function printDoctorSummary(result) {
138
105
  console.log('');
139
106
  }
140
107
 
141
- // ─── Wizard (shared by bare invocation and `eha init`) ────────────────────────
108
+ function printBanner() {
109
+ const ehaRed = chalk.hex('#A61E14').bold;
110
+ console.log('');
111
+ console.log(ehaRed(' ███████╗██╗ ██╗ █████╗ '));
112
+ console.log(ehaRed(' ██╔════╝██║ ██║██╔══██╗'));
113
+ console.log(ehaRed(' █████╗ ███████║███████║'));
114
+ console.log(ehaRed(' ██╔══╝ ██╔══██║██╔══██║'));
115
+ console.log(ehaRed(' ███████╗██║ ██║██║ ██║'));
116
+ console.log(ehaRed(' ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝'));
117
+ console.log(chalk.gray(` v${pkg.version}`));
118
+ console.log('');
119
+ }
142
120
 
143
- async function runInitWizard(agentIdArg) {
144
- const rootDir = resolveRootDir();
145
- const config = readConfig(rootDir);
146
- const manifest = readProjectManifest(rootDir);
121
+ async function checkForUpdates() {
122
+ try {
123
+ const https = require('node:https');
124
+ const data = await new Promise((resolve, reject) => {
125
+ const req = https.get(
126
+ 'https://registry.npmjs.org/@sallmarta/eye-hate-agent/latest',
127
+ { timeout: 3000 },
128
+ (res) => {
129
+ let body = '';
130
+ res.on('data', (chunk) => (body += chunk));
131
+ res.on('end', () => resolve(JSON.parse(body)));
132
+ },
133
+ );
134
+ req.on('error', reject);
135
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
136
+ });
137
+
138
+ const latest = data.version;
139
+ if (latest && latest !== pkg.version) {
140
+ console.log(
141
+ chalk.yellow(` Update available: ${pkg.version} → ${latest}`) +
142
+ chalk.gray(` — run npm i -g @sallmarta/eye-hate-agent`)
143
+ );
144
+ console.log('');
145
+ }
146
+ } catch {
147
+ // Silently ignore — no network, no problem
148
+ }
149
+ }
147
150
 
148
- let agentId = agentIdArg ? String(agentIdArg).trim().toLowerCase() : null;
149
- const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
151
+ function parseAgentInput(input, runtimes) {
152
+ const trimmed = (input || '').trim();
153
+ if (!trimmed) return [runtimes[0].id];
154
+ if (trimmed === '0' || trimmed.toLowerCase() === 'all') return SUPPORTED_AGENT_IDS.slice();
150
155
 
151
- if (!agentId) {
152
- if (isInteractive) {
153
- agentId = await promptAgentChoice(config.agent);
156
+ const parts = trimmed.split(',').map(p => p.trim()).filter(Boolean);
157
+ const ids = new Set();
158
+
159
+ for (const part of parts) {
160
+ const num = parseInt(part, 10);
161
+ if (num === 0) return SUPPORTED_AGENT_IDS.slice();
162
+ if (num >= 1 && num <= runtimes.length) {
163
+ ids.add(runtimes[num - 1].id);
154
164
  } else {
155
- agentId = config.agent || SUPPORTED_AGENT_IDS[0];
165
+ const normalized = part.toLowerCase();
166
+ if (normalized === 'all') return SUPPORTED_AGENT_IDS.slice();
167
+ const match = runtimes.find(r => r.id === normalized);
168
+ if (match) ids.add(match.id);
169
+ else ids.add(normalized);
156
170
  }
157
171
  }
158
172
 
159
- const normalized = String(agentId).trim().toLowerCase();
173
+ return ids.size > 0 ? [...ids] : [runtimes[0].id];
174
+ }
160
175
 
161
- if (normalized === 'all') {
162
- const installedAgents = config.agents || (config.agent ? [config.agent] : []);
163
- if (isInteractive && installedAgents.length > 0) {
164
- const listStr = installedAgents.map(a => chalk.cyan(a)).join(', ');
165
- const confirm = await promptConfirm(
166
- `EHA is set up for: ${listStr}. Overwrite / setup all agents?`,
176
+ async function promptAgentChoice() {
177
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
178
+ try {
179
+ const runtimes = listSupportedRuntimes();
180
+
181
+ console.log('');
182
+ console.log('Which agent(s)?');
183
+ for (let i = 0; i < runtimes.length; i++) {
184
+ console.log(` ${i + 1}. ${runtimes[i].name}`);
185
+ }
186
+ console.log(` 0. All Agents`);
187
+
188
+ const answer = await rl.question(
189
+ `Choose agent(s) — comma-separate for multiple (e.g. 1,3): `,
190
+ );
191
+ return parseAgentInput(answer, runtimes);
192
+ } finally {
193
+ rl.close();
194
+ }
195
+ }
196
+
197
+ async function promptScope() {
198
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
199
+ try {
200
+ console.log('');
201
+ console.log('Where should EHA be installed?');
202
+ console.log(` 1. This project ${chalk.gray('(files in .claude/, .github/, .agents/)')}`);
203
+ console.log(` 2. Your device — all projects ${chalk.gray('(files in ~/.claude/, ~/.copilot/, ~/.gemini/)')}`);
204
+
205
+ const answer = await rl.question(`Choose [1-2]: `);
206
+ const trimmed = answer.trim();
207
+
208
+ if (trimmed === '2' || trimmed.toLowerCase() === 'device' || trimmed.toLowerCase() === 'global') {
209
+ return 'device';
210
+ }
211
+
212
+ return 'project';
213
+ } finally {
214
+ rl.close();
215
+ }
216
+ }
217
+
218
+ async function runDeviceInstall(agentIds) {
219
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
220
+
221
+ if (isInteractive && deviceManifestExists()) {
222
+ const manifest = readDeviceManifest();
223
+ const installedAgentIds = (manifest.agents || []).map(a => a.id);
224
+ if (installedAgentIds.length > 0) {
225
+ const listStr = installedAgentIds.map(a => chalk.cyan(a)).join(', ');
226
+ const ver = manifest.packageVersion || 'unknown';
227
+ const confirmed = await promptConfirm(
228
+ `EHA is already installed on your device (${listStr}, v${ver}). Update / overwrite?`,
167
229
  true,
168
230
  );
169
- if (!confirm) {
231
+ if (!confirmed) {
170
232
  console.log('Skipped.');
171
233
  return;
172
234
  }
173
235
  }
236
+ }
174
237
 
175
- console.log(chalk.blue('\nInitializing EHA for all agents...'));
176
- let fileCount = 0;
177
- for (const id of SUPPORTED_AGENT_IDS) {
178
- const result = initProject({ rootDir, agentId: id });
179
- fileCount += result.fileCount;
180
- }
238
+ console.log('');
239
+ console.log(chalk.blue('Installing EHA to your device...'));
240
+ console.log('');
181
241
 
242
+ const result = installDevice({ agentIds });
243
+
244
+ const agentNames = { claude: 'Claude', copilot: 'GitHub Copilot', antigravity: 'Antigravity' };
245
+
246
+ for (const agentId of result.agentIds) {
247
+ const agentResult = result.results[agentId];
248
+ console.log(` ${chalk.cyan(agentNames[agentId] || agentId)}:`);
249
+ for (const file of agentResult.files) {
250
+ const suffix = file.isSentinel ? ` (EHA rules block ${file.action})` : '';
251
+ console.log(` ${chalk.green('✓')} ${file.displayPath}${chalk.gray(suffix)}`);
252
+ }
182
253
  console.log('');
183
- console.log(chalk.green('✓ EHA is ready for all agents.'));
184
- console.log(` Agents : ${SUPPORTED_AGENT_IDS.map(a => chalk.cyan(a)).join(', ')}`);
185
- console.log(` Files : ${fileCount} file(s) generated`);
186
- console.log('');
187
- console.log('Open Agents in this project and run ' + chalk.cyan('/eha-help') + ' to get started or run ' + chalk.cyan('eha doctor') + ' to see all files.');
188
- console.log('');
189
- return;
190
254
  }
191
255
 
192
- if (!SUPPORTED_AGENT_IDS.includes(normalized)) {
193
- const runtimes = listSupportedRuntimes();
194
- const list = runtimes.map((r, i) => `${i + 1}. ${r.name}`).join(', ');
256
+ console.log(chalk.green('✓ EHA installed to your device!'));
257
+ console.log(` Open your agent in any project and run ${chalk.cyan('/eha-help')} to get started.`);
258
+ console.log('');
259
+ }
260
+
261
+ async function runProjectInstall(agentIds) {
262
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
263
+
264
+ let rootDir;
265
+ try {
266
+ rootDir = findRepoRoot(process.cwd());
267
+ } catch {
195
268
  console.error(
196
- chalk.red(`Unsupported agent: ${agentIdArg || agentId}.`) +
197
- ` Choose one of: ${list}, or ${runtimes.length + 1}. All Agents.`,
269
+ chalk.red('No project root found.') +
270
+ ' Run ' + chalk.cyan('npm init -y') + ' or ' + chalk.cyan('git init') + ' first.',
198
271
  );
199
272
  process.exit(1);
200
273
  }
201
- agentId = normalized;
202
-
203
- const installedAgents = config.agents || (config.agent ? [config.agent] : []);
204
- const isAlreadyInstalled = installedAgents.includes(agentId);
205
-
206
- if (isInteractive) {
207
- if (isAlreadyInstalled) {
208
- const currentVer = manifest.packageVersion || 'unknown';
209
- let msg = '';
210
- if (currentVer !== pkg.version) {
211
- msg = `EHA is already set up for ${chalk.cyan(agentId)} (v${currentVer}). Regenerate with v${pkg.version}?`;
212
- } else {
213
- msg = `EHA is already set up for ${chalk.cyan(agentId)}. Regenerate/overwrite?`;
214
- }
215
- const confirm = await promptConfirm(msg, true);
216
- if (!confirm) {
217
- console.log('Skipped.');
218
- return;
219
- }
220
- } else if (installedAgents.length > 0) {
221
- const listStr = installedAgents.map(a => chalk.cyan(a)).join(', ');
222
- const confirm = await promptConfirm(
223
- `EHA is set up for: ${listStr}. Add ${chalk.cyan(agentId)}?`,
224
- true,
225
- );
226
- if (!confirm) {
227
- console.log('Skipped.');
228
- return;
229
- }
274
+
275
+ const config = readConfig(rootDir);
276
+ const installedAgents = config.agents || [];
277
+
278
+ if (isInteractive && installedAgents.length > 0) {
279
+ const listStr = installedAgents.map(a => chalk.cyan(a)).join(', ');
280
+ const confirmed = await promptConfirm(
281
+ `EHA is set up for: ${listStr}. Overwrite / add selected agents?`,
282
+ true,
283
+ );
284
+ if (!confirmed) {
285
+ console.log('Skipped.');
286
+ return;
230
287
  }
231
288
  }
232
289
 
233
- const result = initProject({ rootDir, agentId });
234
- printInitSummary(result);
290
+ console.log('');
291
+ console.log(chalk.blue('Installing EHA to this project...'));
292
+
293
+ let totalFiles = 0;
294
+ const allFiles = [];
295
+ for (const id of agentIds) {
296
+ const result = initProject({ rootDir, agentId: id });
297
+ totalFiles += result.fileCount;
298
+ allFiles.push(...result.files);
299
+ }
300
+
301
+ console.log('');
302
+ console.log(chalk.green(`✓ EHA is ready.`));
303
+ console.log(` Agent${agentIds.length > 1 ? 's' : ''} : ${agentIds.map(a => chalk.cyan(a)).join(', ')}`);
304
+ console.log(` Files : ${totalFiles} file(s) generated`);
305
+ for (const f of allFiles) {
306
+ console.log(` ${chalk.gray(f)}`);
307
+ }
308
+ console.log('');
309
+ console.log(`Open your agent in this project and run ${chalk.cyan('/eha-help')} to get started.`);
310
+ console.log('');
235
311
  }
236
312
 
237
313
  // ─── CLI definition ────────────────────────────────────────────────────────────
238
314
 
239
315
  program.name('eha').description('Eye Hate Agent (EHA) — AI workflow toolkit').version(pkg.version);
240
316
 
241
- // Bare `eha` / `eyehateagent` with no subcommand runs the init wizard.
242
317
  program.action(async () => {
243
- await runInitWizard(null);
318
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
319
+
320
+ if (!isInteractive) {
321
+ program.outputHelp();
322
+ return;
323
+ }
324
+
325
+ printBanner();
326
+ await checkForUpdates();
327
+
328
+ const agentIds = await promptAgentChoice();
329
+
330
+ for (const id of agentIds) {
331
+ if (!SUPPORTED_AGENT_IDS.includes(id)) {
332
+ console.error(
333
+ chalk.red(`Unsupported agent: ${id}.`) +
334
+ ` Choose from: ${SUPPORTED_AGENT_IDS.join(', ')}`
335
+ );
336
+ process.exit(1);
337
+ }
338
+ }
339
+
340
+ const scope = await promptScope();
341
+
342
+ if (scope === 'device') {
343
+ await runDeviceInstall(agentIds);
344
+ } else {
345
+ await runProjectInstall(agentIds);
346
+ }
244
347
  });
245
348
 
246
349
  program
247
- .command('init [agent]')
248
- .description(`Set up EHA in this project. Agent: ${SUPPORTED_AGENT_IDS.join(' | ')}`)
350
+ .command('init [agent]', { hidden: true })
351
+ .description('(hidden) Project-level install alias for the unified wizard with scope=project')
249
352
  .action(async (agentArg) => {
250
- await runInitWizard(agentArg || null);
353
+ const rootDir = resolveRootDir();
354
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
355
+
356
+ let agentIds;
357
+ if (agentArg) {
358
+ const normalized = String(agentArg).trim().toLowerCase();
359
+ agentIds = normalized === 'all' ? SUPPORTED_AGENT_IDS.slice() : [normalized];
360
+ } else if (isInteractive) {
361
+ printBanner();
362
+ agentIds = await promptAgentChoice();
363
+ } else {
364
+ agentIds = [SUPPORTED_AGENT_IDS[0]];
365
+ }
366
+
367
+ let totalFiles = 0;
368
+ const allFiles = [];
369
+ for (const id of agentIds) {
370
+ const result = initProject({ rootDir, agentId: id });
371
+ totalFiles += result.fileCount;
372
+ allFiles.push(...result.files);
373
+ }
374
+
375
+ console.log('');
376
+ console.log(chalk.green(`✓ EHA is ready.`));
377
+ console.log(` Agent${agentIds.length > 1 ? 's' : ''} : ${agentIds.map(a => chalk.cyan(a)).join(', ')}`);
378
+ console.log(` Files : ${totalFiles} file(s) generated`);
379
+ for (const f of allFiles) {
380
+ console.log(` ${chalk.gray(f)}`);
381
+ }
382
+ console.log('');
251
383
  });
252
384
 
253
385
  program
@@ -311,6 +443,37 @@ program
311
443
  }
312
444
  });
313
445
 
446
+ program
447
+ .command('uninstall')
448
+ .description('Remove EHA device-level files from your machine')
449
+ .action(async () => {
450
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
451
+
452
+ if (isInteractive) {
453
+ const confirmed = await promptConfirm(
454
+ 'Remove all device-level EHA files from your machine?',
455
+ );
456
+ if (!confirmed) {
457
+ console.log('Aborted.');
458
+ return;
459
+ }
460
+ }
461
+
462
+ const result = uninstallDevice();
463
+
464
+ if (result.removedFiles.length === 0) {
465
+ console.log(chalk.yellow('No device-level EHA installation found.'));
466
+ return;
467
+ }
468
+
469
+ console.log('');
470
+ console.log(chalk.green('✓ EHA device-level files removed:'));
471
+ for (const f of result.removedFiles) {
472
+ console.log(` ${chalk.gray(f)}`);
473
+ }
474
+ console.log('');
475
+ });
476
+
314
477
  program
315
478
  .command('doctor')
316
479
  .description('Show EHA status: config, agent, and generated files')
@@ -319,7 +482,13 @@ program
319
482
  printDoctorSummary(doctor({ rootDir }));
320
483
  });
321
484
 
322
- program.parseAsync(process.argv).catch((error) => {
323
- console.error(chalk.red(error.message));
324
- process.exitCode = 1;
325
- });
485
+ if (require.main === module) {
486
+ program.parseAsync(process.argv).catch((error) => {
487
+ console.error(chalk.red(error.message));
488
+ process.exitCode = 1;
489
+ });
490
+ }
491
+
492
+ module.exports = {
493
+ parseAgentInput,
494
+ };
@@ -98,21 +98,24 @@ Additionally, ask the user:
98
98
  If yes, generate a boilerplate `foundation/changelog.md` with an initial unreleased section.
99
99
 
100
100
  ### For Brownfield Projects (with existing code):
101
- Analyze the codebase for active development signals:
102
- - Recent commits, open branches, TODO comments.
103
- - Sprint-style branch names (`sprint-*`, `release-*`, `feat/*`).
104
- - Issue tracker references in commits or code.
105
- - CI/CD pipeline activity.
106
-
107
- If active development signals are found, ask the user:
108
- "This project appears to be in active development. Would you like to set up phase-based planning to track your development cycles?
101
+ You **MUST** check all four active development signals. Do NOT skip this step:
102
+
103
+ 1. **Recent commits** run `git log --oneline -20` or equivalent; if there are commits within the last 14 days, OR 10+ commits within the last 30 days, this signal is positive.
104
+ 2. **Sprint/feature branches** — run `git branch -a` and look for naming patterns like `sprint/`, `phase/`, `release/`, `feature/`, `feat/`, `dev/`.
105
+ 3. **Planning artifacts** — check for `TODO.md`, `ROADMAP.md`, `.github/ISSUE_TEMPLATE/`, issue tracker references in recent commits (e.g., `#123`, `fixes #`, `closes #`), or project board configs.
106
+ 4. **TODO density** — grep the codebase for `TODO`, `FIXME`, `HACK` comments; if count ≥ 5, this signal is positive.
107
+
108
+ If **any one** signal is positive, you **MUST** ask the user:
109
+ "This project shows active development signals ([list which signals were positive and what was found]).
110
+ Would you like to set up `foundation/phases/` to track your development cycles?
109
111
  If yes, describe the current and upcoming phases (or I can infer from your codebase)."
110
112
 
111
113
  If the user provides phases:
112
114
  - Create `foundation/phases/index.md` with the phase registry.
113
115
  - Create individual phase files using brownfield naming: `phase-P{N}[-description].md` (e.g., `phase-P1-refactor.md`, `phase-P2-auth.md`).
114
116
 
115
- If the user declines or no active development signals: Skip phases entirely.
117
+ If the user declines: Skip phases entirely.
118
+ If all four signals are negative: Skip the phases prompt but note in your output that all four active development signals were negative and no phases were offered.
116
119
 
117
120
  Additionally, check for release signals (e.g., git tags, version updates in `package.json`, release branches). If found, ask the user:
118
121
  "Would you like to set up a changelog (`foundation/changelog.md`) to track historical releases?"
@@ -124,6 +127,7 @@ Before finishing, check that:
124
127
  2. `foundation/architecture.md` and `development/testing.md` do not conflict.
125
128
  3. The generated documents strictly match the approved Taxonomy Tier, conditional choices, and structural definitions cataloged in the master registry.
126
129
  4. If phases were generated, verify `foundation/phases/index.md` correctly registry-links to all individual phase files (`phase-*.md`), and each phase file has complete stable headings.
130
+ 5. For brownfield projects: if any active development signal was positive during Step 2.5, confirm that the user was prompted about setting up phases. If this prompt was skipped, **stop and prompt the user now before finishing**.
127
131
 
128
132
  ## Inputs
129
133
  Use the project brief, codebase, and constraints provided below to begin your analysis.
@@ -66,11 +66,21 @@ Proceed to the applicable action path.
66
66
  - i18n config, locale files → development/internationalization
67
67
  - README, inline comments, decision rationale → foundation/prd, architecture
68
68
  23. Mark all codebase-inferred facts as `Inferred from codebase` until the user confirms them.
69
- 24. **Active Development & Phases Detection.** When refreshing a project that does not yet have `foundation/phases/`, check for active development signals (recent commits, sprint branches, open milestones, TODO density). If signals are found, prompt the user:
70
- "This project appears to be in active development but has no phase-based planning docs.
71
- Would you like to set up development phases to track your sprints and milestones?
69
+ 24. **Active Development & Phases Detection (MANDATORY).** When refreshing a project that does not yet have `foundation/phases/`, you **MUST** check all four active development signals before proceeding to doc refresh. Do NOT skip this step. The signals are:
70
+
71
+ 1. **Recent commits** run `git log --oneline -20` or equivalent; if there are commits within the last 14 days, OR 10+ commits within the last 30 days, this signal is positive.
72
+ 2. **Sprint/feature branches** — run `git branch -a` and look for naming patterns like `sprint/`, `phase/`, `release/`, `feature/`, `feat/`, `dev/`.
73
+ 3. **Planning artifacts** — check for `TODO.md`, `ROADMAP.md`, `.github/ISSUE_TEMPLATE/`, issue tracker references in recent commits (e.g., `#123`, `fixes #`, `closes #`), or project board configs.
74
+ 4. **TODO density** — grep the codebase for `TODO`, `FIXME`, `HACK` comments; if count ≥ 5, this signal is positive.
75
+
76
+ If **any one** signal is positive, you **MUST** prompt the user:
77
+ "This project shows active development signals ([list which signals were positive and what was found]).
78
+ Would you like to set up `foundation/phases/` to track your development cycles?
72
79
  If yes, describe the current and upcoming phases (or I can infer from your codebase)."
80
+
73
81
  If the user agrees, create `foundation/phases/index.md` and individual phase files using brownfield naming (`phase-P{N}[-description].md`, e.g., `phase-P1-refactor.md`).
82
+ If the user declines, skip phases creation entirely — but still report the detection outcome in the Output Contract.
83
+ If all four signals are negative, skip the phases prompt but note in your output that all four active development signals were negative and no phases were offered.
74
84
  25. **Phases Update Workflow.** When `foundation/phases/` already exists, treat it as a living operational document:
75
85
  - Update sprint tracker in the active phase file when sprint-related changes are detected.
76
86
  - Mark completed phases by updating their status.
@@ -82,15 +92,16 @@ Proceed to the applicable action path.
82
92
  1. Run Step 0 (Doc State Detection).
83
93
  2. Read the change summary (if provided) or the user's intent.
84
94
  3. **Scan the codebase** — inspect source code, configs, tests, CI/CD pipelines, and package manifests for current truth. This step is NOT optional.
85
- 4. Read the owning project docs (if Active SDD or Mixed state).
86
- 5. Read `docs/project-docs/index.md` and `docs/project-docs/technical-guidelines/index.md` when present.
87
- 6. Read legacy/reference folders when present.
88
- 7. Read relevant guideline docs when the change touches technical rules.
89
- 8. Identify impacted dependent docs.
90
- 9. Cross-reference codebase findings against doc/legacy claims — resolve conflicts by prompting the user (see rule 21).
91
- 10. Refresh/create the owning docs first (using combined codebase + docs evidence).
92
- 11. Refresh summary or index docs second.
93
- 12. Run a consistency pass.
95
+ 4. **Phases Detection Gate** If `foundation/phases/` does not exist, execute Rule 24 (Active Development & Phases Detection) now. Run all four signal checks using the codebase data from step 3. If any signal is positive, prompt the user about setting up phases before continuing. If `foundation/phases/` already exists, proceed to step 5.
96
+ 5. Read the owning project docs (if Active SDD or Mixed state).
97
+ 6. Read `docs/project-docs/index.md` and `docs/project-docs/technical-guidelines/index.md` when present.
98
+ 7. Read legacy/reference folders when present.
99
+ 8. Read relevant guideline docs when the change touches technical rules.
100
+ 9. Identify impacted dependent docs.
101
+ 10. Cross-reference codebase findings against doc/legacy claims resolve conflicts by prompting the user (see rule 21).
102
+ 11. Refresh/create the owning docs first (using combined codebase + docs evidence).
103
+ 12. Refresh summary or index docs second.
104
+ 13. Run a consistency pass.
94
105
 
95
106
  ## Ownership Examples
96
107
 
@@ -122,6 +133,7 @@ Your result should state:
122
133
  4. any remaining consistency risks or open questions
123
134
  5. which codebase-vs-doc conflicts were resolved and how (per user direction)
124
135
  6. the auto-detected tier (for Legacy Only / Non-SDD states), if applicable
136
+ 7. whether active development signals were detected, which signals were positive/negative, and whether the user was prompted about `foundation/phases/` setup (include the user's response: accepted, declined, or not yet answered)
125
137
 
126
138
  ## Final Pass
127
139
 
@@ -132,6 +144,7 @@ Before finishing, check that:
132
144
  3. no stale summary remains in `foundation/status.md`, `docs/project-docs/index.md`, `technical-guidelines/index.md`, or other index docs
133
145
  4. codebase-inferred facts are clearly marked and do not silently override user-confirmed truths
134
146
  5. the auto-detected tier (for Legacy Only / Non-SDD states) is stated in the output so the user can override it if needed
147
+ 6. if `foundation/phases/` did not exist at the start of this refresh and any active development signal was positive, confirm that the user was prompted about setting up phases — if this prompt was skipped, **stop and prompt the user now before finishing**
135
148
 
136
149
  ## Inputs
137
150
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sallmarta/eye-hate-agent",
3
- "version": "1.0.8",
4
- "description": "Template-and-engine toolkit for AI-agent-assisted project workflows.",
3
+ "version": "1.0.10",
4
+ "description": "A simple Spec-Driven Development (SDD) for AI-agent-assisted project workflows.",
5
5
  "directories": {
6
6
  "doc": "docs"
7
7
  },
@@ -12,7 +12,8 @@
12
12
  "README.md"
13
13
  ],
14
14
  "scripts": {
15
- "test": "node --test"
15
+ "test": "node --test",
16
+ "postinstall": "node -e \"try{var c=require('chalk');console.log('\\n'+c.green('✓ EHA installed!')+' Run '+c.cyan('eha')+' to set up.\\n')}catch(e){console.log('\\nEHA installed! Run eha to set up.\\n')}\""
16
17
  },
17
18
  "keywords": [
18
19
  "ai-agent",
@@ -1,19 +1,23 @@
1
1
  const { getWorkflow, listWorkflows } = require('./workflow-registry');
2
2
  const { listSkills } = require('./skill-registry');
3
- const { findRepoRoot, readConfig } = require('./state');
3
+ const { findRepoRoot, readConfig, deviceManifestExists, readDeviceManifest } = require('./state');
4
4
  const { listSupportedRuntimes } = require('./runtime-adapters');
5
- const { SUPPORTED_AGENT_IDS, doctor, initProject, readProjectManifest, removeProject } = require('./install');
5
+ const { SUPPORTED_AGENT_IDS, doctor, initProject, installDevice, readProjectManifest, removeProject, uninstallDevice } = require('./install');
6
6
 
7
7
  module.exports = {
8
8
  SUPPORTED_AGENT_IDS,
9
+ deviceManifestExists,
9
10
  doctor,
10
11
  findRepoRoot,
11
12
  getWorkflow,
12
13
  initProject,
14
+ installDevice,
13
15
  listSkills,
14
16
  listSupportedRuntimes,
15
17
  listWorkflows,
16
18
  readConfig,
19
+ readDeviceManifest,
17
20
  readProjectManifest,
18
21
  removeProject,
22
+ uninstallDevice,
19
23
  };
@@ -1,5 +1,6 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
+ const os = require('node:os');
3
4
  const { version: EHA_PACKAGE_VERSION } = require('../../package.json');
4
5
 
5
6
  const { listWorkflows } = require('./workflow-registry');
@@ -16,6 +17,11 @@ const {
16
17
  writeConfig,
17
18
  writeJson,
18
19
  writeText,
20
+ getDeviceManifestPath,
21
+ readDeviceManifest,
22
+ writeDeviceManifest,
23
+ upsertSentinelBlock,
24
+ removeSentinelBlock,
19
25
  } = require('./state');
20
26
 
21
27
  function resolveAgentId(agentId) {
@@ -235,10 +241,165 @@ function readProjectManifest(rootDir) {
235
241
  return readManifest(manifestPath);
236
242
  }
237
243
 
244
+ /**
245
+ * Install EHA files to the user's device (global/user-level directories).
246
+ *
247
+ * @param {Object} options
248
+ * @param {string[]} options.agentIds - Array of agent IDs to install
249
+ * @param {string} [options.homeDir] - Override home dir (for testing)
250
+ * @returns {Object} Result with per-agent file lists and summary
251
+ */
252
+ function installDevice({ agentIds, homeDir }) {
253
+ const home = homeDir || os.homedir();
254
+ const workflows = listWorkflows();
255
+ const skills = listSkills();
256
+ const results = {};
257
+
258
+ for (const agentId of agentIds) {
259
+ const normalizedId = resolveAgentId(agentId);
260
+ const adapter = getRuntimeAdapter(normalizedId);
261
+
262
+ if (typeof adapter.generateDeviceFiles !== 'function') {
263
+ throw new Error(`Agent '${normalizedId}' does not support device-level installation.`);
264
+ }
265
+
266
+ const files = adapter.generateDeviceFiles(home, workflows, skills);
267
+ const written = [];
268
+
269
+ for (const file of files) {
270
+ if (file.isSentinel) {
271
+ const action = upsertSentinelBlock(file.absolutePath, file.content);
272
+ written.push({
273
+ absolutePath: file.absolutePath,
274
+ displayPath: file.absolutePath.replace(home, '~'),
275
+ action,
276
+ isSentinel: true,
277
+ });
278
+ } else {
279
+ ensureDir(path.dirname(file.absolutePath));
280
+ writeText(file.absolutePath, file.content);
281
+ written.push({
282
+ absolutePath: file.absolutePath,
283
+ displayPath: file.absolutePath.replace(home, '~'),
284
+ action: 'written',
285
+ isSentinel: false,
286
+ });
287
+ }
288
+ }
289
+
290
+ results[normalizedId] = {
291
+ agentId: normalizedId,
292
+ files: written,
293
+ fileCount: written.length,
294
+ };
295
+ }
296
+
297
+ // Write/update device manifest
298
+ const existingManifest = readDeviceManifest(home);
299
+ const existingAgents = existingManifest.agents || [];
300
+
301
+ const updatedAgents = [...existingAgents];
302
+ for (const agentId of agentIds) {
303
+ const normalizedId = resolveAgentId(agentId);
304
+ const idx = updatedAgents.findIndex(a => a.id === normalizedId);
305
+ const entry = {
306
+ id: normalizedId,
307
+ files: results[normalizedId].files.map(f => f.absolutePath),
308
+ updatedAt: new Date().toISOString(),
309
+ packageVersion: EHA_PACKAGE_VERSION,
310
+ };
311
+ if (idx !== -1) updatedAgents[idx] = entry;
312
+ else updatedAgents.push(entry);
313
+ }
314
+
315
+ const allFiles = new Set();
316
+ for (const agent of updatedAgents) {
317
+ for (const f of agent.files || []) allFiles.add(f);
318
+ }
319
+
320
+ writeDeviceManifest({
321
+ manifestVersion: 1,
322
+ agents: updatedAgents,
323
+ files: [...allFiles],
324
+ installedAt: existingManifest.installedAt || new Date().toISOString(),
325
+ updatedAt: new Date().toISOString(),
326
+ packageVersion: EHA_PACKAGE_VERSION,
327
+ }, home);
328
+
329
+ return {
330
+ homeDir: home,
331
+ agentIds: agentIds.map(a => resolveAgentId(a)),
332
+ results,
333
+ totalFiles: Object.values(results).reduce((sum, r) => sum + r.fileCount, 0),
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Remove EHA device-level files.
339
+ * If agentId is provided, removes only that agent's files.
340
+ * If agentId is null, removes all device-level EHA files.
341
+ */
342
+ function uninstallDevice({ agentId = null, homeDir } = {}) {
343
+ const home = homeDir || os.homedir();
344
+ const manifest = readDeviceManifest(home);
345
+ const removedFiles = [];
346
+
347
+ if (!manifest.agents || manifest.agents.length === 0) {
348
+ return { homeDir: home, removedFiles, message: 'No device-level EHA installation found.' };
349
+ }
350
+
351
+ const agentsToRemove = agentId
352
+ ? manifest.agents.filter(a => a.id === resolveAgentId(agentId))
353
+ : manifest.agents;
354
+
355
+ for (const agent of agentsToRemove) {
356
+ for (const filePath of agent.files || []) {
357
+ const basename = path.basename(filePath);
358
+ // Sentinel files: GEMINI.md is current; CLAUDE.md is legacy (pre-1.0.10 installs)
359
+ if (basename === 'CLAUDE.md' || basename === 'GEMINI.md') {
360
+ const removed = removeSentinelBlock(filePath, home);
361
+ if (removed) removedFiles.push(filePath.replace(home, '~'));
362
+ } else {
363
+ removeFileIfExists(filePath);
364
+ removeEmptyParents(path.dirname(filePath), home);
365
+ removedFiles.push(filePath.replace(home, '~'));
366
+ }
367
+ }
368
+ }
369
+
370
+ if (agentId) {
371
+ const remainingAgents = manifest.agents.filter(a => a.id !== resolveAgentId(agentId));
372
+ if (remainingAgents.length === 0) {
373
+ removeFileIfExists(getDeviceManifestPath(home));
374
+ removeEmptyParents(path.dirname(getDeviceManifestPath(home)), home);
375
+ } else {
376
+ const allFiles = new Set();
377
+ for (const a of remainingAgents) {
378
+ for (const f of a.files || []) allFiles.add(f);
379
+ }
380
+ writeDeviceManifest({
381
+ manifestVersion: 1,
382
+ agents: remainingAgents,
383
+ files: [...allFiles],
384
+ installedAt: manifest.installedAt,
385
+ updatedAt: new Date().toISOString(),
386
+ packageVersion: EHA_PACKAGE_VERSION,
387
+ }, home);
388
+ }
389
+ } else {
390
+ removeFileIfExists(getDeviceManifestPath(home));
391
+ removeEmptyParents(path.dirname(getDeviceManifestPath(home)), home);
392
+ }
393
+
394
+ return { homeDir: home, removedFiles };
395
+ }
396
+
238
397
  module.exports = {
239
398
  SUPPORTED_AGENT_IDS,
240
399
  doctor,
241
400
  initProject,
401
+ installDevice,
242
402
  readProjectManifest,
243
403
  removeProject,
404
+ uninstallDevice,
244
405
  };
@@ -200,6 +200,57 @@ When a user asks to run an EHA workflow, use the matching prompt file below.
200
200
  ${workflowTable}`;
201
201
  }
202
202
 
203
+ function buildDeviceRulesContent(agentId, workflows) {
204
+ const rulesContent = loadRuleContent(agentId);
205
+
206
+ let routingSection = '';
207
+ if (agentId === 'claude') {
208
+ const routes = workflows
209
+ .map(w => `- \`${w.commandName}\` → \`~/.claude/commands/eha/eha-${w.commandName}.md\``)
210
+ .join('\n');
211
+ routingSection = `\n\n# EHA Workflow Routing\n\nWhen a user asks to run an EHA workflow, use the matching command file:\n\n${routes}`;
212
+ } else if (agentId === 'antigravity') {
213
+ const routes = workflows
214
+ .map(w => `- \`${w.commandName}\` → \`~/.gemini/config/global_workflows/eha-${w.commandName}.md\``)
215
+ .join('\n');
216
+ routingSection = `\n\n# EHA Workflow Routing\n\nWhen a user asks to run an EHA workflow, use the matching workflow file:\n\n${routes}`;
217
+ }
218
+
219
+ return `${rulesContent}${routingSection}`;
220
+ }
221
+
222
+ function buildCopilotDeviceSkillFile(skill) {
223
+ return `---
224
+ name: "eha-${skill.commandName}"
225
+ description: "EHA skill — ${skill.commandName}"
226
+ ---
227
+
228
+ ${EHA_COMPACT_RULES}
229
+
230
+ ---
231
+
232
+ ${loadSkillContent(skill)}`;
233
+ }
234
+
235
+ function buildCopilotDeviceInstructionsFile(workflows) {
236
+ const workflowTable = workflows
237
+ .map(w => `- \`${w.commandName}\` → \`~/.copilot/prompts/eha-${w.commandName}.prompt.md\``)
238
+ .join('\n');
239
+
240
+ return `---
241
+ description: "EHA workflow routing and agent rules for GitHub Copilot"
242
+ applyTo: "**"
243
+ ---
244
+
245
+ ${loadRuleContent('copilot')}
246
+
247
+ # EHA Workflow Routing
248
+
249
+ When a user asks to run an EHA workflow, use the matching prompt file:
250
+
251
+ ${workflowTable}`;
252
+ }
253
+
203
254
  const RUNTIME_ADAPTERS = {
204
255
  claude: {
205
256
  id: 'claude',
@@ -225,6 +276,36 @@ const RUNTIME_ADAPTERS = {
225
276
  content: buildClaudeRuleFile(),
226
277
  });
227
278
 
279
+ return files;
280
+ },
281
+ generateDeviceFiles(homeDir, workflows, skills) {
282
+ const files = [];
283
+
284
+ // Commands → ~/.claude/commands/eha/
285
+ for (const workflow of workflows) {
286
+ files.push({
287
+ absolutePath: path.join(homeDir, '.claude', 'commands', 'eha', `eha-${workflow.commandName}.md`),
288
+ content: buildClaudeCommandFile(workflow),
289
+ isSentinel: false,
290
+ });
291
+ }
292
+
293
+ // Skills → ~/.claude/skills/eha-<name>/SKILL.md
294
+ for (const skill of skills) {
295
+ files.push({
296
+ absolutePath: path.join(homeDir, '.claude', 'skills', `eha-${skill.commandName}`, 'SKILL.md'),
297
+ content: buildClaudeSkillFile(skill),
298
+ isSentinel: false,
299
+ });
300
+ }
301
+
302
+ // Rules → ~/.claude/rules/eha-agent-rules.md (own file, not sentinel)
303
+ files.push({
304
+ absolutePath: path.join(homeDir, '.claude', 'rules', 'eha-agent-rules.md'),
305
+ content: buildDeviceRulesContent('claude', workflows),
306
+ isSentinel: false,
307
+ });
308
+
228
309
  return files;
229
310
  },
230
311
  },
@@ -247,7 +328,7 @@ const RUNTIME_ADAPTERS = {
247
328
 
248
329
  for (const skill of skills) {
249
330
  files.push({
250
- relativePath: path.join('.github', 'prompts', 'skills', `eha-${skill.commandName}.prompt.md`),
331
+ relativePath: path.join('.github', 'skills', `eha-${skill.commandName}`, 'SKILL.md'),
251
332
  content: buildCopilotSkillFile(skill),
252
333
  });
253
334
  }
@@ -257,18 +338,48 @@ const RUNTIME_ADAPTERS = {
257
338
  content: buildCopilotRuleFile(),
258
339
  });
259
340
 
341
+ return files;
342
+ },
343
+ generateDeviceFiles(homeDir, workflows, skills) {
344
+ const files = [];
345
+
346
+ // Prompts → ~/.copilot/prompts/eha-<name>.prompt.md
347
+ for (const workflow of workflows) {
348
+ files.push({
349
+ absolutePath: path.join(homeDir, '.copilot', 'prompts', `eha-${workflow.commandName}.prompt.md`),
350
+ content: buildCopilotPromptFile(workflow),
351
+ isSentinel: false,
352
+ });
353
+ }
354
+
355
+ // Skills → ~/.copilot/skills/eha-<name>/SKILL.md
356
+ for (const skill of skills) {
357
+ files.push({
358
+ absolutePath: path.join(homeDir, '.copilot', 'skills', `eha-${skill.commandName}`, 'SKILL.md'),
359
+ content: buildCopilotDeviceSkillFile(skill),
360
+ isSentinel: false,
361
+ });
362
+ }
363
+
364
+ // Instructions → ~/.copilot/instructions/eha.instructions.md (own file, NOT sentinel)
365
+ files.push({
366
+ absolutePath: path.join(homeDir, '.copilot', 'instructions', 'eha.instructions.md'),
367
+ content: buildCopilotDeviceInstructionsFile(workflows),
368
+ isSentinel: false,
369
+ });
370
+
260
371
  return files;
261
372
  },
262
373
  },
263
374
  antigravity: {
264
375
  id: 'antigravity',
265
376
  name: 'Antigravity',
266
- description: 'Generates Antigravity-compatible skills in .agents/skills/ and rules in .agents/rules/',
377
+ description: 'Generates Antigravity-compatible workflows in .agents/workflows/, skills in .agents/skills/, and rules in .agents/rules/',
267
378
  generateFiles(rootDir, workflows, skills) {
268
379
  const files = [];
269
380
  for (const workflow of workflows) {
270
381
  files.push({
271
- relativePath: path.join('.agents', 'skills', `eha-${workflow.commandName}`, 'SKILL.md'),
382
+ relativePath: path.join('.agents', 'workflows', `eha-${workflow.commandName}`, 'SKILL.md'),
272
383
  content: buildAntigravityCommandFile(workflow),
273
384
  });
274
385
  }
@@ -284,6 +395,34 @@ const RUNTIME_ADAPTERS = {
284
395
  content: buildAntigravityRuleFile(),
285
396
  });
286
397
 
398
+ return files;
399
+ },
400
+ generateDeviceFiles(homeDir, workflows, skills) {
401
+ const files = [];
402
+
403
+ // Workflows → ~/.gemini/config/global_workflows/eha-<name>.md
404
+ for (const workflow of workflows) {
405
+ files.push({
406
+ absolutePath: path.join(homeDir, '.gemini', 'config', 'global_workflows', `eha-${workflow.commandName}.md`),
407
+ content: buildAntigravityCommandFile(workflow),
408
+ isSentinel: false,
409
+ });
410
+ }
411
+ for (const skill of skills) {
412
+ files.push({
413
+ absolutePath: path.join(homeDir, '.gemini', 'skills', `eha-${skill.commandName}`, 'SKILL.md'),
414
+ content: buildAntigravitySkillFile(skill),
415
+ isSentinel: false,
416
+ });
417
+ }
418
+
419
+ // Rules → ~/.gemini/GEMINI.md (sentinel block)
420
+ files.push({
421
+ absolutePath: path.join(homeDir, '.gemini', 'GEMINI.md'),
422
+ content: buildDeviceRulesContent('antigravity', workflows),
423
+ isSentinel: true,
424
+ });
425
+
287
426
  return files;
288
427
  },
289
428
  },
@@ -1,5 +1,6 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
+ const os = require('node:os');
3
4
 
4
5
  const ROOT_MARKERS = ['package.json', '.git'];
5
6
  const DEFAULT_CONFIG = {
@@ -122,17 +123,100 @@ function removeEmptyParents(startPath, stopPath) {
122
123
  }
123
124
  }
124
125
 
126
+ const EHA_SENTINEL_START = '<!-- EHA:START — managed by eye-hate-agent, do not edit manually -->';
127
+ const EHA_SENTINEL_END = '<!-- EHA:END -->';
128
+
129
+ function getDeviceManifestPath(homeDir) {
130
+ return path.join(homeDir || os.homedir(), '.eha', 'manifest.json');
131
+ }
132
+
133
+ function readDeviceManifest(homeDir) {
134
+ const manifestPath = getDeviceManifestPath(homeDir);
135
+ return readJsonIfExists(manifestPath) || {
136
+ manifestVersion: 1,
137
+ agents: [],
138
+ files: [],
139
+ installedAt: null,
140
+ updatedAt: null,
141
+ packageVersion: null,
142
+ };
143
+ }
144
+
145
+ function writeDeviceManifest(manifest, homeDir) {
146
+ const manifestPath = getDeviceManifestPath(homeDir);
147
+ writeJson(manifestPath, manifest);
148
+ }
149
+
150
+ function deviceManifestExists(homeDir) {
151
+ return fs.existsSync(getDeviceManifestPath(homeDir));
152
+ }
153
+
154
+ function upsertSentinelBlock(filePath, content) {
155
+ const block = `${EHA_SENTINEL_START}\n${content}\n${EHA_SENTINEL_END}`;
156
+
157
+ if (!fs.existsSync(filePath)) {
158
+ ensureDir(path.dirname(filePath));
159
+ fs.writeFileSync(filePath, block + '\n');
160
+ return 'created';
161
+ }
162
+
163
+ const existing = fs.readFileSync(filePath, 'utf8');
164
+ const startIdx = existing.indexOf(EHA_SENTINEL_START);
165
+ const endIdx = existing.indexOf(EHA_SENTINEL_END);
166
+
167
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
168
+ const before = existing.substring(0, startIdx);
169
+ const after = existing.substring(endIdx + EHA_SENTINEL_END.length);
170
+ fs.writeFileSync(filePath, before + block + after);
171
+ return 'updated';
172
+ }
173
+
174
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
175
+ fs.writeFileSync(filePath, existing + separator + block + '\n');
176
+ return 'appended';
177
+ }
178
+
179
+ function removeSentinelBlock(filePath, stopPath) {
180
+ if (!fs.existsSync(filePath)) return false;
181
+
182
+ const existing = fs.readFileSync(filePath, 'utf8');
183
+ const startIdx = existing.indexOf(EHA_SENTINEL_START);
184
+ const endIdx = existing.indexOf(EHA_SENTINEL_END);
185
+
186
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return false;
187
+
188
+ const before = existing.substring(0, startIdx);
189
+ const after = existing.substring(endIdx + EHA_SENTINEL_END.length);
190
+ const result = (before + after).replace(/\n{3,}/g, '\n\n').trim();
191
+
192
+ if (!result) {
193
+ fs.unlinkSync(filePath);
194
+ removeEmptyParents(path.dirname(filePath), stopPath || os.homedir());
195
+ } else {
196
+ fs.writeFileSync(filePath, result + '\n');
197
+ }
198
+ return true;
199
+ }
200
+
125
201
  module.exports = {
202
+ EHA_SENTINEL_START,
203
+ EHA_SENTINEL_END,
204
+ deviceManifestExists,
126
205
  ensureDir,
127
206
  findRepoRoot,
128
207
  getBundledAssetPath,
208
+ getDeviceManifestPath,
129
209
  getEnginePaths,
130
210
  getPackageRoot,
131
211
  readConfig,
212
+ readDeviceManifest,
132
213
  readJsonIfExists,
133
214
  removeEmptyParents,
134
215
  removeFileIfExists,
216
+ removeSentinelBlock,
217
+ upsertSentinelBlock,
135
218
  writeConfig,
219
+ writeDeviceManifest,
136
220
  writeJson,
137
221
  writeText,
138
222
  };