@metaphorli/pingcode-cli 0.3.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.
package/bin/install.js ADDED
@@ -0,0 +1,546 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const readline = require("node:readline");
7
+ const { stdin, stdout } = require("node:process");
8
+
9
+ const packageRoot = path.resolve(__dirname, "..");
10
+ const SKILL = {
11
+ name: "pingcode",
12
+ entries: ["skills/pingcode/SKILL.md", "skills/pingcode/references"],
13
+ };
14
+
15
+ const WRAPPER_PATH_BLOCK = "# pingcode-cli PATH";
16
+
17
+ const AGENT_KEYS = ["codex", "opencode"];
18
+
19
+ function defaultAgentRoots() {
20
+ const home = os.homedir();
21
+ const codexHome = process.env.CODEX_HOME
22
+ ? path.resolve(process.env.CODEX_HOME)
23
+ : path.join(home, ".codex");
24
+ return {
25
+ codex: {
26
+ label: "Codex",
27
+ agentHome: codexHome,
28
+ skillsRoot: path.join(codexHome, "skills"),
29
+ },
30
+ opencode: {
31
+ label: "OpenCode",
32
+ agentHome: path.join(home, ".config", "opencode"),
33
+ skillsRoot: path.join(home, ".config", "opencode", "skills"),
34
+ },
35
+ };
36
+ }
37
+
38
+ function projectAgentRoots() {
39
+ const cwd = process.cwd();
40
+ return {
41
+ codex: {
42
+ label: "Codex",
43
+ skillsRoot: path.join(cwd, ".codex", "skills"),
44
+ },
45
+ opencode: {
46
+ label: "OpenCode",
47
+ skillsRoot: path.join(cwd, ".opencode", "skills"),
48
+ },
49
+ };
50
+ }
51
+
52
+ function usage() {
53
+ return [
54
+ "Usage: npx @metaphorli/pingcode-cli [--force] [--target <dir>]",
55
+ " [--codex-only|--opencode-only]",
56
+ " [--interactive|--non-interactive]",
57
+ "",
58
+ "Default behavior installs the PingCode skill",
59
+ "pingcode",
60
+ "only into supported agent homes that already exist for the current user:",
61
+ " Codex: ~/.codex/skills/pingcode",
62
+ " OpenCode: ~/.config/opencode/skills/pingcode",
63
+ "",
64
+ "Project-level OpenCode install is supported via --target:",
65
+ " npx @metaphorli/pingcode-cli --target \".opencode/skills\" --force",
66
+ "",
67
+ "Interactive install lets you choose global/project scope and agents:",
68
+ " npx @metaphorli/pingcode-cli --interactive",
69
+ "",
70
+ "Non-interactive auto-install (useful for CI/scripts):",
71
+ " npx @metaphorli/pingcode-cli --non-interactive",
72
+ "",
73
+ "Options:",
74
+ " --force Overwrite existing installs in every selected root",
75
+ " --target DIR Install only into DIR (skips the multi-root flow)",
76
+ " --codex-only Install only into the Codex skills root",
77
+ " --opencode-only Install only into the OpenCode skills root",
78
+ " --interactive Prompt for install scope and agent selection",
79
+ " --non-interactive Skip prompts and use the default auto-install behavior",
80
+ " -h, --help Show this help",
81
+ "",
82
+ "CODEX_HOME overrides the Codex root only.",
83
+ ].join("\n");
84
+ }
85
+
86
+ function parseArgs(argv) {
87
+ const options = {
88
+ force: false,
89
+ target: null,
90
+ only: null,
91
+ help: false,
92
+ interactive: false,
93
+ nonInteractive: false,
94
+ };
95
+ const onlyFlags = {
96
+ "--codex-only": "codex",
97
+ "--opencode-only": "opencode",
98
+ };
99
+ for (let index = 0; index < argv.length; index += 1) {
100
+ const arg = argv[index];
101
+ if (arg === "--force") {
102
+ options.force = true;
103
+ } else if (arg === "--target") {
104
+ const value = argv[index + 1];
105
+ if (!value) {
106
+ throw new Error("--target requires a directory");
107
+ }
108
+ options.target = path.resolve(value);
109
+ index += 1;
110
+ } else if (arg === "--help" || arg === "-h") {
111
+ options.help = true;
112
+ } else if (arg === "--interactive") {
113
+ options.interactive = true;
114
+ } else if (arg === "--non-interactive") {
115
+ options.nonInteractive = true;
116
+ } else if (Object.prototype.hasOwnProperty.call(onlyFlags, arg)) {
117
+ if (options.only && options.only !== onlyFlags[arg]) {
118
+ throw new Error(
119
+ "Only one of --codex-only / --opencode-only may be set",
120
+ );
121
+ }
122
+ options.only = onlyFlags[arg];
123
+ } else {
124
+ throw new Error(`Unknown option: ${arg}`);
125
+ }
126
+ }
127
+ if (options.target && options.only) {
128
+ throw new Error("--target cannot be combined with --codex-only / --opencode-only");
129
+ }
130
+ if (options.interactive && (options.target || options.only)) {
131
+ throw new Error("--interactive cannot be combined with --target or --*-only flags");
132
+ }
133
+ if (options.interactive && options.nonInteractive) {
134
+ throw new Error("--interactive and --non-interactive cannot be combined");
135
+ }
136
+ return options;
137
+ }
138
+
139
+ function shellQuote(value) {
140
+ if (/^[A-Za-z0-9_/:=.,+-]+$/.test(value)) {
141
+ return value;
142
+ }
143
+ return `'${value.replace(/'/g, "'\\''")}'`;
144
+ }
145
+
146
+ function rewriteDocFile(filePath) {
147
+ if (!fs.existsSync(filePath)) {
148
+ return;
149
+ }
150
+ const cliCommand = "pingcode";
151
+ const ctxCommand = "pingcode context init";
152
+ const original = fs.readFileSync(filePath, "utf8");
153
+ const rewritten = original
154
+ // Defensive: absolute paths from historical installs
155
+ .replace(/node\s+\S*\/scripts\/pingcode-ctx\.js/g, ctxCommand)
156
+ .replace(/node\s+\S*\/scripts\/pingcode\.js/g, cliCommand)
157
+ // Python3 legacy
158
+ .replaceAll("python3 scripts/pingcode_ctx.py", ctxCommand)
159
+ .replaceAll("python3 scripts/pingcode.py", cliCommand)
160
+ // Literal node scripts references (relative paths)
161
+ .replaceAll("node scripts/pingcode-ctx.js", ctxCommand)
162
+ .replaceAll("node scripts/pingcode.js", cliCommand);
163
+ if (rewritten !== original) {
164
+ fs.writeFileSync(filePath, rewritten);
165
+ }
166
+ }
167
+
168
+ function installSubSkill(parentDir, subSkill) {
169
+ const target = path.join(parentDir, subSkill.name);
170
+ fs.rmSync(target, { recursive: true, force: true });
171
+ fs.mkdirSync(target, { recursive: true });
172
+ for (const entry of subSkill.entries) {
173
+ const source = path.join(packageRoot, entry);
174
+ const destination = path.join(target, path.basename(entry));
175
+ fs.cpSync(source, destination, { recursive: true, force: true });
176
+ }
177
+ rewriteDocFile(path.join(target, "SKILL.md"));
178
+ return target;
179
+ }
180
+
181
+ function installToTarget(targetDir, options) {
182
+ const oldAliases = ["pingcode-auth", "pingcode-ctx", "pingcode-workitem"];
183
+
184
+ // Detect existing installs: the new pingcode skill or any old alias directories
185
+ const existing = [path.join(targetDir, SKILL.name)]
186
+ .concat(oldAliases.map((name) => path.join(targetDir, name)))
187
+ .filter((dir) => fs.existsSync(dir));
188
+
189
+ if (existing.length > 0 && !options.force) {
190
+ const error = new Error(
191
+ `PingCode skills already exist at ${existing[0]}. Re-run with --force to overwrite them.`,
192
+ );
193
+ error.code = "EEXIST_TARGET";
194
+ throw error;
195
+ }
196
+
197
+ // Clean up old alias directories unconditionally before installing
198
+ for (const alias of oldAliases) {
199
+ const aliasDir = path.join(targetDir, alias);
200
+ if (fs.existsSync(aliasDir)) {
201
+ fs.rmSync(aliasDir, { recursive: true, force: true });
202
+ }
203
+ }
204
+
205
+ fs.mkdirSync(targetDir, { recursive: true });
206
+
207
+ const result = {
208
+ name: SKILL.name,
209
+ target: installSubSkill(targetDir, SKILL),
210
+ };
211
+
212
+ installGlobalWrapper();
213
+ return { target: targetDir, subTargets: [result] };
214
+ }
215
+
216
+ function printCredentialGuidance() {
217
+ console.log("");
218
+ console.log("Configure PingCode credentials before use:");
219
+ console.log(' export PINGCODE_CLIENT_ID="..."');
220
+ console.log(' export PINGCODE_CLIENT_SECRET="..."');
221
+ }
222
+
223
+ function installGlobalWrapper() {
224
+ // On Windows, only print guidance — global wrapper via npm install -g
225
+ if (process.platform === "win32") {
226
+ console.log("");
227
+ console.log("For a global pingcode command on Windows, run:");
228
+ console.log(" npm install -g @metaphorli/pingcode-cli");
229
+ return;
230
+ }
231
+
232
+ const home = os.homedir();
233
+ const binDir = path.join(home, ".local", "bin");
234
+ const wrapperPath = path.join(binDir, "pingcode");
235
+ const pingcodeScript = path.join(packageRoot, "scripts", "pingcode.js");
236
+
237
+ // Create ~/.local/bin if it does not exist
238
+ try {
239
+ fs.mkdirSync(binDir, { recursive: true });
240
+ } catch (err) {
241
+ console.warn(`Could not create ${binDir}: ${err.message}`);
242
+ console.warn("Skipping global pingcode wrapper.");
243
+ return;
244
+ }
245
+
246
+ // Write the POSIX wrapper script
247
+ const wrapperContent = [
248
+ "#!/bin/sh",
249
+ `exec node ${shellQuote(pingcodeScript)} "$@"`,
250
+ "",
251
+ ].join("\n");
252
+
253
+ try {
254
+ fs.writeFileSync(wrapperPath, wrapperContent, { mode: 0o755 });
255
+ } catch (err) {
256
+ console.warn(`Could not write ${wrapperPath}: ${err.message}`);
257
+ console.warn("Skipping global pingcode wrapper.");
258
+ return;
259
+ }
260
+
261
+ console.log("");
262
+ console.log(`Global wrapper installed: ${wrapperPath}`);
263
+
264
+ // Append ~/.local/bin to existing shell profiles behind a guard comment
265
+ const pathLine = 'export PATH="$HOME/.local/bin:$PATH"';
266
+ const block = [
267
+ WRAPPER_PATH_BLOCK,
268
+ pathLine,
269
+ ].join("\n");
270
+
271
+ const profiles = [".bashrc", ".zshrc", ".bash_profile", ".zprofile"];
272
+ let anyUpdated = false;
273
+
274
+ for (const profile of profiles) {
275
+ const profilePath = path.join(home, profile);
276
+ if (!fs.existsSync(profilePath)) {
277
+ continue;
278
+ }
279
+ try {
280
+ const content = fs.readFileSync(profilePath, "utf8");
281
+ if (content.includes(WRAPPER_PATH_BLOCK)) {
282
+ // Already present, skip
283
+ anyUpdated = true;
284
+ continue;
285
+ }
286
+ const newContent = content.trimEnd() + "\n\n" + block + "\n";
287
+ fs.writeFileSync(profilePath, newContent);
288
+ console.log(` Added ~/.local/bin to ${profile}`);
289
+ anyUpdated = true;
290
+ } catch (err) {
291
+ console.warn(` Could not update ${profile}: ${err.message}`);
292
+ }
293
+ }
294
+
295
+ if (!anyUpdated) {
296
+ console.log("");
297
+ console.log("No writable shell profile found. Add this line to your shell profile manually:");
298
+ console.log(` ${pathLine}`);
299
+ }
300
+ }
301
+
302
+ function existingAgentKeys(roots) {
303
+ return AGENT_KEYS.filter((key) => fs.existsSync(roots[key].agentHome));
304
+ }
305
+
306
+ function runMultiRootInstall(options) {
307
+ const roots = defaultAgentRoots();
308
+ const keys = options.only ? [options.only] : existingAgentKeys(roots);
309
+ const successes = [];
310
+ const failures = [];
311
+ const skipped = options.only
312
+ ? []
313
+ : AGENT_KEYS.filter((key) => !keys.includes(key)).map((key) => roots[key]);
314
+
315
+ for (const key of keys) {
316
+ const root = roots[key];
317
+ const target = root.skillsRoot;
318
+ try {
319
+ const result = installToTarget(target, options);
320
+ successes.push({
321
+ key,
322
+ label: root.label,
323
+ target: result.target,
324
+ subTargets: result.subTargets,
325
+ });
326
+ } catch (error) {
327
+ failures.push({
328
+ key,
329
+ label: root.label,
330
+ target,
331
+ message: error.message,
332
+ });
333
+ }
334
+ }
335
+
336
+ console.log("Install summary:");
337
+ for (const item of successes) {
338
+ console.log(` [ok] ${item.label}: ${item.target}`);
339
+ }
340
+ for (const item of skipped) {
341
+ console.log(` [skip] ${item.label}: ${item.agentHome} does not exist`);
342
+ }
343
+ for (const item of failures) {
344
+ console.error(` [fail] ${item.label}: ${item.target}`);
345
+ console.error(` ${item.message}`);
346
+ }
347
+
348
+ if (successes.length > 0) {
349
+ printCredentialGuidance();
350
+ }
351
+
352
+ if (keys.length === 0) {
353
+ console.log("");
354
+ console.log("No supported agent directories were found for the current user.");
355
+ console.log("Create an agent home first, or use --target DIR to install to a custom location.");
356
+ return 0;
357
+ }
358
+ if (failures.length === 0) {
359
+ return 0;
360
+ }
361
+ if (successes.length === 0) {
362
+ return 1;
363
+ }
364
+ // Partial success: surface a non-zero exit so CI / agents can detect it.
365
+ return 2;
366
+ }
367
+
368
+ function runSingleTargetInstall(options) {
369
+ const target = options.target;
370
+ try {
371
+ const result = installToTarget(target, options);
372
+ console.log(`Installed PingCode skill to ${result.target}`);
373
+ printCredentialGuidance();
374
+ return 0;
375
+ } catch (error) {
376
+ if (error.code === "EEXIST_TARGET") {
377
+ console.error(error.message);
378
+ return 1;
379
+ }
380
+ throw error;
381
+ }
382
+ }
383
+
384
+ function agentRootsForScope(scope) {
385
+ if (scope === "project") {
386
+ return projectAgentRoots();
387
+ }
388
+ return defaultAgentRoots();
389
+ }
390
+
391
+ function buildTargets(scope, keys) {
392
+ const roots = agentRootsForScope(scope);
393
+ return keys.map((key) => ({
394
+ key,
395
+ label: roots[key].label,
396
+ target: roots[key].skillsRoot,
397
+ }));
398
+ }
399
+
400
+ function printInteractiveSummary(successes, failures, scope) {
401
+ console.log("");
402
+ console.log(`Install summary (${scope}):`);
403
+ for (const item of successes) {
404
+ console.log(` [ok] ${item.label}: ${item.target}`);
405
+ }
406
+ for (const item of failures) {
407
+ console.error(` [fail] ${item.label}: ${item.target}`);
408
+ console.error(` ${item.message}`);
409
+ }
410
+ if (successes.length > 0) {
411
+ printCredentialGuidance();
412
+ }
413
+ }
414
+
415
+ function runInteractiveInstall(options) {
416
+ const rl = readline.createInterface({ input: stdin, output: stdout });
417
+ return new Promise((resolve, reject) => {
418
+ let scope = null;
419
+ let selectedKeys = null;
420
+
421
+ function finish(error, code) {
422
+ rl.close();
423
+ if (error) {
424
+ reject(error);
425
+ } else {
426
+ resolve(code);
427
+ }
428
+ }
429
+
430
+ function doInstall() {
431
+ try {
432
+ const targets = buildTargets(scope, selectedKeys);
433
+ const successes = [];
434
+ const failures = [];
435
+
436
+ console.log("");
437
+ console.log("Installing...");
438
+ for (const target of targets) {
439
+ try {
440
+ const result = installToTarget(target.target, options);
441
+ successes.push({ ...target, subTargets: result.subTargets });
442
+ } catch (err) {
443
+ failures.push({ ...target, message: err.message });
444
+ }
445
+ }
446
+
447
+ printInteractiveSummary(successes, failures, scope);
448
+
449
+ if (successes.length === 0) {
450
+ finish(null, 1);
451
+ } else if (failures.length === 0) {
452
+ finish(null, 0);
453
+ } else {
454
+ finish(null, 2);
455
+ }
456
+ } catch (err) {
457
+ finish(err);
458
+ }
459
+ }
460
+
461
+ function askAgents() {
462
+ const roots = defaultAgentRoots();
463
+ console.log("");
464
+ console.log("Select agents to install (comma-separated numbers, default: all):");
465
+ AGENT_KEYS.forEach((key, index) => {
466
+ console.log(` ${index + 1}) ${roots[key].label}`);
467
+ });
468
+ rl.question(`Enter choices (1-${AGENT_KEYS.length}): `, (answer) => {
469
+ const trimmed = answer.trim();
470
+ if (!trimmed) {
471
+ selectedKeys = [...AGENT_KEYS];
472
+ doInstall();
473
+ return;
474
+ }
475
+ const selected = new Set();
476
+ for (const part of trimmed.split(",")) {
477
+ const num = parseInt(part.trim(), 10);
478
+ if (Number.isNaN(num) || num < 1 || num > AGENT_KEYS.length) {
479
+ continue;
480
+ }
481
+ selected.add(AGENT_KEYS[num - 1]);
482
+ }
483
+ if (selected.size === 0) {
484
+ finish(new Error("No valid agents selected"));
485
+ return;
486
+ }
487
+ selectedKeys = [...selected];
488
+ doInstall();
489
+ });
490
+ }
491
+
492
+ function askScope() {
493
+ console.log("");
494
+ console.log("Select install scope:");
495
+ console.log(" 1) Global - install under home directory (e.g. ~/.config/opencode/skills)");
496
+ console.log(" 2) Project-level - install under current directory (e.g. ./.opencode/skills)");
497
+ rl.question("Enter choice (1-2, default: 1): ", (answer) => {
498
+ const trimmed = answer.trim();
499
+ if (!trimmed || trimmed === "1") {
500
+ scope = "global";
501
+ } else if (trimmed === "2") {
502
+ scope = "project";
503
+ } else {
504
+ finish(new Error(`Invalid scope choice: ${trimmed}`));
505
+ return;
506
+ }
507
+ askAgents();
508
+ });
509
+ }
510
+
511
+ askScope();
512
+ });
513
+ }
514
+
515
+ async function main() {
516
+ const options = parseArgs(process.argv.slice(2));
517
+ if (options.help) {
518
+ console.log(usage());
519
+ return 0;
520
+ }
521
+ let code;
522
+ if (options.interactive) {
523
+ code = await runInteractiveInstall(options);
524
+ } else if (options.target || options.only || options.nonInteractive) {
525
+ if (options.target) {
526
+ code = runSingleTargetInstall(options);
527
+ } else {
528
+ code = runMultiRootInstall(options);
529
+ }
530
+ } else if (process.stdin.isTTY) {
531
+ code = await runInteractiveInstall(options);
532
+ } else {
533
+ code = runMultiRootInstall(options);
534
+ }
535
+ installGlobalWrapper();
536
+ return code;
537
+ }
538
+
539
+ main().then((code) => {
540
+ process.exitCode = code;
541
+ }).catch((error) => {
542
+ console.error(`error: ${error.message}`);
543
+ console.error("");
544
+ console.error(usage());
545
+ process.exitCode = 1;
546
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@metaphorli/pingcode-cli",
3
+ "version": "0.3.2",
4
+ "description": "PingCode CLI and Codex skill, written in Node.js.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/metaphor/pingcode-cli.git"
12
+ },
13
+ "homepage": "https://github.com/metaphor/pingcode-cli#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/metaphor/pingcode-cli/issues"
16
+ },
17
+ "bin": {
18
+ "pingcode": "scripts/pingcode.js",
19
+ "pingcode-cli": "bin/install.js"
20
+ },
21
+ "files": [
22
+ "README.md",
23
+ "skills/pingcode/SKILL.md",
24
+ "skills/pingcode/references/",
25
+ "scripts/",
26
+ "bin/install.js"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test tests/test_pingcode.js tests/test_install.js tests/test_context.js tests/test_workitem.js tests/test_auth.js",
30
+ "pack:check": "npm pack --dry-run"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "keywords": [
36
+ "codex",
37
+ "skill",
38
+ "pingcode",
39
+ "ai-agent",
40
+ "cli"
41
+ ]
42
+ }