opencode-agent-skills-md 1.0.1 → 1.1.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/dist/cli.mjs +770 -0
- package/dist/plugin.mjs +1138 -0
- package/dist/src/cli/config.d.ts +144 -0
- package/dist/src/cli/install.d.ts +33 -0
- package/dist/src/cli/main.d.ts +11 -0
- package/dist/src/cli/real-fs.d.ts +6 -0
- package/dist/src/cli/status.d.ts +34 -0
- package/dist/src/cli/uninstall.d.ts +22 -0
- package/dist/src/host.d.ts +51 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/plugin.d.ts +35 -0
- package/dist/src/sdk.d.ts +51 -0
- package/dist/src/tools.d.ts +86 -0
- package/package.json +48 -18
- package/.beads/.local_version +0 -1
- package/.beads/README.md +0 -81
- package/.beads/config.yaml +0 -61
- package/.beads/deletions.jsonl +0 -1
- package/.beads/issues.jsonl +0 -64
- package/.beads/metadata.json +0 -4
- package/.gitattributes +0 -3
- package/.github/CODEOWNERS +0 -1
- package/.github/copilot-instructions.md +0 -78
- package/.github/dependabot.yml +0 -13
- package/.github/workflows/release.yml +0 -51
- package/.opencode/command/test-compaction.md +0 -9
- package/.opencode/command/test-find-skills.md +0 -7
- package/.opencode/command/test-read-skill-file.md +0 -14
- package/.opencode/command/test-run-skill-script.md +0 -13
- package/.opencode/command/test-skills.md +0 -14
- package/.opencode/command/test-use-skill.md +0 -10
- package/.opencode/skills/git-helper/SKILL.md +0 -65
- package/.opencode/skills/test-skill/SKILL.md +0 -43
- package/.opencode/skills/test-skill/example-config.json +0 -16
- package/.opencode/skills/test-skill/helper-docs.md +0 -29
- package/.opencode/skills/test-skill/scripts/echo-args +0 -14
- package/.opencode/skills/test-skill/scripts/greet +0 -6
- package/AGENTS.md +0 -43
- package/CHANGELOG.md +0 -178
- package/Justfile +0 -39
- package/README.md +0 -220
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +0 -74
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +0 -64
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +0 -75
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +0 -136
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +0 -77
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +0 -89
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +0 -65
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +0 -77
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +0 -65
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +0 -165
- package/openspec/specs/core-decoupling/spec.md +0 -110
- package/packages/core/package.json +0 -30
- package/packages/core/src/content.d.ts +0 -16
- package/packages/core/src/content.ts +0 -30
- package/packages/core/src/debug.ts +0 -16
- package/packages/core/src/discovery.d.ts +0 -86
- package/packages/core/src/discovery.ts +0 -257
- package/packages/core/src/index.d.ts +0 -20
- package/packages/core/src/index.ts +0 -55
- package/packages/core/src/match.d.ts +0 -19
- package/packages/core/src/match.ts +0 -75
- package/packages/core/src/parse.d.ts +0 -26
- package/packages/core/src/parse.ts +0 -141
- package/packages/core/src/scripts.d.ts +0 -17
- package/packages/core/src/scripts.ts +0 -79
- package/packages/core/src/search.d.ts +0 -83
- package/packages/core/src/search.ts +0 -188
- package/packages/core/src/types.d.ts +0 -82
- package/packages/core/src/types.ts +0 -131
- package/packages/core/src/walk.ts +0 -109
- package/packages/core/tests/agnostic.test.ts +0 -346
- package/packages/core/tests/content.test.ts +0 -65
- package/packages/core/tests/discovery.test.ts +0 -370
- package/packages/core/tests/package-boundary.test.ts +0 -310
- package/packages/core/tests/parse-trigger.test.ts +0 -282
- package/packages/core/tests/search.test.ts +0 -374
- package/packages/core/tests/subpath.test.ts +0 -87
- package/packages/core/tsconfig.json +0 -10
- package/packages/opencode-agent-skills-md/package.json +0 -66
- package/packages/opencode-agent-skills-md/rolldown.config.js +0 -47
- package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +0 -1423
- package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +0 -66
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +0 -12
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +0 -11
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +0 -2
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +0 -1
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +0 -114
- package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +0 -316
- package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +0 -315
- package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +0 -179
- package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +0 -551
- package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +0 -66
- package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +0 -213
- package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +0 -345
- package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +0 -72
- package/packages/opencode-agent-skills-md/tsconfig.build.json +0 -11
- package/packages/opencode-agent-skills-md/tsconfig.json +0 -10
- package/plans/001-ci-gate.md +0 -177
- package/plans/002-is-path-safe.md +0 -243
- package/plans/003-escape-prompts.md +0 -310
- package/plans/004-test-security-paths.md +0 -228
- package/plans/005-stop-swallowing-errors.md +0 -246
- package/plans/006-preserve-jsonc-commas.md +0 -144
- package/plans/007-write-before-purge.md +0 -144
- package/plans/008-reuse-walkdir-for-list-skill-files.md +0 -164
- package/plans/README.md +0 -43
- package/pnpm-workspace.yaml +0 -6
- package/tests/workspace.test.ts +0 -367
- package/tsconfig.json +0 -15
- /package/{packages/opencode-agent-skills-md/src → src}/cli/config.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/install.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/main.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/real-fs.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/status.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/uninstall.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/host.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/index.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/plugin.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/sdk.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/tools.ts +0 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
//#region src/cli/config.ts
|
|
8
|
+
/** npm package name for this plugin. */
|
|
9
|
+
const PLUGIN_NAME = "opencode-agent-skills-md";
|
|
10
|
+
/** Filename used for the OpenCode global config (preferred). */
|
|
11
|
+
const CONFIG_FILE_BASENAME = "opencode";
|
|
12
|
+
/** Subdirectory under the user config root that holds `opencode.json`. */
|
|
13
|
+
const OPENCODE_CONFIG_SUBDIR = "opencode";
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the parent directory that holds the global OpenCode config.
|
|
16
|
+
* `$OPENCODE_CONFIG_DIR` wins; otherwise we fall back to
|
|
17
|
+
* `$HOME/.config/opencode` (or `os.homedir()` as a last-resort).
|
|
18
|
+
*
|
|
19
|
+
* Exposed separately so tests and the rotation helper can reuse it
|
|
20
|
+
* without re-deriving the precedence rules.
|
|
21
|
+
*/
|
|
22
|
+
const resolveConfigDir = (env = process.env) => {
|
|
23
|
+
const explicit = env.OPENCODE_CONFIG_DIR;
|
|
24
|
+
if (typeof explicit === "string" && explicit.trim().length > 0) return explicit;
|
|
25
|
+
const home = env.HOME;
|
|
26
|
+
if (typeof home === "string" && home.trim().length > 0) return join(home, ".config", OPENCODE_CONFIG_SUBDIR);
|
|
27
|
+
return join(homedir(), ".config", OPENCODE_CONFIG_SUBDIR);
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the global OpenCode config file path.
|
|
31
|
+
*
|
|
32
|
+
* Precedence:
|
|
33
|
+
* 1. If `$OPENCODE_CONFIG_DIR/opencode.json` exists, use it.
|
|
34
|
+
* 2. Else if `$OPENCODE_CONFIG_DIR/opencode.jsonc` exists, use it.
|
|
35
|
+
* 3. Else fall back to `$HOME/.config/opencode/opencode.json`, then `.jsonc`.
|
|
36
|
+
* 4. If nothing exists, return the preferred target `.json` in the
|
|
37
|
+
* resolved directory so `install` knows where to create the file.
|
|
38
|
+
*
|
|
39
|
+
* `.json` always wins over `.jsonc` when both exist.
|
|
40
|
+
*/
|
|
41
|
+
const resolveGlobalConfigPath = (fs, env = process.env) => {
|
|
42
|
+
const primaryDir = resolveConfigDir(env);
|
|
43
|
+
const explicit = env.OPENCODE_CONFIG_DIR;
|
|
44
|
+
const hasExplicit = typeof explicit === "string" && explicit.trim().length > 0 && explicit !== primaryDir;
|
|
45
|
+
const fallbackDir = computeFallbackDir(env);
|
|
46
|
+
const json = (dir) => ({
|
|
47
|
+
path: join(dir, `${CONFIG_FILE_BASENAME}.json`),
|
|
48
|
+
format: "json"
|
|
49
|
+
});
|
|
50
|
+
const jsonc = (dir) => ({
|
|
51
|
+
path: join(dir, `${CONFIG_FILE_BASENAME}.jsonc`),
|
|
52
|
+
format: "jsonc"
|
|
53
|
+
});
|
|
54
|
+
const candidateFns = [json, jsonc];
|
|
55
|
+
const dirs = hasExplicit ? [primaryDir] : fallbackDir && fallbackDir !== primaryDir ? [primaryDir, fallbackDir] : [primaryDir];
|
|
56
|
+
for (const dir of dirs) for (const fn of candidateFns) {
|
|
57
|
+
const candidate = fn(dir);
|
|
58
|
+
if (fs.existsSync(candidate.path)) return {
|
|
59
|
+
path: candidate.path,
|
|
60
|
+
format: candidate.format,
|
|
61
|
+
existed: true
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
...json(dirs[0]),
|
|
66
|
+
existed: false
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
const computeFallbackDir = (env) => {
|
|
70
|
+
const explicit = env.OPENCODE_CONFIG_DIR;
|
|
71
|
+
if (typeof explicit === "string" && explicit.trim().length > 0) return null;
|
|
72
|
+
const home = env.HOME;
|
|
73
|
+
if (typeof home === "string" && home.trim().length > 0) return join(home, ".config", OPENCODE_CONFIG_SUBDIR);
|
|
74
|
+
return join(homedir(), ".config", OPENCODE_CONFIG_SUBDIR);
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Strip JSONC-style comments and trailing commas, then parse with `JSON.parse`.
|
|
78
|
+
*
|
|
79
|
+
* Returns `{}` for empty input or whitespace-only input.
|
|
80
|
+
* **Throws** on malformed JSON — callers must handle the error to avoid
|
|
81
|
+
* silently overwriting a corrupt config with an empty one.
|
|
82
|
+
*/
|
|
83
|
+
const parseJsonc = (text) => {
|
|
84
|
+
const trimmed = text.trim();
|
|
85
|
+
if (trimmed.length === 0) return {};
|
|
86
|
+
const stripped = stripJsoncComments(trimmed);
|
|
87
|
+
const parsed = JSON.parse(stripped);
|
|
88
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("config root must be a JSON object");
|
|
89
|
+
return parsed;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Internal: walk the input, dropping `// ...` and `/* ... * /` comments
|
|
93
|
+
* while preserving everything inside string literals (including strings
|
|
94
|
+
* that contain URL slashes). Trailing commas are removed after the
|
|
95
|
+
* comment pass.
|
|
96
|
+
*/
|
|
97
|
+
const stripJsoncComments = (text) => {
|
|
98
|
+
let out = "";
|
|
99
|
+
let inString = false;
|
|
100
|
+
let escaped = false;
|
|
101
|
+
let i = 0;
|
|
102
|
+
const len = text.length;
|
|
103
|
+
while (i < len) {
|
|
104
|
+
const c = text[i];
|
|
105
|
+
if (inString) {
|
|
106
|
+
out += c;
|
|
107
|
+
if (escaped) escaped = false;
|
|
108
|
+
else if (c === "\\") escaped = true;
|
|
109
|
+
else if (c === "\"") inString = false;
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (c === "\"") {
|
|
114
|
+
inString = true;
|
|
115
|
+
out += c;
|
|
116
|
+
i++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
120
|
+
i += 2;
|
|
121
|
+
while (i < len && text[i] !== "\n") i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
125
|
+
i += 2;
|
|
126
|
+
while (i < len && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
127
|
+
i += 2;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
out += c;
|
|
131
|
+
i++;
|
|
132
|
+
}
|
|
133
|
+
let result = "";
|
|
134
|
+
let inStr = false;
|
|
135
|
+
let esc = false;
|
|
136
|
+
let j = 0;
|
|
137
|
+
const outLen = out.length;
|
|
138
|
+
while (j < outLen) {
|
|
139
|
+
const ch = out[j];
|
|
140
|
+
if (inStr) {
|
|
141
|
+
result += ch;
|
|
142
|
+
if (esc) esc = false;
|
|
143
|
+
else if (ch === "\\") esc = true;
|
|
144
|
+
else if (ch === "\"") inStr = false;
|
|
145
|
+
j++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (ch === "\"") {
|
|
149
|
+
inStr = true;
|
|
150
|
+
result += ch;
|
|
151
|
+
j++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === ",") {
|
|
155
|
+
let k = j + 1;
|
|
156
|
+
while (k < outLen && /\s/.test(out[k])) k++;
|
|
157
|
+
if (k < outLen && /[}\]]/.test(out[k])) {
|
|
158
|
+
j = k;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
result += ch;
|
|
163
|
+
j++;
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* True when `entry` is a string that resolves to this plugin by base name.
|
|
169
|
+
* Matches `opencode-agent-skills-md` and any `opencode-agent-skills-md@<spec>`
|
|
170
|
+
* variant. Non-string entries (legacy object-form leftover) return false.
|
|
171
|
+
*/
|
|
172
|
+
const matchesPlugin = (entry) => {
|
|
173
|
+
if (typeof entry !== "string") return false;
|
|
174
|
+
const at = entry.indexOf("@");
|
|
175
|
+
return (at === -1 ? entry : entry.slice(0, at)) === PLUGIN_NAME;
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Coerce the raw value of `config.plugin` into a clean string array.
|
|
179
|
+
*
|
|
180
|
+
* Handles:
|
|
181
|
+
* - `undefined` / `null` → `[]`
|
|
182
|
+
* - array of strings (or mixed) → only the string entries
|
|
183
|
+
* - the broken object form `{ "<name>": ... }` → the keys (in declaration order)
|
|
184
|
+
* - any other non-object, non-array shape → `[]` (doctor surfaces it)
|
|
185
|
+
*/
|
|
186
|
+
const normalizePlugin = (raw) => {
|
|
187
|
+
if (raw === void 0 || raw === null) return [];
|
|
188
|
+
if (Array.isArray(raw)) {
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const item of raw) if (typeof item === "string") out.push(item);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
if (typeof raw === "object") return Object.keys(raw);
|
|
194
|
+
return [];
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Dedupe the plugin list by base name (the part before the first `@`),
|
|
198
|
+
* keeping the LAST occurrence of each base. Any `opencode-agent-skills-md`
|
|
199
|
+
* entries are removed entirely so the install flow can append one fresh
|
|
200
|
+
* entry at the end without leaving stale versions behind.
|
|
201
|
+
*
|
|
202
|
+
* Order is preserved for the surviving entries (last-wins per base).
|
|
203
|
+
*/
|
|
204
|
+
const dedupePlugins = (entries) => {
|
|
205
|
+
const filtered = [];
|
|
206
|
+
for (const entry of entries) if (!matchesPlugin(entry)) filtered.push(entry);
|
|
207
|
+
const byBase = /* @__PURE__ */ new Map();
|
|
208
|
+
for (const raw of filtered) {
|
|
209
|
+
if (typeof raw !== "string" || raw.length === 0) continue;
|
|
210
|
+
const at = raw.indexOf("@");
|
|
211
|
+
const base = at === -1 ? raw : raw.slice(0, at);
|
|
212
|
+
if (base.length === 0) continue;
|
|
213
|
+
byBase.set(base, raw);
|
|
214
|
+
}
|
|
215
|
+
return Array.from(byBase.values());
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Build the npm specifier we will write into `plugin[]`:
|
|
219
|
+
* `"opencode-agent-skills-md"` when no version is supplied, otherwise
|
|
220
|
+
* `"opencode-agent-skills-md@<version>"`. Empty / whitespace-only versions
|
|
221
|
+
* are treated as "no version".
|
|
222
|
+
*/
|
|
223
|
+
const buildSpecifier = (version) => {
|
|
224
|
+
if (typeof version !== "string") return PLUGIN_NAME;
|
|
225
|
+
const trimmed = version.trim();
|
|
226
|
+
if (trimmed.length === 0) return PLUGIN_NAME;
|
|
227
|
+
return `${PLUGIN_NAME}@${trimmed}`;
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* If `configPath` already exists, copy it next to itself as a timestamped
|
|
231
|
+
* sibling and prune older CLI-created backups so at most `BACKUP_LIMIT`
|
|
232
|
+
* survive (newest first). Returns the backup path, or `null` when no
|
|
233
|
+
* backup was needed (file missing or not writable).
|
|
234
|
+
*/
|
|
235
|
+
const backupIfWritable = (configPath, fs) => {
|
|
236
|
+
if (!fs.existsSync(configPath)) return null;
|
|
237
|
+
const backupPath = join(dirname(configPath), `${basename(configPath)}.bak.${backupTimestamp(/* @__PURE__ */ new Date())}`);
|
|
238
|
+
fs.copyFileSync(configPath, backupPath);
|
|
239
|
+
try {
|
|
240
|
+
rotateBackups(configPath, 3, fs);
|
|
241
|
+
} catch {}
|
|
242
|
+
return backupPath;
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* Prune CLI-created backups of `configPath`, keeping only the newest
|
|
246
|
+
* `limit` siblings (lexical order on the timestamp suffix is fine because
|
|
247
|
+
* the stamp is fixed-width and ISO-8601-derived).
|
|
248
|
+
*/
|
|
249
|
+
const rotateBackups = (configPath, limit, fs) => {
|
|
250
|
+
if (limit < 1) return;
|
|
251
|
+
const dir = dirname(configPath);
|
|
252
|
+
const prefix = `${basename(configPath)}.bak.`;
|
|
253
|
+
const backups = fs.readdirSync(dir).filter((name) => name.startsWith(prefix)).sort();
|
|
254
|
+
if (backups.length <= limit) return;
|
|
255
|
+
const toRemove = backups.slice(0, backups.length - limit);
|
|
256
|
+
for (const oldName of toRemove) fs.unlinkSync(join(dir, oldName));
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Internal: build a filesystem-safe, chronologically-sortable timestamp
|
|
260
|
+
* for backup filenames. Format: `YYYYMMDDTHHmmssSSSZ` — fixed-width, no
|
|
261
|
+
* colons (Windows-safe), and lexical-sortable from newest to oldest.
|
|
262
|
+
*/
|
|
263
|
+
const backupTimestamp = (date) => {
|
|
264
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
265
|
+
return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}${pad(date.getUTCMilliseconds(), 3)}Z`;
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* Write `content` to `targetPath` via a temp sibling + rename. The
|
|
269
|
+
* rename is atomic on POSIX (and best-effort on Windows), so a crashed
|
|
270
|
+
* CLI never leaves a half-written config behind. Any parent directories
|
|
271
|
+
* are created with `{ recursive: true }` so first-run installs Just Work.
|
|
272
|
+
*/
|
|
273
|
+
const writeAtomically = (targetPath, content, fs) => {
|
|
274
|
+
const dir = dirname(targetPath);
|
|
275
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
276
|
+
const tmp = `${targetPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
277
|
+
try {
|
|
278
|
+
fs.writeFileSync(tmp, content);
|
|
279
|
+
fs.renameSync(tmp, targetPath);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
try {
|
|
282
|
+
fs.unlinkSync(tmp);
|
|
283
|
+
} catch {}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Resolve the global config path, read it (if it exists), and parse it.
|
|
289
|
+
* Missing files yield `config = {}` and `existed = false` so the install
|
|
290
|
+
* flow can treat "fresh install" and "already installed" the same way.
|
|
291
|
+
*
|
|
292
|
+
* Malformed JSONC is surfaced via `LoadedConfig.parseError`. Commands MUST
|
|
293
|
+
* check this field and abort instead of silently overwriting the user's
|
|
294
|
+
* corrupt config with an empty one.
|
|
295
|
+
*/
|
|
296
|
+
const loadGlobalConfig = (fs, env = process.env) => {
|
|
297
|
+
const resolved = resolveGlobalConfigPath(fs, env);
|
|
298
|
+
if (!resolved.existed) return {
|
|
299
|
+
path: resolved.path,
|
|
300
|
+
config: {},
|
|
301
|
+
existed: false
|
|
302
|
+
};
|
|
303
|
+
const raw = fs.readFileSync(resolved.path);
|
|
304
|
+
try {
|
|
305
|
+
return {
|
|
306
|
+
path: resolved.path,
|
|
307
|
+
config: parseJsonc(raw),
|
|
308
|
+
existed: true
|
|
309
|
+
};
|
|
310
|
+
} catch (err) {
|
|
311
|
+
return {
|
|
312
|
+
path: resolved.path,
|
|
313
|
+
config: {},
|
|
314
|
+
existed: true,
|
|
315
|
+
parseError: err.message
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/cli/real-fs.ts
|
|
321
|
+
/**
|
|
322
|
+
* Build a `CliFs` that delegates to `node:fs`. All methods are sync; the
|
|
323
|
+
* CLI is short-lived and never benefits from async I/O.
|
|
324
|
+
*/
|
|
325
|
+
const createRealFs = () => ({
|
|
326
|
+
readFileSync: (path) => readFileSync(path, "utf8"),
|
|
327
|
+
writeFileSync: (path, content) => {
|
|
328
|
+
writeFileSync(path, content);
|
|
329
|
+
},
|
|
330
|
+
renameSync: (from, to) => {
|
|
331
|
+
renameSync(from, to);
|
|
332
|
+
},
|
|
333
|
+
copyFileSync: (from, to) => {
|
|
334
|
+
copyFileSync(from, to);
|
|
335
|
+
},
|
|
336
|
+
unlinkSync: (path) => {
|
|
337
|
+
unlinkSync(path);
|
|
338
|
+
},
|
|
339
|
+
mkdirSync: (path, opts) => {
|
|
340
|
+
mkdirSync(path, opts);
|
|
341
|
+
},
|
|
342
|
+
readdirSync: (path) => readdirSync(path),
|
|
343
|
+
existsSync: (path) => existsSync(path)
|
|
344
|
+
});
|
|
345
|
+
//#endregion
|
|
346
|
+
//#region src/cli/install.ts
|
|
347
|
+
const JSON_INDENT$1 = 2;
|
|
348
|
+
/**
|
|
349
|
+
* Run `oas install` against the global OpenCode config.
|
|
350
|
+
*
|
|
351
|
+
* Steps: load → normalize → drop existing oas variants → dedupe surviving
|
|
352
|
+
* entries → append one requested specifier → backup → atomic write. The
|
|
353
|
+
* backup is a timestamped sibling of the config file; rotation to
|
|
354
|
+
* `BACKUP_LIMIT` is handled inside `backupIfWritable`.
|
|
355
|
+
*
|
|
356
|
+
* Idempotency: re-running with the same specifier resolves to a `noop`
|
|
357
|
+
* result without touching disk. A malformed config triggers an error so
|
|
358
|
+
* the user can fix the JSONC instead of silently losing it to an empty
|
|
359
|
+
* overwrite.
|
|
360
|
+
*/
|
|
361
|
+
const runInstall = (opts = {}, fs = createRealFs()) => {
|
|
362
|
+
const specifier = buildSpecifier(opts.version);
|
|
363
|
+
const loaded = loadGlobalConfig(fs);
|
|
364
|
+
if (loaded.parseError) throw new Error(`oas: config file is malformed JSON — aborting to avoid data loss.\n path: ${loaded.path}\n error: ${loaded.parseError}\nFix the JSON error, or delete the file and re-run to create a fresh config.`);
|
|
365
|
+
const config = { ...loaded.config };
|
|
366
|
+
const existing = normalizePlugin(config.plugin);
|
|
367
|
+
const finalPlugins = [...dedupePlugins(existing.filter((entry) => !matchesPlugin(entry))), specifier];
|
|
368
|
+
if (!opts.dryRun && JSON.stringify(finalPlugins) === JSON.stringify(existing)) {
|
|
369
|
+
console.log(`✓ Already installed (${specifier}) at ${loaded.path}`);
|
|
370
|
+
return {
|
|
371
|
+
status: "noop",
|
|
372
|
+
path: loaded.path,
|
|
373
|
+
specifier,
|
|
374
|
+
backup: null
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
config.plugin = finalPlugins;
|
|
378
|
+
if (opts.dryRun) {
|
|
379
|
+
console.log(`[dry-run] Would write to ${loaded.path}:`);
|
|
380
|
+
console.log(JSON.stringify(config, null, JSON_INDENT$1));
|
|
381
|
+
return {
|
|
382
|
+
status: "planned",
|
|
383
|
+
path: loaded.path,
|
|
384
|
+
specifier,
|
|
385
|
+
backup: null
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const backup = backupIfWritable(loaded.path, fs);
|
|
389
|
+
writeAtomically(loaded.path, JSON.stringify(config, null, JSON_INDENT$1), fs);
|
|
390
|
+
console.log(`✓ Installed ${specifier}`);
|
|
391
|
+
console.log(` config: ${loaded.path}`);
|
|
392
|
+
if (backup) console.log(` backup: ${backup}`);
|
|
393
|
+
return {
|
|
394
|
+
status: "wrote",
|
|
395
|
+
path: loaded.path,
|
|
396
|
+
specifier,
|
|
397
|
+
backup
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region src/cli/status.ts
|
|
402
|
+
const formatFromPath = (path) => path.endsWith(".jsonc") ? "jsonc" : "json";
|
|
403
|
+
/**
|
|
404
|
+
* Read-only status probe. Prints a human-readable report to stdout and
|
|
405
|
+
* returns the same data as a structured result so callers (including
|
|
406
|
+
* `main.ts` and tests) can consume it without parsing the message.
|
|
407
|
+
*/
|
|
408
|
+
const runStatus = (fs = createRealFs()) => {
|
|
409
|
+
const loaded = loadGlobalConfig(fs);
|
|
410
|
+
const plugins = normalizePlugin(loaded.config.plugin);
|
|
411
|
+
const oasEntries = plugins.filter(matchesPlugin);
|
|
412
|
+
const extras = plugins.filter((entry) => !matchesPlugin(entry));
|
|
413
|
+
const format = formatFromPath(loaded.path);
|
|
414
|
+
console.log(`Config path: ${loaded.path}`);
|
|
415
|
+
console.log(`Format: ${format}`);
|
|
416
|
+
console.log(`Exists on disk: ${loaded.existed ? "yes" : "no (will be created on install)"}`);
|
|
417
|
+
if (oasEntries.length === 0) {
|
|
418
|
+
console.log(`Installed: no`);
|
|
419
|
+
return {
|
|
420
|
+
installed: false,
|
|
421
|
+
path: loaded.path,
|
|
422
|
+
format,
|
|
423
|
+
specifier: null,
|
|
424
|
+
extras
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const specifier = oasEntries[0] ?? null;
|
|
428
|
+
console.log(`Installed: yes`);
|
|
429
|
+
console.log(`Specifier: ${specifier}`);
|
|
430
|
+
if (extras.length > 0) console.log(`Other plugins: ${extras.join(", ")}`);
|
|
431
|
+
return {
|
|
432
|
+
installed: true,
|
|
433
|
+
path: loaded.path,
|
|
434
|
+
format,
|
|
435
|
+
specifier,
|
|
436
|
+
extras
|
|
437
|
+
};
|
|
438
|
+
};
|
|
439
|
+
/**
|
|
440
|
+
* Health checks. The function does not exit on its own — it returns a
|
|
441
|
+
* `DoctorResult` and `main.ts` maps `ok === false` to exit code 1.
|
|
442
|
+
*/
|
|
443
|
+
const runDoctor = (fs = createRealFs(), env = process.env) => {
|
|
444
|
+
const issues = [];
|
|
445
|
+
const warnings = [];
|
|
446
|
+
const info = [];
|
|
447
|
+
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
|
|
448
|
+
if (!Number.isFinite(nodeMajor) || nodeMajor < 18) issues.push(`Node ${process.versions.node} detected — ${PLUGIN_NAME} requires Node >= 18`);
|
|
449
|
+
else info.push(`Node ${process.versions.node} OK`);
|
|
450
|
+
const loaded = loadGlobalConfig(fs, env);
|
|
451
|
+
const format = formatFromPath(loaded.path);
|
|
452
|
+
info.push(`Config path: ${loaded.path}`);
|
|
453
|
+
info.push(`Config format: ${format}`);
|
|
454
|
+
if (!loaded.existed) warnings.push(`Config file does not exist yet — install will create it`);
|
|
455
|
+
const rawPlugin = loaded.config.plugin;
|
|
456
|
+
if (rawPlugin === void 0 || rawPlugin === null) info.push(`Plugin entries: 0`);
|
|
457
|
+
else if (!(Array.isArray(rawPlugin) || typeof rawPlugin === "object")) issues.push(`config.plugin is neither array nor object — install will reset it`);
|
|
458
|
+
else {
|
|
459
|
+
const plugins = normalizePlugin(rawPlugin);
|
|
460
|
+
info.push(`Plugin entries: ${plugins.length}`);
|
|
461
|
+
const oasCount = plugins.filter(matchesPlugin).length;
|
|
462
|
+
if (oasCount > 1) warnings.push(`${oasCount} ${PLUGIN_NAME} entries present — install will dedupe`);
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
const dir = dirname(loaded.path);
|
|
466
|
+
try {
|
|
467
|
+
if (statSync(dir).isDirectory()) try {
|
|
468
|
+
accessSync(dir, constants.W_OK);
|
|
469
|
+
info.push(`Config directory writable: ${dir}`);
|
|
470
|
+
} catch {
|
|
471
|
+
warnings.push(`Config directory ${dir} is not writable`);
|
|
472
|
+
}
|
|
473
|
+
else issues.push(`${dir} exists but is not a directory`);
|
|
474
|
+
} catch {
|
|
475
|
+
warnings.push(`Config directory ${dir} does not exist yet — will be created on first install`);
|
|
476
|
+
}
|
|
477
|
+
} catch {}
|
|
478
|
+
for (const line of info) console.log(` ✓ ${line}`);
|
|
479
|
+
for (const line of warnings) console.warn(` ! ${line}`);
|
|
480
|
+
for (const line of issues) console.error(` ✗ ${line}`);
|
|
481
|
+
const ok = issues.length === 0;
|
|
482
|
+
if (ok) console.log(`\n✓ Doctor: all checks passed`);
|
|
483
|
+
else console.log(`\n✗ Doctor: ${issues.length} issue(s) found`);
|
|
484
|
+
return {
|
|
485
|
+
ok,
|
|
486
|
+
issues,
|
|
487
|
+
warnings,
|
|
488
|
+
info
|
|
489
|
+
};
|
|
490
|
+
};
|
|
491
|
+
//#endregion
|
|
492
|
+
//#region src/cli/uninstall.ts
|
|
493
|
+
const JSON_INDENT = 2;
|
|
494
|
+
/** Resolve `$HOME` (or `os.homedir()` as last resort) for purge paths. */
|
|
495
|
+
const homeRoot = (env = process.env) => {
|
|
496
|
+
const home = env.HOME;
|
|
497
|
+
if (typeof home === "string" && home.trim().length > 0) return home;
|
|
498
|
+
return homedir();
|
|
499
|
+
};
|
|
500
|
+
/** Bun/npm-style cache path where the plugin gets installed at runtime. */
|
|
501
|
+
const cachePath = (env = process.env) => join(homeRoot(env), ".cache", "opencode", "node_modules", PLUGIN_NAME);
|
|
502
|
+
/** Plugin's own XDG config dir (separate from the OpenCode config it edits). */
|
|
503
|
+
const pluginConfigPath = (env = process.env) => join(homeRoot(env), ".config", PLUGIN_NAME);
|
|
504
|
+
/**
|
|
505
|
+
* Best-effort recursive delete. Returns the path on success or `null` when
|
|
506
|
+
* the target was missing (we don't want to fail the whole command if the
|
|
507
|
+
* user never ran `install` to create these dirs in the first place).
|
|
508
|
+
*/
|
|
509
|
+
const purgeDir = (path) => {
|
|
510
|
+
try {
|
|
511
|
+
rmSync(path, {
|
|
512
|
+
recursive: true,
|
|
513
|
+
force: true
|
|
514
|
+
});
|
|
515
|
+
return path;
|
|
516
|
+
} catch {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
const runUninstall = (opts = {}, fs = createRealFs()) => {
|
|
521
|
+
const loaded = loadGlobalConfig(fs);
|
|
522
|
+
if (loaded.parseError) throw new Error(`oas: config file is malformed JSON — aborting to avoid data loss.\n path: ${loaded.path}\n error: ${loaded.parseError}\nFix the JSON error, or delete the file and re-run.`);
|
|
523
|
+
const config = { ...loaded.config };
|
|
524
|
+
const existing = normalizePlugin(config.plugin);
|
|
525
|
+
const removed = existing.filter(matchesPlugin);
|
|
526
|
+
const remaining = existing.filter((entry) => !removed.includes(entry));
|
|
527
|
+
const purgeCandidates = opts.purge ? [cachePath(), pluginConfigPath()] : [];
|
|
528
|
+
const purged = [];
|
|
529
|
+
const plannedPurge = [];
|
|
530
|
+
if (opts.purge && opts.dryRun) plannedPurge.push(...purgeCandidates);
|
|
531
|
+
if (removed.length === 0 && purgeCandidates.length === 0) {
|
|
532
|
+
console.log(`✓ Not installed: ${PLUGIN_NAME} not found in ${loaded.path}`);
|
|
533
|
+
return {
|
|
534
|
+
status: "noop",
|
|
535
|
+
path: loaded.path,
|
|
536
|
+
removed: [],
|
|
537
|
+
purged: []
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (removed.length > 0) if (remaining.length === 0) delete config.plugin;
|
|
541
|
+
else config.plugin = remaining;
|
|
542
|
+
if (opts.dryRun) {
|
|
543
|
+
if (plannedPurge.length > 0) {
|
|
544
|
+
console.log(`[dry-run] Would purge:`);
|
|
545
|
+
for (const p of plannedPurge) console.log(` ${p}`);
|
|
546
|
+
}
|
|
547
|
+
console.log(`[dry-run] Would write to ${loaded.path}:`);
|
|
548
|
+
console.log(JSON.stringify(config, null, JSON_INDENT));
|
|
549
|
+
return {
|
|
550
|
+
status: "planned",
|
|
551
|
+
path: loaded.path,
|
|
552
|
+
removed,
|
|
553
|
+
purged: plannedPurge
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
let backup = null;
|
|
557
|
+
if (removed.length > 0 && loaded.existed) {
|
|
558
|
+
backup = backupIfWritable(loaded.path, fs);
|
|
559
|
+
writeAtomically(loaded.path, JSON.stringify(config, null, JSON_INDENT), fs);
|
|
560
|
+
}
|
|
561
|
+
for (const p of purgeCandidates) {
|
|
562
|
+
const result = purgeDir(p);
|
|
563
|
+
if (result) purged.push(result);
|
|
564
|
+
}
|
|
565
|
+
console.log(`✓ Uninstalled ${PLUGIN_NAME}`);
|
|
566
|
+
if (removed.length > 0) console.log(` config: ${loaded.path}`);
|
|
567
|
+
if (backup) console.log(` backup: ${backup}`);
|
|
568
|
+
for (const p of purged) console.log(` purged: ${p}`);
|
|
569
|
+
return {
|
|
570
|
+
status: "wrote",
|
|
571
|
+
path: loaded.path,
|
|
572
|
+
removed,
|
|
573
|
+
purged
|
|
574
|
+
};
|
|
575
|
+
};
|
|
576
|
+
//#endregion
|
|
577
|
+
//#region src/cli/main.ts
|
|
578
|
+
const USAGE = `Usage: oas <command> [options]
|
|
579
|
+
|
|
580
|
+
Commands:
|
|
581
|
+
install Register the plugin in the global OpenCode config
|
|
582
|
+
uninstall Remove the plugin from the global OpenCode config
|
|
583
|
+
status Show current installation status
|
|
584
|
+
doctor Run health checks against the global config
|
|
585
|
+
|
|
586
|
+
Options (install):
|
|
587
|
+
-v, --version <v> Install a specific version (default: latest)
|
|
588
|
+
--latest Alias for --version latest
|
|
589
|
+
--dry-run Print the planned change without writing
|
|
590
|
+
--yes Skip confirmation prompts (reserved)
|
|
591
|
+
|
|
592
|
+
Options (uninstall):
|
|
593
|
+
--purge Also remove cache + ~/.config/opencode-agent-skills-md/
|
|
594
|
+
--dry-run Print the planned change without writing
|
|
595
|
+
--yes Skip confirmation prompts (reserved)
|
|
596
|
+
|
|
597
|
+
Options (all):
|
|
598
|
+
-h, --help Show this help and exit
|
|
599
|
+
`;
|
|
600
|
+
const printUsage = () => {
|
|
601
|
+
console.log(USAGE);
|
|
602
|
+
};
|
|
603
|
+
const setExit = (code) => {
|
|
604
|
+
process.exitCode = code;
|
|
605
|
+
};
|
|
606
|
+
const parseCliArgs = (argv) => {
|
|
607
|
+
const parsed = parseArgs({
|
|
608
|
+
args: argv,
|
|
609
|
+
allowPositionals: true,
|
|
610
|
+
strict: true,
|
|
611
|
+
options: {
|
|
612
|
+
version: {
|
|
613
|
+
type: "string",
|
|
614
|
+
short: "v"
|
|
615
|
+
},
|
|
616
|
+
latest: { type: "boolean" },
|
|
617
|
+
yes: {
|
|
618
|
+
type: "boolean",
|
|
619
|
+
short: "y"
|
|
620
|
+
},
|
|
621
|
+
"dry-run": { type: "boolean" },
|
|
622
|
+
purge: { type: "boolean" },
|
|
623
|
+
help: {
|
|
624
|
+
type: "boolean",
|
|
625
|
+
short: "h"
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
return {
|
|
630
|
+
values: parsed.values,
|
|
631
|
+
positionals: parsed.positionals
|
|
632
|
+
};
|
|
633
|
+
};
|
|
634
|
+
/**
|
|
635
|
+
* Strip Node + script argv entries when the entry point is invoked via
|
|
636
|
+
* the shell (shebang) or via `node ./dist/cli.mjs`. When called from a
|
|
637
|
+
* test harness with synthetic args, no stripping happens.
|
|
638
|
+
*/
|
|
639
|
+
const sliceProcessArgv = (argv) => {
|
|
640
|
+
if (argv.length < 2) return argv;
|
|
641
|
+
const first = argv[0] ?? "";
|
|
642
|
+
if (first === process.argv[0] || first.endsWith("node") || first.endsWith("node.exe")) return argv.slice(2);
|
|
643
|
+
return argv;
|
|
644
|
+
};
|
|
645
|
+
/**
|
|
646
|
+
* Pure(ish) dispatcher: takes argv, runs the matching command, sets
|
|
647
|
+
* `process.exitCode`, and returns a structured result so tests can assert
|
|
648
|
+
* without reading the exit code.
|
|
649
|
+
*/
|
|
650
|
+
const runMain = (argv = process.argv) => {
|
|
651
|
+
const args = sliceProcessArgv(argv);
|
|
652
|
+
if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
|
|
653
|
+
printUsage();
|
|
654
|
+
return {
|
|
655
|
+
command: "help",
|
|
656
|
+
exitCode: 0
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
let parsed;
|
|
660
|
+
try {
|
|
661
|
+
parsed = parseCliArgs(args);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
console.error(`oas: ${err.message}`);
|
|
664
|
+
setExit(2);
|
|
665
|
+
return {
|
|
666
|
+
command: null,
|
|
667
|
+
exitCode: 2
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
if (parsed.values.help) {
|
|
671
|
+
printUsage();
|
|
672
|
+
return {
|
|
673
|
+
command: "help",
|
|
674
|
+
exitCode: 0
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
const command = parsed.positionals[0];
|
|
678
|
+
if (!command) {
|
|
679
|
+
console.error("oas: missing command. Run `oas --help` for usage.");
|
|
680
|
+
setExit(2);
|
|
681
|
+
return {
|
|
682
|
+
command: null,
|
|
683
|
+
exitCode: 2
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
switch (command) {
|
|
688
|
+
case "install": {
|
|
689
|
+
const versionRaw = parsed.values.version;
|
|
690
|
+
runInstall({
|
|
691
|
+
version: parsed.values.latest === true ? "latest" : typeof versionRaw === "string" ? versionRaw : void 0,
|
|
692
|
+
dryRun: parsed.values["dry-run"] === true,
|
|
693
|
+
yes: parsed.values.yes === true
|
|
694
|
+
});
|
|
695
|
+
return {
|
|
696
|
+
command,
|
|
697
|
+
exitCode: 0
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
case "uninstall":
|
|
701
|
+
runUninstall({
|
|
702
|
+
purge: parsed.values.purge === true,
|
|
703
|
+
dryRun: parsed.values["dry-run"] === true,
|
|
704
|
+
yes: parsed.values.yes === true
|
|
705
|
+
});
|
|
706
|
+
return {
|
|
707
|
+
command,
|
|
708
|
+
exitCode: 0
|
|
709
|
+
};
|
|
710
|
+
case "status":
|
|
711
|
+
runStatus();
|
|
712
|
+
return {
|
|
713
|
+
command,
|
|
714
|
+
exitCode: 0
|
|
715
|
+
};
|
|
716
|
+
case "doctor": {
|
|
717
|
+
const result = runDoctor();
|
|
718
|
+
if (!result.ok) setExit(1);
|
|
719
|
+
return {
|
|
720
|
+
command,
|
|
721
|
+
exitCode: result.ok ? 0 : 1
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
default:
|
|
725
|
+
console.error(`oas: unknown command '${command}'. Run \`oas --help\` for usage.`);
|
|
726
|
+
setExit(2);
|
|
727
|
+
return {
|
|
728
|
+
command: null,
|
|
729
|
+
exitCode: 2
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
} catch (err) {
|
|
733
|
+
console.error(`oas: ${err.message}`);
|
|
734
|
+
setExit(1);
|
|
735
|
+
return {
|
|
736
|
+
command,
|
|
737
|
+
exitCode: 1
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
/**
|
|
742
|
+
* `true` when the file is the program's entry point (shebang / `node
|
|
743
|
+
* cli.mjs`), `false` when it was imported from a test harness. We avoid
|
|
744
|
+
* `import.meta.main` because the package floor is Node 18 and that field
|
|
745
|
+
* only landed in Node 22.
|
|
746
|
+
*
|
|
747
|
+
* Symlink-aware: when the script is invoked through a symlink (e.g. the
|
|
748
|
+
* pnpm global store, where `node_modules/<pkg>` is a symlink into a
|
|
749
|
+
* content-addressable store), `process.argv[1]` carries the symlink path
|
|
750
|
+
* while `import.meta.url` carries the real path after symlink resolution.
|
|
751
|
+
* Comparing the two verbatim would always be `false` under pnpm, causing
|
|
752
|
+
* the CLI to silently exit. We resolve both sides through `realpathSync`
|
|
753
|
+
* before comparing.
|
|
754
|
+
*/
|
|
755
|
+
const checkInvokedAsMain = () => {
|
|
756
|
+
if (!process.argv[1]) return false;
|
|
757
|
+
try {
|
|
758
|
+
const realArgv = pathToFileURL(realpathSync(process.argv[1])).href;
|
|
759
|
+
return import.meta.url === realArgv;
|
|
760
|
+
} catch {
|
|
761
|
+
try {
|
|
762
|
+
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
763
|
+
} catch {
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
if (checkInvokedAsMain()) runMain(process.argv);
|
|
769
|
+
//#endregion
|
|
770
|
+
export { runMain };
|