@boyingliu01/xp-gate 0.6.1 → 0.6.2

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.
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * @test detect-deps
3
- * @intent Verify checkDeps() correctly detects missing/present/outdated dependencies
3
+ * @intent Verify checkDeps() correctly detects missing/present/outdated dependencies across platforms,
4
+ * detectPlatform() identifies the correct AI agent platform,
5
+ * and autoInstallDeps() handles install scenarios
4
6
  */
5
7
  const fs = require('fs');
6
8
  const path = require('path');
@@ -16,6 +18,7 @@ describe('detect-deps', () => {
16
18
  process.env.HOME = tmpHome;
17
19
  vi.resetModules();
18
20
  delete require.cache[require.resolve('../detect-deps')];
21
+ delete require.cache[require.resolve('../shared-paths')];
19
22
  });
20
23
 
21
24
  afterEach(() => {
@@ -24,8 +27,10 @@ describe('detect-deps', () => {
24
27
  vi.restoreAllMocks();
25
28
  });
26
29
 
27
- function makeSkillDir(skillName, contents = {}) {
28
- const dir = path.join(tmpHome, '.config', 'opencode', 'skills', skillName);
30
+ function makeSkillDir(skillName, contents = {}, baseDir) {
31
+ const dir = baseDir
32
+ ? path.join(baseDir, skillName)
33
+ : path.join(tmpHome, '.config', 'opencode', 'skills', skillName);
29
34
  fs.mkdirSync(dir, { recursive: true });
30
35
  if (contents.packageJson) {
31
36
  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(contents.packageJson));
@@ -48,6 +53,8 @@ describe('detect-deps', () => {
48
53
  return dir;
49
54
  }
50
55
 
56
+ // ── checkDeps: backward-compatible tests (default platform = opencode) ──
57
+
51
58
  it('returns ok:false missing:superpowers when no deps exist', async () => {
52
59
  const { checkDeps } = require('../detect-deps');
53
60
  const result = await checkDeps();
@@ -127,7 +134,6 @@ describe('detect-deps', () => {
127
134
  });
128
135
 
129
136
  it('returns ok:true (no version check) when package.json has no version and no SKILL.md', async () => {
130
- // getSkillVersion returns null → skips version check → ok
131
137
  makeSkillDir('superpowers', { packageJson: {} });
132
138
  makeSkillDir('gstack', { packageJson: {} });
133
139
  const { checkDeps } = require('../detect-deps');
@@ -147,13 +153,8 @@ describe('detect-deps', () => {
147
153
  });
148
154
 
149
155
  it('returns null version when neither package.json nor SKILL.md exist (skips version check)', async () => {
150
- // Create empty dirs (no metadata files)
151
- fs.mkdirSync(path.join(tmpHome, '.config', 'opencode', 'skills', 'superpowers'), {
152
- recursive: true,
153
- });
154
- fs.mkdirSync(path.join(tmpHome, '.config', 'opencode', 'skills', 'gstack'), {
155
- recursive: true,
156
- });
156
+ fs.mkdirSync(path.join(tmpHome, '.config', 'opencode', 'skills', 'superpowers'), { recursive: true });
157
+ fs.mkdirSync(path.join(tmpHome, '.config', 'opencode', 'skills', 'gstack'), { recursive: true });
157
158
  const { checkDeps } = require('../detect-deps');
158
159
  const result = await checkDeps();
159
160
  expect(result.ok).toBe(true);
@@ -164,17 +165,14 @@ describe('detect-deps', () => {
164
165
  makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
165
166
  const { checkDeps } = require('../detect-deps');
166
167
  const result = await checkDeps();
167
- // superpowers version=null → skips version check → ok
168
168
  expect(result.ok).toBe(true);
169
169
  });
170
170
 
171
- it('compareVersions handles partial versions (e.g. 1.0 treated as 1.0.0)', async () => {
172
- // version "1.0" in SKILL.md regex requires X.Y.Z so won't match; use package.json
171
+ it('compareVersions handles partial versions (e.g. 1 treated as 1.0.0)', async () => {
173
172
  makeSkillDir('superpowers', { packageJson: { version: '1' } });
174
173
  makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
175
174
  const { checkDeps } = require('../detect-deps');
176
175
  const result = await checkDeps();
177
- // '1' parsed as [1] vs [1,0,0] → equal at index 0; index 1: 0 vs 0; index 2: 0 vs 0 → equal → passes
178
176
  expect(result.ok).toBe(true);
179
177
  });
180
178
 
@@ -196,14 +194,187 @@ describe('detect-deps', () => {
196
194
  });
197
195
 
198
196
  it('prefers SKILLS_DIR over OPENCODE_DIR when both exist', async () => {
199
- // Put low version in SKILLS_DIR, high in OPENCODE_DIR
200
197
  makeSkillDir('superpowers', { packageJson: { version: '0.0.1' } });
201
198
  makeOpencodeDir('superpowers', { packageJson: { version: '2.0.0' } });
202
199
  makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
203
200
  const { checkDeps } = require('../detect-deps');
204
201
  const result = await checkDeps();
205
- // Should use SKILLS_DIR (first in possiblePaths) → 0.0.1 fails
206
202
  expect(result.ok).toBe(false);
207
203
  expect(result.versionMismatch.found).toBe('0.0.1');
208
204
  });
205
+
206
+ // ── Platform-specific tests (Issue #128) ──
207
+
208
+ describe('checkDeps with platform parameter', () => {
209
+ it('qoder: checks ~/.qoder/skills/ for dependencies', async () => {
210
+ const qoderSkills = path.join(tmpHome, '.qoder', 'skills');
211
+ makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } }, path.join(qoderSkills, 'superpowers'));
212
+ makeSkillDir('gstack', { packageJson: { version: '1.0.0' } }, path.join(qoderSkills, 'gstack'));
213
+ const { checkDeps } = require('../detect-deps');
214
+ const result = await checkDeps('qoder');
215
+ expect(result.ok).toBe(true);
216
+ });
217
+
218
+ it('qoder: returns missing when qoder skills dir is empty', async () => {
219
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
220
+ const { checkDeps } = require('../detect-deps');
221
+ const result = await checkDeps('qoder');
222
+ expect(result.ok).toBe(false);
223
+ expect(result.missing).toBe('superpowers');
224
+ });
225
+
226
+ it('claude-code: checks ~/.claude/skills/ for dependencies', async () => {
227
+ const claudeSkills = path.join(tmpHome, '.claude', 'skills');
228
+ makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } }, path.join(claudeSkills, 'superpowers'));
229
+ makeSkillDir('gstack', { packageJson: { version: '1.0.0' } }, path.join(claudeSkills, 'gstack'));
230
+ const { checkDeps } = require('../detect-deps');
231
+ const result = await checkDeps('claude-code');
232
+ expect(result.ok).toBe(true);
233
+ });
234
+
235
+ it('claude-code: returns missing when claude skills dir is empty', async () => {
236
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'skills'), { recursive: true });
237
+ const { checkDeps } = require('../detect-deps');
238
+ const result = await checkDeps('claude-code');
239
+ expect(result.ok).toBe(false);
240
+ expect(result.missing).toBe('superpowers');
241
+ });
242
+
243
+ it('unknown platform falls back to opencode profile', async () => {
244
+ makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } });
245
+ makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
246
+ const { checkDeps } = require('../detect-deps');
247
+ const result = await checkDeps('unknown-platform');
248
+ expect(result.ok).toBe(true);
249
+ });
250
+
251
+ it('qoder platform does NOT find deps in opencode dir', async () => {
252
+ // Put deps in opencode dir but NOT in qoder dir
253
+ makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } });
254
+ makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
255
+ const { checkDeps } = require('../detect-deps');
256
+ const result = await checkDeps('qoder');
257
+ expect(result.ok).toBe(false);
258
+ expect(result.missing).toBe('superpowers');
259
+ });
260
+ });
261
+
262
+ // ── detectPlatform tests ──
263
+
264
+ describe('detectPlatform', () => {
265
+ it('returns "qoder" when ~/.qoder/skills/ exists', () => {
266
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
267
+ const { detectPlatform } = require('../detect-deps');
268
+ expect(detectPlatform()).toBe('qoder');
269
+ });
270
+
271
+ it('returns "claude-code" when ~/.claude/skills/ exists', () => {
272
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'skills'), { recursive: true });
273
+ const { detectPlatform } = require('../detect-deps');
274
+ expect(detectPlatform()).toBe('claude-code');
275
+ });
276
+
277
+ it('returns "opencode" as default when no platform dirs exist', () => {
278
+ const { detectPlatform } = require('../detect-deps');
279
+ expect(detectPlatform()).toBe('opencode');
280
+ });
281
+
282
+ it('prefers qoder over claude-code when both exist', () => {
283
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
284
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'skills'), { recursive: true });
285
+ const { detectPlatform } = require('../detect-deps');
286
+ expect(detectPlatform()).toBe('qoder');
287
+ });
288
+ });
289
+
290
+ // ── getSkillsDirs tests ──
291
+
292
+ describe('getSkillsDirs', () => {
293
+ it('returns opencode paths for opencode platform', () => {
294
+ const { getSkillsDirs } = require('../detect-deps');
295
+ const dirs = getSkillsDirs('opencode');
296
+ expect(dirs[0]).toContain('.config');
297
+ expect(dirs[0]).toContain('opencode');
298
+ });
299
+
300
+ it('returns qoder paths for qoder platform', () => {
301
+ const { getSkillsDirs } = require('../detect-deps');
302
+ const dirs = getSkillsDirs('qoder');
303
+ expect(dirs[0]).toContain('.qoder');
304
+ });
305
+
306
+ it('returns claude-code paths for claude-code platform', () => {
307
+ const { getSkillsDirs } = require('../detect-deps');
308
+ const dirs = getSkillsDirs('claude-code');
309
+ expect(dirs[0]).toContain('.claude');
310
+ });
311
+ });
312
+
313
+ // ── PLATFORM_PROFILES tests ──
314
+
315
+ describe('PLATFORM_PROFILES', () => {
316
+ it('all platforms have requiredDeps defined', () => {
317
+ const { PLATFORM_PROFILES } = require('../detect-deps');
318
+ expect(PLATFORM_PROFILES.opencode.requiredDeps.length).toBe(2);
319
+ expect(PLATFORM_PROFILES.qoder.requiredDeps.length).toBe(2);
320
+ expect(PLATFORM_PROFILES['claude-code'].requiredDeps.length).toBe(2);
321
+ });
322
+
323
+ it('all platforms have skillsDirs defined', () => {
324
+ const { PLATFORM_PROFILES } = require('../detect-deps');
325
+ expect(PLATFORM_PROFILES.opencode.skillsDirs.length).toBeGreaterThan(0);
326
+ expect(PLATFORM_PROFILES.qoder.skillsDirs.length).toBeGreaterThan(0);
327
+ expect(PLATFORM_PROFILES['claude-code'].skillsDirs.length).toBeGreaterThan(0);
328
+ });
329
+ });
330
+
331
+ // ── autoInstallDeps tests ──
332
+
333
+ describe('autoInstallDeps', () => {
334
+ it('returns ok:true with empty installed when deps already exist', async () => {
335
+ makeSkillDir('superpowers', { packageJson: { version: '2.0.0' } });
336
+ makeSkillDir('gstack', { packageJson: { version: '1.0.0' } });
337
+ const { autoInstallDeps } = require('../detect-deps');
338
+ const result = await autoInstallDeps();
339
+ expect(result.ok).toBe(true);
340
+ expect(result.installed).toEqual([]);
341
+ });
342
+
343
+ it('creates skills directory if it does not exist', async () => {
344
+ const { autoInstallDeps } = require('../detect-deps');
345
+ // Mock execSync to avoid actual git clone
346
+ const { execSync } = require('child_process');
347
+ vi.spyOn(require('child_process'), 'execSync').mockImplementation((cmd) => {
348
+ // Simulate successful clone by creating the target dir
349
+ const match = cmd.match(/"([^"]+)"$/);
350
+ if (match) {
351
+ fs.mkdirSync(match[1], { recursive: true });
352
+ fs.writeFileSync(path.join(match[1], 'package.json'), JSON.stringify({ version: '2.0.0' }));
353
+ }
354
+ return '';
355
+ });
356
+
357
+ const result = await autoInstallDeps();
358
+ // Should have attempted to install
359
+ expect(result.ok).toBe(true);
360
+ expect(result.installed).toContain('superpowers');
361
+ expect(result.installed).toContain('gstack');
362
+
363
+ vi.restoreAllMocks();
364
+ });
365
+
366
+ it('returns errors when git clone fails', async () => {
367
+ const { autoInstallDeps } = require('../detect-deps');
368
+ vi.spyOn(require('child_process'), 'execSync').mockImplementation(() => {
369
+ throw new Error('git not found');
370
+ });
371
+
372
+ const result = await autoInstallDeps();
373
+ expect(result.ok).toBe(false);
374
+ expect(result.errors.length).toBeGreaterThan(0);
375
+ expect(result.errors[0].message).toContain('git not found');
376
+
377
+ vi.restoreAllMocks();
378
+ });
379
+ });
209
380
  });
@@ -27,6 +27,7 @@ describe('doctor', () => {
27
27
  delete require.cache[require.resolve('../uninstall')];
28
28
  delete require.cache[require.resolve('../init')];
29
29
  delete require.cache[require.resolve('../detect-deps.js')];
30
+ delete require.cache[require.resolve('../shared-paths')];
30
31
  logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
31
32
  warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
32
33
  errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -24,6 +24,7 @@ describe('init', () => {
24
24
  vi.resetModules();
25
25
  delete require.cache[require.resolve('../init')];
26
26
  delete require.cache[require.resolve('../detect-deps.js')];
27
+ delete require.cache[require.resolve('../shared-paths')];
27
28
  logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
28
29
  warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
29
30
  errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -102,6 +103,13 @@ describe('init', () => {
102
103
  });
103
104
 
104
105
  it('init([]) with missing superpowers warns Missing dependencies', async () => {
106
+ // Mock execSync to make auto-install fail immediately
107
+ vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
108
+ if (typeof cmd === 'string' && cmd.includes('git clone')) {
109
+ throw new Error('git clone not available in test');
110
+ }
111
+ return '';
112
+ });
105
113
  const { init } = require('../init');
106
114
  const result = await init([]);
107
115
  expect(result).toBe(0);
@@ -110,6 +118,13 @@ describe('init', () => {
110
118
  });
111
119
 
112
120
  it('init([]) with versionMismatch warns version detail', async () => {
121
+ // Mock execSync to make auto-install fail immediately
122
+ vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
123
+ if (typeof cmd === 'string' && cmd.includes('git clone')) {
124
+ throw new Error('git clone not available in test');
125
+ }
126
+ return '';
127
+ });
113
128
  // superpowers too old, gstack good
114
129
  const sp = path.join(skillsDir(), 'superpowers');
115
130
  fs.mkdirSync(sp, { recursive: true });
@@ -69,6 +69,7 @@ describe('install-skill', () => {
69
69
  vi.resetModules();
70
70
  delete require.cache[require.resolve('../install-skill')];
71
71
  delete require.cache[require.resolve('../detect-deps')];
72
+ delete require.cache[require.resolve('../shared-paths')];
72
73
  delete require.cache[require.resolve('../download-skill')];
73
74
  delete require.cache[require.resolve('../rollback')];
74
75
  vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -27,6 +27,7 @@ describe('uninstall', () => {
27
27
  delete require.cache[require.resolve('../uninstall')];
28
28
  delete require.cache[require.resolve('../init')];
29
29
  delete require.cache[require.resolve('../detect-deps.js')];
30
+ delete require.cache[require.resolve('../shared-paths')];
30
31
  logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
31
32
  warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
32
33
  errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -3,20 +3,72 @@ const path = require('path');
3
3
  const os = require('os');
4
4
  const { execSync } = require('child_process');
5
5
 
6
+ const { HOME_DIR } = require('./shared-paths.js');
7
+
8
+ const REQUIRED_DEPS = [
9
+ { name: 'superpowers', repo: 'obra/superpowers', minVersion: '1.0.0' },
10
+ { name: 'gstack', repo: 'garrytan/gstack', minVersion: '1.0.0' }
11
+ ];
12
+
6
13
  /**
7
- * Resolve the user's home directory cross-platform.
8
- * Fallback chain: HOME USERPROFILE os.homedir()
9
- * This covers all known Windows/Linux/macOS/edge cases.
14
+ * Platform profiles each AI agent platform has its own skills directory.
15
+ * All platforms require the same dependencies (superpowers, gstack).
16
+ *
17
+ * @covers Issue #128 Bug 1 — Qoder platform was excluded from dependency checks
10
18
  */
11
- const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
19
+ const PLATFORM_PROFILES = {
20
+ opencode: {
21
+ skillsDirs: [
22
+ path.join(HOME_DIR, '.config', 'opencode', 'skills'),
23
+ path.join(HOME_DIR, '.config', 'opencode'),
24
+ ],
25
+ requiredDeps: REQUIRED_DEPS,
26
+ },
27
+ 'claude-code': {
28
+ skillsDirs: [
29
+ path.join(HOME_DIR, '.claude', 'skills'),
30
+ path.join(HOME_DIR, '.claude'),
31
+ ],
32
+ requiredDeps: REQUIRED_DEPS,
33
+ },
34
+ qoder: {
35
+ skillsDirs: [
36
+ path.join(HOME_DIR, '.qoder', 'skills'),
37
+ path.join(HOME_DIR, '.qoder'),
38
+ ],
39
+ requiredDeps: REQUIRED_DEPS,
40
+ },
41
+ };
12
42
 
13
- const SKILLS_DIR = path.join(HOME, '.config', 'opencode', 'skills');
14
- const OPENCODE_DIR = path.join(HOME, '.config', 'opencode');
43
+ /**
44
+ * Detect which AI agent platform is currently in use.
45
+ * Checks for platform-specific directories in the user's home.
46
+ * Falls back to 'opencode' if no platform is detected.
47
+ *
48
+ * @returns {'opencode' | 'claude-code' | 'qoder'}
49
+ */
50
+ function detectPlatform() {
51
+ // Check for Qoder-specific marker
52
+ if (fs.existsSync(path.join(HOME_DIR, '.qoder', 'skills'))) {
53
+ return 'qoder';
54
+ }
55
+ // Check for Claude Code-specific marker
56
+ if (fs.existsSync(path.join(HOME_DIR, '.claude', 'skills'))) {
57
+ return 'claude-code';
58
+ }
59
+ // Default to opencode (most common, backward compatible)
60
+ return 'opencode';
61
+ }
15
62
 
16
- const REQUIRED_DEPS = [
17
- { name: 'superpowers', minVersion: '1.0.0' },
18
- { name: 'gstack', minVersion: '1.0.0' }
19
- ];
63
+ /**
64
+ * Get the skills directories for a given platform.
65
+ * @param {string} platform - 'opencode' | 'claude-code' | 'qoder'
66
+ * @returns {string[]}
67
+ */
68
+ function getSkillsDirs(platform) {
69
+ const profile = PLATFORM_PROFILES[platform] || PLATFORM_PROFILES.opencode;
70
+ return profile.skillsDirs;
71
+ }
20
72
 
21
73
  /**
22
74
  * Check if bash is available on the system.
@@ -84,25 +136,28 @@ function checkBash() {
84
136
  }
85
137
  }
86
138
 
87
- async function checkDeps() {
139
+ /**
140
+ * Check if required dependencies are installed for the given platform.
141
+ * @param {string} [platform='opencode'] - AI agent platform
142
+ * @returns {Promise<{ok: boolean, missing?: string, versionMismatch?: object}>}
143
+ */
144
+ async function checkDeps(platform = 'opencode') {
145
+ const skillsDirs = getSkillsDirs(platform);
146
+
88
147
  for (const dep of REQUIRED_DEPS) {
89
- const possiblePaths = [
90
- path.join(SKILLS_DIR, dep.name),
91
- path.join(OPENCODE_DIR, dep.name)
92
- ];
93
-
94
148
  let depDir = null;
95
- for (const p of possiblePaths) {
96
- if (fs.existsSync(p)) {
97
- depDir = p;
149
+ for (const baseDir of skillsDirs) {
150
+ const candidate = path.join(baseDir, dep.name);
151
+ if (fs.existsSync(candidate)) {
152
+ depDir = candidate;
98
153
  break;
99
154
  }
100
155
  }
101
-
156
+
102
157
  if (!depDir) {
103
158
  return { ok: false, missing: dep.name };
104
159
  }
105
-
160
+
106
161
  const version = await getSkillVersion(depDir);
107
162
  if (version && compareVersions(version, dep.minVersion) < 0) {
108
163
  return {
@@ -115,10 +170,101 @@ async function checkDeps() {
115
170
  };
116
171
  }
117
172
  }
118
-
173
+
119
174
  return { ok: true };
120
175
  }
121
176
 
177
+ /**
178
+ * Auto-install missing dependencies by cloning from GitHub.
179
+ * @param {string} [platform='opencode'] - AI agent platform
180
+ * @returns {Promise<{ok: boolean, installed?: string[], errors?: Array<{name: string, message: string}>}>}
181
+ */
182
+ async function autoInstallDeps(platform = 'opencode') {
183
+ const skillsDirs = getSkillsDirs(platform);
184
+ const targetDir = skillsDirs[0]; // Install to the primary skills directory
185
+
186
+ if (!fs.existsSync(targetDir)) {
187
+ try {
188
+ fs.mkdirSync(targetDir, { recursive: true });
189
+ } catch (e) {
190
+ return {
191
+ ok: false,
192
+ errors: [{ name: 'mkdir', message: `Cannot create skills directory: ${e.message}` }]
193
+ };
194
+ }
195
+ }
196
+
197
+ const check = await checkDeps(platform);
198
+ if (check.ok) {
199
+ return { ok: true, installed: [] };
200
+ }
201
+
202
+ const errors = [];
203
+ const installed = [];
204
+
205
+ // Check each dep: missing → install, version mismatch → can't auto-fix
206
+ const depCheck = await checkDeps(platform);
207
+ if (depCheck.ok) {
208
+ return { ok: true, installed: [] };
209
+ }
210
+
211
+ // If the issue is version mismatch, auto-install can't help
212
+ if (depCheck.versionMismatch) {
213
+ return {
214
+ ok: false,
215
+ installed: [],
216
+ errors: [{
217
+ name: depCheck.versionMismatch.name,
218
+ message: `version mismatch: need ${depCheck.versionMismatch.required}, found ${depCheck.versionMismatch.found} (auto-install cannot upgrade)`
219
+ }]
220
+ };
221
+ }
222
+
223
+ for (const dep of REQUIRED_DEPS) {
224
+ // Re-check if this specific dep is missing
225
+ let exists = false;
226
+ for (const baseDir of skillsDirs) {
227
+ if (fs.existsSync(path.join(baseDir, dep.name))) {
228
+ exists = true;
229
+ break;
230
+ }
231
+ }
232
+ if (exists) continue;
233
+
234
+ const destPath = path.join(targetDir, dep.name);
235
+ const repoUrl = `https://github.com/${dep.repo}.git`;
236
+
237
+ try {
238
+ console.log(` ${dep.name}: not found → installing from ${repoUrl} ...`);
239
+ const cp = require('child_process');
240
+ cp.execSync(`git clone --depth 1 "${repoUrl}" "${destPath}"`, {
241
+ encoding: 'utf8',
242
+ stdio: ['ignore', 'pipe', 'pipe'],
243
+ timeout: 120000, // 2 minute timeout
244
+ });
245
+ installed.push(dep.name);
246
+ console.log(` ${dep.name}: OK`);
247
+ } catch (e) {
248
+ errors.push({ name: dep.name, message: e.message || 'git clone failed' });
249
+ console.warn(` ${dep.name}: FAILED (${e.message || 'unknown error'})`);
250
+ // Clean up partial clone
251
+ try {
252
+ if (fs.existsSync(destPath)) {
253
+ fs.rmSync(destPath, { recursive: true, force: true });
254
+ }
255
+ } catch {
256
+ // Ignore cleanup failures
257
+ }
258
+ }
259
+ }
260
+
261
+ if (errors.length > 0) {
262
+ return { ok: false, installed, errors };
263
+ }
264
+
265
+ return { ok: true, installed };
266
+ }
267
+
122
268
  async function getSkillVersion(skillDir) {
123
269
  const pkgFile = path.join(skillDir, 'package.json');
124
270
  if (fs.existsSync(pkgFile)) {
@@ -127,7 +273,7 @@ async function getSkillVersion(skillDir) {
127
273
  return pkg.version;
128
274
  } catch {}
129
275
  }
130
-
276
+
131
277
  const skillFile = path.join(skillDir, 'SKILL.md');
132
278
  if (fs.existsSync(skillFile)) {
133
279
  const content = fs.readFileSync(skillFile, 'utf8');
@@ -136,22 +282,30 @@ async function getSkillVersion(skillDir) {
136
282
  return versionMatch[1];
137
283
  }
138
284
  }
139
-
285
+
140
286
  return null;
141
287
  }
142
288
 
143
289
  function compareVersions(a, b) {
144
290
  const partsA = a.split('.').map(Number);
145
291
  const partsB = b.split('.').map(Number);
146
-
292
+
147
293
  for (let i = 0; i < 3; i++) {
148
294
  const partA = partsA[i] || 0;
149
295
  const partB = partsB[i] || 0;
150
296
  if (partA > partB) return 1;
151
297
  if (partA < partB) return -1;
152
298
  }
153
-
299
+
154
300
  return 0;
155
301
  }
156
302
 
157
- module.exports = { checkDeps, checkBash };
303
+ module.exports = {
304
+ checkDeps,
305
+ checkBash,
306
+ autoInstallDeps,
307
+ detectPlatform,
308
+ getSkillsDirs,
309
+ PLATFORM_PROFILES,
310
+ REQUIRED_DEPS,
311
+ };
package/lib/doctor.js CHANGED
@@ -1,15 +1,13 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
4
3
  const { execSync } = require('child_process');
5
-
6
- // Cross-platform home directory resolution
7
- const HOME_DIR = process.env.HOME || process.env.USERPROFILE || os.homedir();
8
-
9
- const CONFIG_DIR = path.join(HOME_DIR, '.config', 'xp-gate');
10
- const CONFIG_FILE = path.join(CONFIG_DIR, 'xp-gate.json');
11
- const GLOBAL_HOOKS_DIR = path.join(CONFIG_DIR, 'hooks');
12
- const GLOBAL_ADAPTERS_DIR = path.join(CONFIG_DIR, 'adapters');
4
+ const {
5
+ HOME_DIR,
6
+ CONFIG_DIR,
7
+ CONFIG_FILE,
8
+ GLOBAL_HOOKS_DIR,
9
+ GLOBAL_ADAPTERS_DIR,
10
+ } = require('./shared-paths.js');
13
11
 
14
12
  // npm package source dir (template hooks/adapters)
15
13
  const PKG_DIR = path.dirname(__dirname);
package/lib/init.js CHANGED
@@ -1,18 +1,15 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
4
3
  const crypto = require('crypto');
5
- const { checkDeps } = require('./detect-deps.js');
6
- const { checkBash } = require('./detect-deps.js');
7
-
8
- // Cross-platform home directory resolution with os.homedir() fallback
9
- const HOME_DIR = process.env.HOME || process.env.USERPROFILE || os.homedir();
10
-
11
- const CONFIG_DIR = path.join(HOME_DIR, '.config', 'xp-gate');
12
- const CONFIG_FILE = path.join(CONFIG_DIR, 'xp-gate.json');
13
- const TEMPLATE_DIR = path.join(HOME_DIR, '.config', 'opencode', 'git-hooks-template');
14
- const GLOBAL_HOOKS_DIR = path.join(CONFIG_DIR, 'hooks');
15
- const GLOBAL_ADAPTERS_DIR = path.join(CONFIG_DIR, 'adapters');
4
+ const { checkDeps, checkBash, autoInstallDeps, detectPlatform } = require('./detect-deps.js');
5
+ const {
6
+ HOME_DIR,
7
+ CONFIG_DIR,
8
+ CONFIG_FILE,
9
+ TEMPLATE_DIR,
10
+ GLOBAL_HOOKS_DIR,
11
+ GLOBAL_ADAPTERS_DIR,
12
+ } = require('./shared-paths.js');
16
13
 
17
14
  function copyHooks(srcDir, destDir) {
18
15
  ['pre-commit', 'pre-push'].forEach(hook => {
@@ -54,6 +51,35 @@ function logDeps(depCheck) {
54
51
  }
55
52
  }
56
53
 
54
+ /**
55
+ * Detect which AI agent platform is in use and check/auto-install deps.
56
+ * @param {string} platform - 'opencode' | 'claude-code' | 'qoder'
57
+ */
58
+ async function checkAndInstallDeps(platform) {
59
+ const depCheck = await checkDeps(platform);
60
+ if (depCheck.ok) {
61
+ logDeps(depCheck);
62
+ return depCheck;
63
+ }
64
+
65
+ // Auto-install missing deps
66
+ console.log(`Checking dependencies (platform: ${platform})...`);
67
+ const installResult = await autoInstallDeps(platform);
68
+ if (installResult.ok) {
69
+ console.log('Dependencies: OK (auto-installed)\n');
70
+ return { ok: true };
71
+ }
72
+
73
+ // Auto-install failed — report and continue with warning
74
+ logDeps(depCheck);
75
+ if (installResult.errors) {
76
+ for (const err of installResult.errors) {
77
+ console.warn(` Auto-install failed for ${err.name}: ${err.message}`);
78
+ }
79
+ }
80
+ return depCheck;
81
+ }
82
+
57
83
  function printUsage() {
58
84
  console.log('Choose installation mode:');
59
85
  console.log(' 1) Global — all git projects use the same hooks (recommended)');
@@ -228,7 +254,10 @@ async function init(args) {
228
254
  console.warn(` ${bashCheck.message}\n`);
229
255
  }
230
256
 
231
- logDeps(await checkDeps());
257
+ // Detect platform and check/auto-install dependencies
258
+ const platform = detectPlatform();
259
+ console.log(`Platform: ${platform}\n`);
260
+ await checkAndInstallDeps(platform);
232
261
 
233
262
  const installMode = args.includes('--global') ? 'global' :
234
263
  args.includes('--core-only') ? 'local' :
@@ -1,18 +1,14 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
4
3
  const https = require('https');
5
4
  const http = require('http');
6
5
  const { execSync } = require('child_process');
7
- const { checkDeps } = require('./detect-deps.js');
6
+ const { checkDeps, detectPlatform } = require('./detect-deps.js');
8
7
  const { downloadFromGitHub } = require('./download-skill.js');
9
8
  const { rollback } = require('./rollback.js');
9
+ const { HOME_DIR, CONFIG_DIR } = require('./shared-paths.js');
10
10
 
11
- // Cross-platform home directory resolution
12
- const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
13
-
14
- const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
15
- const SKILLS_DIR = path.join(HOME, '.config', 'opencode', 'skills');
11
+ const SKILLS_DIR = path.join(HOME_DIR, '.config', 'opencode', 'skills');
16
12
 
17
13
  const SKILLS_REGISTRY = {
18
14
  'sprint-flow': { repo: 'boyingliu01/xp-gate', path: 'skills/sprint-flow' },
@@ -24,7 +20,8 @@ const SKILLS_REGISTRY = {
24
20
  async function installSkill(name, options = {}) {
25
21
  const { offline = false, verbose = false, force = false } = options;
26
22
 
27
- const depCheck = await checkDeps();
23
+ const platform = detectPlatform();
24
+ const depCheck = await checkDeps(platform);
28
25
  if (!depCheck.ok) {
29
26
  if (depCheck.missing) {
30
27
  console.error(`Error: ${depCheck.missing} is required but not installed`);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared filesystem path constants used across CLI modules.
3
+ * Cross-platform home directory resolution with os.homedir() fallback.
4
+ *
5
+ * @intent Eliminate duplicate path constants in init.js / uninstall.js / detect-deps.js
6
+ * @covers Issue #107 — duplicate code between init.js and uninstall.js
7
+ */
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ /**
12
+ * Resolve the user's home directory cross-platform.
13
+ * Fallback chain: HOME → USERPROFILE → os.homedir()
14
+ */
15
+ const HOME_DIR = process.env.HOME || process.env.USERPROFILE || os.homedir();
16
+
17
+ const CONFIG_DIR = path.join(HOME_DIR, '.config', 'xp-gate');
18
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'xp-gate.json');
19
+ const TEMPLATE_DIR = path.join(HOME_DIR, '.config', 'opencode', 'git-hooks-template');
20
+ const GLOBAL_HOOKS_DIR = path.join(CONFIG_DIR, 'hooks');
21
+ const GLOBAL_ADAPTERS_DIR = path.join(CONFIG_DIR, 'adapters');
22
+
23
+ module.exports = {
24
+ HOME_DIR,
25
+ CONFIG_DIR,
26
+ CONFIG_FILE,
27
+ TEMPLATE_DIR,
28
+ GLOBAL_HOOKS_DIR,
29
+ GLOBAL_ADAPTERS_DIR,
30
+ };
package/lib/uninstall.js CHANGED
@@ -1,16 +1,15 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
4
3
  const crypto = require('crypto');
4
+ const {
5
+ HOME_DIR,
6
+ CONFIG_DIR,
7
+ CONFIG_FILE,
8
+ TEMPLATE_DIR,
9
+ GLOBAL_HOOKS_DIR,
10
+ GLOBAL_ADAPTERS_DIR,
11
+ } = require('./shared-paths.js');
5
12
 
6
- // Cross-platform home directory resolution
7
- const HOME_DIR = process.env.HOME || process.env.USERPROFILE || os.homedir();
8
-
9
- const CONFIG_DIR = path.join(HOME_DIR, '.config', 'xp-gate');
10
- const CONFIG_FILE = path.join(CONFIG_DIR, 'xp-gate.json');
11
- const TEMPLATE_DIR = path.join(HOME_DIR, '.config', 'opencode', 'git-hooks-template');
12
- const GLOBAL_HOOKS_DIR = path.join(CONFIG_DIR, 'hooks');
13
- const GLOBAL_ADAPTERS_DIR = path.join(CONFIG_DIR, 'adapters');
14
13
  const BACKUP_DIR = path.join(CONFIG_DIR, '.uninstall-backup');
15
14
 
16
15
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "./bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 6 quality gates, Sprint Flow, and Delphi multi-expert review.",
6
6
  "author": {
@@ -462,9 +462,12 @@ Phase 2 第一步必须执行 DELPHI-GATE 检查。没有 delphi-review APPROVED
462
462
  - 输入: phase-4-summary.md(验收结果)+ emergent-issues.md(如有)
463
463
  - 输出: `feedback-log.md`
464
464
  - **HARD-GATE**: Phase 5 不可跳过。Phase 4 完成后 → 必须进入 Phase 5 → 完成后才能进入 Phase 6。
465
- - **`learn` (gstack)** — Sprint 级复盘(这是 /learn 在本项目中的主要调用时机)
465
+ - **`learn` (gstack)** — Sprint 级复盘(**Phase 5 必须自动调用,不依赖手动触发**)
466
+ - **默认提炼模板**(无需用户额外输入):
467
+ > 提炼总结并保存可复用的经验教训,把大模型不知道、并且犯错后无法立即发现和纠正的知识保存下来。如果是对其他项目也有价值的,就保存成全局记忆,否则保存为项目记忆。
466
468
  - ralph-loop 已在 BUILD Phase 内部实现 per-REQ learn(permanent/contextual 分类)
467
469
  - Phase 5 额外进行 Sprint 级复盘,总结全 Phase 经验
470
+ - **learnings 自动注入**:`/learn export` 时自动 append 到 CLAUDE.md / AGENTS.md 末尾形成 `## Project Learnings` 章节
468
471
  - **`retro` (gstack)** — 工程回顾:提交历史、工作模式、代码质量趋势
469
472
  - **`systematic-debugging` (superpowers)** — 根因调试
470
473
 
@@ -620,10 +623,11 @@ Phase 2 第一步必须执行 DELPHI-GATE 检查。没有 delphi-review APPROVED
620
623
 
621
624
  3. **等待用户确认 checkpoint**(如适用)
622
625
 
623
- 4. **展示进度看板**:读取 `sprint-state.json`,使用 `@templates/sprint-progress-template.md` 渲染进度看板
626
+ 4. **展示进度看板**:执行 `node scripts/render-sprint-progress.cjs` 渲染进度看板
627
+ - 脚本自动读取 `.sprint-state/sprint-state.json` 并输出 ASCII 进度看板
624
628
  - 渲染规则:已完成阶段显示 ✅ + 耗时,当前阶段 🔄,待做 ⬜,跳过 ⏭️,失败 ❌
625
629
  - 进度条:`[████▓░░░░░░] {pct}%`(已完成数/总阶段数 11)
626
- - 下一步行动:根据当前阶段 + 状态,从模板的"下一步行动表"中查找对应提示
630
+ - 下一步行动:根据当前阶段 + 状态,自动查找对应提示
627
631
  - 输出物路径:列出 `outputs` 中已有的文件路径
628
632
  - 时机:每个 Phase 完成后的 transition 阶段自动展示,用户无需请求
629
633
  - 向后兼容:旧版 `sprint-state.json` 缺少 `phase_history` 时,从 `phase` 字段推断状态
@@ -792,17 +796,16 @@ Sprint state is persisted as JSON in `.sprint-state/sprint-state.json`:
792
796
 
793
797
  ```bash
794
798
  /sprint-flow --status
795
- # → 读取 .sprint-state/sprint-state.json
796
- # → 渲染进度看板(使用 @templates/sprint-progress-template.md)
799
+ # → 执行 node scripts/render-sprint-progress.cjs
800
+ # → 读取 .sprint-state/sprint-state.json 并渲染 ASCII 进度看板
797
801
  # → 不执行任何 Phase,仅展示当前状态
798
802
  # 适用场景:碎片时间恢复时快速查看进度、中断后确认当前阶段和下一步操作
799
803
  ```
800
804
 
801
805
  **行为规则**:
802
- - 读取 `.sprint-state/sprint-state.json` 获取 `id`, `task_description`, `phase`, `status`, `phase_history`, `outputs`, `isolation`
803
- - 扫描 `.sprint-state/phase-outputs/phase-*-summary.md` 获取各阶段 `status` `phase_name`
804
- - 如果 `sprint-state.json` 不存在 输出 `[INFO] 未找到活跃的 Sprint。请先运行 /sprint-flow "[需求描述]" 启动新 Sprint。`
805
- - 如果 `status == "completed"` → 展示完整看板 + `[INFO] Sprint 已完成。` + Sprint Summary 路径
806
+ - 执行 `node scripts/render-sprint-progress.cjs`(脚本自动处理所有渲染逻辑)
807
+ - 如果 `sprint-state.json` 不存在 → 脚本输出 `[INFO] 未找到活跃的 Sprint。请先运行 /sprint-flow "[需求描述]" 启动新 Sprint。`
808
+ - 如果 `status == "completed"` → 脚本输出完整看板 + `[INFO] Sprint 已完成。`
806
809
  - `--status` 可与其他参数组合:`--status --resume-from build` → 先展示状态,再提示 "将从 Phase 2 BUILD 继续"
807
810
  - 向后兼容:旧版 `sprint-state.json` 缺少 `phase_history`/`task_description` 时,按模板"向后兼容"规则渲染
808
811