@exchanet/enet 1.0.17 → 1.0.19

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.17",
3
+ "version": "1.0.19",
4
4
  "description": "enet — exchanet methods manager. Install, scaffold and manage AI coding methods.",
5
5
  "bin": {
6
6
  "enet": "src/index.js"
@@ -2,7 +2,8 @@ import chalk from 'chalk'
2
2
  import ora from 'ora'
3
3
  import fs from 'fs-extra'
4
4
  import path from 'path'
5
- import readline from 'readline'
5
+ import enquirer from 'enquirer'
6
+ const { MultiSelect } = enquirer
6
7
  import { detectSystemAgents, getInstallPath, AGENTS } from '../utils/agent_detector.js'
7
8
  import { getMethod, fetchFromGitHub, readInstallRecord, writeInstallRecord } from '../utils/registry.js'
8
9
 
@@ -19,7 +20,12 @@ export async function installCommand(methodId, options) {
19
20
  }
20
21
 
21
22
  console.log(chalk.bold(`\n ◆ ${method.name}`))
22
- console.log(chalk.dim(` ${method.description}\n`))
23
+ console.log(chalk.dim(` ${method.description}`))
24
+ if (options.global) {
25
+ console.log(chalk.cyan(' Destination: global (home) — available for all agents/projects\n'))
26
+ } else {
27
+ console.log(chalk.dim(` Destination: current project (use ${chalk.white('--global')} for all agents)\n`))
28
+ }
23
29
 
24
30
  // 2. Read existing install record — know what is already installed
25
31
  const record = await readInstallRecord(methodId)
@@ -55,7 +61,7 @@ export async function installCommand(methodId, options) {
55
61
  targetAgents = detected
56
62
  } else {
57
63
  // Always show checkbox — even with 1 agent, even on re-run
58
- targetAgents = await checkboxSelect(detected, method, alreadyInstalled)
64
+ targetAgents = await checkboxSelect(detected, method, alreadyInstalled, options)
59
65
  }
60
66
 
61
67
  if (targetAgents.length === 0) {
@@ -84,14 +90,10 @@ export async function installCommand(methodId, options) {
84
90
  }
85
91
 
86
92
  // ─────────────────────────────────────────────────────────────────
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
93
+ // Adapter selection via Enquirer (works on Windows/PowerShell)
92
94
  // ─────────────────────────────────────────────────────────────────
93
95
 
94
- async function checkboxSelect(detected, method, alreadyInstalled = new Set()) {
96
+ async function checkboxSelect(detected, method, alreadyInstalled = new Set(), options = {}) {
95
97
  const available = detected.filter(a => method.adapters[a.key] || method.adapters['generic'])
96
98
  const unavailable = detected.filter(a => !method.adapters[a.key] && !method.adapters['generic'])
97
99
 
@@ -101,76 +103,42 @@ async function checkboxSelect(detected, method, alreadyInstalled = new Set()) {
101
103
  process.exit(1)
102
104
  }
103
105
 
104
- const items = available.map(a => ({
105
- agent: a,
106
- checked: true,
107
- installed: alreadyInstalled.has(a.key),
108
- usesGeneric: !method.adapters[a.key]
109
- }))
110
-
111
- console.log(chalk.white(' Select adapters to install:\n'))
112
-
113
- return new Promise((resolve) => {
114
- let cursor = 0
115
-
116
- const lineCount = () => items.length + (unavailable.length > 0 ? 3 : 2) + 2
106
+ if (unavailable.length > 0) {
107
+ console.log(chalk.dim(` No adapter for: ${unavailable.map(a => a.name).join(', ')}\n`))
108
+ }
117
109
 
118
- const render = () => {
119
- if (render.drawn) process.stdout.write(`\x1B[${lineCount()}A`)
120
- render.drawn = true
110
+ const installScope = options.global ? 'global (home)' : 'current project'
111
+ const prompt = new MultiSelect({
112
+ name: 'agents',
113
+ message: `Select adapters to install (destination: ${installScope})`,
114
+ choices: available.map(a => ({
115
+ name: a.key,
116
+ message: `${a.name}${alreadyInstalled.has(a.key) ? ' — installed' : ' — new'}${!method.adapters[a.key] ? ' (generic)' : ''}`,
117
+ value: a,
118
+ enabled: true
119
+ })),
120
+ result (names) {
121
+ return this.options.choices.filter(c => names.includes(c.name)).map(c => c.value)
122
+ }
123
+ })
121
124
 
122
- items.forEach((item, i) => {
123
- const isCursor = i === cursor
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`)
130
- })
125
+ if (!process.stdin.isTTY) {
126
+ return available
127
+ }
131
128
 
132
- if (unavailable.length > 0) {
133
- process.stdout.write(chalk.dim(`\n No adapter for: ${unavailable.map(a => a.name).join(', ')}\n`))
134
- }
135
- process.stdout.write('\n')
136
- process.stdout.write(chalk.dim(' ↑↓ navigate · Space toggle · A all · Enter confirm · Ctrl+C cancel\n\n'))
129
+ try {
130
+ const selected = await prompt.run()
131
+ if (selected && selected.length > 0) {
132
+ console.log(chalk.dim(`\n Installing for: ${selected.map(a => a.name).join(', ')}\n`))
137
133
  }
138
-
139
- render()
140
-
141
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
142
- if (process.stdin.isTTY) process.stdin.setRawMode(true)
143
- process.stdin.resume()
144
-
145
- process.stdin.on('data', (key) => {
146
- const k = key.toString()
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 })
153
- render()
154
- }
155
- else if (k === '\r' || k === '\n') {
156
- if (process.stdin.isTTY) process.stdin.setRawMode(false)
157
- process.stdin.pause()
158
- rl.close()
159
- const selected = items.filter(i => i.checked).map(i => i.agent)
160
- if (selected.length > 0) {
161
- console.log(chalk.dim(`\n Installing for: ${selected.map(a => a.name).join(', ')}\n`))
162
- }
163
- resolve(selected)
164
- }
165
- else if (k === '\u0003') {
166
- if (process.stdin.isTTY) process.stdin.setRawMode(false)
167
- process.stdin.pause()
168
- rl.close()
169
- console.log('\n')
170
- process.exit(0)
171
- }
172
- })
173
- })
134
+ return selected || []
135
+ } catch (err) {
136
+ if (err.name === 'ENOTTY' || err.message?.includes('cancel')) {
137
+ console.log(chalk.dim('\n Cancelled.\n'))
138
+ process.exit(0)
139
+ }
140
+ throw err
141
+ }
174
142
  }
175
143
 
176
144
  // ─────────────────────────────────────────────────────────────────
@@ -2,7 +2,8 @@ import chalk from 'chalk'
2
2
  import ora from 'ora'
3
3
  import fs from 'fs-extra'
4
4
  import path from 'path'
5
- import readline from 'readline'
5
+ import enquirer from 'enquirer'
6
+ const { MultiSelect } = enquirer
6
7
  import { getAllMethods, getMethod, fetchFromGitHub, readInstallRecord, writeInstallRecord } from '../utils/registry.js'
7
8
  import { detectSystemAgents, detectAgent, getInstallPath, AGENTS } from '../utils/agent_detector.js'
8
9
  import { installForAgent } from './install.js'
@@ -158,66 +159,36 @@ async function updateOneAdapter(method, agent, options = {}) {
158
159
  }
159
160
 
160
161
  // ─────────────────────────────────────────────────────────────────
161
- // Checkbox for new agents only (shown during update)
162
+ // New agents selection during update (Enquirer, Windows-safe)
162
163
  // ─────────────────────────────────────────────────────────────────
163
164
 
164
165
  async function checkboxSelectNew(newAgents, method) {
165
- const items = newAgents.map(a => ({
166
- agent: a,
167
- checked: true,
168
- usesGeneric: !method.adapters[a.key]
169
- }))
170
-
171
- return new Promise((resolve) => {
172
- let cursor = 0
173
- const lineCount = () => items.length + 4
174
-
175
- const render = () => {
176
- if (render.drawn) process.stdout.write(`\x1B[${lineCount()}A`)
177
- render.drawn = true
178
-
179
- items.forEach((item, i) => {
180
- const isCursor = i === cursor
181
- const box = item.checked ? chalk.green('[✓]') : chalk.dim('[ ]')
182
- const arrow = isCursor ? chalk.cyan(' ❯ ') : ' '
183
- const name = isCursor ? chalk.white(item.agent.name) : chalk.dim(item.agent.name)
184
- const generic = item.usesGeneric ? chalk.dim(' (generic adapter)') : ''
185
- process.stdout.write(`${arrow}${box} ${name}${generic}\n`)
186
- })
187
-
188
- process.stdout.write('\n')
189
- process.stdout.write(chalk.dim(' ↑↓ navigate · Space toggle · A all · Enter confirm · Ctrl+C cancel\n\n'))
190
- }
166
+ if (newAgents.length === 0) return []
191
167
 
192
- render()
193
-
194
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
195
- if (process.stdin.isTTY) process.stdin.setRawMode(true)
196
- process.stdin.resume()
197
-
198
- process.stdin.on('data', (key) => {
199
- const k = key.toString()
200
- if (k === '\u001b[A') { cursor = (cursor - 1 + items.length) % items.length; render() }
201
- else if (k === '\u001b[B') { cursor = (cursor + 1) % items.length; render() }
202
- else if (k === ' ') { items[cursor].checked = !items[cursor].checked; render() }
203
- else if (k === 'a' || k === 'A') {
204
- const all = items.every(i => i.checked)
205
- items.forEach(i => { i.checked = !all })
206
- render()
207
- }
208
- else if (k === '\r' || k === '\n') {
209
- if (process.stdin.isTTY) process.stdin.setRawMode(false)
210
- process.stdin.pause()
211
- rl.close()
212
- resolve(items.filter(i => i.checked).map(i => i.agent))
213
- }
214
- else if (k === '\u0003') {
215
- if (process.stdin.isTTY) process.stdin.setRawMode(false)
216
- process.stdin.pause()
217
- rl.close()
218
- console.log('\n')
219
- process.exit(0)
220
- }
221
- })
168
+ if (!process.stdin.isTTY) {
169
+ return newAgents
170
+ }
171
+
172
+ const prompt = new MultiSelect({
173
+ name: 'newAgents',
174
+ message: 'Add these agents for this method?',
175
+ choices: newAgents.map(a => ({
176
+ name: a.key,
177
+ message: `${a.name}${!method.adapters[a.key] ? ' (generic adapter)' : ''}`,
178
+ value: a,
179
+ enabled: true
180
+ })),
181
+ result (names) {
182
+ return this.options.choices.filter(c => names.includes(c.name)).map(c => c.value)
183
+ }
222
184
  })
185
+
186
+ try {
187
+ return await prompt.run() || []
188
+ } catch (err) {
189
+ if (err.name === 'ENOTTY' || err.message?.includes('cancel')) {
190
+ return []
191
+ }
192
+ throw err
193
+ }
223
194
  }
package/src/index.js CHANGED
@@ -17,36 +17,42 @@ const require = createRequire(import.meta.url)
17
17
  const pkg = require('../package.json')
18
18
  const VERSION = pkg.version
19
19
 
20
- // ── Version check ─────────────────────────────────────────────────────────────
21
- // Runs in background never blocks the command, never crashes if offline
20
+ function isNewerVersion (latest, current) {
21
+ const parts = (v) => String(v).replace(/^v/, '').split('.').map(Number)
22
+ const a = parts(latest)
23
+ const b = parts(current)
24
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
25
+ const x = a[i] || 0
26
+ const y = b[i] || 0
27
+ if (x > y) return true
28
+ if (x < y) return false
29
+ }
30
+ return false
31
+ }
32
+
33
+ // ── Version check (every command) ─────────────────────────────────────────────
34
+ // Shows warning if a newer enet is on npm; never blocks, never crashes if offline
22
35
 
23
- async function checkForUpdate() {
36
+ async function checkForUpdate () {
24
37
  try {
25
- const res = await fetch(
26
- 'https://registry.npmjs.org/@exchanet/enet/latest',
27
- { signal: AbortSignal.timeout(3000) }
28
- )
38
+ const res = await fetch('https://registry.npmjs.org/@exchanet/enet/latest', { signal: AbortSignal.timeout(3000) })
29
39
  if (!res.ok) return
30
40
  const data = await res.json()
31
- const latest = data.version
32
- if (latest && latest !== VERSION) {
41
+ const latest = data?.version
42
+ if (latest && isNewerVersion(latest, VERSION)) {
33
43
  console.log(
34
- chalk.yellow(' ⚠ Update available: ') +
35
- chalk.dim(`v${VERSION}`) +
36
- chalk.white(' ') +
37
- chalk.green(`v${latest}`) + '\n' +
38
- chalk.dim(' Run ') +
39
- chalk.white('npm install -g @exchanet/enet') +
40
- chalk.dim(' to update.\n')
44
+ chalk.yellow(' ⚠ Warning: a newer version of enet is available.\n') +
45
+ chalk.dim(` Current: v${VERSION} → Latest: v${latest}\n`) +
46
+ chalk.dim(' Update: ') +
47
+ chalk.white('npm install -g @exchanet/enet\n')
41
48
  )
42
49
  }
43
50
  } catch {
44
- // Offline or npm unreachable — silently skip
51
+ // Offline or timeoutskip silently
45
52
  }
46
53
  }
47
54
 
48
- // Fire in background without await — command runs immediately
49
- checkForUpdate()
55
+ await checkForUpdate()
50
56
 
51
57
  // ── Header ────────────────────────────────────────────────────────────────────
52
58
 
@@ -1,189 +1,189 @@
1
- import fs from 'fs-extra'
2
- import path from 'path'
3
- import os from 'os'
4
-
5
- const HOME = os.homedir()
6
-
7
- export const AGENTS = {
8
- cursor: {
9
- name: 'Cursor',
10
- systemSignals: [
11
- path.join(HOME, '.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
- ],
16
- projectSignals: ['.cursor/rules', '.cursor'],
17
- projectInstallDir: '.cursor/rules',
18
- globalInstallDir: path.join(HOME, '.cursor', 'rules'),
19
- filename: 'enet-{id}.md',
20
- configNote: 'Rule auto-applies to all files (alwaysApply: true)'
21
- },
22
-
23
- windsurf: {
24
- name: 'Windsurf',
25
- systemSignals: [
26
- path.join(HOME, '.codeium', 'windsurf'),
27
- path.join(HOME, 'Library', 'Application Support', 'Windsurf'), // macOS
28
- path.join(HOME, 'AppData', 'Roaming', 'Windsurf'), // Windows
29
- ],
30
- projectSignals: ['.windsurfrules', '.windsurf'],
31
- projectInstallDir: '.',
32
- globalInstallDir: path.join(HOME, '.codeium', 'windsurf', 'memories'),
33
- globalFilename: 'global_rules.md',
34
- filename: '.windsurfrules',
35
- configNote: 'Appended to .windsurfrules in project root'
36
- },
37
-
38
- antigravity: {
39
- name: 'Antigravity (Google)',
40
- systemSignals: [
41
- path.join(HOME, '.gemini', 'antigravity'),
42
- path.join(HOME, 'Library', 'Application Support', 'Google', 'Antigravity'), // macOS
43
- path.join(HOME, 'AppData', 'Roaming', 'Google', 'Antigravity'), // Windows
44
- ],
45
- projectSignals: ['.agent/rules', '.agent'],
46
- projectInstallDir: '.agent/rules',
47
- // globalInstallDir is a base — getInstallPath() resolves the full path dynamically:
48
- // ~/.gemini/antigravity/skills/method-{id}/SKILL.md
49
- globalInstallDir: path.join(HOME, '.gemini', 'antigravity', 'skills'),
50
- globalFilename: 'SKILL.md',
51
- filename: 'enet-{id}.md',
52
- configNote: 'Rule placed in .agent/rules/ — activates automatically in Antigravity'
53
- },
54
-
55
- claudecode: {
56
- name: 'Claude Code',
57
- systemSignals: [
58
- path.join(HOME, '.claude'),
59
- ],
60
- projectSignals: ['CLAUDE.md', '.claude'],
61
- projectInstallDir: '.',
62
- globalInstallDir: path.join(HOME, '.claude'),
63
- globalFilename: 'CLAUDE.md',
64
- filename: 'CLAUDE.md',
65
- configNote: 'Written to CLAUDE.md — Claude Code reads this automatically'
66
- },
67
-
68
- copilot: {
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/
72
- systemSignals: [
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
77
- ],
78
- systemSignalFilter: (signalPath) => {
79
- // Only return true if a github.copilot extension folder exists inside
80
- try {
81
- const entries = fs.readdirSync(signalPath)
82
- return entries.some(e => e.toLowerCase().startsWith('github.copilot'))
83
- } catch {
84
- return false
85
- }
86
- },
87
- projectSignals: ['.github/copilot-instructions.md'],
88
- projectInstallDir: '.github',
89
- globalInstallDir: null, // Copilot has no global rules path
90
- filename: 'copilot-instructions.md',
91
- configNote: 'Written to .github/copilot-instructions.md'
92
- },
93
-
94
- generic: {
95
- name: 'Generic / Other agent',
96
- systemSignals: [],
97
- projectSignals: [],
98
- projectInstallDir: '.enet',
99
- globalInstallDir: null,
100
- filename: '{id}.md',
101
- configNote: 'Saved to .enet/ — paste contents into your agent\'s context'
102
- }
103
- }
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
- */
114
- export async function detectSystemAgents() {
115
- const found = []
116
- for (const [key, agent] of Object.entries(AGENTS)) {
117
- if (key === 'generic') continue
118
- for (const signal of agent.systemSignals) {
119
- const exists = await fs.pathExists(signal)
120
- if (!exists) continue
121
- if (agent.systemSignalFilter) {
122
- if (agent.systemSignalFilter(signal)) {
123
- found.push({ key, ...agent })
124
- break
125
- }
126
- continue // signal dir exists but filter didn't match — try next signal path
127
- }
128
- found.push({ key, ...agent })
129
- break
130
- }
131
- }
132
- return found
133
- }
134
-
135
- /**
136
- * Detects ALL agents present in the current project folder.
137
- */
138
- export async function detectProjectAgents(cwd = process.cwd()) {
139
- const found = []
140
- for (const [key, agent] of Object.entries(AGENTS)) {
141
- if (key === 'generic') continue
142
- for (const signal of agent.projectSignals) {
143
- if (await fs.pathExists(path.join(cwd, signal))) {
144
- found.push({ key, ...agent })
145
- break
146
- }
147
- }
148
- }
149
- return found
150
- }
151
-
152
- /**
153
- * Returns the first detected agent (legacy — used by status/doctor).
154
- */
155
- export async function detectAgent(cwd = process.cwd()) {
156
- const agents = await detectProjectAgents(cwd)
157
- return agents[0] ?? { key: 'generic', ...AGENTS.generic }
158
- }
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
- */
173
- export function getInstallPath(agent, methodId, { global = false, cwd = process.cwd() } = {}) {
174
- if (global) {
175
- if (!agent.globalInstallDir) return null // agent doesn't support global install
176
-
177
- if (agent.key === 'antigravity') {
178
- // Dynamic subfolder per method: skills/method-{id}/SKILL.md
179
- return path.join(agent.globalInstallDir, `method-${methodId}`, 'SKILL.md')
180
- }
181
-
182
- const filename = agent.globalFilename ?? agent.filename.replace('{id}', methodId)
183
- return path.join(agent.globalInstallDir, filename)
184
- }
185
-
186
- // Project-level install
187
- const filename = agent.filename.replace('{id}', methodId)
188
- return path.join(cwd, agent.projectInstallDir, filename)
189
- }
1
+ import fs from 'fs-extra'
2
+ import path from 'path'
3
+ import os from 'os'
4
+
5
+ const HOME = os.homedir()
6
+
7
+ export const AGENTS = {
8
+ cursor: {
9
+ name: 'Cursor',
10
+ systemSignals: [
11
+ path.join(HOME, '.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
+ ],
16
+ projectSignals: ['.cursor/rules', '.cursor'],
17
+ projectInstallDir: '.cursor/rules',
18
+ globalInstallDir: path.join(HOME, '.cursor', 'rules'),
19
+ filename: 'enet-{id}.md',
20
+ configNote: 'Rule auto-applies to all files (alwaysApply: true)'
21
+ },
22
+
23
+ windsurf: {
24
+ name: 'Windsurf',
25
+ systemSignals: [
26
+ path.join(HOME, '.codeium', 'windsurf'),
27
+ path.join(HOME, 'Library', 'Application Support', 'Windsurf'), // macOS
28
+ path.join(HOME, 'AppData', 'Roaming', 'Windsurf'), // Windows
29
+ ],
30
+ projectSignals: ['.windsurfrules', '.windsurf'],
31
+ projectInstallDir: '.',
32
+ globalInstallDir: path.join(HOME, '.codeium', 'windsurf', 'memories'),
33
+ globalFilename: 'global_rules.md',
34
+ filename: '.windsurfrules',
35
+ configNote: 'Appended to .windsurfrules in project root'
36
+ },
37
+
38
+ antigravity: {
39
+ name: 'Antigravity (Google)',
40
+ systemSignals: [
41
+ path.join(HOME, '.gemini', 'antigravity'),
42
+ path.join(HOME, 'Library', 'Application Support', 'Google', 'Antigravity'), // macOS
43
+ path.join(HOME, 'AppData', 'Roaming', 'Google', 'Antigravity'), // Windows
44
+ ],
45
+ projectSignals: ['.agent/rules', '.agent'],
46
+ projectInstallDir: '.agent/rules',
47
+ // globalInstallDir is a base — getInstallPath() resolves the full path dynamically:
48
+ // ~/.gemini/antigravity/skills/method-{id}/SKILL.md
49
+ globalInstallDir: path.join(HOME, '.gemini', 'antigravity', 'skills'),
50
+ globalFilename: 'SKILL.md',
51
+ filename: 'enet-{id}.md',
52
+ configNote: 'Rule placed in .agent/rules/ — activates automatically in Antigravity'
53
+ },
54
+
55
+ claudecode: {
56
+ name: 'Claude Code',
57
+ systemSignals: [
58
+ path.join(HOME, '.claude'),
59
+ ],
60
+ projectSignals: ['CLAUDE.md', '.claude'],
61
+ projectInstallDir: '.',
62
+ globalInstallDir: path.join(HOME, '.claude'),
63
+ globalFilename: 'CLAUDE.md',
64
+ filename: 'CLAUDE.md',
65
+ configNote: 'Written to CLAUDE.md — Claude Code reads this automatically'
66
+ },
67
+
68
+ copilot: {
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/
72
+ systemSignals: [
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
77
+ ],
78
+ systemSignalFilter: (signalPath) => {
79
+ // Only return true if a github.copilot extension folder exists inside
80
+ try {
81
+ const entries = fs.readdirSync(signalPath)
82
+ return entries.some(e => e.toLowerCase().startsWith('github.copilot'))
83
+ } catch {
84
+ return false
85
+ }
86
+ },
87
+ projectSignals: ['.github/copilot-instructions.md'],
88
+ projectInstallDir: '.github',
89
+ globalInstallDir: null, // Copilot has no global rules path
90
+ filename: 'copilot-instructions.md',
91
+ configNote: 'Written to .github/copilot-instructions.md'
92
+ },
93
+
94
+ generic: {
95
+ name: 'Generic / Other agent',
96
+ systemSignals: [],
97
+ projectSignals: [],
98
+ projectInstallDir: '.enet',
99
+ globalInstallDir: null,
100
+ filename: '{id}.md',
101
+ configNote: 'Saved to .enet/ — paste contents into your agent\'s context'
102
+ }
103
+ }
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
+ */
114
+ export async function detectSystemAgents() {
115
+ const found = []
116
+ for (const [key, agent] of Object.entries(AGENTS)) {
117
+ if (key === 'generic') continue
118
+ for (const signal of agent.systemSignals) {
119
+ const exists = await fs.pathExists(signal)
120
+ if (!exists) continue
121
+ if (agent.systemSignalFilter) {
122
+ if (agent.systemSignalFilter(signal)) {
123
+ found.push({ key, ...agent })
124
+ break
125
+ }
126
+ continue // signal dir exists but filter didn't match — try next signal path
127
+ }
128
+ found.push({ key, ...agent })
129
+ break
130
+ }
131
+ }
132
+ return found
133
+ }
134
+
135
+ /**
136
+ * Detects ALL agents present in the current project folder.
137
+ */
138
+ export async function detectProjectAgents(cwd = process.cwd()) {
139
+ const found = []
140
+ for (const [key, agent] of Object.entries(AGENTS)) {
141
+ if (key === 'generic') continue
142
+ for (const signal of agent.projectSignals) {
143
+ if (await fs.pathExists(path.join(cwd, signal))) {
144
+ found.push({ key, ...agent })
145
+ break
146
+ }
147
+ }
148
+ }
149
+ return found
150
+ }
151
+
152
+ /**
153
+ * Returns the first detected agent (legacy — used by status/doctor).
154
+ */
155
+ export async function detectAgent(cwd = process.cwd()) {
156
+ const agents = await detectProjectAgents(cwd)
157
+ return agents[0] ?? { key: 'generic', ...AGENTS.generic }
158
+ }
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
+ */
173
+ export function getInstallPath(agent, methodId, { global = false, cwd = process.cwd() } = {}) {
174
+ if (global) {
175
+ if (!agent.globalInstallDir) return null // agent doesn't support global install
176
+
177
+ if (agent.key === 'antigravity') {
178
+ // Dynamic subfolder per method: skills/method-{id}/SKILL.md
179
+ return path.join(agent.globalInstallDir, `method-${methodId}`, 'SKILL.md')
180
+ }
181
+
182
+ const filename = agent.globalFilename ?? agent.filename.replace('{id}', methodId)
183
+ return path.join(agent.globalInstallDir, filename)
184
+ }
185
+
186
+ // Project-level install
187
+ const filename = agent.filename.replace('{id}', methodId)
188
+ return path.join(cwd, agent.projectInstallDir, filename)
189
+ }