opencode-agent-skills-md 1.0.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.
Files changed (117) hide show
  1. package/.beads/.local_version +1 -0
  2. package/.beads/README.md +81 -0
  3. package/.beads/config.yaml +61 -0
  4. package/.beads/deletions.jsonl +1 -0
  5. package/.beads/issues.jsonl +64 -0
  6. package/.beads/metadata.json +4 -0
  7. package/.gitattributes +3 -0
  8. package/.github/CODEOWNERS +1 -0
  9. package/.github/copilot-instructions.md +78 -0
  10. package/.github/dependabot.yml +13 -0
  11. package/.github/workflows/release.yml +51 -0
  12. package/.opencode/command/test-compaction.md +9 -0
  13. package/.opencode/command/test-find-skills.md +7 -0
  14. package/.opencode/command/test-read-skill-file.md +14 -0
  15. package/.opencode/command/test-run-skill-script.md +13 -0
  16. package/.opencode/command/test-skills.md +14 -0
  17. package/.opencode/command/test-use-skill.md +10 -0
  18. package/.opencode/skills/git-helper/SKILL.md +65 -0
  19. package/.opencode/skills/test-skill/SKILL.md +43 -0
  20. package/.opencode/skills/test-skill/example-config.json +16 -0
  21. package/.opencode/skills/test-skill/helper-docs.md +29 -0
  22. package/.opencode/skills/test-skill/scripts/echo-args +14 -0
  23. package/.opencode/skills/test-skill/scripts/greet +6 -0
  24. package/AGENTS.md +43 -0
  25. package/CHANGELOG.md +178 -0
  26. package/Justfile +39 -0
  27. package/LICENSE +9 -0
  28. package/README.md +189 -0
  29. package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +74 -0
  30. package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +64 -0
  31. package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +75 -0
  32. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +136 -0
  33. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +77 -0
  34. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +89 -0
  35. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +65 -0
  36. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +77 -0
  37. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +65 -0
  38. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +165 -0
  39. package/openspec/specs/core-decoupling/spec.md +110 -0
  40. package/package.json +35 -0
  41. package/packages/core/package.json +30 -0
  42. package/packages/core/src/content.d.ts +16 -0
  43. package/packages/core/src/content.ts +30 -0
  44. package/packages/core/src/debug.ts +16 -0
  45. package/packages/core/src/discovery.d.ts +86 -0
  46. package/packages/core/src/discovery.ts +257 -0
  47. package/packages/core/src/index.d.ts +20 -0
  48. package/packages/core/src/index.ts +55 -0
  49. package/packages/core/src/match.d.ts +19 -0
  50. package/packages/core/src/match.ts +75 -0
  51. package/packages/core/src/parse.d.ts +26 -0
  52. package/packages/core/src/parse.ts +141 -0
  53. package/packages/core/src/scripts.d.ts +17 -0
  54. package/packages/core/src/scripts.ts +79 -0
  55. package/packages/core/src/search.d.ts +83 -0
  56. package/packages/core/src/search.ts +188 -0
  57. package/packages/core/src/types.d.ts +82 -0
  58. package/packages/core/src/types.ts +131 -0
  59. package/packages/core/src/walk.ts +109 -0
  60. package/packages/core/tests/agnostic.test.ts +346 -0
  61. package/packages/core/tests/content.test.ts +65 -0
  62. package/packages/core/tests/discovery.test.ts +370 -0
  63. package/packages/core/tests/package-boundary.test.ts +310 -0
  64. package/packages/core/tests/parse-trigger.test.ts +282 -0
  65. package/packages/core/tests/search.test.ts +374 -0
  66. package/packages/core/tests/subpath.test.ts +87 -0
  67. package/packages/core/tsconfig.json +10 -0
  68. package/packages/opencode-agent-skills-md/package.json +42 -0
  69. package/packages/opencode-agent-skills-md/rolldown.config.js +48 -0
  70. package/packages/opencode-agent-skills-md/src/cli/config.ts +522 -0
  71. package/packages/opencode-agent-skills-md/src/cli/install.ts +111 -0
  72. package/packages/opencode-agent-skills-md/src/cli/main.ts +201 -0
  73. package/packages/opencode-agent-skills-md/src/cli/real-fs.ts +51 -0
  74. package/packages/opencode-agent-skills-md/src/cli/status.ts +183 -0
  75. package/packages/opencode-agent-skills-md/src/cli/uninstall.ts +157 -0
  76. package/packages/opencode-agent-skills-md/src/host.ts +119 -0
  77. package/packages/opencode-agent-skills-md/src/index.ts +25 -0
  78. package/packages/opencode-agent-skills-md/src/plugin.ts +343 -0
  79. package/packages/opencode-agent-skills-md/src/sdk.ts +71 -0
  80. package/packages/opencode-agent-skills-md/src/tools.ts +373 -0
  81. package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +1423 -0
  82. package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +66 -0
  83. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +8 -0
  84. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +8 -0
  85. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +8 -0
  86. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +8 -0
  87. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +12 -0
  88. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +8 -0
  89. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +11 -0
  90. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +8 -0
  91. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +2 -0
  92. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +1 -0
  93. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +8 -0
  94. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +8 -0
  95. package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +114 -0
  96. package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +316 -0
  97. package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +315 -0
  98. package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +179 -0
  99. package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +551 -0
  100. package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +66 -0
  101. package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +213 -0
  102. package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +346 -0
  103. package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +72 -0
  104. package/packages/opencode-agent-skills-md/tsconfig.build.json +11 -0
  105. package/packages/opencode-agent-skills-md/tsconfig.json +10 -0
  106. package/plans/001-ci-gate.md +177 -0
  107. package/plans/002-is-path-safe.md +243 -0
  108. package/plans/003-escape-prompts.md +310 -0
  109. package/plans/004-test-security-paths.md +228 -0
  110. package/plans/005-stop-swallowing-errors.md +246 -0
  111. package/plans/006-preserve-jsonc-commas.md +144 -0
  112. package/plans/007-write-before-purge.md +144 -0
  113. package/plans/008-reuse-walkdir-for-list-skill-files.md +164 -0
  114. package/plans/README.md +43 -0
  115. package/pnpm-workspace.yaml +6 -0
  116. package/tests/workspace.test.ts +367 -0
  117. package/tsconfig.json +15 -0
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // src/cli/main.ts — `oas` CLI entry point.
4
+ //
5
+ // Parses argv with `node:util.parseArgs` and dispatches to install,
6
+ // uninstall, status, or doctor. Exit codes follow the standard CLI
7
+ // convention used elsewhere in this repo:
8
+ //
9
+ // 0 — success (including idempotent no-ops)
10
+ // 1 — operational / health failure
11
+ // 2 — invalid usage (unknown command, missing required arg, etc.)
12
+ //
13
+ // When the file is built (rolldown, PR 2), the shebang stays in place via
14
+ // the banner plugin so `dist/cli.mjs` is directly executable as `oas`.
15
+ // During dev, `pnpm tsx src/cli/main.ts ...` works the same way.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ import { pathToFileURL } from "node:url";
19
+ import { parseArgs } from "node:util";
20
+ import { runInstall } from "./install";
21
+ import { runDoctor, runStatus } from "./status";
22
+ import { runUninstall } from "./uninstall";
23
+
24
+ const USAGE = `Usage: oas <command> [options]
25
+
26
+ Commands:
27
+ install Register the plugin in the global OpenCode config
28
+ uninstall Remove the plugin from the global OpenCode config
29
+ status Show current installation status
30
+ doctor Run health checks against the global config
31
+
32
+ Options (install):
33
+ -v, --version <v> Install a specific version (default: latest)
34
+ --latest Alias for --version latest
35
+ --dry-run Print the planned change without writing
36
+ --yes Skip confirmation prompts (reserved)
37
+
38
+ Options (uninstall):
39
+ --purge Also remove cache + ~/.config/opencode-agent-skills-md/
40
+ --dry-run Print the planned change without writing
41
+ --yes Skip confirmation prompts (reserved)
42
+
43
+ Options (all):
44
+ -h, --help Show this help and exit
45
+ `;
46
+
47
+ const printUsage = (): void => {
48
+ console.log(USAGE);
49
+ };
50
+
51
+ const setExit = (code: 0 | 1 | 2): void => {
52
+ process.exitCode = code;
53
+ };
54
+
55
+ interface ParsedArgs {
56
+ values: Record<string, string | boolean | undefined>;
57
+ positionals: string[];
58
+ }
59
+
60
+ const parseCliArgs = (argv: readonly string[]): ParsedArgs => {
61
+ // parseArgs with `strict: true` (the default) rejects unknown options
62
+ // with a clean error message — we surface that as exit code 2.
63
+ const parsed = parseArgs({
64
+ args: argv as string[],
65
+ allowPositionals: true,
66
+ strict: true,
67
+ options: {
68
+ version: { type: "string", short: "v" },
69
+ latest: { type: "boolean" },
70
+ yes: { type: "boolean", short: "y" },
71
+ "dry-run": { type: "boolean" },
72
+ purge: { type: "boolean" },
73
+ help: { type: "boolean", short: "h" },
74
+ },
75
+ });
76
+ return {
77
+ values: parsed.values as Record<string, string | boolean | undefined>,
78
+ positionals: parsed.positionals,
79
+ };
80
+ };
81
+
82
+ /**
83
+ * Strip Node + script argv entries when the entry point is invoked via
84
+ * the shell (shebang) or via `node ./dist/cli.mjs`. When called from a
85
+ * test harness with synthetic args, no stripping happens.
86
+ */
87
+ const sliceProcessArgv = (argv: readonly string[]): readonly string[] => {
88
+ if (argv.length < 2) return argv;
89
+ const first = argv[0] ?? "";
90
+ if (first === process.argv[0] || first.endsWith("node") || first.endsWith("node.exe")) {
91
+ return argv.slice(2);
92
+ }
93
+ return argv;
94
+ };
95
+
96
+ export interface MainResult {
97
+ command: string | null;
98
+ exitCode: 0 | 1 | 2;
99
+ }
100
+
101
+ /**
102
+ * Pure(ish) dispatcher: takes argv, runs the matching command, sets
103
+ * `process.exitCode`, and returns a structured result so tests can assert
104
+ * without reading the exit code.
105
+ */
106
+ export const runMain = (argv: readonly string[] = process.argv): MainResult => {
107
+ const args = sliceProcessArgv(argv);
108
+
109
+ // Short-circuit `--help` / `-h` before `parseArgs` so the user can ask
110
+ // for help without supplying a command.
111
+ if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
112
+ printUsage();
113
+ return { command: "help", exitCode: 0 };
114
+ }
115
+
116
+ let parsed: ParsedArgs;
117
+ try {
118
+ parsed = parseCliArgs(args);
119
+ } catch (err) {
120
+ console.error(`oas: ${(err as Error).message}`);
121
+ setExit(2);
122
+ return { command: null, exitCode: 2 };
123
+ }
124
+
125
+ if (parsed.values.help) {
126
+ printUsage();
127
+ return { command: "help", exitCode: 0 };
128
+ }
129
+
130
+ const command = parsed.positionals[0];
131
+
132
+ if (!command) {
133
+ console.error("oas: missing command. Run `oas --help` for usage.");
134
+ setExit(2);
135
+ return { command: null, exitCode: 2 };
136
+ }
137
+
138
+ try {
139
+ switch (command) {
140
+ case "install": {
141
+ const versionRaw = parsed.values.version;
142
+ const version =
143
+ parsed.values.latest === true
144
+ ? "latest"
145
+ : typeof versionRaw === "string"
146
+ ? versionRaw
147
+ : undefined;
148
+ runInstall({
149
+ version,
150
+ dryRun: parsed.values["dry-run"] === true,
151
+ yes: parsed.values.yes === true,
152
+ });
153
+ return { command, exitCode: 0 };
154
+ }
155
+ case "uninstall": {
156
+ runUninstall({
157
+ purge: parsed.values.purge === true,
158
+ dryRun: parsed.values["dry-run"] === true,
159
+ yes: parsed.values.yes === true,
160
+ });
161
+ return { command, exitCode: 0 };
162
+ }
163
+ case "status": {
164
+ runStatus();
165
+ return { command, exitCode: 0 };
166
+ }
167
+ case "doctor": {
168
+ const result = runDoctor();
169
+ if (!result.ok) setExit(1);
170
+ return { command, exitCode: result.ok ? 0 : 1 };
171
+ }
172
+ default:
173
+ console.error(`oas: unknown command '${command}'. Run \`oas --help\` for usage.`);
174
+ setExit(2);
175
+ return { command: null, exitCode: 2 };
176
+ }
177
+ } catch (err) {
178
+ console.error(`oas: ${(err as Error).message}`);
179
+ setExit(1);
180
+ return { command, exitCode: 1 };
181
+ }
182
+ };
183
+
184
+ /**
185
+ * `true` when the file is the program's entry point (shebang / `node
186
+ * cli.mjs`), `false` when it was imported from a test harness. We avoid
187
+ * `import.meta.main` because the package floor is Node 18 and that field
188
+ * only landed in Node 22.
189
+ */
190
+ const invokedAsMain = ((): boolean => {
191
+ if (!process.argv[1]) return false;
192
+ try {
193
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
194
+ } catch {
195
+ return false;
196
+ }
197
+ })();
198
+
199
+ if (invokedAsMain) {
200
+ runMain(process.argv);
201
+ }
@@ -0,0 +1,51 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/real-fs.ts — Default `CliFs` adapter backed by `node:fs`.
3
+ //
4
+ // The CLI commands (`install`, `uninstall`, `status`, `doctor`) default to
5
+ // the real filesystem in production. Tests inject an in-memory adapter via
6
+ // the second argument to keep everything deterministic and fast.
7
+ //
8
+ // The mapping is intentionally thin: only the methods `CliFs` exposes are
9
+ // bound, and `readFileSync` returns a UTF-8 string (the only shape the CLI
10
+ // helpers consume). Anything that does not belong on the `CliFs` interface
11
+ // — for example, recursive directory removal in the uninstall purge path —
12
+ // uses `node:fs` directly at the call site instead of widening the
13
+ // abstraction.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import {
17
+ copyFileSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ readFileSync,
22
+ renameSync,
23
+ unlinkSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import type { CliFs } from "./config";
27
+
28
+ /**
29
+ * Build a `CliFs` that delegates to `node:fs`. All methods are sync; the
30
+ * CLI is short-lived and never benefits from async I/O.
31
+ */
32
+ export const createRealFs = (): CliFs => ({
33
+ readFileSync: (path) => readFileSync(path, "utf8"),
34
+ writeFileSync: (path, content) => {
35
+ writeFileSync(path, content);
36
+ },
37
+ renameSync: (from, to) => {
38
+ renameSync(from, to);
39
+ },
40
+ copyFileSync: (from, to) => {
41
+ copyFileSync(from, to);
42
+ },
43
+ unlinkSync: (path) => {
44
+ unlinkSync(path);
45
+ },
46
+ mkdirSync: (path, opts) => {
47
+ mkdirSync(path, opts);
48
+ },
49
+ readdirSync: (path) => readdirSync(path),
50
+ existsSync: (path) => existsSync(path),
51
+ });
@@ -0,0 +1,183 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/status.ts — `oas status` and `oas doctor` commands.
3
+ //
4
+ // `status` reports whether the plugin is installed (and at what version)
5
+ // in the global OpenCode config — it's a read-only, idempotent probe
6
+ // suitable for scripting.
7
+ //
8
+ // `doctor` runs a small battery of health checks: Node version, config
9
+ // file readability, plugin-array shape, and config-directory writability.
10
+ // Issues are reported grouped by severity; the caller (main.ts) decides
11
+ // whether the exit code reflects health.
12
+ //
13
+ // `doctor`'s writability probe touches the real filesystem directly via
14
+ // `node:fs` because `CliFs` deliberately does not expose access checks —
15
+ // the probe is best-effort and an injected in-memory fs should not pretend
16
+ // to model POSIX permissions.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import { accessSync, constants as fsConstants, statSync } from "node:fs";
20
+ import { dirname } from "node:path";
21
+ import { type CliFs, loadGlobalConfig, matchesPlugin, normalizePlugin, PLUGIN_NAME } from "./config";
22
+ import { createRealFs } from "./real-fs";
23
+
24
+ export interface StatusResult {
25
+ /** Whether an `opencode-agent-skills-md` entry is present in `plugin`. */
26
+ installed: boolean;
27
+ /** Resolved config path the loader used. */
28
+ path: string;
29
+ /** Detected on-disk format. */
30
+ format: "json" | "jsonc";
31
+ /** The active specifier, or `null` when not installed. */
32
+ specifier: string | null;
33
+ /** Other plugin entries preserved alongside the oas one. */
34
+ extras: string[];
35
+ }
36
+
37
+ export interface DoctorResult {
38
+ /** True when there are zero blocking issues. */
39
+ ok: boolean;
40
+ /** Blocking problems — the install flow will not work until they are fixed. */
41
+ issues: string[];
42
+ /** Non-blocking advisories — install may still work. */
43
+ warnings: string[];
44
+ /** Informational notes about what was checked. */
45
+ info: string[];
46
+ }
47
+
48
+ const formatFromPath = (path: string): "json" | "jsonc" =>
49
+ path.endsWith(".jsonc") ? "jsonc" : "json";
50
+
51
+ /**
52
+ * Read-only status probe. Prints a human-readable report to stdout and
53
+ * returns the same data as a structured result so callers (including
54
+ * `main.ts` and tests) can consume it without parsing the message.
55
+ */
56
+ export const runStatus = (fs: CliFs = createRealFs()): StatusResult => {
57
+ const loaded = loadGlobalConfig(fs);
58
+ const plugins = normalizePlugin(loaded.config.plugin);
59
+ const oasEntries = plugins.filter(matchesPlugin);
60
+ const extras = plugins.filter((entry) => !matchesPlugin(entry));
61
+ const format = formatFromPath(loaded.path);
62
+
63
+ console.log(`Config path: ${loaded.path}`);
64
+ console.log(`Format: ${format}`);
65
+ console.log(`Exists on disk: ${loaded.existed ? "yes" : "no (will be created on install)"}`);
66
+
67
+ if (oasEntries.length === 0) {
68
+ console.log(`Installed: no`);
69
+ return {
70
+ installed: false,
71
+ path: loaded.path,
72
+ format,
73
+ specifier: null,
74
+ extras,
75
+ };
76
+ }
77
+
78
+ // In practice `install` dedupes so at most one oas entry survives;
79
+ // reporting the first keeps the output stable for scripting.
80
+ const specifier = oasEntries[0] ?? null;
81
+ console.log(`Installed: yes`);
82
+ console.log(`Specifier: ${specifier}`);
83
+ if (extras.length > 0) {
84
+ console.log(`Other plugins: ${extras.join(", ")}`);
85
+ }
86
+
87
+ return {
88
+ installed: true,
89
+ path: loaded.path,
90
+ format,
91
+ specifier,
92
+ extras,
93
+ };
94
+ };
95
+
96
+ /**
97
+ * Health checks. The function does not exit on its own — it returns a
98
+ * `DoctorResult` and `main.ts` maps `ok === false` to exit code 1.
99
+ */
100
+ export const runDoctor = (
101
+ fs: CliFs = createRealFs(),
102
+ env: NodeJS.ProcessEnv = process.env,
103
+ ): DoctorResult => {
104
+ const issues: string[] = [];
105
+ const warnings: string[] = [];
106
+ const info: string[] = [];
107
+
108
+ // 1. Node major version — `package.json#engines.node` requires >= 18.
109
+ const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
110
+ if (!Number.isFinite(nodeMajor) || nodeMajor < 18) {
111
+ issues.push(`Node ${process.versions.node} detected — ${PLUGIN_NAME} requires Node >= 18`);
112
+ } else {
113
+ info.push(`Node ${process.versions.node} OK`);
114
+ }
115
+
116
+ // 2. Config file readability + format detection.
117
+ const loaded = loadGlobalConfig(fs, env);
118
+ const format = formatFromPath(loaded.path);
119
+ info.push(`Config path: ${loaded.path}`);
120
+ info.push(`Config format: ${format}`);
121
+ if (!loaded.existed) {
122
+ warnings.push(`Config file does not exist yet — install will create it`);
123
+ }
124
+
125
+ // 3. `plugin` shape — must be array, object (legacy), or absent.
126
+ const rawPlugin = loaded.config.plugin;
127
+ if (rawPlugin === undefined || rawPlugin === null) {
128
+ info.push(`Plugin entries: 0`);
129
+ } else {
130
+ const validShape = Array.isArray(rawPlugin) || typeof rawPlugin === "object";
131
+ if (!validShape) {
132
+ issues.push(`config.plugin is neither array nor object — install will reset it`);
133
+ } else {
134
+ const plugins = normalizePlugin(rawPlugin);
135
+ info.push(`Plugin entries: ${plugins.length}`);
136
+ const oasCount = plugins.filter(matchesPlugin).length;
137
+ if (oasCount > 1) {
138
+ warnings.push(`${oasCount} ${PLUGIN_NAME} entries present — install will dedupe`);
139
+ }
140
+ }
141
+ }
142
+
143
+ // 4. Parent dir existence + writability. We probe the real filesystem
144
+ // because POSIX permissions are not something the in-memory `CliFs` can
145
+ // meaningfully model. Failures here are warnings, not blocking issues:
146
+ // install will surface a real error when it tries to write.
147
+ try {
148
+ const dir = dirname(loaded.path);
149
+ try {
150
+ const stat = statSync(dir);
151
+ if (stat.isDirectory()) {
152
+ try {
153
+ accessSync(dir, fsConstants.W_OK);
154
+ info.push(`Config directory writable: ${dir}`);
155
+ } catch {
156
+ warnings.push(`Config directory ${dir} is not writable`);
157
+ }
158
+ } else {
159
+ issues.push(`${dir} exists but is not a directory`);
160
+ }
161
+ } catch {
162
+ warnings.push(
163
+ `Config directory ${dir} does not exist yet — will be created on first install`,
164
+ );
165
+ }
166
+ } catch {
167
+ // best-effort — never block on permission probes
168
+ }
169
+
170
+ // Render the report. Order: info, warnings, errors, summary.
171
+ for (const line of info) console.log(` ✓ ${line}`);
172
+ for (const line of warnings) console.warn(` ! ${line}`);
173
+ for (const line of issues) console.error(` ✗ ${line}`);
174
+
175
+ const ok = issues.length === 0;
176
+ if (ok) {
177
+ console.log(`\n✓ Doctor: all checks passed`);
178
+ } else {
179
+ console.log(`\n✗ Doctor: ${issues.length} issue(s) found`);
180
+ }
181
+
182
+ return { ok, issues, warnings, info };
183
+ };
@@ -0,0 +1,157 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/uninstall.ts — `oas uninstall` command.
3
+ //
4
+ // Removes every `opencode-agent-skills-md` entry from the global OpenCode
5
+ // config's `plugin` list. With `--purge`, also deletes the runtime cache
6
+ // directory (`~/.cache/opencode/node_modules/opencode-agent-skills-md`) and
7
+ // the plugin's own config dir (`~/.config/opencode-agent-skills-md/`).
8
+ //
9
+ // Like `install`, the function is side-effect-free beyond prints and disk
10
+ // writes through `fs`. Tests inject an in-memory `CliFs` to exercise the
11
+ // config-mutation path; the purge path uses `node:fs` directly because
12
+ // `CliFs` deliberately does not expose recursive directory removal.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { rmSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import {
19
+ backupIfWritable,
20
+ type CliFs,
21
+ loadGlobalConfig,
22
+ matchesPlugin,
23
+ normalizePlugin,
24
+ PLUGIN_NAME,
25
+ writeAtomically,
26
+ } from "./config";
27
+ import { createRealFs } from "./real-fs";
28
+
29
+ export interface UninstallOptions {
30
+ /** Also remove the runtime cache and the plugin's own config dir. */
31
+ purge?: boolean;
32
+ /** Plan the change and print it without writing. */
33
+ dryRun?: boolean;
34
+ /** Reserved for future confirmation prompts; accepted but unused for now. */
35
+ yes?: boolean;
36
+ }
37
+
38
+ export interface UninstallResult {
39
+ status: "wrote" | "planned" | "noop";
40
+ path: string;
41
+ /** Plugin entries that were (or would be) removed from the config. */
42
+ removed: string[];
43
+ /** Cache / config dirs removed under `--purge`. Empty when `--purge` was not set. */
44
+ purged: string[];
45
+ }
46
+
47
+ const JSON_INDENT = 2;
48
+
49
+ /** Resolve `$HOME` (or `os.homedir()` as last resort) for purge paths. */
50
+ const homeRoot = (env: NodeJS.ProcessEnv = process.env): string => {
51
+ const home = env.HOME;
52
+ if (typeof home === "string" && home.trim().length > 0) return home;
53
+ return homedir();
54
+ };
55
+
56
+ /** Bun/npm-style cache path where the plugin gets installed at runtime. */
57
+ export const cachePath = (env: NodeJS.ProcessEnv = process.env): string =>
58
+ join(homeRoot(env), ".cache", "opencode", "node_modules", PLUGIN_NAME);
59
+
60
+ /** Plugin's own XDG config dir (separate from the OpenCode config it edits). */
61
+ export const pluginConfigPath = (env: NodeJS.ProcessEnv = process.env): string =>
62
+ join(homeRoot(env), ".config", PLUGIN_NAME);
63
+
64
+ /**
65
+ * Best-effort recursive delete. Returns the path on success or `null` when
66
+ * the target was missing (we don't want to fail the whole command if the
67
+ * user never ran `install` to create these dirs in the first place).
68
+ */
69
+ const purgeDir = (path: string): string | null => {
70
+ try {
71
+ rmSync(path, { recursive: true, force: true });
72
+ return path;
73
+ } catch {
74
+ return null;
75
+ }
76
+ };
77
+
78
+ export const runUninstall = (
79
+ opts: UninstallOptions = {},
80
+ fs: CliFs = createRealFs(),
81
+ ): UninstallResult => {
82
+ const loaded = loadGlobalConfig(fs);
83
+
84
+ if (loaded.parseError) {
85
+ throw new Error(
86
+ `oas: config file is malformed JSON — aborting to avoid data loss.\n` +
87
+ ` path: ${loaded.path}\n` +
88
+ ` error: ${loaded.parseError}\n` +
89
+ `Fix the JSON error, or delete the file and re-run.`,
90
+ );
91
+ }
92
+
93
+ const config: Record<string, unknown> = { ...loaded.config };
94
+ const existing = normalizePlugin(config.plugin);
95
+ const removed = existing.filter(matchesPlugin);
96
+ const remaining = existing.filter((entry) => !removed.includes(entry));
97
+
98
+ // Compute purge candidates up front so dry-run can report them too.
99
+ const purgeCandidates = opts.purge ? [cachePath(), pluginConfigPath()] : [];
100
+ const purged: string[] = [];
101
+ const plannedPurge: string[] = [];
102
+
103
+ if (opts.purge && opts.dryRun) {
104
+ plannedPurge.push(...purgeCandidates);
105
+ }
106
+
107
+ // Nothing to remove from the config AND nothing to purge → true no-op.
108
+ if (removed.length === 0 && purgeCandidates.length === 0) {
109
+ console.log(`✓ Not installed: ${PLUGIN_NAME} not found in ${loaded.path}`);
110
+ return { status: "noop", path: loaded.path, removed: [], purged: [] };
111
+ }
112
+
113
+ // Build the post-uninstall config object.
114
+ if (removed.length > 0) {
115
+ if (remaining.length === 0) {
116
+ delete config.plugin;
117
+ } else {
118
+ config.plugin = remaining;
119
+ }
120
+ }
121
+
122
+ if (opts.dryRun) {
123
+ if (plannedPurge.length > 0) {
124
+ console.log(`[dry-run] Would purge:`);
125
+ for (const p of plannedPurge) console.log(` ${p}`);
126
+ }
127
+ console.log(`[dry-run] Would write to ${loaded.path}:`);
128
+ console.log(JSON.stringify(config, null, JSON_INDENT));
129
+ return {
130
+ status: "planned",
131
+ path: loaded.path,
132
+ removed,
133
+ purged: plannedPurge,
134
+ };
135
+ }
136
+
137
+ // Write the updated config before purging — this preserves the invariant
138
+ // that config state is committed before side-effectful purge runs.
139
+ let backup: string | null = null;
140
+ if (removed.length > 0 && loaded.existed) {
141
+ backup = backupIfWritable(loaded.path, fs);
142
+ writeAtomically(loaded.path, JSON.stringify(config, null, JSON_INDENT), fs);
143
+ }
144
+
145
+ // Best-effort purge after config write is committed.
146
+ for (const p of purgeCandidates) {
147
+ const result = purgeDir(p);
148
+ if (result) purged.push(result);
149
+ }
150
+
151
+ console.log(`✓ Uninstalled ${PLUGIN_NAME}`);
152
+ if (removed.length > 0) console.log(` config: ${loaded.path}`);
153
+ if (backup) console.log(` backup: ${backup}`);
154
+ for (const p of purged) console.log(` purged: ${p}`);
155
+
156
+ return { status: "wrote", path: loaded.path, removed, purged };
157
+ };