@zhijiewang/openharness 2.8.0 → 2.10.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/data/registry.json +262 -0
- package/data/skills/code-review.md +19 -0
- package/data/skills/commit.md +17 -0
- package/data/skills/debug.md +24 -0
- package/data/skills/diagnose.md +24 -0
- package/data/skills/plan.md +25 -0
- package/data/skills/simplify.md +24 -0
- package/data/skills/tdd.md +22 -0
- package/dist/agents/roles.d.ts +12 -2
- package/dist/agents/roles.js +65 -6
- package/dist/commands/ai.js +27 -7
- package/dist/commands/skills.d.ts +1 -1
- package/dist/commands/skills.js +51 -6
- package/dist/components/App.js +7 -1
- package/dist/harness/config.d.ts +24 -0
- package/dist/harness/hooks.d.ts +14 -0
- package/dist/harness/hooks.js +205 -11
- package/dist/harness/marketplace.d.ts +77 -2
- package/dist/harness/marketplace.js +260 -38
- package/dist/harness/memory.d.ts +34 -0
- package/dist/harness/memory.js +96 -0
- package/dist/harness/plugins.d.ts +13 -3
- package/dist/harness/plugins.js +98 -17
- package/dist/harness/session-db.d.ts +8 -1
- package/dist/harness/session-db.js +24 -3
- package/dist/harness/skill-registry.d.ts +26 -2
- package/dist/harness/skill-registry.js +42 -4
- package/dist/tools/AgentTool/index.d.ts +2 -2
- package/dist/tools/DiagnosticsTool/index.d.ts +1 -1
- package/dist/tools/GrepTool/index.d.ts +6 -6
- package/dist/tools/MemoryTool/index.d.ts +4 -4
- package/dist/tools/MonitorTool/index.js +5 -1
- package/dist/types/permissions.js +104 -42
- package/dist/utils/bash-safety.d.ts +19 -0
- package/dist/utils/bash-safety.js +179 -1
- package/dist/utils/safe-env.d.ts +5 -1
- package/dist/utils/safe-env.js +19 -1
- package/package.json +3 -1
package/dist/commands/skills.js
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skill management commands — /skill-create, /skill-delete, /skill-edit, /skill-search, /skill-install
|
|
2
|
+
* Skill management commands — /skills, /skill-create, /skill-delete, /skill-edit, /skill-search, /skill-install
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { discoverSkills } from "../harness/plugins.js";
|
|
6
7
|
export function registerSkillCommands(register) {
|
|
8
|
+
register("skills", "List all available skills", () => {
|
|
9
|
+
const skills = discoverSkills();
|
|
10
|
+
if (skills.length === 0) {
|
|
11
|
+
return {
|
|
12
|
+
output: "No skills found. Create .oh/skills/*.md to add one, or run /skill-search to browse the registry.",
|
|
13
|
+
handled: true,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
// Group by source for readability
|
|
17
|
+
const lines = ["Available skills:"];
|
|
18
|
+
const sourceLabel = {
|
|
19
|
+
project: "[project]",
|
|
20
|
+
global: "[global]",
|
|
21
|
+
plugin: "[plugin]",
|
|
22
|
+
};
|
|
23
|
+
// Sort: bundled-style (project, no path under .oh) first, then by source then name
|
|
24
|
+
const sorted = [...skills].sort((a, b) => {
|
|
25
|
+
if (a.source !== b.source)
|
|
26
|
+
return a.source.localeCompare(b.source);
|
|
27
|
+
return a.name.localeCompare(b.name);
|
|
28
|
+
});
|
|
29
|
+
for (const s of sorted) {
|
|
30
|
+
const tag = sourceLabel[s.source] ?? `[${s.source}]`;
|
|
31
|
+
const desc = s.description ? `: ${s.description}` : "";
|
|
32
|
+
lines.push(` - ${s.name} ${tag}${desc}`);
|
|
33
|
+
}
|
|
34
|
+
return { output: lines.join("\n"), handled: true };
|
|
35
|
+
});
|
|
7
36
|
register("skill-create", "Create a new skill file", (args) => {
|
|
8
37
|
const name = args.trim();
|
|
9
38
|
if (!name)
|
|
@@ -92,10 +121,21 @@ How to confirm the skill worked correctly.
|
|
|
92
121
|
});
|
|
93
122
|
return { output: "Searching skills registry...", handled: true };
|
|
94
123
|
});
|
|
95
|
-
register("skill-install", "Install a skill from the registry", (args) => {
|
|
96
|
-
|
|
124
|
+
register("skill-install", "Install a skill from the registry. Use --accept-license=<SPDX> for non-permissive licenses.", (args) => {
|
|
125
|
+
// Parse: <name> [--accept-license=<SPDX>]
|
|
126
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
127
|
+
if (tokens.length === 0)
|
|
128
|
+
return { output: "Usage: /skill-install <name> [--accept-license=<SPDX>]", handled: true };
|
|
129
|
+
let name = "";
|
|
130
|
+
let acceptLicense;
|
|
131
|
+
for (const tok of tokens) {
|
|
132
|
+
if (tok.startsWith("--accept-license="))
|
|
133
|
+
acceptLicense = tok.slice("--accept-license=".length);
|
|
134
|
+
else if (!name)
|
|
135
|
+
name = tok;
|
|
136
|
+
}
|
|
97
137
|
if (!name)
|
|
98
|
-
return { output: "Usage: /skill-install <name>", handled: true };
|
|
138
|
+
return { output: "Usage: /skill-install <name> [--accept-license=<SPDX>]", handled: true };
|
|
99
139
|
import("../harness/skill-registry.js").then(async ({ fetchRegistry, installSkill }) => {
|
|
100
140
|
try {
|
|
101
141
|
const registry = await fetchRegistry();
|
|
@@ -104,8 +144,13 @@ How to confirm the skill worked correctly.
|
|
|
104
144
|
console.log(`Skill "${name}" not found in registry. Try /skill-search first.`);
|
|
105
145
|
return;
|
|
106
146
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
147
|
+
const result = await installSkill(skill, { acceptLicense });
|
|
148
|
+
if (result.ok) {
|
|
149
|
+
console.log(`Installed skill "${skill.name}" to ${result.filePath}`);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
console.log(result.message);
|
|
153
|
+
}
|
|
109
154
|
}
|
|
110
155
|
catch (err) {
|
|
111
156
|
console.log(`Installation failed: ${err.message}`);
|
package/dist/components/App.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
import { getCompanionSystemPrompt, loadCompanionRuntime } from "../cybergotchi/config.js";
|
|
4
4
|
import { readOhConfig } from "../harness/config.js";
|
|
5
|
-
import { loadMemories, memoriesToPrompt } from "../harness/memory.js";
|
|
5
|
+
import { claudeMdToPrompt, loadClaudeMdHierarchy, loadMemories, memoriesToPrompt } from "../harness/memory.js";
|
|
6
6
|
import { detectProject, projectContextToPrompt } from "../harness/onboarding.js";
|
|
7
7
|
import { discoverSkills, skillsToPrompt } from "../harness/plugins.js";
|
|
8
8
|
import { loadRulesAsPrompt } from "../harness/rules.js";
|
|
@@ -59,6 +59,12 @@ export default function App({ provider, tools, permissionMode, systemPrompt, mod
|
|
|
59
59
|
const rulesPrompt = loadRulesAsPrompt();
|
|
60
60
|
if (rulesPrompt)
|
|
61
61
|
parts.push(rulesPrompt);
|
|
62
|
+
// CLAUDE.md: hierarchical project instructions (Anthropic convention).
|
|
63
|
+
// Additive with OH's own memory system — both layers inject into the prompt.
|
|
64
|
+
const claudeMd = loadClaudeMdHierarchy();
|
|
65
|
+
const claudeMdPrompt = claudeMdToPrompt(claudeMd);
|
|
66
|
+
if (claudeMdPrompt)
|
|
67
|
+
parts.push(claudeMdPrompt);
|
|
62
68
|
// Auto-memory: load saved learnings into context
|
|
63
69
|
const memories = loadMemories();
|
|
64
70
|
const memoryPrompt = memoriesToPrompt(memories);
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -16,6 +16,19 @@ export type HookDef = {
|
|
|
16
16
|
prompt?: string;
|
|
17
17
|
match?: string;
|
|
18
18
|
timeout?: number;
|
|
19
|
+
/**
|
|
20
|
+
* When true (and this hook has a `command`), OH sends a JSON envelope
|
|
21
|
+
* `{event, ...context}` on stdin and parses a JSON response from stdout.
|
|
22
|
+
* Response shape (Claude Code compatible):
|
|
23
|
+
* { "decision": "allow" | "deny",
|
|
24
|
+
* "reason"?: string,
|
|
25
|
+
* "hookSpecificOutput"?: {...} }
|
|
26
|
+
*
|
|
27
|
+
* When false (default), OH passes context via `OH_EVENT` / `OH_TOOL_NAME`
|
|
28
|
+
* env vars and gates on the command's exit code (0 = allow). The env-var
|
|
29
|
+
* mode remains the default for backward compatibility.
|
|
30
|
+
*/
|
|
31
|
+
jsonIO?: boolean;
|
|
19
32
|
};
|
|
20
33
|
export type HooksConfig = {
|
|
21
34
|
sessionStart?: HookDef[];
|
|
@@ -98,6 +111,17 @@ export type OhConfig = {
|
|
|
98
111
|
rateLimit?: number;
|
|
99
112
|
allowedTools?: string[];
|
|
100
113
|
};
|
|
114
|
+
/**
|
|
115
|
+
* Environment variables injected into child processes spawned by the harness —
|
|
116
|
+
* Bash/Monitor/PowerShell tool executions and MCP server subprocesses. Useful
|
|
117
|
+
* for passing API keys to MCP servers without embedding them in the server's
|
|
118
|
+
* `env` field (which is per-server) or requiring the user to export them in
|
|
119
|
+
* their shell. Claude Code convention: same shape as `settings.json.env`.
|
|
120
|
+
*
|
|
121
|
+
* Implementation: read by `safeEnv()` in `src/utils/safe-env.ts` — every
|
|
122
|
+
* call-site that already uses `safeEnv()` picks this up automatically.
|
|
123
|
+
*/
|
|
124
|
+
env?: Record<string, string>;
|
|
101
125
|
};
|
|
102
126
|
/** Clear cached config (call after writes or to force re-read) */
|
|
103
127
|
export declare function invalidateConfigCache(): void;
|
package/dist/harness/hooks.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - http: POST JSON to URL, expect { allowed: true/false }
|
|
10
10
|
* - prompt: LLM yes/no check via provider.complete()
|
|
11
11
|
*/
|
|
12
|
+
import type { HookDef } from "./config.js";
|
|
12
13
|
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
|
|
13
14
|
export type HookContext = {
|
|
14
15
|
toolName?: string;
|
|
@@ -32,6 +33,19 @@ export type HookContext = {
|
|
|
32
33
|
};
|
|
33
34
|
/** Clear hook cache (call after config changes) */
|
|
34
35
|
export declare function invalidateHookCache(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Evaluate a hook matcher against the current tool name.
|
|
38
|
+
*
|
|
39
|
+
* Supported forms (Claude Code compatible):
|
|
40
|
+
* - No matcher → always matches.
|
|
41
|
+
* - `/pattern/flags` → treated as a regex. Flags optional.
|
|
42
|
+
* - `mcp__server__tool` → literal match is a substring check (works for the
|
|
43
|
+
* standard `mcp__<server>__<tool>` naming convention).
|
|
44
|
+
* - `prefix*` or glob-ish → simple wildcard translated to regex.
|
|
45
|
+
* - Anything else → case-sensitive substring (legacy behavior — back-compat).
|
|
46
|
+
*/
|
|
47
|
+
/** @internal Exposed for testing. */
|
|
48
|
+
export declare function matchesHook(def: HookDef, ctx: HookContext): boolean;
|
|
35
49
|
/**
|
|
36
50
|
* Emit a hook event. For preToolUse, returns false if any hook blocks the call.
|
|
37
51
|
*
|
package/dist/harness/hooks.js
CHANGED
|
@@ -58,11 +58,54 @@ function buildEnv(event, ctx) {
|
|
|
58
58
|
env.OH_MESSAGE = ctx.message;
|
|
59
59
|
return env;
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Evaluate a hook matcher against the current tool name.
|
|
63
|
+
*
|
|
64
|
+
* Supported forms (Claude Code compatible):
|
|
65
|
+
* - No matcher → always matches.
|
|
66
|
+
* - `/pattern/flags` → treated as a regex. Flags optional.
|
|
67
|
+
* - `mcp__server__tool` → literal match is a substring check (works for the
|
|
68
|
+
* standard `mcp__<server>__<tool>` naming convention).
|
|
69
|
+
* - `prefix*` or glob-ish → simple wildcard translated to regex.
|
|
70
|
+
* - Anything else → case-sensitive substring (legacy behavior — back-compat).
|
|
71
|
+
*/
|
|
72
|
+
/** @internal Exposed for testing. */
|
|
73
|
+
export function matchesHook(def, ctx) {
|
|
74
|
+
if (!def.match)
|
|
75
|
+
return true;
|
|
76
|
+
if (!ctx.toolName)
|
|
77
|
+
return true;
|
|
78
|
+
const match = def.match;
|
|
79
|
+
// /regex/flags form
|
|
80
|
+
if (match.length > 2 && match.startsWith("/")) {
|
|
81
|
+
const lastSlash = match.lastIndexOf("/");
|
|
82
|
+
if (lastSlash > 0) {
|
|
83
|
+
try {
|
|
84
|
+
const pattern = match.slice(1, lastSlash);
|
|
85
|
+
const flags = match.slice(lastSlash + 1);
|
|
86
|
+
return new RegExp(pattern, flags).test(ctx.toolName);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
64
92
|
}
|
|
65
|
-
|
|
93
|
+
// Simple glob: asterisks translated to `.*`, anchored. Only activates if the
|
|
94
|
+
// match contains an asterisk — otherwise treat as substring for back-compat.
|
|
95
|
+
if (match.includes("*")) {
|
|
96
|
+
const escaped = match
|
|
97
|
+
.split("*")
|
|
98
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
99
|
+
.join(".*");
|
|
100
|
+
try {
|
|
101
|
+
return new RegExp(`^${escaped}$`).test(ctx.toolName);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Legacy substring match
|
|
108
|
+
return ctx.toolName.includes(match);
|
|
66
109
|
}
|
|
67
110
|
// ── Hook Executors ──
|
|
68
111
|
/** Run a command hook. Returns exit code (0 = success/allowed). */
|
|
@@ -98,6 +141,86 @@ function runCommandHookAsync(command, env, timeoutMs = 10_000) {
|
|
|
98
141
|
});
|
|
99
142
|
});
|
|
100
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Run a JSON-mode command hook (Claude Code convention).
|
|
146
|
+
*
|
|
147
|
+
* Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
|
|
148
|
+
* `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
|
|
149
|
+
*
|
|
150
|
+
* Gating logic:
|
|
151
|
+
* - `decision: "deny"` → blocks (returns false).
|
|
152
|
+
* - `decision: "allow"` or omitted decision → allow (returns true).
|
|
153
|
+
* - Non-zero exit code → block.
|
|
154
|
+
* - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
|
|
155
|
+
* - Timeout or spawn error → block.
|
|
156
|
+
*/
|
|
157
|
+
function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const proc = spawn(command, {
|
|
160
|
+
shell: true,
|
|
161
|
+
timeout: timeoutMs,
|
|
162
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
163
|
+
env,
|
|
164
|
+
});
|
|
165
|
+
let settled = false;
|
|
166
|
+
let stdoutBuf = "";
|
|
167
|
+
const timer = setTimeout(() => {
|
|
168
|
+
if (!settled) {
|
|
169
|
+
settled = true;
|
|
170
|
+
proc.kill();
|
|
171
|
+
resolve(false);
|
|
172
|
+
}
|
|
173
|
+
}, timeoutMs);
|
|
174
|
+
proc.stdout?.on("data", (chunk) => {
|
|
175
|
+
stdoutBuf += chunk.toString();
|
|
176
|
+
});
|
|
177
|
+
// Write the event + context JSON envelope to stdin then close it so the
|
|
178
|
+
// hook knows there's no more input coming.
|
|
179
|
+
try {
|
|
180
|
+
const payload = JSON.stringify({ event, ...ctx });
|
|
181
|
+
proc.stdin?.end(payload);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* stdin already closed — ignore */
|
|
185
|
+
}
|
|
186
|
+
proc.on("close", (code) => {
|
|
187
|
+
if (settled)
|
|
188
|
+
return;
|
|
189
|
+
settled = true;
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
// Non-zero exit is always a block, regardless of stdout.
|
|
192
|
+
if ((code ?? 1) !== 0) {
|
|
193
|
+
resolve(false);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Empty stdout → treat exit code as the signal (allow for exit 0).
|
|
197
|
+
if (!stdoutBuf.trim()) {
|
|
198
|
+
resolve(true);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(stdoutBuf);
|
|
203
|
+
if (parsed.decision === "deny") {
|
|
204
|
+
resolve(false);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
resolve(true); // "allow" or omitted → allow
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Malformed JSON with a zero exit — fail closed conservatively.
|
|
212
|
+
resolve(false);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
proc.on("error", () => {
|
|
216
|
+
if (!settled) {
|
|
217
|
+
settled = true;
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
resolve(false);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
101
224
|
/** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
|
|
102
225
|
async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
|
|
103
226
|
try {
|
|
@@ -118,15 +241,63 @@ async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
|
|
|
118
241
|
}
|
|
119
242
|
}
|
|
120
243
|
/**
|
|
121
|
-
* Run a prompt hook. Uses LLM to make a yes/no decision.
|
|
244
|
+
* Run a prompt hook. Uses an LLM to make a yes/no allow/deny decision.
|
|
245
|
+
*
|
|
246
|
+
* The hook's `prompt:` field is the question posed to the model along with
|
|
247
|
+
* the event context. The response is parsed case-insensitively: responses
|
|
248
|
+
* starting with YES / ALLOW / TRUE / PASS / APPROVE allow; anything else
|
|
249
|
+
* (including explicit NO, DENY, errors, timeouts, empty) blocks.
|
|
250
|
+
*
|
|
251
|
+
* Fail-closed semantics: if the provider isn't reachable or the response
|
|
252
|
+
* can't be parsed, the hook denies. This matches command hooks (non-zero
|
|
253
|
+
* exit = deny) and HTTP hooks (network error = deny).
|
|
122
254
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
255
|
+
* Provider selection: reads `.oh/config.yaml` to get the configured provider
|
|
256
|
+
* and model. A separate provider instance is created per call — no caching,
|
|
257
|
+
* since hooks are rare and cold-start cost is negligible compared to the
|
|
258
|
+
* LLM call itself.
|
|
127
259
|
*/
|
|
128
|
-
async function runPromptHook(
|
|
129
|
-
|
|
260
|
+
async function runPromptHook(promptText, ctx, timeoutMs = 10_000) {
|
|
261
|
+
try {
|
|
262
|
+
const cfg = readOhConfig();
|
|
263
|
+
if (!cfg)
|
|
264
|
+
return false; // no config → no provider → fail closed
|
|
265
|
+
const { createProvider } = (await import("../providers/index.js"));
|
|
266
|
+
const modelArg = cfg.model ? `${cfg.provider}/${cfg.model}` : cfg.provider;
|
|
267
|
+
const overrides = {};
|
|
268
|
+
if (cfg.apiKey)
|
|
269
|
+
overrides.apiKey = cfg.apiKey;
|
|
270
|
+
if (cfg.baseUrl)
|
|
271
|
+
overrides.baseUrl = cfg.baseUrl;
|
|
272
|
+
const { provider, model } = await createProvider(modelArg, overrides);
|
|
273
|
+
const systemPrompt = "You are a policy gate. Read the question and the event context. Answer with a single word: YES to allow, NO to deny. Do not explain unless asked.";
|
|
274
|
+
const userContent = [
|
|
275
|
+
`Question: ${promptText}`,
|
|
276
|
+
"",
|
|
277
|
+
"Event context:",
|
|
278
|
+
JSON.stringify({ event: ctx }, null, 2),
|
|
279
|
+
"",
|
|
280
|
+
"Answer (YES or NO):",
|
|
281
|
+
].join("\n");
|
|
282
|
+
const { createUserMessage } = (await import("../types/message.js"));
|
|
283
|
+
const messages = [createUserMessage(userContent)];
|
|
284
|
+
// Race the completion against a hard timeout so a hung provider doesn't
|
|
285
|
+
// block the agent loop indefinitely.
|
|
286
|
+
const completion = await Promise.race([
|
|
287
|
+
provider.complete(messages, systemPrompt, undefined, model),
|
|
288
|
+
new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)),
|
|
289
|
+
]);
|
|
290
|
+
if (!completion)
|
|
291
|
+
return false; // timeout → deny
|
|
292
|
+
const text = (completion.content ?? "").trim().toUpperCase();
|
|
293
|
+
if (!text)
|
|
294
|
+
return false;
|
|
295
|
+
// Accept multiple allow synonyms; default to deny on anything else.
|
|
296
|
+
return /^(YES|ALLOW|TRUE|PASS|APPROVE)\b/.test(text);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return false; // any error path → deny
|
|
300
|
+
}
|
|
130
301
|
}
|
|
131
302
|
// ── Hook Execution ──
|
|
132
303
|
/** Execute a single hook definition. Returns true if allowed. */
|
|
@@ -134,6 +305,12 @@ async function executeHookDef(def, event, ctx) {
|
|
|
134
305
|
const timeout = def.timeout ?? 10_000;
|
|
135
306
|
if (def.command) {
|
|
136
307
|
const env = buildEnv(event, ctx);
|
|
308
|
+
// JSON-mode (Claude Code convention): send `{event, ...ctx}` on stdin,
|
|
309
|
+
// parse `{decision}` from stdout. Env-var mode (legacy default): gate on
|
|
310
|
+
// exit code.
|
|
311
|
+
if (def.jsonIO) {
|
|
312
|
+
return runJsonIoHookAsync(def.command, env, event, ctx, timeout);
|
|
313
|
+
}
|
|
137
314
|
const code = await runCommandHookAsync(def.command, env, timeout);
|
|
138
315
|
return code === 0;
|
|
139
316
|
}
|
|
@@ -163,14 +340,31 @@ export function emitHook(event, ctx = {}) {
|
|
|
163
340
|
if (!matchesHook(def, ctx))
|
|
164
341
|
continue;
|
|
165
342
|
if (def.command) {
|
|
343
|
+
const input = def.jsonIO ? JSON.stringify({ event, ...ctx }) : undefined;
|
|
166
344
|
const result = spawnSync(def.command, {
|
|
167
345
|
shell: true,
|
|
168
346
|
timeout: def.timeout ?? 10_000,
|
|
169
347
|
stdio: "pipe",
|
|
170
348
|
env,
|
|
349
|
+
input,
|
|
171
350
|
});
|
|
172
351
|
if (result.status !== 0 || result.error)
|
|
173
352
|
return false;
|
|
353
|
+
// JSON mode: parse stdout for {decision: "deny"} → block. Allow on empty
|
|
354
|
+
// stdout (exit-code already gated above). Malformed JSON fails closed.
|
|
355
|
+
if (def.jsonIO) {
|
|
356
|
+
const out = result.stdout?.toString() ?? "";
|
|
357
|
+
if (out.trim()) {
|
|
358
|
+
try {
|
|
359
|
+
const parsed = JSON.parse(out);
|
|
360
|
+
if (parsed.decision === "deny")
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
174
368
|
}
|
|
175
369
|
// HTTP and prompt hooks for preToolUse are handled in emitHookAsync
|
|
176
370
|
}
|
|
@@ -36,8 +36,76 @@ export type InstalledPlugin = {
|
|
|
36
36
|
marketplace: string;
|
|
37
37
|
installedAt: number;
|
|
38
38
|
cachePath: string;
|
|
39
|
+
/** Optional fields populated from `.claude-plugin/plugin.json` if present */
|
|
40
|
+
description?: string;
|
|
41
|
+
author?: string;
|
|
42
|
+
license?: string;
|
|
43
|
+
homepage?: string;
|
|
44
|
+
keywords?: string[];
|
|
45
|
+
};
|
|
46
|
+
/** Claude Code plugin manifest (`.claude-plugin/plugin.json`).
|
|
47
|
+
* Required: name, description. All other fields optional.
|
|
48
|
+
*/
|
|
49
|
+
export type CcPluginManifest = {
|
|
50
|
+
name: string;
|
|
51
|
+
description: string;
|
|
52
|
+
version?: string;
|
|
53
|
+
author?: {
|
|
54
|
+
name?: string;
|
|
55
|
+
email?: string;
|
|
56
|
+
} | string;
|
|
57
|
+
homepage?: string;
|
|
58
|
+
repository?: string;
|
|
59
|
+
license?: string;
|
|
60
|
+
keywords?: string[];
|
|
61
|
+
};
|
|
62
|
+
/** Parse a `.claude-plugin/plugin.json` file at the given plugin root, or null if missing/invalid. */
|
|
63
|
+
export declare function parseCcPluginManifest(pluginRoot: string): CcPluginManifest | null;
|
|
64
|
+
/** Claude Code marketplace.json format — superset of OH's marketplace.json with source-typed entries. */
|
|
65
|
+
export type CcMarketplacePluginSource = string | {
|
|
66
|
+
source: "github";
|
|
67
|
+
repo: string;
|
|
68
|
+
ref?: string;
|
|
69
|
+
} | {
|
|
70
|
+
source: "url";
|
|
71
|
+
url: string;
|
|
72
|
+
} | {
|
|
73
|
+
source: "npm";
|
|
74
|
+
package: string;
|
|
75
|
+
version?: string;
|
|
76
|
+
};
|
|
77
|
+
export type CcMarketplaceEntry = {
|
|
78
|
+
name: string;
|
|
79
|
+
description?: string;
|
|
80
|
+
version?: string;
|
|
81
|
+
author?: {
|
|
82
|
+
name?: string;
|
|
83
|
+
email?: string;
|
|
84
|
+
} | string;
|
|
85
|
+
source: CcMarketplacePluginSource;
|
|
39
86
|
};
|
|
40
|
-
|
|
87
|
+
export type CcMarketplace = {
|
|
88
|
+
name: string;
|
|
89
|
+
description?: string;
|
|
90
|
+
owner?: {
|
|
91
|
+
name?: string;
|
|
92
|
+
email?: string;
|
|
93
|
+
};
|
|
94
|
+
plugins: CcMarketplaceEntry[];
|
|
95
|
+
};
|
|
96
|
+
/** Convert a CcMarketplace to OH's internal Marketplace type (lossy: dropped fields like `owner` are not stored). */
|
|
97
|
+
export declare function ccMarketplaceToOh(cc: CcMarketplace): Marketplace;
|
|
98
|
+
/** Parse marketplace JSON text. Tries CC format (.claude-plugin/marketplace.json shape) first, falls back to OH-native. */
|
|
99
|
+
export declare function parseMarketplaceJson(text: string): Marketplace | null;
|
|
100
|
+
/** Discover plugin-shipped MCP servers from `cachePath/.mcp.json`. Returns raw object for the runtime to merge. */
|
|
101
|
+
export declare function getPluginMcpServers(cachePath: string): Record<string, unknown> | null;
|
|
102
|
+
/** Discover plugin-shipped hooks from `cachePath/hooks/hooks.json`. Returns raw config for the runtime to register. */
|
|
103
|
+
export declare function getPluginHooks(cachePath: string): Record<string, unknown> | null;
|
|
104
|
+
/** Discover plugin-shipped LSP servers from `cachePath/.lsp.json`. Returns raw config for the runtime to register. */
|
|
105
|
+
export declare function getPluginLspServers(cachePath: string): Record<string, unknown> | null;
|
|
106
|
+
/** Add a marketplace from a URL, GitHub repo, or local path.
|
|
107
|
+
* Probes for both OH-native `marketplace.json` and Claude Code `.claude-plugin/marketplace.json`.
|
|
108
|
+
*/
|
|
41
109
|
export declare function addMarketplace(nameOrUrl: string): Marketplace | null;
|
|
42
110
|
/** Remove a marketplace */
|
|
43
111
|
export declare function removeMarketplace(name: string): boolean;
|
|
@@ -51,7 +119,14 @@ export declare function searchMarketplace(query: string): Array<MarketplaceEntry
|
|
|
51
119
|
export declare function installPlugin(pluginName: string, marketplaceName?: string): InstalledPlugin | null;
|
|
52
120
|
/** Uninstall a plugin */
|
|
53
121
|
export declare function uninstallPlugin(name: string): boolean;
|
|
54
|
-
/** Get all installed plugins
|
|
122
|
+
/** Get all installed plugins.
|
|
123
|
+
* Sources merged in priority order:
|
|
124
|
+
* 1. installed.json (plugins installed via /plugin install or addMarketplace flow)
|
|
125
|
+
* 2. CC-style plugins discovered in PLUGIN_CACHE_DIR via .claude-plugin/plugin.json
|
|
126
|
+
* (covers plugins manually dropped in the cache, or installed by parallel tooling)
|
|
127
|
+
* Plugins from #1 are enriched with manifest data if their cachePath has one.
|
|
128
|
+
* De-duplication is by cachePath.
|
|
129
|
+
*/
|
|
55
130
|
export declare function getInstalledPlugins(): InstalledPlugin[];
|
|
56
131
|
/** Format marketplace entries for display */
|
|
57
132
|
export declare function formatMarketplaceSearch(results: Array<MarketplaceEntry & {
|