@oharness/engine 0.2.0 → 0.3.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 (54) hide show
  1. package/dist/agent/skills.d.ts +10 -2
  2. package/dist/agent/skills.js +38 -12
  3. package/dist/agent/skills.js.map +1 -1
  4. package/dist/auth/manager.d.ts +13 -0
  5. package/dist/auth/manager.js +22 -0
  6. package/dist/auth/manager.js.map +1 -1
  7. package/dist/auth/providers/openai-codex.d.ts +46 -0
  8. package/dist/auth/providers/openai-codex.js +201 -0
  9. package/dist/auth/providers/openai-codex.js.map +1 -0
  10. package/dist/harness.js +14 -3
  11. package/dist/harness.js.map +1 -1
  12. package/dist/mcp/builtin.d.ts +13 -0
  13. package/dist/mcp/builtin.js +93 -0
  14. package/dist/mcp/builtin.js.map +1 -0
  15. package/dist/mcp/client.d.ts +5 -1
  16. package/dist/mcp/client.js +11 -4
  17. package/dist/mcp/client.js.map +1 -1
  18. package/dist/mcp/manager.d.ts +13 -1
  19. package/dist/mcp/manager.js +30 -2
  20. package/dist/mcp/manager.js.map +1 -1
  21. package/dist/mcp/secrets.d.ts +15 -0
  22. package/dist/mcp/secrets.js +61 -0
  23. package/dist/mcp/secrets.js.map +1 -1
  24. package/dist/providers/builtin.js +17 -4
  25. package/dist/providers/builtin.js.map +1 -1
  26. package/dist/providers/catalog.d.ts +27 -3
  27. package/dist/providers/catalog.js +10 -0
  28. package/dist/providers/catalog.js.map +1 -1
  29. package/dist/providers/catalog.json +41 -5
  30. package/dist/providers/piai.d.ts +1 -1
  31. package/dist/providers/piai.js +10 -1
  32. package/dist/providers/piai.js.map +1 -1
  33. package/dist/providers/registry.js +4 -2
  34. package/dist/providers/registry.js.map +1 -1
  35. package/dist/runtime-plugins/builtin/media.d.ts +33 -0
  36. package/dist/runtime-plugins/builtin/media.js +315 -0
  37. package/dist/runtime-plugins/builtin/media.js.map +1 -0
  38. package/dist/runtime-plugins/host.d.ts +2 -0
  39. package/dist/runtime-plugins/host.js +3 -2
  40. package/dist/runtime-plugins/host.js.map +1 -1
  41. package/dist/runtime-plugins/index.d.ts +1 -0
  42. package/dist/runtime-plugins/index.js.map +1 -1
  43. package/dist/runtime-plugins/types.d.ts +5 -0
  44. package/package.json +23 -2
  45. package/skills/media-generation/SKILL.md +96 -0
  46. package/skills/plugin-creator/SKILL.md +120 -0
  47. package/skills/plugin-creator/scripts/create-plugin.mjs +76 -0
  48. package/skills/plugin-creator/scripts/validate-plugin.mjs +46 -0
  49. package/skills/plugin-creator/scripts/validate-runtime-plugin.mjs +28 -0
  50. package/skills/skill-authoring/SKILL.md +4 -1
  51. package/skills/skill-creator/SKILL.md +98 -0
  52. package/skills/skill-creator/scripts/create-skill.mjs +75 -0
  53. package/skills/skill-creator/scripts/validate-runtime-skill.mjs +36 -0
  54. package/skills/skill-creator/scripts/validate-skill.mjs +40 -0
@@ -0,0 +1,120 @@
1
+ ---
2
+ name: plugin-creator
3
+ description: Creating or updating native OHarness plugin bundles with skills, subagents, hooks, MCP servers, project or user installation, and manifest validation.
4
+ ---
5
+
6
+ # Create an OHarness plugin
7
+
8
+ Choose between two native OHarness formats. A declarative bundle packages
9
+ Skills, Subagents, Hooks, and MCP servers without importing JavaScript. A
10
+ Runtime Plugin is trusted executable code that can register tools, Runtime
11
+ Skills, models, authentication, hooks, audit sinks, prompts, pages, and
12
+ background lifecycle work.
13
+
14
+ ## Choose the installation scope
15
+
16
+ - Default to `<project>/.oharness/plugins/<name>/` for a repository-specific
17
+ plugin.
18
+ - For a local/global installation available to every project, use
19
+ `$OHARNESS_HOME/plugins/<name>/` when set,
20
+ `$XDG_CONFIG_HOME/oharness/plugins/<name>/` on configured Linux systems, or
21
+ otherwise `~/.oharness/plugins/<name>/`.
22
+ - Respect an explicit directory. Inspect an existing plugin before editing it
23
+ and never overwrite unrelated files.
24
+
25
+ Both the Web and CLI frontends use the same plugin discovery in `Harness`, so a
26
+ user-installed plugin is available to either frontend on its next Harness
27
+ startup. Project and configured plugin directories override earlier plugins
28
+ with the same name.
29
+
30
+ Runtime Plugins are different: project configuration cannot activate executable
31
+ modules. They must be supplied by the embedding application or enabled in the
32
+ user `plugins.json` runtime-plugin list. Changes take effect on the next Harness
33
+ startup.
34
+
35
+ ## Native bundle format
36
+
37
+ Create only the parts the requested plugin needs:
38
+
39
+ ```text
40
+ my-plugin/
41
+ oharness.plugin.json
42
+ skills/example/SKILL.md
43
+ agents/reviewer.md
44
+ scripts/audit.sh
45
+ ```
46
+
47
+ The manifest needs only `name`. Supported optional fields are `version`,
48
+ `description`, `mcpServers`, `hooks`, and inline `subagents`:
49
+
50
+ ```json
51
+ {
52
+ "name": "my-plugin",
53
+ "version": "1.0.0",
54
+ "description": "Release and review workflows",
55
+ "mcpServers": {
56
+ "github": { "type": "http", "url": "https://example.com/mcp" }
57
+ },
58
+ "hooks": {
59
+ "PreToolUse": [{ "matcher": "mcp__github__*", "command": "./scripts/audit.sh" }]
60
+ }
61
+ }
62
+ ```
63
+
64
+ Skills under `skills/` and Markdown agents under `agents/` are discovered
65
+ automatically and do not need manifest entries. A plugin can alternatively be
66
+ added through `plugins.dirs` in configuration. OHarness can consume compatible
67
+ Claude Code plugin bundles, but create the native `oharness.plugin.json` format
68
+ unless the user explicitly requests cross-product compatibility.
69
+
70
+ For a deterministic skeleton, run:
71
+
72
+ ```bash
73
+ node <skill-base>/scripts/create-plugin.mjs <name> [--description "<text>"] [--version 1.0.0] [--with skills,agents,scripts] [--project <directory> | --user]
74
+ ```
75
+
76
+ Resolve `<skill-base>` from the base directory shown when this skill loads; do
77
+ not assume the current working directory is the built-in skill directory.
78
+ The command refuses to replace an existing manifest unless `--force` is given.
79
+ Use that flag only after inspecting the target and intentionally replacing it.
80
+ Populate requested hooks and MCP declarations after scaffolding; do not invent
81
+ commands, endpoints, credentials, or permissions.
82
+
83
+ ## Runtime Plugin mode
84
+
85
+ When the request needs executable in-process behavior, add `--runtime`:
86
+
87
+ ```bash
88
+ node <skill-base>/scripts/create-plugin.mjs <name> --runtime [--description "<text>"] [--version 1.0.0] [--project <directory> | --user]
89
+ node <skill-base>/scripts/validate-runtime-plugin.mjs <path-to-runtime-plugin>
90
+ ```
91
+
92
+ Implement only the requested capabilities through `setup(ctx)`. Register all
93
+ contributions during setup, keep mutable state per Harness instance, honor
94
+ `ctx.signal`, register cleanup immediately with `ctx.onDispose`, and make
95
+ `ctx.onStart` return after starting background work rather than awaiting an
96
+ infinite loop. Runtime Skill contributions use
97
+ `ctx.skills.register({ name, description, body, directory? })`; the optional
98
+ directory must be absolute.
99
+
100
+ When installation is requested, add an entry with the generated id and absolute
101
+ module path to the user `plugins.json` `runtimePlugins` list. Never enable
102
+ executable code from project configuration, and do not change user configuration
103
+ unless installation or enablement was explicitly requested.
104
+
105
+ Plugin hooks and stdio MCP servers execute local commands. Treat the bundle as
106
+ code, keep secrets as environment placeholders, and call out those executable
107
+ parts when handing the plugin back.
108
+
109
+ ## Validate and activate
110
+
111
+ Run:
112
+
113
+ ```bash
114
+ node <skill-base>/scripts/validate-plugin.mjs <path-to-plugin>
115
+ ```
116
+
117
+ Verify every referenced script or skill exists and test deterministic helpers.
118
+ Plugin discovery happens when `Harness` starts, so restart the local CLI/Web
119
+ process or begin a new session after installing a new bundle. Use `/plugins` to
120
+ confirm the effective plugin and `/mcp` to inspect contributed MCP connections.
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ function fail(message) {
7
+ console.error(`plugin-creator: ${message}`);
8
+ process.exit(1);
9
+ }
10
+
11
+ function userRoot() {
12
+ if (process.env.OHARNESS_HOME) return path.resolve(process.env.OHARNESS_HOME);
13
+ if (process.platform === "linux" && process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, "oharness");
14
+ return path.join(homedir(), ".oharness");
15
+ }
16
+
17
+ function normalize(value) {
18
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63).replace(/-+$/g, "");
19
+ }
20
+
21
+ const args = process.argv.slice(2);
22
+ const rawName = args.shift();
23
+ if (!rawName) fail("usage: create-plugin.mjs <name> [--runtime] [--description <text>] [--version <value>] [--with a,b] [--project <dir> | --user] [--force]");
24
+
25
+ let description;
26
+ let version;
27
+ let project = process.cwd();
28
+ let useUser = false;
29
+ let projectSpecified = false;
30
+ let runtime = false;
31
+ let force = false;
32
+ let companions = [];
33
+ for (let i = 0; i < args.length; i++) {
34
+ const arg = args[i];
35
+ if (arg === "--description") description = args[++i];
36
+ else if (arg === "--version") version = args[++i];
37
+ else if (arg === "--project") { project = path.resolve(args[++i] ?? fail("--project needs a directory")); projectSpecified = true; }
38
+ else if (arg === "--user") useUser = true;
39
+ else if (arg === "--runtime") runtime = true;
40
+ else if (arg === "--with") companions = (args[++i] ?? "").split(",").filter(Boolean);
41
+ else if (arg === "--force") force = true;
42
+ else fail(`unknown option: ${arg}`);
43
+ }
44
+ if (useUser && projectSpecified) fail("choose either --user or --project");
45
+
46
+ const name = normalize(rawName);
47
+ if (!name) fail("name must contain a letter or digit");
48
+ const allowed = new Set(["skills", "agents", "scripts"]);
49
+ for (const companion of companions) if (!allowed.has(companion)) fail(`unsupported companion directory: ${companion}`);
50
+
51
+ const parent = runtime
52
+ ? (useUser ? path.join(userRoot(), "runtime-plugins") : path.join(project, ".oharness", "runtime-plugins"))
53
+ : (useUser ? path.join(userRoot(), "plugins") : path.join(project, ".oharness", "plugins"));
54
+ const directory = path.join(parent, name);
55
+ const file = path.join(directory, runtime ? "index.mjs" : "oharness.plugin.json");
56
+ if (!force) {
57
+ try {
58
+ await readFile(file, "utf8");
59
+ fail(`${file} already exists (pass --force only to replace it intentionally)`);
60
+ } catch (error) {
61
+ if (error?.code !== "ENOENT") throw error;
62
+ }
63
+ }
64
+
65
+ await mkdir(directory, { recursive: true });
66
+ for (const companion of companions) await mkdir(path.join(directory, companion), { recursive: true });
67
+ const manifest = {
68
+ name,
69
+ ...(version?.trim() ? { version: version.trim() } : {}),
70
+ ...(description?.trim() ? { description: description.trim() } : {}),
71
+ };
72
+ const output = runtime
73
+ ? `export default {\n id: ${JSON.stringify(name)},\n version: ${JSON.stringify(version?.trim() || "1.0.0")},\n apiVersion: 1,\n ${description?.trim() ? `description: ${JSON.stringify(description.trim())},\n ` : ""}parseConfig(raw) {\n if (raw !== undefined) throw new Error("This plugin takes no configuration");\n return {};\n },\n setup(_ctx) {\n // Register requested runtime capabilities here before returning.\n },\n};\n`
74
+ : `${JSON.stringify(manifest, null, 2)}\n`;
75
+ await writeFile(file, output, "utf8");
76
+ console.log(file);
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const input = process.argv[2];
6
+ if (!input) {
7
+ console.error("usage: validate-plugin.mjs <plugin-directory-or-oharness.plugin.json>");
8
+ process.exit(1);
9
+ }
10
+
11
+ const file = path.basename(input) === "oharness.plugin.json" ? path.resolve(input) : path.resolve(input, "oharness.plugin.json");
12
+ const directory = path.dirname(file);
13
+ const errors = [];
14
+ let manifest;
15
+ try {
16
+ manifest = JSON.parse(await readFile(file, "utf8"));
17
+ } catch (error) {
18
+ errors.push(`cannot parse ${file}: ${error.message}`);
19
+ }
20
+
21
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) errors.push("manifest must be a JSON object");
22
+ const name = manifest?.name;
23
+ if (typeof name !== "string" || !name.trim()) errors.push("manifest requires a non-empty name");
24
+ if (typeof name === "string" && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) errors.push("name must be lowercase hyphen-case");
25
+ if (typeof name === "string" && name.length >= 64) errors.push("name must be shorter than 64 characters");
26
+ if (typeof name === "string" && path.basename(directory) !== name) errors.push("directory name must match manifest name");
27
+ if (manifest?.mcpServers !== undefined && (!manifest.mcpServers || typeof manifest.mcpServers !== "object" || Array.isArray(manifest.mcpServers))) errors.push("mcpServers must be an object");
28
+ if (manifest?.hooks !== undefined && (!manifest.hooks || typeof manifest.hooks !== "object" || Array.isArray(manifest.hooks))) errors.push("hooks must be an object");
29
+ if (manifest?.subagents !== undefined && !Array.isArray(manifest.subagents)) errors.push("subagents must be an array");
30
+
31
+ try {
32
+ const skills = path.join(directory, "skills");
33
+ for (const entry of await readdir(skills, { withFileTypes: true })) {
34
+ if (!entry.isDirectory()) continue;
35
+ try { await readFile(path.join(skills, entry.name, "SKILL.md"), "utf8"); }
36
+ catch { errors.push(`skills/${entry.name} is missing SKILL.md`); }
37
+ }
38
+ } catch (error) {
39
+ if (error?.code !== "ENOENT") errors.push(`cannot inspect skills/: ${error.message}`);
40
+ }
41
+
42
+ if (errors.length) {
43
+ for (const error of errors) console.error(`- ${error}`);
44
+ process.exit(1);
45
+ }
46
+ console.log(`valid OHarness plugin: ${name}`);
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ const input = process.argv[2];
7
+ if (!input) {
8
+ console.error("usage: validate-runtime-plugin.mjs <runtime-plugin-directory-or-module>");
9
+ process.exit(1);
10
+ }
11
+
12
+ const target = path.resolve(input);
13
+ const file = (await stat(target)).isDirectory() ? path.join(target, "index.mjs") : target;
14
+ const errors = [];
15
+ let plugin;
16
+ try { plugin = (await import(`${pathToFileURL(file).href}?validate=${Date.now()}`)).default; }
17
+ catch (error) { errors.push(`cannot import ${file}: ${error.message}`); }
18
+ if (!plugin || typeof plugin !== "object") errors.push("module must default-export a plugin object");
19
+ if (typeof plugin?.id !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(plugin.id)) errors.push("plugin id is invalid");
20
+ if (typeof plugin?.version !== "string" || !plugin.version) errors.push("plugin version is required");
21
+ if (plugin?.apiVersion !== 1) errors.push("apiVersion must be 1");
22
+ if (typeof plugin?.parseConfig !== "function") errors.push("parseConfig is required");
23
+ if (typeof plugin?.setup !== "function") errors.push("setup is required");
24
+ if (errors.length) {
25
+ for (const error of errors) console.error(`- ${error}`);
26
+ process.exit(1);
27
+ }
28
+ console.log(`valid OHarness runtime plugin: ${plugin.id}`);
@@ -1,10 +1,13 @@
1
1
  ---
2
2
  name: skill-authoring
3
- description: Writing a SKILL.md — the frontmatter, where skills are discovered, and what makes a description one that actually gets matched.
3
+ description: Explaining or reviewing the OHarness SKILL.md format, discovery order, and description matching. For creating or installing a skill, use skill-creator.
4
4
  ---
5
5
 
6
6
  # Writing a skill
7
7
 
8
+ This is the format reference. Use the built-in `skill-creator` for a complete
9
+ creation or installation workflow with scaffolding and validation.
10
+
8
11
  A skill is packaged instructions loaded on demand. Its **description** sits in
9
12
  the system prompt of every request; its **body** is only loaded when a task
10
13
  matches. That split is the whole point, and it drives every rule below.
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: skill-creator
3
+ description: Creating or updating reusable OHarness skills, including project-local and user-wide installation, SKILL.md authoring, supporting files, and validation.
4
+ ---
5
+
6
+ # Create an OHarness skill
7
+
8
+ Create a small, discoverable skill that changes the agent's decisions for a
9
+ specific workflow. Do not turn generic knowledge into a permanent system-prompt
10
+ cost.
11
+
12
+ ## Choose the installation scope
13
+
14
+ - Default to `<project>/.oharness/skills/<name>/` when the skill belongs to the
15
+ current repository or the user did not ask for a global installation.
16
+ - Use the user skills directory when the user asks to install the skill locally,
17
+ globally, for every project, or for their account. This is
18
+ `$OHARNESS_HOME/skills/<name>/` when `OHARNESS_HOME` is set,
19
+ `$XDG_CONFIG_HOME/oharness/skills/<name>/` on Linux when XDG is configured,
20
+ and otherwise `~/.oharness/skills/<name>/`.
21
+ - Respect an explicit destination. Do not write outside the configured
22
+ workspace roots without the required approval.
23
+
24
+ Choose a persisted skill unless the user explicitly asks for a Runtime Skill.
25
+ A Runtime Skill is trusted JavaScript registered in memory by a Runtime Plugin;
26
+ it is useful when the instructions or lifecycle must be supplied
27
+ programmatically. It is not a second executable escape hatch for ordinary
28
+ markdown skills.
29
+
30
+ Project skills override user and built-in skills with the same name. Inspect an
31
+ existing destination before changing it and preserve unrelated resources.
32
+
33
+ ## Author the skill
34
+
35
+ Use a lowercase hyphenated name under 64 characters. Every skill is a directory
36
+ with one required `SKILL.md`:
37
+
38
+ ```markdown
39
+ ---
40
+ name: deploy-service
41
+ description: Deploying this service to Railway, including its preflight checks and rollback boundary.
42
+ ---
43
+
44
+ # Deploy the service
45
+
46
+ Run `scripts/preflight.sh` before changing the deployment.
47
+ ```
48
+
49
+ The `description` is the only part visible before selection. Name the concrete
50
+ request that should activate the skill; avoid descriptions such as "helper" or
51
+ "utilities". The body should contain only non-obvious workflow, constraints,
52
+ decision criteria, and references to supporting files.
53
+
54
+ Add `scripts/`, `reference/`, or `assets/` only when the workflow needs them.
55
+ Relative paths in the body resolve from the skill directory. Test executable
56
+ helpers rather than assuming they work.
57
+
58
+ For a deterministic initial file, run the bundled scaffold with the destination
59
+ scope and complete instructions:
60
+
61
+ ```bash
62
+ node <skill-base>/scripts/create-skill.mjs <name> --description "<selection description>" --instructions "<skill instructions>" [--project <directory> | --user]
63
+ ```
64
+
65
+ Resolve `<skill-base>` from the base directory shown when this skill loads; do
66
+ not assume the current working directory is the built-in skill directory.
67
+ Pass `--resources scripts,reference,assets` only for directories that will be
68
+ used. The command refuses to replace an existing skill unless `--force` is
69
+ given; use that flag only after inspecting the target and intentionally
70
+ replacing it.
71
+
72
+ For a Runtime Skill, add `--runtime`. The scaffold produces an `index.mjs`
73
+ whose Runtime Plugin calls `ctx.skills.register(...)`:
74
+
75
+ ```bash
76
+ node <skill-base>/scripts/create-skill.mjs <name> --runtime --description "<selection description>" --instructions "<skill instructions>" [--project <directory> | --user]
77
+ node <skill-base>/scripts/validate-runtime-skill.mjs <path-to-runtime-skill>
78
+ ```
79
+
80
+ Runtime Skills are trusted executable modules. They must be enabled through the
81
+ user `plugins.json` runtime-plugin list or supplied explicitly by an embedding
82
+ application; project configuration cannot activate them. When activation is
83
+ requested, use the generated plugin id `skill-<name>` and an absolute module
84
+ path. Do not edit user plugin configuration unless the user asked to install or
85
+ enable the Runtime Skill.
86
+
87
+ ## Validate and finish
88
+
89
+ Run:
90
+
91
+ ```bash
92
+ node <skill-base>/scripts/validate-skill.mjs <path-to-skill>
93
+ ```
94
+
95
+ Also verify that the description matches realistic user wording, the body has
96
+ no unfinished placeholders, and referenced files exist. Writing a
97
+ `**/skills/**/SKILL.md` triggers OHarness skill discovery immediately, so the
98
+ new skill can be used in the same Web or CLI session without restarting.
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ function fail(message) {
7
+ console.error(`skill-creator: ${message}`);
8
+ process.exit(1);
9
+ }
10
+
11
+ function userRoot() {
12
+ if (process.env.OHARNESS_HOME) return path.resolve(process.env.OHARNESS_HOME);
13
+ if (process.platform === "linux" && process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, "oharness");
14
+ return path.join(homedir(), ".oharness");
15
+ }
16
+
17
+ function normalize(value) {
18
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63).replace(/-+$/g, "");
19
+ }
20
+
21
+ const args = process.argv.slice(2);
22
+ const rawName = args.shift();
23
+ if (!rawName) fail("usage: create-skill.mjs <name> --description <text> --instructions <text> [--runtime] [--project <dir> | --user] [--resources a,b] [--force]");
24
+
25
+ let description;
26
+ let instructions;
27
+ let project = process.cwd();
28
+ let useUser = false;
29
+ let projectSpecified = false;
30
+ let runtime = false;
31
+ let force = false;
32
+ let resources = [];
33
+ for (let i = 0; i < args.length; i++) {
34
+ const arg = args[i];
35
+ if (arg === "--description") description = args[++i];
36
+ else if (arg === "--instructions") instructions = args[++i];
37
+ else if (arg === "--project") { project = path.resolve(args[++i] ?? fail("--project needs a directory")); projectSpecified = true; }
38
+ else if (arg === "--user") useUser = true;
39
+ else if (arg === "--runtime") runtime = true;
40
+ else if (arg === "--resources") resources = (args[++i] ?? "").split(",").filter(Boolean);
41
+ else if (arg === "--force") force = true;
42
+ else fail(`unknown option: ${arg}`);
43
+ }
44
+ if (!description?.trim()) fail("--description is required");
45
+ if (!instructions?.trim()) fail("--instructions is required");
46
+ if (useUser && projectSpecified) fail("choose either --user or --project");
47
+
48
+ const name = normalize(rawName);
49
+ if (!name) fail("name must contain a letter or digit");
50
+ const allowedResources = new Set(["scripts", "reference", "assets"]);
51
+ for (const resource of resources) if (!allowedResources.has(resource)) fail(`unsupported resource directory: ${resource}`);
52
+
53
+ const parent = runtime
54
+ ? (useUser ? path.join(userRoot(), "runtime-skills") : path.join(project, ".oharness", "runtime-skills"))
55
+ : (useUser ? path.join(userRoot(), "skills") : path.join(project, ".oharness", "skills"));
56
+ const directory = path.join(parent, name);
57
+ const file = path.join(directory, runtime ? "index.mjs" : "SKILL.md");
58
+ if (!force) {
59
+ try {
60
+ await readFile(file, "utf8");
61
+ fail(`${file} already exists (pass --force only to replace it intentionally)`);
62
+ } catch (error) {
63
+ if (error?.code !== "ENOENT") throw error;
64
+ }
65
+ }
66
+
67
+ await mkdir(directory, { recursive: true });
68
+ for (const resource of resources) await mkdir(path.join(directory, resource), { recursive: true });
69
+ const title = name.split("-").map((word) => word[0]?.toUpperCase() + word.slice(1)).join(" ");
70
+ const cleanDescription = description.trim().replace(/\s+/g, " ");
71
+ const body = runtime
72
+ ? `import { fileURLToPath } from "node:url";\n\nexport default {\n id: ${JSON.stringify(`skill-${name}`)},\n version: "1.0.0",\n apiVersion: 1,\n description: ${JSON.stringify(`Runtime skill: ${cleanDescription}`)},\n parseConfig(raw) {\n if (raw !== undefined) throw new Error("This runtime skill takes no configuration");\n return {};\n },\n setup(ctx) {\n ctx.skills.register({\n name: ${JSON.stringify(name)},\n description: ${JSON.stringify(cleanDescription)},\n body: ${JSON.stringify(instructions.trim())},\n directory: fileURLToPath(new URL(".", import.meta.url)),\n });\n },\n};\n`
73
+ : `---\nname: ${name}\ndescription: ${cleanDescription}\n---\n\n# ${title}\n\n${instructions.trim()}\n`;
74
+ await writeFile(file, body, "utf8");
75
+ console.log(file);
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ const input = process.argv[2];
7
+ if (!input) {
8
+ console.error("usage: validate-runtime-skill.mjs <runtime-skill-directory-or-module>");
9
+ process.exit(1);
10
+ }
11
+
12
+ const file = (await stat(path.resolve(input))).isDirectory() ? path.resolve(input, "index.mjs") : path.resolve(input);
13
+ const errors = [];
14
+ let plugin;
15
+ try { plugin = (await import(`${pathToFileURL(file).href}?validate=${Date.now()}`)).default; }
16
+ catch (error) { errors.push(`cannot import ${file}: ${error.message}`); }
17
+ if (!plugin || typeof plugin !== "object") errors.push("module must default-export a plugin object");
18
+ if (plugin?.apiVersion !== 1 || typeof plugin?.id !== "string" || typeof plugin?.version !== "string") errors.push("runtime plugin requires id, version, and apiVersion: 1");
19
+ if (typeof plugin?.parseConfig !== "function" || typeof plugin?.setup !== "function") errors.push("runtime plugin requires parseConfig and setup functions");
20
+
21
+ let captured;
22
+ if (!errors.length) {
23
+ try {
24
+ const config = await plugin.parseConfig(undefined);
25
+ await plugin.setup({ skills: { register(skill) { captured = skill; } } }, config);
26
+ } catch (error) { errors.push(`runtime skill setup failed: ${error.message}`); }
27
+ }
28
+ if (!captured) errors.push("plugin setup must register one runtime skill");
29
+ if (captured && (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(captured.name) || !captured.description?.trim() || !captured.body?.trim())) errors.push("registered skill requires a valid name, description, and body");
30
+ if (captured?.directory !== undefined && !path.isAbsolute(captured.directory)) errors.push("runtime skill directory must be absolute");
31
+
32
+ if (errors.length) {
33
+ for (const error of errors) console.error(`- ${error}`);
34
+ process.exit(1);
35
+ }
36
+ console.log(`valid OHarness runtime skill: ${captured.name}`);
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const input = process.argv[2];
6
+ if (!input) {
7
+ console.error("usage: validate-skill.mjs <skill-directory-or-SKILL.md>");
8
+ process.exit(1);
9
+ }
10
+
11
+ const file = path.basename(input) === "SKILL.md" ? path.resolve(input) : path.resolve(input, "SKILL.md");
12
+ const directory = path.dirname(file);
13
+ const errors = [];
14
+ let raw = "";
15
+ try {
16
+ if (!(await stat(file)).isFile()) errors.push("SKILL.md is not a file");
17
+ raw = await readFile(file, "utf8");
18
+ } catch (error) {
19
+ errors.push(`cannot read ${file}: ${error.message}`);
20
+ }
21
+
22
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
23
+ if (!match) errors.push("SKILL.md must start with closed YAML frontmatter");
24
+ const frontmatter = match?.[1] ?? "";
25
+ const body = match?.[2]?.trim() ?? "";
26
+ const name = /^name:\s*(.+)$/m.exec(frontmatter)?.[1]?.trim();
27
+ const description = /^description:\s*(.+)$/m.exec(frontmatter)?.[1]?.trim();
28
+ if (!name) errors.push("frontmatter requires name");
29
+ if (!description) errors.push("frontmatter requires description");
30
+ if (name && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) errors.push("name must be lowercase hyphen-case");
31
+ if (name && name.length >= 64) errors.push("name must be shorter than 64 characters");
32
+ if (name && path.basename(directory) !== name) errors.push("directory name must match frontmatter name");
33
+ if (body.length < 40) errors.push("skill body is too short to provide useful instructions");
34
+ if (/\bTODO\b|\[replace|<skill instructions>|<selection description>/i.test(raw)) errors.push("unfinished placeholder found");
35
+
36
+ if (errors.length) {
37
+ for (const error of errors) console.error(`- ${error}`);
38
+ process.exit(1);
39
+ }
40
+ console.log(`valid OHarness skill: ${name}`);