@boyingliu01/xp-gate 0.5.3 → 0.7.0

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/bin/xp-gate.js CHANGED
@@ -22,7 +22,7 @@ const COMMANDS = {
22
22
  'install-skill': {
23
23
  description: 'Install a xp-gate skill from GitHub',
24
24
  fn: installSkill,
25
- usage: 'xp-gate install-skill <name>[@<version>] [--offline] [--verbose] [--force]'
25
+ usage: 'xp-gate install-skill <name>[@<version>] [--offline] [--verbose] [--force] [--platform opencode|qoder]'
26
26
  },
27
27
  'update-skill': {
28
28
  description: 'Update installed skill(s)',
@@ -53,6 +53,11 @@ const COMMANDS = {
53
53
  description: 'Run UI review for non-sprint developers (generates .ui-gate-result.json)',
54
54
  fn: null,
55
55
  usage: 'xp-gate ui-review'
56
+ },
57
+ 'audit': {
58
+ description: 'Gate audit logging (record, --tail, --stats)',
59
+ fn: null,
60
+ usage: 'xp-gate audit [--tail [N]|--stats|record --gate-id X --gate-name Y ...]'
56
61
  }
57
62
  };
58
63
 
@@ -98,7 +103,7 @@ function main() {
98
103
  const name = subargs[0];
99
104
  if (!name) {
100
105
  console.error('Error: Skill name required');
101
- console.error('Usage: xp-gate install-skill <name>[@<version>]');
106
+ console.error('Usage: xp-gate install-skill <name>[@<version>] [--platform opencode|qoder]');
102
107
  process.exit(1);
103
108
  return;
104
109
  }
@@ -153,20 +158,145 @@ function main() {
153
158
  process.exit(1);
154
159
  }
155
160
  }
161
+
162
+ if (command === 'audit') {
163
+ handleAudit(subargs);
164
+ return;
165
+ }
156
166
 
157
167
  console.error(`Unknown command: ${command}`);
158
168
  printHelp();
159
169
  process.exit(1);
160
170
  }
161
171
 
172
+ function handleAudit(args) {
173
+ const path = require('path');
174
+ const auditPath = path.join(__dirname, '..', 'lib', 'gate-audit.ts');
175
+
176
+ // --tail [N]
177
+ const tailIdx = args.indexOf('--tail');
178
+ if (tailIdx !== -1) {
179
+ const count = parseInt(args[tailIdx + 1] || '20', 10);
180
+ try {
181
+ const { readTailEntries } = require(auditPath);
182
+ const entries = readTailEntries(count);
183
+ if (entries.length === 0) {
184
+ console.log('No audit entries found.');
185
+ return;
186
+ }
187
+ printAuditTable(entries);
188
+ } catch (err) {
189
+ console.error('Error reading audit entries:', err.message);
190
+ process.exit(1);
191
+ }
192
+ return;
193
+ }
194
+
195
+ // --stats
196
+ if (args.includes('--stats')) {
197
+ try {
198
+ const { computeStats } = require(auditPath);
199
+ const stats = computeStats();
200
+ if (stats.length === 0) {
201
+ console.log('No audit data found.');
202
+ return;
203
+ }
204
+ printStatsTable(stats);
205
+ } catch (err) {
206
+ console.error('Error computing stats:', err.message);
207
+ process.exit(1);
208
+ }
209
+ return;
210
+ }
211
+
212
+ // record --gate-id X --gate-name Y --passed true/false ...
213
+ if (args[0] === 'record') {
214
+ try {
215
+ const { appendAuditEntry } = require(auditPath);
216
+ const rest = args.slice(1);
217
+ const opts = {};
218
+ for (let i = 0; i < rest.length; i++) {
219
+ if (rest[i].startsWith('--') && i + 1 < rest.length) {
220
+ opts[rest[i].slice(2)] = rest[i + 1];
221
+ i++;
222
+ }
223
+ }
224
+ const entry = {
225
+ timestamp: new Date().toISOString(),
226
+ gate_id: opts['gate-id'] || 'unknown',
227
+ gate_name: opts['gate-name'] || 'unknown',
228
+ passed: opts['passed'] === 'true',
229
+ issues_found: parseInt(opts['issues-found'] || '0', 10),
230
+ duration_ms: parseInt(opts['duration-ms'] || '0', 10),
231
+ trigger: opts['trigger'] || 'manual',
232
+ repo_path: process.cwd(),
233
+ commit_hash: opts['commit-hash'] || 'HEAD',
234
+ };
235
+ appendAuditEntry(entry);
236
+ } catch (err) {
237
+ console.error('Error recording audit entry:', err.message);
238
+ process.exit(1);
239
+ }
240
+ return;
241
+ }
242
+
243
+ console.error('Error: Unknown audit subcommand or missing flag');
244
+ console.error('Usage:');
245
+ console.error(' xp-gate audit record --gate-id X --gate-name Y --passed true/false --issues-found N --duration-ms N --trigger commit');
246
+ console.error(' xp-gate audit --tail [N] (default: 20)');
247
+ console.error(' xp-gate audit --stats');
248
+ process.exit(1);
249
+ }
250
+
251
+ function printAuditTable(entries) {
252
+ console.log('');
253
+ console.log('Recent Audit Entries:');
254
+ console.log('┌─────────────────────┬──────────┬─────────────────────┬────────┬────────┬───────────┬──────────┐');
255
+ console.log('│ Timestamp │ Gate │ Name │ Passed │ Issues │ Duration │ Trigger │');
256
+ console.log('├─────────────────────┼──────────┼─────────────────────┼────────┼────────┼───────────┼──────────┤');
257
+ for (const e of entries) {
258
+ const ts = e.timestamp ? e.timestamp.slice(0, 19) : 'N/A';
259
+ const gid = (e.gate_id || '').padEnd(8);
260
+ const gname = (e.gate_name || '').slice(0, 19).padEnd(19);
261
+ const pass = (e.passed ? 'Y' : 'N').padEnd(6);
262
+ const issues = String(e.issues_found ?? 0).padEnd(6);
263
+ const dur = String(e.duration_ms ?? 0).padEnd(9);
264
+ const trig = (e.trigger || 'manual').padEnd(8);
265
+ console.log(`│ ${ts} │ ${gid} │ ${gname} │ ${pass} │ ${issues} │ ${dur} │ ${trig} │`);
266
+ }
267
+ console.log('└─────────────────────┴──────────┴─────────────────────┴────────┴────────┴───────────┴──────────┘');
268
+ console.log(`Total: ${entries.length} entries`);
269
+ }
270
+
271
+ function printStatsTable(stats) {
272
+ console.log('');
273
+ console.log('Gate Statistics:');
274
+ console.log('┌──────────────────┬────────┬────────┬────────────┐');
275
+ console.log('│ Gate │ Pass% │ Avg ms │ Issues Avg │');
276
+ console.log('├──────────────────┼────────┼────────┼────────────┤');
277
+ for (const s of stats) {
278
+ const gid = (s.gate_id || '').slice(0, 16).padEnd(16);
279
+ const pp = (s.pass_pct || 'N/A').padEnd(6);
280
+ const avg = String(s.avg_ms ?? 0).padEnd(6);
281
+ const iss = String(s.avg_issues ?? 0).padEnd(10);
282
+ console.log(`│ ${gid} │ ${pp} │ ${avg} │ ${iss} │`);
283
+ }
284
+ console.log('└──────────────────┴────────┴────────┴────────────┘');
285
+ }
286
+
162
287
  function parseOptions(args) {
163
- const options = { offline: false, verbose: false, force: false, all: false, check: false };
164
- for (const arg of args) {
288
+ const options = { offline: false, verbose: false, force: false, all: false, check: false, platform: 'opencode' };
289
+ for (let i = 0; i < args.length; i++) {
290
+ const arg = args[i];
165
291
  if (arg === '--offline') options.offline = true;
166
292
  if (arg === '--verbose') options.verbose = true;
167
293
  if (arg === '--force') options.force = true;
168
294
  if (arg === '--all') options.all = true;
169
295
  if (arg === '--check') options.check = true;
296
+ if (arg === '--platform' && i + 1 < args.length) {
297
+ options.platform = args[i + 1];
298
+ i++;
299
+ }
170
300
  }
171
301
  return options;
172
302
  }
package/hooks/pre-push CHANGED
@@ -185,20 +185,29 @@ else
185
185
  for test_file in $ALL_PUSHED_TEST_FILES; do
186
186
  if [ -f "$test_file" ]; then
187
187
  # Count mock keyword references (precise patterns only)
188
+ # Use grep -o piped to wc -l for reliable single-number output (grep -c can emit
189
+ # multi-line output with filenames under some grep versions, causing bash arithmetic
190
+ # syntax errors). grep -o extracts each match on its own line, wc -l counts them.
188
191
  MOCK_COUNT=0
189
192
  for kw in 'jest\.mock' 'vi\.mock' 'jest\.spyOn' 'vi\.spyOn' 'jest\.fn' 'vi\.fn' \
190
193
  'mockResolvedValue' 'mockRejectedValue' 'mockReturnValue' 'mockImplementation' \
191
194
  'createMock' 'mockReset' 'mockClear' 'mockRestore' 'MagicMock' 'unittest\.mock' \
192
- '\.patch(' 'gomock' 'mockgen' '.EXPECT()'; do
193
- c=$(grep -o -c "$kw" "$test_file" 2>/dev/null || echo "0")
195
+ '\.patch(' 'gomock' 'mockgen' '\.EXPECT()'; do
196
+ c=$(grep -o "$kw" "$test_file" 2>/dev/null | wc -l || echo "0")
197
+ # Guard against empty/non-numeric values that break bash arithmetic
198
+ c=${c//[^0-9]/}
199
+ c=${c:-0}
194
200
  MOCK_COUNT=$((MOCK_COUNT + c))
195
201
  done
196
202
 
197
203
  # Count total non-empty, non-comment lines for density denominator
198
204
  TOTAL_LINES=$(grep -v '^\s*$' "$test_file" | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '^\s*#' | wc -l | awk '{print $1}')
205
+ TOTAL_LINES=${TOTAL_LINES//[^0-9]/}
206
+ TOTAL_LINES=${TOTAL_LINES:-0}
199
207
 
200
208
  if [ "$TOTAL_LINES" -gt 0 ] 2>/dev/null; then
201
209
  MOCK_DENSITY=$(awk "BEGIN {printf \"%.1f\", ($MOCK_COUNT / $TOTAL_LINES) * 100}")
210
+ MOCK_DENSITY=${MOCK_DENSITY:-"0"}
202
211
  else
203
212
  MOCK_DENSITY="0"
204
213
  fi
@@ -206,4 +206,38 @@ describe('detect-deps', () => {
206
206
  expect(result.ok).toBe(false);
207
207
  expect(result.versionMismatch.found).toBe('0.0.1');
208
208
  });
209
+
210
+ // --- Qoder platform tests ---
211
+
212
+ it('returns ok:true immediately for qoder platform (no hard deps)', async () => {
213
+ // Qoder has no superpowers/gstack dependency — should pass even with empty dirs
214
+ const { checkDeps } = require('../detect-deps');
215
+ const result = await checkDeps('qoder');
216
+ expect(result.ok).toBe(true);
217
+ expect(result.platform).toBe('qoder');
218
+ });
219
+
220
+ it('qoder platform passes even when superpowers/gstack are completely absent', async () => {
221
+ // No skill directories at all — qoder should still pass
222
+ const { checkDeps } = require('../detect-deps');
223
+ const result = await checkDeps('qoder');
224
+ expect(result.ok).toBe(true);
225
+ expect(result.missing).toBeUndefined();
226
+ });
227
+
228
+ it('claude-code platform behaves like default opencode (requires deps)', async () => {
229
+ // claude-code platform profile requires the same deps as opencode
230
+ const { checkDeps } = require('../detect-deps');
231
+ const result = await checkDeps('claude-code');
232
+ expect(result.ok).toBe(false);
233
+ expect(result.missing).toBe('superpowers');
234
+ });
235
+
236
+ it('unknown platform falls back to opencode profile', async () => {
237
+ const { checkDeps } = require('../detect-deps');
238
+ const result = await checkDeps('unknown-platform');
239
+ // Falls back to opencode profile → requires deps → fails
240
+ expect(result.ok).toBe(false);
241
+ expect(result.missing).toBe('superpowers');
242
+ });
209
243
  });
@@ -0,0 +1,199 @@
1
+ /**
2
+ * @test Gate Audit Logger
3
+ * @intent Verify JSONL audit logging for quality gate executions
4
+ * @covers AUDIT-001-01, AUDIT-001-02, AUDIT-001-03, AUDIT-001-04, AUDIT-001-05, AUDIT-001-06
5
+ */
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs';
8
+ import { join } from 'path';
9
+ import {
10
+ appendAuditEntry,
11
+ readTailEntries,
12
+ computeStats,
13
+ rotateIfNeeded,
14
+ type GateAuditEntry,
15
+ } from '../gate-audit';
16
+
17
+ const TEST_DIR = join(process.cwd(), '.xp-gate-test');
18
+
19
+ function makeEntry(overrides: Partial<GateAuditEntry> = {}): GateAuditEntry {
20
+ return {
21
+ timestamp: new Date().toISOString(),
22
+ gate_id: 'gate-1',
23
+ gate_name: 'code-quality',
24
+ passed: true,
25
+ issues_found: 0,
26
+ duration_ms: 100,
27
+ trigger: 'commit',
28
+ repo_path: TEST_DIR,
29
+ commit_hash: 'abc123',
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe('gate-audit', () => {
35
+ beforeEach(() => {
36
+ if (existsSync(TEST_DIR)) {
37
+ rmSync(TEST_DIR, { recursive: true, force: true });
38
+ }
39
+ mkdirSync(TEST_DIR, { recursive: true });
40
+ });
41
+
42
+ afterEach(() => {
43
+ if (existsSync(TEST_DIR)) {
44
+ rmSync(TEST_DIR, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ describe('appendAuditEntry', () => {
49
+ it('should create .xp-gate/ directory on first write', () => {
50
+ const repoRoot = join(TEST_DIR, 'new-project');
51
+ appendAuditEntry(makeEntry({ repo_path: repoRoot }), repoRoot);
52
+ expect(existsSync(join(repoRoot, '.xp-gate'))).toBe(true);
53
+ });
54
+
55
+ it('should write valid JSONL', () => {
56
+ const repoRoot = join(TEST_DIR, 'jsonl-test');
57
+ const entry = makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', gate_name: 'dup-code' });
58
+ appendAuditEntry(entry, repoRoot);
59
+
60
+ const logPath = join(repoRoot, '.xp-gate', 'audit.jsonl');
61
+ expect(existsSync(logPath)).toBe(true);
62
+
63
+ const content = readFileSync(logPath, 'utf8').trim();
64
+ const parsed = JSON.parse(content) as GateAuditEntry;
65
+ expect(parsed.gate_id).toBe('gate-2');
66
+ expect(parsed.gate_name).toBe('dup-code');
67
+ expect(typeof parsed.timestamp).toBe('string');
68
+ });
69
+
70
+ it('should append multiple entries as separate lines', () => {
71
+ const repoRoot = join(TEST_DIR, 'multi-test');
72
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1' }), repoRoot);
73
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2' }), repoRoot);
74
+
75
+ const logPath = join(repoRoot, '.xp-gate', 'audit.jsonl');
76
+ const lines = readFileSync(logPath, 'utf8').trim().split('\n');
77
+ expect(lines.length).toBe(2);
78
+ expect(JSON.parse(lines[0]).gate_id).toBe('gate-1');
79
+ expect(JSON.parse(lines[1]).gate_id).toBe('gate-2');
80
+ });
81
+
82
+ it('should never throw even when filesystem is broken', () => {
83
+ const badPath = '/nonexistent/path/that/cannot/exist/for/real';
84
+ expect(() => appendAuditEntry(makeEntry({ repo_path: badPath }), badPath)).not.toThrow();
85
+ });
86
+ });
87
+
88
+ describe('readTailEntries', () => {
89
+ it('should return last N entries', () => {
90
+ const repoRoot = join(TEST_DIR, 'tail-test');
91
+ for (let i = 1; i <= 30; i++) {
92
+ appendAuditEntry(
93
+ makeEntry({ repo_path: repoRoot, gate_id: `gate-${i % 3}`, duration_ms: i * 10 }),
94
+ repoRoot,
95
+ );
96
+ }
97
+
98
+ const tail = readTailEntries(5, repoRoot);
99
+ expect(tail.length).toBe(5);
100
+ expect(tail[0].duration_ms).toBe(260);
101
+ expect(tail[4].duration_ms).toBe(300);
102
+ });
103
+
104
+ it('should return empty array when log does not exist', () => {
105
+ const result = readTailEntries(10, join(TEST_DIR, 'empty-project'));
106
+ expect(result).toEqual([]);
107
+ });
108
+
109
+ it('should default to 20 entries', () => {
110
+ const repoRoot = join(TEST_DIR, 'default-tail');
111
+ for (let i = 0; i < 50; i++) {
112
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, duration_ms: i }), repoRoot);
113
+ }
114
+ const tail = readTailEntries(undefined, repoRoot);
115
+ expect(tail.length).toBe(20);
116
+ });
117
+ });
118
+
119
+ describe('computeStats', () => {
120
+ it('should aggregate per-gate statistics correctly', () => {
121
+ const repoRoot = join(TEST_DIR, 'stats-test');
122
+ // gate-1: 3 runs, 2 passed, durations 100+200+300
123
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: true, duration_ms: 100 }), repoRoot);
124
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: true, duration_ms: 200 }), repoRoot);
125
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-1', passed: false, duration_ms: 300 }), repoRoot);
126
+ // gate-2: 2 runs, 2 passed
127
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', passed: true, duration_ms: 50 }), repoRoot);
128
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-2', passed: true, duration_ms: 150 }), repoRoot);
129
+
130
+ const stats = computeStats(repoRoot);
131
+ expect(stats.length).toBe(2);
132
+
133
+ const g1 = stats.find(s => s.gate_id === 'gate-1')!;
134
+ expect(g1.pass_pct).toBe('66.7%');
135
+ expect(g1.avg_ms).toBe(200);
136
+
137
+ const g2 = stats.find(s => s.gate_id === 'gate-2')!;
138
+ expect(g2.pass_pct).toBe('100.0%');
139
+ expect(g2.avg_ms).toBe(100);
140
+ });
141
+
142
+ it('should return empty array when no log exists', () => {
143
+ const result = computeStats(join(TEST_DIR, 'no-stats'));
144
+ expect(result).toEqual([]);
145
+ });
146
+
147
+ it('should calculate avg_issues correctly', () => {
148
+ const repoRoot = join(TEST_DIR, 'issues-test');
149
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 5 }), repoRoot);
150
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 3 }), repoRoot);
151
+ appendAuditEntry(makeEntry({ repo_path: repoRoot, gate_id: 'gate-3', issues_found: 0 }), repoRoot);
152
+
153
+ const stats = computeStats(repoRoot);
154
+ expect(stats[0].avg_issues).toBe(2.67);
155
+ });
156
+ });
157
+
158
+ describe('rotateIfNeeded', () => {
159
+ it('should not rotate when file is under 10MB', () => {
160
+ const logPath = join(TEST_DIR, 'small-log', '.xp-gate', 'audit.jsonl');
161
+ mkdirSync(join(TEST_DIR, 'small-log', '.xp-gate'), { recursive: true });
162
+ writeFileSync(logPath, 'small content', 'utf8');
163
+
164
+ rotateIfNeeded(logPath);
165
+ expect(existsSync(logPath)).toBe(true);
166
+ expect(existsSync(`${logPath}.1`)).toBe(false);
167
+ });
168
+
169
+ it('should rotate when file exceeds 10MB', () => {
170
+ const logPath = join(TEST_DIR, 'big-log', '.xp-gate', 'audit.jsonl');
171
+ mkdirSync(join(TEST_DIR, 'big-log', '.xp-gate'), { recursive: true });
172
+
173
+ // Write > 10MB of data
174
+ const bigContent = 'x'.repeat(10 * 1024 * 1024 + 100);
175
+ writeFileSync(logPath, bigContent, 'utf8');
176
+
177
+ rotateIfNeeded(logPath);
178
+ expect(existsSync(`${logPath}.1`)).toBe(true);
179
+ });
180
+
181
+ it('should keep max 3 archives', () => {
182
+ const logPath = join(TEST_DIR, 'archive-test', '.xp-gate', 'audit.jsonl');
183
+ mkdirSync(join(TEST_DIR, 'archive-test', '.xp-gate'), { recursive: true });
184
+ const bigContent = 'x'.repeat(10 * 1024 * 1024 + 100);
185
+
186
+ // Create 3 rotations
187
+ for (let i = 0; i < 3; i++) {
188
+ writeFileSync(logPath, `${bigContent} round ${i}`, 'utf8');
189
+ rotateIfNeeded(logPath);
190
+ }
191
+
192
+ // Should have .1, .2, .3 but NOT .4
193
+ expect(existsSync(`${logPath}.1`)).toBe(true);
194
+ expect(existsSync(`${logPath}.2`)).toBe(true);
195
+ expect(existsSync(`${logPath}.3`)).toBe(true);
196
+ expect(existsSync(`${logPath}.4`)).toBe(false);
197
+ });
198
+ });
199
+ });
@@ -323,4 +323,49 @@ describe('install-skill', () => {
323
323
  expect(console.warn).not.toHaveBeenCalled();
324
324
  expect(console.error).toHaveBeenCalledWith('Error: Failed to download test-spec');
325
325
  });
326
+
327
+ // --- Qoder platform tests ---
328
+
329
+ function qoderSkillsDir() {
330
+ return path.join(tmpHome, '.qoder', 'skills');
331
+ }
332
+
333
+ it('qoder platform skips dep check (installs without superpowers/gstack)', async () => {
334
+ // Do NOT call setupValidDeps() — no superpowers/gstack present
335
+ mockHttpsGet({ statusCode: 200, body: '# Sprint Flow Qoder' });
336
+
337
+ const { installSkill } = require('../install-skill');
338
+ const result = await installSkill('sprint-flow', { platform: 'qoder' });
339
+
340
+ expect(result).toBe(0);
341
+ expect(console.log).toHaveBeenCalledWith('✓ sprint-flow installed');
342
+ });
343
+
344
+ it('qoder platform installs to ~/.qoder/skills/ instead of ~/.config/opencode/skills/', async () => {
345
+ mockHttpsGet({ statusCode: 200, body: '# Qoder Skill' });
346
+
347
+ const { installSkill } = require('../install-skill');
348
+ const result = await installSkill('sprint-flow', { platform: 'qoder' });
349
+
350
+ expect(result).toBe(0);
351
+ // Verify installed in Qoder directory
352
+ const qoderFile = path.join(qoderSkillsDir(), 'sprint-flow', 'SKILL.md');
353
+ expect(fs.existsSync(qoderFile)).toBe(true);
354
+ expect(fs.readFileSync(qoderFile, 'utf8')).toContain('Qoder Skill');
355
+
356
+ // Verify NOT installed in opencode directory
357
+ const opencodeFile = path.join(skillsDir(), 'sprint-flow', 'SKILL.md');
358
+ expect(fs.existsSync(opencodeFile)).toBe(false);
359
+ });
360
+
361
+ it('default platform still requires deps and installs to opencode dir', async () => {
362
+ // Without setupValidDeps, default platform should fail on dep check
363
+ const { installSkill } = require('../install-skill');
364
+ const result = await installSkill('sprint-flow');
365
+
366
+ expect(result).toBe(1);
367
+ expect(console.error).toHaveBeenCalledWith(
368
+ expect.stringContaining('superpowers is required')
369
+ );
370
+ });
326
371
  });
@@ -18,6 +18,14 @@ const REQUIRED_DEPS = [
18
18
  { name: 'gstack', minVersion: '1.0.0' }
19
19
  ];
20
20
 
21
+ // Platform-specific dependency profiles
22
+ // Qoder has no external skill dependencies (superpowers/gstack not required)
23
+ const PLATFORM_PROFILES = {
24
+ opencode: { requiredDeps: REQUIRED_DEPS, skillsDirs: [SKILLS_DIR, OPENCODE_DIR] },
25
+ qoder: { requiredDeps: [], skillsDirs: [path.join(HOME, '.qoder', 'skills')] },
26
+ 'claude-code': { requiredDeps: REQUIRED_DEPS, skillsDirs: [SKILLS_DIR, OPENCODE_DIR] },
27
+ };
28
+
21
29
  /**
22
30
  * Check if bash is available on the system.
23
31
  * XP-Gate hooks are bash scripts — Windows users need Git Bash installed.
@@ -84,12 +92,17 @@ function checkBash() {
84
92
  }
85
93
  }
86
94
 
87
- async function checkDeps() {
88
- for (const dep of REQUIRED_DEPS) {
89
- const possiblePaths = [
90
- path.join(SKILLS_DIR, dep.name),
91
- path.join(OPENCODE_DIR, dep.name)
92
- ];
95
+ async function checkDeps(platform = 'opencode') {
96
+ const profile = PLATFORM_PROFILES[platform] || PLATFORM_PROFILES.opencode;
97
+ const { requiredDeps, skillsDirs } = profile;
98
+
99
+ // Qoder and other platforms with no hard dependencies pass immediately
100
+ if (requiredDeps.length === 0) {
101
+ return { ok: true, platform };
102
+ }
103
+
104
+ for (const dep of requiredDeps) {
105
+ const possiblePaths = skillsDirs.map(dir => path.join(dir, dep.name));
93
106
 
94
107
  let depDir = null;
95
108
  for (const p of possiblePaths) {