@exchanet/enet 1.0.9 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exchanet/enet",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "enet — exchanet methods manager. Install, scaffold and manage AI coding methods.",
5
5
  "bin": {
6
6
  "enet": "src/index.js"
@@ -4,7 +4,7 @@ import fs from 'fs-extra'
4
4
  import path from 'path'
5
5
  import readline from 'readline'
6
6
  import { detectSystemAgents, getInstallPath, AGENTS } from '../utils/agent-detector.js'
7
- import { getMethod, fetchFromGitHub } from '../utils/registry.js'
7
+ import { getMethod, fetchFromGitHub, readInstallRecord, writeInstallRecord } from '../utils/registry.js'
8
8
 
9
9
  export async function installCommand(methodId, options) {
10
10
  // 1. Load method from registry
@@ -21,11 +21,19 @@ export async function installCommand(methodId, options) {
21
21
  console.log(chalk.bold(`\n ◆ ${method.name}`))
22
22
  console.log(chalk.dim(` ${method.description}\n`))
23
23
 
24
- // 2. Determine target agents
24
+ // 2. Read existing install record — know what is already installed
25
+ const record = await readInstallRecord(methodId)
26
+ const alreadyInstalled = new Set(record?.agents ?? [])
27
+
28
+ if (alreadyInstalled.size > 0) {
29
+ console.log(chalk.dim(` Already installed for: ${[...alreadyInstalled].join(', ')}\n`))
30
+ }
31
+
32
+ // 3. Determine target agents
25
33
  let targetAgents = []
26
34
 
27
35
  if (options.agent) {
28
- // --agent flag: skip detection, install for this specific agent only
36
+ // --agent flag: bypass detection and checkbox entirely
29
37
  if (!AGENTS[options.agent]) {
30
38
  console.log(chalk.red(` ✗ Unknown agent: "${options.agent}"`))
31
39
  console.log(chalk.dim(` Valid: ${Object.keys(AGENTS).filter(k => k !== 'generic').join(', ')}\n`))
@@ -34,23 +42,20 @@ export async function installCommand(methodId, options) {
34
42
  targetAgents = [{ key: options.agent, ...AGENTS[options.agent] }]
35
43
 
36
44
  } else {
37
- // Detect all agents installed on the system
38
45
  const detected = await detectSystemAgents()
39
46
 
40
47
  if (detected.length === 0) {
41
48
  console.log(chalk.yellow(' ⚠ No AI agents detected on this system.'))
42
- console.log(chalk.dim(` Use ${chalk.white('--agent <name>')} to force an agent.`))
49
+ console.log(chalk.dim(` Use ${chalk.white('--agent <n>')} to force an agent.`))
43
50
  console.log(chalk.dim(` Valid: ${Object.keys(AGENTS).filter(k => k !== 'generic').join(', ')}\n`))
44
51
  process.exit(1)
45
52
  }
46
53
 
47
- // 3. Show checkbox selection — always, even with 1 agent detected
48
- // This is the core UX fix: user always chooses, never surprised
49
54
  if (options.all) {
50
- // --all flag skips the prompt
51
55
  targetAgents = detected
52
56
  } else {
53
- targetAgents = await checkboxSelect(detected, method)
57
+ // Always show checkbox — even with 1 agent, even on re-run
58
+ targetAgents = await checkboxSelect(detected, method, alreadyInstalled)
54
59
  }
55
60
 
56
61
  if (targetAgents.length === 0) {
@@ -61,66 +66,74 @@ export async function installCommand(methodId, options) {
61
66
 
62
67
  console.log()
63
68
 
64
- // 4. Install for each selected agent
69
+ // 4. Install each selected agent
70
+ const newlyInstalled = []
65
71
  let schemaInstalled = false
72
+
66
73
  for (const agent of targetAgents) {
67
- await installForAgent(method, agent, options, schemaInstalled)
74
+ const ok = await installForAgent(method, agent, options, schemaInstalled)
68
75
  schemaInstalled = true
76
+ if (ok) newlyInstalled.push(agent.key)
69
77
  }
70
78
 
79
+ // 5. Persist updated install record
80
+ const updatedAgents = [...new Set([...alreadyInstalled, ...newlyInstalled])]
81
+ await writeInstallRecord(methodId, { agents: updatedAgents, version: method.version })
82
+
71
83
  printHints(methodId)
72
84
  }
73
85
 
74
86
  // ─────────────────────────────────────────────────────────────────
75
87
  // Checkbox selection UI
88
+ //
89
+ // Shows all detected agents with tags:
90
+ // — installed already present on disk for this method
91
+ // — new detected but never installed for this method
76
92
  // ─────────────────────────────────────────────────────────────────
77
93
 
78
- async function checkboxSelect(detected, method) {
79
- // Build list: detected agents that have an adapter in this method come first,
80
- // then show unavailable ones as disabled so user knows what exists
81
- const available = detected.filter(a => method.adapters[a.key] || method.adapters['generic'])
94
+ async function checkboxSelect(detected, method, alreadyInstalled = new Set()) {
95
+ const available = detected.filter(a => method.adapters[a.key] || method.adapters['generic'])
82
96
  const unavailable = detected.filter(a => !method.adapters[a.key] && !method.adapters['generic'])
83
97
 
84
98
  if (available.length === 0) {
85
99
  console.log(chalk.yellow(' ⚠ No adapters available for your detected agents.'))
86
- console.log(chalk.dim(` Available adapters in this method: ${Object.keys(method.adapters).join(', ')}\n`))
100
+ console.log(chalk.dim(` Adapters in this method: ${Object.keys(method.adapters).join(', ')}\n`))
87
101
  process.exit(1)
88
102
  }
89
103
 
90
- // Initial state: all available agents pre-checked
91
104
  const items = available.map(a => ({
92
- agent: a,
93
- checked: true,
105
+ agent: a,
106
+ checked: true,
107
+ installed: alreadyInstalled.has(a.key),
94
108
  usesGeneric: !method.adapters[a.key]
95
109
  }))
96
110
 
97
- console.log(chalk.white(' Agents detected on your system:\n'))
111
+ console.log(chalk.white(' Select adapters to install:\n'))
98
112
 
99
113
  return new Promise((resolve) => {
100
114
  let cursor = 0
101
115
 
116
+ const lineCount = () => items.length + (unavailable.length > 0 ? 3 : 2) + 2
117
+
102
118
  const render = () => {
103
- // Move cursor up to redraw (after first render)
104
- const lines = items.length + 6
105
- if (render.drawn) process.stdout.write(`\x1B[${lines}A`)
119
+ if (render.drawn) process.stdout.write(`\x1B[${lineCount()}A`)
106
120
  render.drawn = true
107
121
 
108
122
  items.forEach((item, i) => {
109
123
  const isCursor = i === cursor
110
- const box = item.checked ? chalk.green('[✓]') : chalk.dim('[ ]')
111
- const arrow = isCursor ? chalk.cyan(' ❯ ') : ' '
112
- const name = isCursor ? chalk.white(item.agent.name) : chalk.dim(item.agent.name)
113
- const tag = item.usesGeneric ? chalk.dim(' (generic adapter)') : ''
114
- process.stdout.write(`${arrow}${box} ${name}${tag}\n`)
124
+ const box = item.checked ? chalk.green('[✓]') : chalk.dim('[ ]')
125
+ const arrow = isCursor ? chalk.cyan(' ❯ ') : ' '
126
+ const name = isCursor ? chalk.white(item.agent.name) : chalk.dim(item.agent.name)
127
+ const status = item.installed ? chalk.dim(' installed') : chalk.yellow(' — new')
128
+ const generic = item.usesGeneric ? chalk.dim(' (generic adapter)') : ''
129
+ process.stdout.write(`${arrow}${box} ${name}${status}${generic}\n`)
115
130
  })
116
131
 
117
132
  if (unavailable.length > 0) {
118
- process.stdout.write(chalk.dim(`\n Not available for: ${unavailable.map(a => a.name).join(', ')}\n`))
119
- } else {
120
- process.stdout.write('\n')
133
+ process.stdout.write(chalk.dim(`\n No adapter for: ${unavailable.map(a => a.name).join(', ')}\n`))
121
134
  }
122
-
123
- process.stdout.write(chalk.dim(' ↑↓ navigate · Space toggle · A select all · Enter confirm\n\n'))
135
+ process.stdout.write('\n')
136
+ process.stdout.write(chalk.dim(' ↑↓ navigate · Space toggle · A all · Enter confirm · Ctrl+C cancel\n\n'))
124
137
  }
125
138
 
126
139
  render()
@@ -131,28 +144,25 @@ async function checkboxSelect(detected, method) {
131
144
 
132
145
  process.stdin.on('data', (key) => {
133
146
  const k = key.toString()
134
-
135
- if (k === '\u001b[A') { // arrow up
136
- cursor = (cursor - 1 + items.length) % items.length
147
+ if (k === '\u001b[A') { cursor = (cursor - 1 + items.length) % items.length; render() }
148
+ else if (k === '\u001b[B') { cursor = (cursor + 1) % items.length; render() }
149
+ else if (k === ' ') { items[cursor].checked = !items[cursor].checked; render() }
150
+ else if (k === 'a' || k === 'A') {
151
+ const all = items.every(i => i.checked)
152
+ items.forEach(i => { i.checked = !all })
137
153
  render()
138
- } else if (k === '\u001b[B') { // arrow down
139
- cursor = (cursor + 1) % items.length
140
- render()
141
- } else if (k === ' ') { // space: toggle current
142
- items[cursor].checked = !items[cursor].checked
143
- render()
144
- } else if (k === 'a' || k === 'A') { // A: toggle all
145
- const allChecked = items.every(i => i.checked)
146
- items.forEach(i => { i.checked = !allChecked })
147
- render()
148
- } else if (k === '\r' || k === '\n') { // Enter: confirm
154
+ }
155
+ else if (k === '\r' || k === '\n') {
149
156
  if (process.stdin.isTTY) process.stdin.setRawMode(false)
150
157
  process.stdin.pause()
151
158
  rl.close()
152
159
  const selected = items.filter(i => i.checked).map(i => i.agent)
153
- console.log(chalk.dim(`\n Installing for: ${selected.map(a => a.name).join(', ')}\n`))
160
+ if (selected.length > 0) {
161
+ console.log(chalk.dim(`\n Installing for: ${selected.map(a => a.name).join(', ')}\n`))
162
+ }
154
163
  resolve(selected)
155
- } else if (k === '\u0003') { // Ctrl+C
164
+ }
165
+ else if (k === '\u0003') {
156
166
  if (process.stdin.isTTY) process.stdin.setRawMode(false)
157
167
  process.stdin.pause()
158
168
  rl.close()
@@ -165,41 +175,44 @@ async function checkboxSelect(detected, method) {
165
175
 
166
176
  // ─────────────────────────────────────────────────────────────────
167
177
  // Install for one agent
178
+ // Exported so update.js can reuse it without duplication
179
+ // Returns true on success, false on failure/skip
168
180
  // ─────────────────────────────────────────────────────────────────
169
181
 
170
- async function installForAgent(method, agent, options, skipExtras = false) {
171
- const isGlobal = options.global || false
172
-
173
- // Use the agent-specific adapter if available, fall back to generic
174
- const adapterKey = method.adapters[agent.key] ? agent.key : 'generic'
182
+ export async function installForAgent(method, agent, options = {}, skipExtras = false) {
183
+ const isGlobal = options.global || false
184
+ const adapterKey = method.adapters[agent.key] ? agent.key : 'generic'
175
185
  const adapterPath = method.adapters[adapterKey]
176
186
 
177
187
  if (!adapterPath) {
178
188
  console.log(chalk.yellow(` ⚠ ${agent.name} — no adapter found, skipping`))
179
- return
189
+ return false
180
190
  }
181
191
 
182
192
  if (isGlobal && !agent.globalInstallDir) {
183
193
  console.log(chalk.yellow(` ⚠ ${agent.name} — global install not supported, skipping`))
184
- return
194
+ return false
185
195
  }
186
196
 
187
- const spinner = ora(`Installing for ${agent.name}${isGlobal ? ' (global)' : ''}...`).start()
197
+ const spinner = ora(`${agent.name}${isGlobal ? ' (global)' : ''}...`).start()
188
198
 
189
199
  try {
190
- const content = await fetchFromGitHub(method.repo, adapterPath)
200
+ const content = await fetchFromGitHub(method.repo, adapterPath)
191
201
  const installPath = getInstallPath(agent, method.id, { global: isGlobal })
192
202
 
193
203
  await fs.ensureDir(path.dirname(installPath))
194
204
 
195
- // Windsurf: append to .windsurfrules instead of overwriting
205
+ // Windsurf: append to .windsurfrules replace existing section if already present
196
206
  if (agent.key === 'windsurf' && await fs.pathExists(installPath)) {
197
207
  const existing = await fs.readFile(installPath, 'utf8')
198
208
  if (existing.includes(method.name)) {
199
- spinner.warn(chalk.yellow(`${agent.name} already installed`))
200
- return
209
+ const marker = '\n\n---\n\n'
210
+ const idx = existing.indexOf(method.name)
211
+ const before = existing.substring(0, existing.lastIndexOf(marker, idx) + marker.length)
212
+ await fs.writeFile(installPath, before + content)
213
+ } else {
214
+ await fs.appendFile(installPath, `\n\n---\n\n${content}`)
201
215
  }
202
- await fs.appendFile(installPath, `\n\n---\n\n${content}`)
203
216
  } else {
204
217
  await fs.writeFile(installPath, content)
205
218
  }
@@ -210,24 +223,25 @@ async function installForAgent(method, agent, options, skipExtras = false) {
210
223
 
211
224
  } catch (err) {
212
225
  spinner.fail(chalk.red(`${agent.name} — ${err.message}`))
213
- return
226
+ return false
214
227
  }
215
228
 
216
- // Download extras (schema, etc.) — only once across all agents
229
+ // Extras (schema, manifests…) — downloaded once across all agents
217
230
  if (!skipExtras && method.extras) {
218
231
  for (const [key, filePath] of Object.entries(method.extras)) {
219
- const extraSpinner = ora(`Fetching ${key}...`).start()
232
+ const s = ora(`Fetching ${key}...`).start()
220
233
  try {
221
- const extraContent = await fetchFromGitHub(method.repo, filePath)
222
- const extraOut = path.join(process.cwd(), path.basename(filePath))
223
- await fs.writeFile(extraOut, extraContent)
224
- extraSpinner.succeed(chalk.dim(`${key} → ${path.basename(filePath)}`))
234
+ const content = await fetchFromGitHub(method.repo, filePath)
235
+ await fs.writeFile(path.join(process.cwd(), path.basename(filePath)), content)
236
+ s.succeed(chalk.dim(`${key} → ${path.basename(filePath)}`))
225
237
  } catch {
226
- extraSpinner.warn(chalk.dim(`${key} not available (non-critical)`))
238
+ s.warn(chalk.dim(`${key} not available (non-critical)`))
227
239
  }
228
240
  }
229
241
  console.log()
230
242
  }
243
+
244
+ return true
231
245
  }
232
246
 
233
247
  // ─────────────────────────────────────────────────────────────────
@@ -241,7 +255,7 @@ function printHints(methodId) {
241
255
  console.log(chalk.dim(' 2. Agent declares architecture — confirm it'))
242
256
  console.log(chalk.dim(' 3. Agent builds Core → modules → Admin Panel'))
243
257
  console.log()
244
- console.log(chalk.dim(` ${chalk.white('enet new module <name>')} scaffold your first module`))
258
+ console.log(chalk.dim(` ${chalk.white('enet new module <n>')} scaffold your first module`))
245
259
  console.log(chalk.dim(` ${chalk.white('enet validate')} check manifests at any time\n`))
246
260
  }
247
261
  if (methodId === 'pdca-t') {
@@ -1,12 +1,18 @@
1
1
  import chalk from 'chalk'
2
2
  import ora from 'ora'
3
3
  import fs from 'fs-extra'
4
- import { getAllMethods, getMethod, fetchFromGitHub } from '../utils/registry.js'
5
- import { detectAgent, getInstallPath } from '../utils/agent-detector.js'
4
+ import path from 'path'
5
+ import readline from 'readline'
6
+ import { getAllMethods, getMethod, fetchFromGitHub, readInstallRecord, writeInstallRecord } from '../utils/registry.js'
7
+ import { detectSystemAgents, getInstallPath, AGENTS } from '../utils/agent-detector.js'
8
+ import { installForAgent } from './install.js'
6
9
 
7
10
  export async function updateCommand(methodId, options) {
8
11
  const spinner = ora('Fetching registry...').start()
9
- const [allMethods, agent] = await Promise.all([getAllMethods(), detectAgent()])
12
+ const [allMethods, detectedAgents] = await Promise.all([
13
+ getAllMethods(),
14
+ detectSystemAgents()
15
+ ])
10
16
  spinner.stop()
11
17
 
12
18
  const targets = methodId
@@ -18,30 +24,191 @@ export async function updateCommand(methodId, options) {
18
24
  process.exit(1)
19
25
  }
20
26
 
21
- console.log(chalk.white(`\n Updating methods...\n`))
27
+ console.log(chalk.bold('\n enet update\n'))
22
28
 
23
- let updated = 0, skipped = 0
29
+ let totalUpdated = 0, totalAdded = 0, totalSkipped = 0
24
30
 
25
31
  for (const method of targets) {
26
- const installPath = getInstallPath(agent, method.id)
27
- if (!await fs.pathExists(installPath)) { skipped++; continue }
32
+ const record = await readInstallRecord(method.id)
28
33
 
29
- const s = ora(`Updating ${method.name}...`).start()
30
- try {
31
- const adapterPath = method.adapters[agent.key] ?? method.adapters.generic
32
- const content = await fetchFromGitHub(method.repo, adapterPath)
33
- await fs.writeFile(installPath, content)
34
- s.succeed(chalk.green(`${method.name} updated`))
35
- updated++
36
- } catch (err) {
37
- s.fail(chalk.yellow(`${method.name} ${err.message}`))
34
+ // Skip methods not installed at all
35
+ if (!record || record.agents.length === 0) {
36
+ totalSkipped++
37
+ console.log(chalk.dim(` ${method.name} not installed, skipping`))
38
+ continue
39
+ }
40
+
41
+ console.log(chalk.white(` ${method.name}`))
42
+ console.log(chalk.dim(` Installed for: ${record.agents.join(', ')}\n`))
43
+
44
+ // Agents already installed for this method
45
+ const installedAgents = record.agents
46
+ .map(key => AGENTS[key] ? { key, ...AGENTS[key] } : null)
47
+ .filter(Boolean)
48
+
49
+ // Agents newly detected on system but NOT yet installed for this method
50
+ const newAgents = detectedAgents.filter(a =>
51
+ !record.agents.includes(a.key) &&
52
+ (method.adapters[a.key] || method.adapters['generic'])
53
+ )
54
+
55
+ // ── 1. Update already-installed adapters ────────────────────
56
+ if (!options.addOnly) {
57
+ for (const agent of installedAgents) {
58
+ const ok = await updateOneAdapter(method, agent, options)
59
+ if (ok) totalUpdated++
60
+ }
61
+ }
62
+
63
+ // ── 2. Offer to add newly detected agents ───────────────────
64
+ if (newAgents.length > 0 && !options.updateOnly) {
65
+ console.log(chalk.yellow(`\n New agents detected since last install:\n`))
66
+
67
+ let agentsToAdd = []
68
+ if (options.all) {
69
+ agentsToAdd = newAgents
70
+ console.log(chalk.dim(` Adding all: ${newAgents.map(a => a.name).join(', ')}\n`))
71
+ } else {
72
+ agentsToAdd = await checkboxSelectNew(newAgents, method)
73
+ }
74
+
75
+ for (const agent of agentsToAdd) {
76
+ const ok = await installForAgent(method, agent, options, true)
77
+ if (ok) {
78
+ totalAdded++
79
+ record.agents.push(agent.key)
80
+ }
81
+ }
82
+
83
+ // Save updated record with newly added agents
84
+ await writeInstallRecord(method.id, { agents: record.agents, version: method.version })
85
+
86
+ } else if (newAgents.length === 0 && !options.addOnly) {
87
+ console.log(chalk.dim(` No new agents detected.\n`))
38
88
  }
39
89
  }
40
90
 
91
+ // ── Summary ──────────────────────────────────────────────────
92
+ console.log(chalk.dim(' ─────────────────────────────'))
93
+ if (totalUpdated > 0) console.log(chalk.green(` ✓ ${totalUpdated} adapter${totalUpdated !== 1 ? 's' : ''} updated`))
94
+ if (totalAdded > 0) console.log(chalk.green(` ✓ ${totalAdded} new adapter${totalAdded !== 1 ? 's' : ''} installed`))
95
+ if (totalSkipped > 0) console.log(chalk.dim(` ${totalSkipped} method${totalSkipped !== 1 ? 's' : ''} not installed, skipped`))
96
+ if (totalUpdated === 0 && totalAdded === 0 && totalSkipped === 0) {
97
+ console.log(chalk.dim(' Everything is up to date.'))
98
+ }
41
99
  console.log()
42
- if (updated === 0 && skipped > 0) {
43
- console.log(chalk.dim(` No methods installed to update.\n`))
44
- } else {
45
- console.log(chalk.dim(` ${updated} updated, ${skipped} not installed\n`))
100
+ }
101
+
102
+ // ─────────────────────────────────────────────────────────────────
103
+ // Update a single already-installed adapter
104
+ // Re-downloads from GitHub and overwrites the local file
105
+ // ─────────────────────────────────────────────────────────────────
106
+
107
+ async function updateOneAdapter(method, agent, options = {}) {
108
+ const isGlobal = options.global || false
109
+ const adapterKey = method.adapters[agent.key] ? agent.key : 'generic'
110
+ const adapterPath = method.adapters[adapterKey]
111
+
112
+ if (!adapterPath) {
113
+ console.log(chalk.dim(` ${agent.name} — no adapter in registry, skipping`))
114
+ return false
115
+ }
116
+
117
+ const installPath = getInstallPath(agent, method.id, { global: isGlobal })
118
+ const exists = await fs.pathExists(installPath)
119
+ const action = exists ? 'Updating' : 'Restoring'
120
+ const s = ora(`${action} ${agent.name}...`).start()
121
+
122
+ try {
123
+ const content = await fetchFromGitHub(method.repo, adapterPath)
124
+ await fs.ensureDir(path.dirname(installPath))
125
+
126
+ // Windsurf: replace existing section, don't append duplicates
127
+ if (agent.key === 'windsurf' && exists) {
128
+ const existing = await fs.readFile(installPath, 'utf8')
129
+ if (existing.includes(method.name)) {
130
+ const marker = '\n\n---\n\n'
131
+ const idx = existing.indexOf(method.name)
132
+ const before = existing.substring(0, existing.lastIndexOf(marker, idx) + marker.length)
133
+ await fs.writeFile(installPath, before + content)
134
+ } else {
135
+ await fs.appendFile(installPath, `\n\n---\n\n${content}`)
136
+ }
137
+ } else {
138
+ await fs.writeFile(installPath, content)
139
+ }
140
+
141
+ s.succeed(chalk.green(`${agent.name} — ${action.toLowerCase()}d`))
142
+ console.log(chalk.dim(` → ${installPath}\n`))
143
+ return true
144
+
145
+ } catch (err) {
146
+ s.fail(chalk.red(`${agent.name} — ${err.message}`))
147
+ return false
46
148
  }
47
149
  }
150
+
151
+ // ─────────────────────────────────────────────────────────────────
152
+ // Checkbox for new agents only (shown during update)
153
+ // ─────────────────────────────────────────────────────────────────
154
+
155
+ async function checkboxSelectNew(newAgents, method) {
156
+ const items = newAgents.map(a => ({
157
+ agent: a,
158
+ checked: true,
159
+ usesGeneric: !method.adapters[a.key]
160
+ }))
161
+
162
+ return new Promise((resolve) => {
163
+ let cursor = 0
164
+ const lineCount = () => items.length + 4
165
+
166
+ const render = () => {
167
+ if (render.drawn) process.stdout.write(`\x1B[${lineCount()}A`)
168
+ render.drawn = true
169
+
170
+ items.forEach((item, i) => {
171
+ const isCursor = i === cursor
172
+ const box = item.checked ? chalk.green('[✓]') : chalk.dim('[ ]')
173
+ const arrow = isCursor ? chalk.cyan(' ❯ ') : ' '
174
+ const name = isCursor ? chalk.white(item.agent.name) : chalk.dim(item.agent.name)
175
+ const generic = item.usesGeneric ? chalk.dim(' (generic adapter)') : ''
176
+ process.stdout.write(`${arrow}${box} ${name}${generic}\n`)
177
+ })
178
+
179
+ process.stdout.write('\n')
180
+ process.stdout.write(chalk.dim(' ↑↓ navigate · Space toggle · A all · Enter confirm · Ctrl+C cancel\n\n'))
181
+ }
182
+
183
+ render()
184
+
185
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
186
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
187
+ process.stdin.resume()
188
+
189
+ process.stdin.on('data', (key) => {
190
+ const k = key.toString()
191
+ if (k === '\u001b[A') { cursor = (cursor - 1 + items.length) % items.length; render() }
192
+ else if (k === '\u001b[B') { cursor = (cursor + 1) % items.length; render() }
193
+ else if (k === ' ') { items[cursor].checked = !items[cursor].checked; render() }
194
+ else if (k === 'a' || k === 'A') {
195
+ const all = items.every(i => i.checked)
196
+ items.forEach(i => { i.checked = !all })
197
+ render()
198
+ }
199
+ else if (k === '\r' || k === '\n') {
200
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
201
+ process.stdin.pause()
202
+ rl.close()
203
+ resolve(items.filter(i => i.checked).map(i => i.agent))
204
+ }
205
+ else if (k === '\u0003') {
206
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
207
+ process.stdin.pause()
208
+ rl.close()
209
+ console.log('\n')
210
+ process.exit(0)
211
+ }
212
+ })
213
+ })
214
+ }
@@ -9,9 +9,9 @@ export const AGENTS = {
9
9
  name: 'Cursor',
10
10
  systemSignals: [
11
11
  path.join(HOME, '.cursor'),
12
- path.join(HOME, 'Library', 'Application Support', 'Cursor'),
13
- path.join(HOME, 'AppData', 'Roaming', 'Cursor'),
14
- path.join(HOME, '.config', 'Cursor'),
12
+ path.join(HOME, 'Library', 'Application Support', 'Cursor'), // macOS
13
+ path.join(HOME, 'AppData', 'Roaming', 'Cursor'), // Windows
14
+ path.join(HOME, '.config', 'Cursor'), // Linux
15
15
  ],
16
16
  projectSignals: ['.cursor/rules', '.cursor'],
17
17
  projectInstallDir: '.cursor/rules',
@@ -24,8 +24,8 @@ export const AGENTS = {
24
24
  name: 'Windsurf',
25
25
  systemSignals: [
26
26
  path.join(HOME, '.codeium', 'windsurf'),
27
- path.join(HOME, 'Library', 'Application Support', 'Windsurf'),
28
- path.join(HOME, 'AppData', 'Roaming', 'Windsurf'),
27
+ path.join(HOME, 'Library', 'Application Support', 'Windsurf'), // macOS
28
+ path.join(HOME, 'AppData', 'Roaming', 'Windsurf'), // Windows
29
29
  ],
30
30
  projectSignals: ['.windsurfrules', '.windsurf'],
31
31
  projectInstallDir: '.',
@@ -39,11 +39,13 @@ export const AGENTS = {
39
39
  name: 'Antigravity (Google)',
40
40
  systemSignals: [
41
41
  path.join(HOME, '.gemini', 'antigravity'),
42
- path.join(HOME, 'Library', 'Application Support', 'Google', 'Antigravity'),
43
- path.join(HOME, 'AppData', 'Roaming', 'Google', 'Antigravity'),
42
+ path.join(HOME, 'Library', 'Application Support', 'Google', 'Antigravity'), // macOS
43
+ path.join(HOME, 'AppData', 'Roaming', 'Google', 'Antigravity'), // Windows
44
44
  ],
45
45
  projectSignals: ['.agent/rules', '.agent'],
46
46
  projectInstallDir: '.agent/rules',
47
+ // globalInstallDir is a base — getInstallPath() resolves the full path dynamically:
48
+ // ~/.gemini/antigravity/skills/method-{id}/SKILL.md
47
49
  globalInstallDir: path.join(HOME, '.gemini', 'antigravity', 'skills'),
48
50
  globalFilename: 'SKILL.md',
49
51
  filename: 'enet-{id}.md',
@@ -65,13 +67,16 @@ export const AGENTS = {
65
67
 
66
68
  copilot: {
67
69
  name: 'GitHub Copilot',
70
+ // Copilot is a VS Code extension — detect by checking the extensions folder
71
+ // for a github.copilot-* subfolder, not just the presence of .vscode/
68
72
  systemSignals: [
69
- path.join(HOME, '.vscode', 'extensions'),
70
- path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'extensions'),
71
- path.join(HOME, 'AppData', 'Roaming', 'Code', 'User', 'extensions'),
72
- path.join(HOME, '.vscode-server', 'extensions'),
73
+ path.join(HOME, '.vscode', 'extensions'), // Linux / Windows
74
+ path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'extensions'), // macOS
75
+ path.join(HOME, 'AppData', 'Roaming', 'Code', 'User', 'extensions'), // Windows alt
76
+ path.join(HOME, '.vscode-server', 'extensions'), // remote / SSH
73
77
  ],
74
78
  systemSignalFilter: (signalPath) => {
79
+ // Only return true if a github.copilot extension folder exists inside
75
80
  try {
76
81
  const entries = fs.readdirSync(signalPath)
77
82
  return entries.some(e => e.toLowerCase().startsWith('github.copilot'))
@@ -81,7 +86,7 @@ export const AGENTS = {
81
86
  },
82
87
  projectSignals: ['.github/copilot-instructions.md'],
83
88
  projectInstallDir: '.github',
84
- globalInstallDir: null,
89
+ globalInstallDir: null, // Copilot has no global rules path
85
90
  filename: 'copilot-instructions.md',
86
91
  configNote: 'Written to .github/copilot-instructions.md'
87
92
  },
@@ -93,10 +98,19 @@ export const AGENTS = {
93
98
  projectInstallDir: '.enet',
94
99
  globalInstallDir: null,
95
100
  filename: '{id}.md',
96
- configNote: "Saved to .enet/ — paste contents into your agent's context"
101
+ configNote: 'Saved to .enet/ — paste contents into your agent\'s context'
97
102
  }
98
103
  }
99
104
 
105
+ // ─────────────────────────────────────────────────────────────────
106
+ // Detection
107
+ // ─────────────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Detects ALL agents installed on the system by checking known global paths.
111
+ * Uses systemSignalFilter for agents that share directories with other software
112
+ * (e.g. Copilot shares VS Code's extensions folder).
113
+ */
100
114
  export async function detectSystemAgents() {
101
115
  const found = []
102
116
  for (const [key, agent] of Object.entries(AGENTS)) {
@@ -109,7 +123,7 @@ export async function detectSystemAgents() {
109
123
  found.push({ key, ...agent })
110
124
  break
111
125
  }
112
- continue
126
+ continue // signal dir exists but filter didn't match — try next signal path
113
127
  }
114
128
  found.push({ key, ...agent })
115
129
  break
@@ -118,6 +132,9 @@ export async function detectSystemAgents() {
118
132
  return found
119
133
  }
120
134
 
135
+ /**
136
+ * Detects ALL agents present in the current project folder.
137
+ */
121
138
  export async function detectProjectAgents(cwd = process.cwd()) {
122
139
  const found = []
123
140
  for (const [key, agent] of Object.entries(AGENTS)) {
@@ -132,16 +149,33 @@ export async function detectProjectAgents(cwd = process.cwd()) {
132
149
  return found
133
150
  }
134
151
 
152
+ /**
153
+ * Returns the first detected agent (legacy — used by status/doctor).
154
+ */
135
155
  export async function detectAgent(cwd = process.cwd()) {
136
156
  const agents = await detectProjectAgents(cwd)
137
157
  return agents[0] ?? { key: 'generic', ...AGENTS.generic }
138
158
  }
139
159
 
160
+ // ─────────────────────────────────────────────────────────────────
161
+ // Path resolution
162
+ // ─────────────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Returns the resolved install path for a given agent + method.
166
+ *
167
+ * Special cases:
168
+ * - antigravity global → ~/.gemini/antigravity/skills/method-{id}/SKILL.md
169
+ * Each method gets its own subfolder (not a shared file).
170
+ * - windsurf global → ~/.codeium/windsurf/memories/global_rules.md
171
+ * - claudecode global → ~/.claude/CLAUDE.md
172
+ */
140
173
  export function getInstallPath(agent, methodId, { global = false, cwd = process.cwd() } = {}) {
141
174
  if (global) {
142
- if (!agent.globalInstallDir) return null
175
+ if (!agent.globalInstallDir) return null // agent doesn't support global install
143
176
 
144
177
  if (agent.key === 'antigravity') {
178
+ // Dynamic subfolder per method: skills/method-{id}/SKILL.md
145
179
  return path.join(agent.globalInstallDir, `method-${methodId}`, 'SKILL.md')
146
180
  }
147
181
 
@@ -149,6 +183,7 @@ export function getInstallPath(agent, methodId, { global = false, cwd = process.
149
183
  return path.join(agent.globalInstallDir, filename)
150
184
  }
151
185
 
186
+ // Project-level install
152
187
  const filename = agent.filename.replace('{id}', methodId)
153
188
  return path.join(cwd, agent.projectInstallDir, filename)
154
189
  }
@@ -4,62 +4,40 @@ import path from 'path'
4
4
  import { fileURLToPath } from 'url'
5
5
 
6
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
- const RAW_BASE = 'https://raw.githubusercontent.com'
7
+ const RAW_BASE = 'https://raw.githubusercontent.com'
8
8
  const REGISTRY_URL = `${RAW_BASE}/exchanet/enet/main/registry.json`
9
9
  const CACHE_FILE = path.join(__dirname, '../../.registry-cache.json')
10
10
  const CACHE_TTL_MS = 1000 * 60 * 60 // 1 hour
11
11
 
12
12
  // ── Registry ──────────────────────────────────────────────────────────────────
13
13
 
14
- /**
15
- * Loads the registry from:
16
- * 1. Remote GitHub (exchanet/enet/registry.json) — always fresh
17
- * 2. Local cache if remote fails — fallback
18
- * 3. Bundled registry.json in the package — last resort
19
- */
20
14
  export async function loadRegistry() {
21
- // Try remote first
22
15
  try {
23
16
  const res = await fetch(REGISTRY_URL, { timeout: 5000 })
24
17
  if (res.ok) {
25
18
  const data = await res.json()
26
- // Save to cache for offline fallback
27
19
  await fs.writeJson(CACHE_FILE, { ...data, _cachedAt: Date.now() }).catch(() => {})
28
20
  return data
29
21
  }
30
- } catch {
31
- // Network unavailable — fall through to cache
32
- }
22
+ } catch { /* network unavailable */ }
33
23
 
34
- // Try local cache
35
24
  try {
36
25
  if (await fs.pathExists(CACHE_FILE)) {
37
26
  const cached = await fs.readJson(CACHE_FILE)
38
27
  const age = Date.now() - (cached._cachedAt || 0)
39
- if (age < CACHE_TTL_MS * 24) { // Accept cache up to 24h when offline
40
- return cached
41
- }
28
+ if (age < CACHE_TTL_MS * 24) return cached
42
29
  }
43
- } catch {
44
- // Cache corrupted — fall through to bundled
45
- }
30
+ } catch { /* cache corrupted */ }
46
31
 
47
- // Fallback to bundled registry.json
48
32
  const bundled = path.join(__dirname, '../../registry.json')
49
33
  return fs.readJson(bundled)
50
34
  }
51
35
 
52
- /**
53
- * Returns a single method from the registry, or null if not found.
54
- */
55
36
  export async function getMethod(id) {
56
37
  const registry = await loadRegistry()
57
38
  return registry.methods?.[id] ?? null
58
39
  }
59
40
 
60
- /**
61
- * Returns all methods from the registry as an array.
62
- */
63
41
  export async function getAllMethods() {
64
42
  const registry = await loadRegistry()
65
43
  return Object.values(registry.methods ?? {})
@@ -67,19 +45,69 @@ export async function getAllMethods() {
67
45
 
68
46
  // ── GitHub file fetcher ───────────────────────────────────────────────────────
69
47
 
70
- /**
71
- * Fetches a raw file from a GitHub repo (main branch).
72
- */
73
48
  export async function fetchFromGitHub(repo, filePath) {
74
49
  const url = `${RAW_BASE}/${repo}/main/${filePath}`
75
50
  const res = await fetch(url)
76
-
77
51
  if (!res.ok) {
78
52
  throw new Error(
79
53
  `Could not fetch ${filePath} from ${repo} (HTTP ${res.status})\n` +
80
54
  ` URL: ${url}`
81
55
  )
82
56
  }
83
-
84
57
  return res.text()
85
58
  }
59
+
60
+ // ── Install state ─────────────────────────────────────────────────────────────
61
+ //
62
+ // Tracks which agents have each method installed.
63
+ // Stored in <project>/.enet/installed.json
64
+ //
65
+ // Format:
66
+ // {
67
+ // "pdca-t": {
68
+ // "agents": ["cursor", "claudecode", "antigravity"],
69
+ // "version": "3.0.0",
70
+ // "updatedAt": "2025-03-01T10:00:00.000Z"
71
+ // }
72
+ // }
73
+
74
+ function getInstallRecordFile() {
75
+ return path.join(process.cwd(), '.enet', 'installed.json')
76
+ }
77
+
78
+ /**
79
+ * Returns the install record for a single method, or null if never installed.
80
+ */
81
+ export async function readInstallRecord(methodId) {
82
+ try {
83
+ const file = getInstallRecordFile()
84
+ if (!await fs.pathExists(file)) return null
85
+ const data = await fs.readJson(file)
86
+ return data[methodId] ?? null
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Writes the install record for a single method.
94
+ * Merges with existing records — other methods are never touched.
95
+ */
96
+ export async function writeInstallRecord(methodId, record) {
97
+ try {
98
+ const file = getInstallRecordFile()
99
+ await fs.ensureDir(path.dirname(file))
100
+ let data = {}
101
+ if (await fs.pathExists(file)) {
102
+ data = await fs.readJson(file).catch(() => ({}))
103
+ }
104
+ data[methodId] = {
105
+ agents: record.agents,
106
+ version: record.version ?? null,
107
+ updatedAt: new Date().toISOString()
108
+ }
109
+ await fs.writeJson(file, data, { spaces: 2 })
110
+ } catch {
111
+ // Non-fatal — install works correctly even if state cannot be saved
112
+ }
113
+ }