@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 +13 -3
- package/assets/manifest.json +1 -1
- package/bin/opentest.js +191 -10
- package/package.json +1 -1
- package/scripts/smoke-test.js +37 -0
package/README.md
CHANGED
|
@@ -26,10 +26,19 @@ requirement / design
|
|
|
26
26
|
|
|
27
27
|
## Install
|
|
28
28
|
|
|
29
|
-
Install
|
|
29
|
+
Install the package, then run the initializer from any project:
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
|
|
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/`:
|
|
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
|
package/assets/manifest.json
CHANGED
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)} (
|
|
51
|
-
--language ID Skill language: ${availableIds(manifest.languages)} (default:
|
|
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:
|
|
65
|
-
platform:
|
|
66
|
-
language:
|
|
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
package/scripts/smoke-test.js
CHANGED
|
@@ -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);
|