@pzy560117/opentest 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,10 +26,19 @@ requirement / design
26
26
 
27
27
  ## Install
28
28
 
29
- Install English OpenTest skills for Codex in the current project:
29
+ Install the package, then run the initializer from any project:
30
30
 
31
31
  ```bash
32
- npx @pzy560117/opentest install --scope project --platform codex --language en
32
+ npm install -g @pzy560117/opentest
33
+ opentest init
34
+ ```
35
+
36
+ `opentest init` lets you choose the target tool, language, project/global scope, and project path. This is the recommended path for Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Qwen Code, and Qoder users.
37
+
38
+ You can still run fully scripted installs. For example, install Chinese OpenTest skills for Claude Code in the current project:
39
+
40
+ ```bash
41
+ opentest install --scope project --platform claude --language zh
33
42
  ```
34
43
 
35
44
  Install Chinese OpenTest skills for Qoder in a specific project:
@@ -98,6 +107,7 @@ Restart the target AI programming tool or open a new session after installation
98
107
 
99
108
  ## Package Contents
100
109
 
101
- - `assets/skills/`: Codex skill files
110
+ - `assets/skills/`: English skill files
111
+ - `assets/skills-zh/`: Chinese skill files
102
112
  - `assets/manifest.json`: published asset manifest
103
113
  - `bin/opentest.js`: installer CLI
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.4",
2
+ "version": "0.1.5",
3
3
  "languages": [
4
4
  {
5
5
  "id": "en",
package/bin/opentest.js CHANGED
@@ -3,6 +3,8 @@
3
3
  import { readFileSync } from 'fs';
4
4
  import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises';
5
5
  import path from 'path';
6
+ import { createInterface } from 'readline/promises';
7
+ import { stdin as input, stdout as output } from 'process';
6
8
  import { fileURLToPath } from 'url';
7
9
 
8
10
  const __filename = fileURLToPath(import.meta.url);
@@ -42,13 +44,14 @@ function usage(exitCode = 0) {
42
44
  const text = `OpenTest skill installer
43
45
 
44
46
  Usage:
47
+ opentest init [--scope <project|global>] [--platform <id>] [--language <id>] [--project <path>] [--force]
45
48
  opentest install [--scope <project|global>] [--platform <id>] [--language <id>] [--project <path>] [--global] [--force]
46
49
  opentest list [--language <id>]
47
50
 
48
51
  Options:
49
52
  --scope SCOPE Install scope: project or global (default: project)
50
- --platform ID Target platform: ${availableIds(manifest.platforms)} (default: codex)
51
- --language ID Skill language: ${availableIds(manifest.languages)} (default: en)
53
+ --platform ID Target platform: ${availableIds(manifest.platforms)} (auto-detected when possible)
54
+ --language ID Skill language: ${availableIds(manifest.languages)} (default: locale-aware)
52
55
  --project PATH Project root for project scope (default: current directory)
53
56
  --global Alias for --scope global --platform codex
54
57
  --force Overwrite existing opentest skill directories
@@ -61,11 +64,12 @@ function parseArgs(argv) {
61
64
  const [command, ...rest] = argv;
62
65
  const options = {
63
66
  command,
64
- scope: 'project',
65
- platform: 'codex',
66
- language: 'en',
67
+ scope: undefined,
68
+ platform: undefined,
69
+ language: undefined,
67
70
  force: false,
68
71
  project: process.cwd(),
72
+ explicitTarget: false,
69
73
  };
70
74
 
71
75
  for (let i = 0; i < rest.length; i += 1) {
@@ -73,28 +77,34 @@ function parseArgs(argv) {
73
77
  if (arg === '--global') {
74
78
  options.scope = 'global';
75
79
  options.platform = 'codex';
80
+ options.explicitTarget = true;
76
81
  } else if (arg === '--force') {
77
82
  options.force = true;
83
+ options.explicitTarget = true;
78
84
  } else if (arg === '--scope') {
79
85
  const value = rest[i + 1];
80
86
  if (!value) usage(1);
81
87
  options.scope = value;
88
+ options.explicitTarget = true;
82
89
  i += 1;
83
90
  } else if (arg === '--platform') {
84
91
  const value = rest[i + 1];
85
92
  if (!value) usage(1);
86
93
  options.platform = value;
94
+ options.explicitTarget = true;
87
95
  i += 1;
88
96
  } else if (arg === '--language') {
89
97
  const value = rest[i + 1];
90
98
  if (!value) usage(1);
91
99
  options.language = value;
100
+ options.explicitTarget = true;
92
101
  i += 1;
93
102
  } else if (arg === '--project') {
94
103
  const value = rest[i + 1];
95
104
  if (!value) usage(1);
96
105
  options.scope = 'project';
97
106
  options.project = path.resolve(value);
107
+ options.explicitTarget = true;
98
108
  i += 1;
99
109
  } else {
100
110
  usage(1);
@@ -116,6 +126,61 @@ function homeRelativePath(manifestPath) {
116
126
  return manifestPath.replace(/^~[\\/]/, '');
117
127
  }
118
128
 
129
+ function platformById(platformId) {
130
+ return manifest.platforms.find((entry) => entry.id === platformId);
131
+ }
132
+
133
+ function languageById(languageId) {
134
+ return manifest.languages.find((entry) => entry.id === languageId);
135
+ }
136
+
137
+ async function detectPlatform(projectRoot) {
138
+ const envHints = [
139
+ ['OPENTEST_PLATFORM', process.env.OPENTEST_PLATFORM],
140
+ ['CLAUDECODE', process.env.CLAUDECODE ? 'claude' : undefined],
141
+ ['CLAUDE_CODE', process.env.CLAUDE_CODE ? 'claude' : undefined],
142
+ ['CODEX', process.env.CODEX ? 'codex' : undefined],
143
+ ];
144
+ for (const [, value] of envHints) {
145
+ if (value && platformById(value)) {
146
+ return value;
147
+ }
148
+ }
149
+
150
+ for (const platform of manifest.platforms) {
151
+ if (await exists(path.join(projectRoot, platform.projectSkillsDir))) {
152
+ return platform.id;
153
+ }
154
+ }
155
+
156
+ return undefined;
157
+ }
158
+
159
+ function detectLanguage() {
160
+ const explicit = process.env.OPENTEST_LANGUAGE;
161
+ if (explicit && languageById(explicit)) {
162
+ return explicit;
163
+ }
164
+
165
+ const localeText = [
166
+ process.env.LC_ALL,
167
+ process.env.LC_MESSAGES,
168
+ process.env.LANG,
169
+ Intl.DateTimeFormat().resolvedOptions().locale,
170
+ ].filter(Boolean).join(' ').toLowerCase();
171
+
172
+ return localeText.includes('zh') ? 'zh' : 'en';
173
+ }
174
+
175
+ async function withInstallDefaults(options) {
176
+ return {
177
+ ...options,
178
+ scope: options.scope ?? 'project',
179
+ platform: options.platform ?? await detectPlatform(options.project) ?? 'codex',
180
+ language: options.language ?? detectLanguage(),
181
+ };
182
+ }
183
+
119
184
  function targetSkillsDir(options, platform) {
120
185
  if (options.scope === 'global') {
121
186
  return path.join(homeDir(), homeRelativePath(platform.globalSkillsDir));
@@ -150,6 +215,119 @@ async function exists(filePath) {
150
215
  return stat(filePath).then(() => true, () => false);
151
216
  }
152
217
 
218
+ function formatChoiceList(entries) {
219
+ return entries.map((entry, index) => `${index + 1}) ${entry.name} [${entry.id}]`).join('\n');
220
+ }
221
+
222
+ function matchChoice(entries, answer) {
223
+ const trimmed = answer.trim().toLowerCase();
224
+ if (!trimmed) return undefined;
225
+
226
+ const numeric = Number.parseInt(trimmed, 10);
227
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= entries.length) {
228
+ return entries[numeric - 1].id;
229
+ }
230
+
231
+ return entries.find((entry) => entry.id.toLowerCase() === trimmed)?.id;
232
+ }
233
+
234
+ async function askChoice(rl, label, entries, defaultId) {
235
+ const defaultEntry = entries.find((entry) => entry.id === defaultId) ?? entries[0];
236
+ for (;;) {
237
+ const answer = await rl.question(`${label}\n${formatChoiceList(entries)}\nChoose [${defaultEntry.id}]: `);
238
+ const selected = matchChoice(entries, answer) ?? (answer.trim() ? undefined : defaultEntry.id);
239
+ if (selected) {
240
+ return selected;
241
+ }
242
+ console.log(`Please enter a number or one of: ${availableIds(entries)}`);
243
+ }
244
+ }
245
+
246
+ async function askText(rl, label, defaultValue) {
247
+ const answer = await rl.question(`${label} [${defaultValue}]: `);
248
+ return answer.trim() || defaultValue;
249
+ }
250
+
251
+ async function askYesNo(rl, label, defaultValue = false) {
252
+ const suffix = defaultValue ? 'Y/n' : 'y/N';
253
+ for (;;) {
254
+ const answer = (await rl.question(`${label} [${suffix}]: `)).trim().toLowerCase();
255
+ if (!answer) return defaultValue;
256
+ if (['y', 'yes'].includes(answer)) return true;
257
+ if (['n', 'no'].includes(answer)) return false;
258
+ console.log('Please answer y or n.');
259
+ }
260
+ }
261
+
262
+ async function readPipedInput() {
263
+ return new Promise((resolve, reject) => {
264
+ let text = '';
265
+ input.setEncoding('utf8');
266
+ input.on('data', (chunk) => {
267
+ text += chunk;
268
+ });
269
+ input.on('end', () => resolve(text));
270
+ input.on('error', reject);
271
+ input.resume();
272
+ });
273
+ }
274
+
275
+ async function createPrompter() {
276
+ if (input.isTTY) {
277
+ return createInterface({ input, output });
278
+ }
279
+
280
+ const lines = (await readPipedInput()).split(/\r?\n/);
281
+ let index = 0;
282
+ return {
283
+ async question(prompt) {
284
+ output.write(prompt);
285
+ const answer = lines[index] ?? '';
286
+ index += 1;
287
+ return answer;
288
+ },
289
+ close() {},
290
+ };
291
+ }
292
+
293
+ async function hasManagedInstall(options, currentManifest) {
294
+ const platform = resolvePlatform(options.platform);
295
+ const target = targetSkillsDir(options, platform);
296
+ for (const skillDir of managedSkillDirs(currentManifest)) {
297
+ if (await exists(path.join(target, skillDir))) {
298
+ return true;
299
+ }
300
+ }
301
+ return false;
302
+ }
303
+
304
+ async function promptInstallOptions(options, currentManifest) {
305
+ const defaults = await withInstallDefaults(options);
306
+ const rl = await createPrompter();
307
+
308
+ try {
309
+ console.log('OpenTest init\n');
310
+ defaults.platform = await askChoice(rl, 'Target AI coding tool:', manifest.platforms, defaults.platform);
311
+ defaults.language = await askChoice(rl, 'Skill language:', manifest.languages, defaults.language);
312
+ defaults.scope = await askChoice(rl, 'Install scope:', [
313
+ { id: 'project', name: 'Current project' },
314
+ { id: 'global', name: 'User global' },
315
+ ], defaults.scope);
316
+
317
+ if (defaults.scope === 'project') {
318
+ defaults.project = path.resolve(await askText(rl, 'Project root', defaults.project));
319
+ }
320
+
321
+ if (!defaults.force && await hasManagedInstall(defaults, currentManifest)) {
322
+ defaults.force = await askYesNo(rl, 'OpenTest skills already exist. Overwrite managed OpenTest directories?', false);
323
+ }
324
+
325
+ return defaults;
326
+ } finally {
327
+ rl.close();
328
+ }
329
+ }
330
+
153
331
  async function copyManifestFile(sourceRoot, targetRoot, relativePath) {
154
332
  const src = path.join(sourceRoot, relativePath);
155
333
  const dest = path.join(targetRoot, relativePath);
@@ -207,17 +385,20 @@ async function main() {
207
385
  usage(0);
208
386
  }
209
387
 
210
- const language = resolveLanguage(options.language);
211
- const platform = resolvePlatform(options.platform);
212
- resolveScope(options.scope);
213
-
214
388
  if (options.command === 'list') {
389
+ const language = resolveLanguage(options.language ?? detectLanguage());
215
390
  await listSkills(language);
216
391
  return;
217
392
  }
218
393
 
394
+ if (options.command === 'init' || (options.command === 'install' && !options.explicitTarget && process.stdin.isTTY)) {
395
+ const promptedOptions = await promptInstallOptions(options, manifest);
396
+ await install(promptedOptions, manifest);
397
+ return;
398
+ }
399
+
219
400
  if (options.command === 'install') {
220
- await install(options, manifest);
401
+ await install(await withInstallDefaults(options), manifest);
221
402
  return;
222
403
  }
223
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pzy560117/opentest",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "OpenTest quality evidence lifecycle skills for Codex",
5
5
  "keywords": [
6
6
  "opentest",
@@ -418,6 +418,42 @@ async function assertInstallBehavior() {
418
418
  noForceResult.stderr.includes('already exists. Re-run with --force to overwrite.'),
419
419
  `[INSTALL] expected overwrite guidance for existing skill\n${noForceResult.stderr}`,
420
420
  );
421
+
422
+ const detectedProject = await makeTempDir('opentest-detected-');
423
+ const detectedResult = assertCliSucceeds(['install', '--project', detectedProject], {
424
+ env: {
425
+ OPENTEST_PLATFORM: 'claude',
426
+ OPENTEST_LANGUAGE: 'zh',
427
+ },
428
+ });
429
+ await assertExists(
430
+ path.join(detectedProject, '.claude', 'skills', 'opentest', 'SKILL.md'),
431
+ '[INSTALL] OPENTEST_PLATFORM=claude must install project skills into .claude/skills',
432
+ );
433
+ assert(detectedResult.stdout.includes('Platform: Claude Code'), '[INSTALL] detected output must include Platform: Claude Code');
434
+ assert(detectedResult.stdout.includes('Language: 中文'), '[INSTALL] detected output must include Language: 中文');
435
+ }
436
+
437
+ async function assertInitBehavior() {
438
+ const project = await makeTempDir('opentest-init-');
439
+ const initResult = assertCliSucceeds(['init'], {
440
+ input: `2\n2\n1\n${project}\n`,
441
+ });
442
+
443
+ await assertExists(
444
+ path.join(project, '.claude', 'skills', 'opentest', 'SKILL.md'),
445
+ '[INIT] interactive init must install Claude project skills when Claude Code is selected',
446
+ );
447
+ await assertExists(
448
+ path.join(project, '.claude', 'skills', 'opentest', 'scripts', 'opentest-state.sh'),
449
+ '[INIT] interactive init must install shared OpenTest scripts',
450
+ );
451
+ assert(initResult.stdout.includes('OpenTest init'), '[INIT] output must show init wizard heading');
452
+ assert(initResult.stdout.includes('Target AI coding tool:'), '[INIT] output must prompt for platform');
453
+ assert(initResult.stdout.includes('Skill language:'), '[INIT] output must prompt for language');
454
+ assert(initResult.stdout.includes('Install scope:'), '[INIT] output must prompt for scope');
455
+ assert(initResult.stdout.includes('Platform: Claude Code'), '[INIT] output must include selected platform');
456
+ assert(initResult.stdout.includes('Language: 中文'), '[INIT] output must include selected language');
421
457
  }
422
458
 
423
459
  runManifestPathRelativitySelfCheck();
@@ -431,6 +467,7 @@ assertCliRejects(['install', '--language', 'jp'], 'Available languages: en, zh')
431
467
  assertCliRejects(['install', '--platform', 'invalid'], 'Available platforms: codex, claude, cursor, opencode, gemini, qwen, qoder');
432
468
  assertCliRejects(['install', '--scope', 'team'], 'Available scopes: project, global');
433
469
  await assertInstallBehavior();
470
+ await assertInitBehavior();
434
471
 
435
472
  for (const assetPath of [...localizedFiles(), ...sharedAssetFiles()]) {
436
473
  const fullPath = path.join('assets', assetPath);