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.
- package/.beads/.local_version +1 -0
- package/.beads/README.md +81 -0
- package/.beads/config.yaml +61 -0
- package/.beads/deletions.jsonl +1 -0
- package/.beads/issues.jsonl +64 -0
- package/.beads/metadata.json +4 -0
- package/.gitattributes +3 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/copilot-instructions.md +78 -0
- package/.github/dependabot.yml +13 -0
- package/.github/workflows/release.yml +51 -0
- package/.opencode/command/test-compaction.md +9 -0
- package/.opencode/command/test-find-skills.md +7 -0
- package/.opencode/command/test-read-skill-file.md +14 -0
- package/.opencode/command/test-run-skill-script.md +13 -0
- package/.opencode/command/test-skills.md +14 -0
- package/.opencode/command/test-use-skill.md +10 -0
- package/.opencode/skills/git-helper/SKILL.md +65 -0
- package/.opencode/skills/test-skill/SKILL.md +43 -0
- package/.opencode/skills/test-skill/example-config.json +16 -0
- package/.opencode/skills/test-skill/helper-docs.md +29 -0
- package/.opencode/skills/test-skill/scripts/echo-args +14 -0
- package/.opencode/skills/test-skill/scripts/greet +6 -0
- package/AGENTS.md +43 -0
- package/CHANGELOG.md +178 -0
- package/Justfile +39 -0
- package/LICENSE +9 -0
- package/README.md +189 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +74 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +64 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +75 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +136 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +77 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +89 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +65 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +77 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +65 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +165 -0
- package/openspec/specs/core-decoupling/spec.md +110 -0
- package/package.json +35 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/content.d.ts +16 -0
- package/packages/core/src/content.ts +30 -0
- package/packages/core/src/debug.ts +16 -0
- package/packages/core/src/discovery.d.ts +86 -0
- package/packages/core/src/discovery.ts +257 -0
- package/packages/core/src/index.d.ts +20 -0
- package/packages/core/src/index.ts +55 -0
- package/packages/core/src/match.d.ts +19 -0
- package/packages/core/src/match.ts +75 -0
- package/packages/core/src/parse.d.ts +26 -0
- package/packages/core/src/parse.ts +141 -0
- package/packages/core/src/scripts.d.ts +17 -0
- package/packages/core/src/scripts.ts +79 -0
- package/packages/core/src/search.d.ts +83 -0
- package/packages/core/src/search.ts +188 -0
- package/packages/core/src/types.d.ts +82 -0
- package/packages/core/src/types.ts +131 -0
- package/packages/core/src/walk.ts +109 -0
- package/packages/core/tests/agnostic.test.ts +346 -0
- package/packages/core/tests/content.test.ts +65 -0
- package/packages/core/tests/discovery.test.ts +370 -0
- package/packages/core/tests/package-boundary.test.ts +310 -0
- package/packages/core/tests/parse-trigger.test.ts +282 -0
- package/packages/core/tests/search.test.ts +374 -0
- package/packages/core/tests/subpath.test.ts +87 -0
- package/packages/core/tsconfig.json +10 -0
- package/packages/opencode-agent-skills-md/package.json +42 -0
- package/packages/opencode-agent-skills-md/rolldown.config.js +48 -0
- package/packages/opencode-agent-skills-md/src/cli/config.ts +522 -0
- package/packages/opencode-agent-skills-md/src/cli/install.ts +111 -0
- package/packages/opencode-agent-skills-md/src/cli/main.ts +201 -0
- package/packages/opencode-agent-skills-md/src/cli/real-fs.ts +51 -0
- package/packages/opencode-agent-skills-md/src/cli/status.ts +183 -0
- package/packages/opencode-agent-skills-md/src/cli/uninstall.ts +157 -0
- package/packages/opencode-agent-skills-md/src/host.ts +119 -0
- package/packages/opencode-agent-skills-md/src/index.ts +25 -0
- package/packages/opencode-agent-skills-md/src/plugin.ts +343 -0
- package/packages/opencode-agent-skills-md/src/sdk.ts +71 -0
- package/packages/opencode-agent-skills-md/src/tools.ts +373 -0
- package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +1423 -0
- package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +66 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +12 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +11 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +2 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +1 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +114 -0
- package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +316 -0
- package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +315 -0
- package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +179 -0
- package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +551 -0
- package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +66 -0
- package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +213 -0
- package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +346 -0
- package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +72 -0
- package/packages/opencode-agent-skills-md/tsconfig.build.json +11 -0
- package/packages/opencode-agent-skills-md/tsconfig.json +10 -0
- package/plans/001-ci-gate.md +177 -0
- package/plans/002-is-path-safe.md +243 -0
- package/plans/003-escape-prompts.md +310 -0
- package/plans/004-test-security-paths.md +228 -0
- package/plans/005-stop-swallowing-errors.md +246 -0
- package/plans/006-preserve-jsonc-commas.md +144 -0
- package/plans/007-write-before-purge.md +144 -0
- package/plans/008-reuse-walkdir-for-list-skill-files.md +164 -0
- package/plans/README.md +43 -0
- package/pnpm-workspace.yaml +6 -0
- package/tests/workspace.test.ts +367 -0
- 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
|
+
};
|