@zhijiewang/openharness 1.2.0 → 1.4.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.
@@ -16,9 +16,11 @@ export type AgentRole = {
16
16
  /** Suggested tools to include (empty = all tools) */
17
17
  suggestedTools?: string[];
18
18
  };
19
- /** Get a role by ID */
19
+ /** Discover markdown agent roles from .oh/agents/ and ~/.oh/agents/ */
20
+ export declare function discoverMarkdownAgents(): AgentRole[];
21
+ /** Get a role by ID (checks built-in first, then markdown agents) */
20
22
  export declare function getRole(id: string): AgentRole | undefined;
21
- /** List all available roles */
23
+ /** List all available roles (built-in + markdown) */
22
24
  export declare function listRoles(): AgentRole[];
23
25
  /** Get role IDs */
24
26
  export declare function getRoleIds(): string[];
@@ -157,16 +157,80 @@ Work methodically: search exhaustively, change incrementally, test after each ba
157
157
  suggestedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
158
158
  },
159
159
  ];
160
- /** Get a role by ID */
160
+ // ── Markdown Agent Discovery ──
161
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
162
+ import { join, basename } from 'node:path';
163
+ import { homedir } from 'node:os';
164
+ const PROJECT_AGENTS_DIR = join('.oh', 'agents');
165
+ const GLOBAL_AGENTS_DIR = join(homedir(), '.oh', 'agents');
166
+ /**
167
+ * Parse a markdown agent file into an AgentRole.
168
+ *
169
+ * Format:
170
+ * ---
171
+ * name: My Agent
172
+ * description: What it does
173
+ * tools: [Read, Grep, Bash]
174
+ * ---
175
+ *
176
+ * System prompt content...
177
+ */
178
+ function parseAgentMarkdown(raw, filePath) {
179
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
180
+ if (!fmMatch)
181
+ return null;
182
+ const fm = fmMatch[1];
183
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
184
+ const descMatch = fm.match(/^description:\s*(.+)$/m);
185
+ const toolsMatch = fm.match(/^tools:\s*\[(.+)\]$/m);
186
+ const fmEnd = raw.indexOf('---', raw.indexOf('---') + 3);
187
+ const content = fmEnd > 0 ? raw.slice(fmEnd + 3).trim() : '';
188
+ const id = basename(filePath, '.md').toLowerCase().replace(/[^a-z0-9]+/g, '-');
189
+ return {
190
+ id,
191
+ name: nameMatch?.[1]?.trim() ?? id,
192
+ description: descMatch?.[1]?.trim() ?? '',
193
+ systemPromptSupplement: content,
194
+ suggestedTools: toolsMatch
195
+ ? toolsMatch[1].split(',').map(t => t.trim())
196
+ : undefined,
197
+ };
198
+ }
199
+ /** Load agent roles from a directory of .md files */
200
+ function loadAgentsFromDir(dir) {
201
+ if (!existsSync(dir))
202
+ return [];
203
+ return readdirSync(dir)
204
+ .filter(f => f.endsWith('.md'))
205
+ .map(f => {
206
+ try {
207
+ const raw = readFileSync(join(dir, f), 'utf-8');
208
+ return parseAgentMarkdown(raw, f);
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ })
214
+ .filter((r) => r !== null);
215
+ }
216
+ /** Discover markdown agent roles from .oh/agents/ and ~/.oh/agents/ */
217
+ export function discoverMarkdownAgents() {
218
+ return [
219
+ ...loadAgentsFromDir(PROJECT_AGENTS_DIR),
220
+ ...loadAgentsFromDir(GLOBAL_AGENTS_DIR),
221
+ ];
222
+ }
223
+ /** Get a role by ID (checks built-in first, then markdown agents) */
161
224
  export function getRole(id) {
162
- return roles.find(r => r.id === id);
225
+ return roles.find(r => r.id === id)
226
+ ?? discoverMarkdownAgents().find(r => r.id === id);
163
227
  }
164
- /** List all available roles */
228
+ /** List all available roles (built-in + markdown) */
165
229
  export function listRoles() {
166
- return [...roles];
230
+ return [...roles, ...discoverMarkdownAgents()];
167
231
  }
168
232
  /** Get role IDs */
169
233
  export function getRoleIds() {
170
- return roles.map(r => r.id);
234
+ return listRoles().map(r => r.id);
171
235
  }
172
236
  //# sourceMappingURL=roles.js.map
@@ -100,15 +100,54 @@ register("undo", "Undo last AI commit", () => {
100
100
  handled: true,
101
101
  };
102
102
  });
103
- register("rewind", "Restore files from last checkpoint (undo last AI edit)", () => {
104
- const { rewindLastCheckpoint, checkpointCount } = require("../harness/checkpoints.js");
105
- const cp = rewindLastCheckpoint();
106
- if (!cp) {
103
+ register("rewind", "Restore files from checkpoint (interactive picker or last)", (args) => {
104
+ const { rewindLastCheckpoint, listCheckpoints, checkpointCount } = require("../harness/checkpoints.js");
105
+ const checkpoints = listCheckpoints();
106
+ if (checkpoints.length === 0) {
107
107
  return { output: "No checkpoints available. Checkpoints are created before file modifications.", handled: true };
108
108
  }
109
- const remaining = checkpointCount();
109
+ const idx = args.trim();
110
+ // /rewind (no args) — show checkpoint list
111
+ if (!idx) {
112
+ const lines = [`Checkpoints (${checkpoints.length}):\n`];
113
+ for (let i = checkpoints.length - 1; i >= 0; i--) {
114
+ const cp = checkpoints[i];
115
+ const age = Math.round((Date.now() - cp.timestamp) / 60_000);
116
+ lines.push(` ${i + 1}. [${age}m ago] ${cp.description}`);
117
+ lines.push(` Files: ${cp.files.join(', ')}`);
118
+ }
119
+ lines.push('');
120
+ lines.push('Usage: /rewind <number> to restore a specific checkpoint');
121
+ lines.push(' /rewind last to restore the most recent');
122
+ return { output: lines.join('\n'), handled: true };
123
+ }
124
+ // /rewind last — restore most recent
125
+ if (idx === 'last') {
126
+ const cp = rewindLastCheckpoint();
127
+ if (!cp)
128
+ return { output: "No checkpoints.", handled: true };
129
+ return {
130
+ output: `Rewound: ${cp.description}\nRestored ${cp.files.length} file(s): ${cp.files.join(", ")}\n${checkpointCount()} checkpoint(s) remaining.`,
131
+ handled: true,
132
+ };
133
+ }
134
+ // /rewind <n> — restore specific checkpoint
135
+ const num = parseInt(idx, 10);
136
+ if (isNaN(num) || num < 1 || num > checkpoints.length) {
137
+ return { output: `Invalid checkpoint number. Use 1-${checkpoints.length}.`, handled: true };
138
+ }
139
+ // Rewind to specific checkpoint (restore all from that point)
140
+ let restored = 0;
141
+ while (checkpointCount() >= num) {
142
+ const cp = rewindLastCheckpoint();
143
+ if (!cp)
144
+ break;
145
+ restored++;
146
+ if (checkpointCount() < num)
147
+ break;
148
+ }
110
149
  return {
111
- output: `Rewound: ${cp.description}\nRestored ${cp.files.length} file(s): ${cp.files.join(", ")}\n${remaining} checkpoint(s) remaining.`,
150
+ output: `Rewound ${restored} checkpoint(s) to point #${num}.\n${checkpointCount()} checkpoint(s) remaining.`,
112
151
  handled: true,
113
152
  };
114
153
  });
@@ -635,51 +674,88 @@ function setPinned(args, ctx, pinned) {
635
674
  }
636
675
  register("pin", "Pin a message (survives /compact)", (args, ctx) => setPinned(args, ctx, true));
637
676
  register("unpin", "Unpin a message", (args, ctx) => setPinned(args, ctx, false));
638
- register("plugins", "List installed plugins and discover new ones", (args) => {
677
+ register("plugins", "Manage plugins: list, search, install, uninstall, marketplace", (args) => {
639
678
  const { discoverPlugins, discoverSkills } = require('../harness/plugins.js');
640
- const query = args.trim();
641
- if (query === 'search' || query.startsWith('search ')) {
642
- // npm registry search
643
- const keyword = query.replace(/^search\s*/, '').trim() || 'openharness-plugin';
644
- return {
645
- output: `To discover plugins, search npm:\n\n npm search openharness-plugin${keyword !== 'openharness-plugin' ? ' ' + keyword : ''}\n\nInstall with:\n npm install <package-name>\n\nPlugins are auto-discovered from node_modules/ if they contain openharness-plugin.json.`,
646
- handled: true,
647
- };
679
+ const { searchMarketplace, installPlugin, uninstallPlugin, getInstalledPlugins, listMarketplaces, addMarketplace, removeMarketplace, formatMarketplaceSearch, formatInstalledPlugins, } = require('../harness/marketplace.js');
680
+ const parts = args.trim().split(/\s+/);
681
+ const subcommand = parts[0] ?? '';
682
+ const rest = parts.slice(1).join(' ');
683
+ // /plugins marketplace add <source>
684
+ if (subcommand === 'marketplace') {
685
+ const action = parts[1];
686
+ const source = parts.slice(2).join(' ');
687
+ if (action === 'add' && source) {
688
+ const mp = addMarketplace(source);
689
+ if (mp)
690
+ return { output: `Added marketplace "${mp.name}" (${mp.plugins.length} plugins)`, handled: true };
691
+ return { output: `Failed to add marketplace from "${source}"`, handled: true };
692
+ }
693
+ if (action === 'remove' && source) {
694
+ return { output: removeMarketplace(source) ? `Removed marketplace "${source}"` : `Marketplace "${source}" not found`, handled: true };
695
+ }
696
+ // List marketplaces
697
+ const mps = listMarketplaces();
698
+ if (mps.length === 0) {
699
+ return { output: 'No marketplaces configured.\n\nAdd one:\n /plugins marketplace add owner/repo\n /plugins marketplace add https://example.com/plugins', handled: true };
700
+ }
701
+ const lines = [`Marketplaces (${mps.length}):\n`];
702
+ for (const mp of mps) {
703
+ lines.push(` ${mp.name} — ${mp.plugins.length} plugins`);
704
+ }
705
+ return { output: lines.join('\n'), handled: true };
706
+ }
707
+ // /plugins search <query>
708
+ if (subcommand === 'search') {
709
+ const query = rest || 'all';
710
+ const results = searchMarketplace(query === 'all' ? '' : query);
711
+ return { output: formatMarketplaceSearch(results), handled: true };
712
+ }
713
+ // /plugins install <name>
714
+ if (subcommand === 'install' && rest) {
715
+ const [name, marketplace] = rest.split('@');
716
+ const result = installPlugin(name, marketplace);
717
+ if (result) {
718
+ return { output: `Installed ${result.name}@${result.version} from ${result.marketplace}\nCached at: ${result.cachePath}`, handled: true };
719
+ }
720
+ return { output: `Failed to install "${rest}". Is it listed in a marketplace?\nRun /plugins search ${name} to check.`, handled: true };
721
+ }
722
+ // /plugins uninstall <name>
723
+ if (subcommand === 'uninstall' && rest) {
724
+ return { output: uninstallPlugin(rest) ? `Uninstalled "${rest}"` : `Plugin "${rest}" not found`, handled: true };
648
725
  }
649
- // List installed
726
+ // /plugins (no args) — show everything
650
727
  const plugins = discoverPlugins();
651
728
  const skills = discoverSkills();
729
+ const marketplacePlugins = getInstalledPlugins();
652
730
  const lines = [];
731
+ if (marketplacePlugins.length > 0) {
732
+ lines.push(formatInstalledPlugins(marketplacePlugins));
733
+ lines.push('');
734
+ }
653
735
  if (plugins.length > 0) {
654
- lines.push(`Installed Plugins (${plugins.length}):`);
736
+ lines.push(`Local Plugins (${plugins.length}):`);
655
737
  for (const p of plugins) {
656
738
  lines.push(` ${p.name}@${p.version} — ${p.description || 'no description'}`);
657
- if (p.skills?.length)
658
- lines.push(` Skills: ${p.skills.length}`);
659
- if (p.mcpServers?.length)
660
- lines.push(` MCP servers: ${p.mcpServers.map((s) => s.name).join(', ')}`);
661
739
  }
662
740
  lines.push('');
663
741
  }
664
742
  if (skills.length > 0) {
665
- lines.push(`Available Skills (${skills.length}):`);
666
- const bySource = {};
743
+ lines.push(`Skills (${skills.length}):`);
667
744
  for (const s of skills) {
668
- (bySource[s.source] ??= []).push(s);
669
- }
670
- for (const [source, sourceSkills] of Object.entries(bySource)) {
671
- lines.push(` ${source}:`);
672
- for (const s of sourceSkills) {
673
- lines.push(` ${s.name} — ${s.description}${s.trigger ? ` (trigger: "${s.trigger}")` : ''}`);
674
- }
745
+ lines.push(` ${s.source}:${s.name} ${s.description || ''}`);
675
746
  }
747
+ lines.push('');
676
748
  }
677
- else if (plugins.length === 0) {
749
+ if (lines.length === 0) {
678
750
  lines.push('No plugins or skills installed.');
679
- lines.push('');
680
- lines.push('Create skills in .oh/skills/ or ~/.oh/skills/');
681
- lines.push('Run /plugins search to find npm packages.');
682
751
  }
752
+ lines.push('');
753
+ lines.push('Commands:');
754
+ lines.push(' /plugins search <query> Search marketplaces');
755
+ lines.push(' /plugins install <name> Install from marketplace');
756
+ lines.push(' /plugins uninstall <name> Remove a plugin');
757
+ lines.push(' /plugins marketplace add <src> Add a marketplace');
758
+ lines.push(' /plugins marketplace List marketplaces');
683
759
  return { output: lines.join('\n'), handled: true };
684
760
  });
685
761
  // ── Command Parser ──
@@ -11,14 +11,25 @@ export type McpServerConfig = {
11
11
  timeout?: number;
12
12
  };
13
13
  export type HookDef = {
14
- command: string;
14
+ command?: string;
15
+ http?: string;
16
+ prompt?: string;
15
17
  match?: string;
18
+ timeout?: number;
16
19
  };
17
20
  export type HooksConfig = {
18
21
  sessionStart?: HookDef[];
19
22
  sessionEnd?: HookDef[];
20
23
  preToolUse?: HookDef[];
21
24
  postToolUse?: HookDef[];
25
+ fileChanged?: HookDef[];
26
+ cwdChanged?: HookDef[];
27
+ subagentStart?: HookDef[];
28
+ subagentStop?: HookDef[];
29
+ preCompact?: HookDef[];
30
+ postCompact?: HookDef[];
31
+ configChange?: HookDef[];
32
+ notification?: HookDef[];
22
33
  };
23
34
  export type ToolPermissionRule = {
24
35
  tool: string;
@@ -77,6 +77,11 @@ export function readOhConfig(root) {
77
77
  }
78
78
  export function writeOhConfig(cfg, root) {
79
79
  invalidateConfigCache();
80
+ // Emit configChange hook (lazy import to avoid circular dependency)
81
+ try {
82
+ require('./hooks.js').emitHook('configChange', {});
83
+ }
84
+ catch { /* ignore */ }
80
85
  const p = configPath(root);
81
86
  mkdirSync(join(root ?? ".", ".oh"), { recursive: true });
82
87
  if (cfg.provider === "llamacpp" || cfg.provider === "lmstudio") {
@@ -1,10 +1,15 @@
1
1
  /**
2
- * Hooks system — run shell commands on lifecycle events.
2
+ * Hooks system — run commands, HTTP requests, or LLM prompts on lifecycle events.
3
3
  *
4
- * preToolUse hooks can block tool execution (exit code 1 = block).
4
+ * preToolUse hooks can block tool execution (exit code 1 / allowed: false).
5
5
  * All other hooks are fire-and-forget (errors are silently ignored).
6
+ *
7
+ * Hook types:
8
+ * - command: shell script (existing)
9
+ * - http: POST JSON to URL, expect { allowed: true/false }
10
+ * - prompt: LLM yes/no check via provider.complete()
6
11
  */
7
- export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse";
12
+ export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
8
13
  export type HookContext = {
9
14
  toolName?: string;
10
15
  toolArgs?: string;
@@ -16,7 +21,17 @@ export type HookContext = {
16
21
  permissionMode?: string;
17
22
  cost?: string;
18
23
  tokens?: string;
24
+ /** For fileChanged: the file path that changed */
25
+ filePath?: string;
26
+ /** For cwdChanged: the new working directory */
27
+ newCwd?: string;
28
+ /** For subagentStart/Stop: the agent ID */
29
+ agentId?: string;
30
+ /** For notification: the message */
31
+ message?: string;
19
32
  };
33
+ /** Clear hook cache (call after config changes) */
34
+ export declare function invalidateHookCache(): void;
20
35
  /**
21
36
  * Emit a hook event. For preToolUse, returns false if any hook blocks the call.
22
37
  *
@@ -26,7 +41,7 @@ export type HookContext = {
26
41
  export declare function emitHook(event: HookEvent, ctx?: HookContext): boolean;
27
42
  /**
28
43
  * Async version of emitHook that waits for all hooks to complete.
29
- * Useful for sessionEnd where you want to ensure hooks finish.
44
+ * Supports all hook types (command, HTTP, prompt).
30
45
  */
31
46
  export declare function emitHookAsync(event: HookEvent, ctx?: HookContext): Promise<boolean>;
32
47
  //# sourceMappingURL=hooks.d.ts.map
@@ -1,8 +1,13 @@
1
1
  /**
2
- * Hooks system — run shell commands on lifecycle events.
2
+ * Hooks system — run commands, HTTP requests, or LLM prompts on lifecycle events.
3
3
  *
4
- * preToolUse hooks can block tool execution (exit code 1 = block).
4
+ * preToolUse hooks can block tool execution (exit code 1 / allowed: false).
5
5
  * All other hooks are fire-and-forget (errors are silently ignored).
6
+ *
7
+ * Hook types:
8
+ * - command: shell script (existing)
9
+ * - http: POST JSON to URL, expect { allowed: true/false }
10
+ * - prompt: LLM yes/no check via provider.complete()
6
11
  */
7
12
  import { spawn, spawnSync } from "node:child_process";
8
13
  import { readOhConfig } from "./config.js";
@@ -14,6 +19,10 @@ function getHooks() {
14
19
  cachedHooks = cfg?.hooks ?? null;
15
20
  return cachedHooks;
16
21
  }
22
+ /** Clear hook cache (call after config changes) */
23
+ export function invalidateHookCache() {
24
+ cachedHooks = undefined;
25
+ }
17
26
  function buildEnv(event, ctx) {
18
27
  const env = {
19
28
  ...process.env,
@@ -39,6 +48,14 @@ function buildEnv(event, ctx) {
39
48
  env.OH_COST = ctx.cost;
40
49
  if (ctx.tokens)
41
50
  env.OH_TOKENS = ctx.tokens;
51
+ if (ctx.filePath)
52
+ env.OH_FILE_PATH = ctx.filePath;
53
+ if (ctx.newCwd)
54
+ env.OH_NEW_CWD = ctx.newCwd;
55
+ if (ctx.agentId)
56
+ env.OH_AGENT_ID = ctx.agentId;
57
+ if (ctx.message)
58
+ env.OH_MESSAGE = ctx.message;
42
59
  return env;
43
60
  }
44
61
  function matchesHook(def, ctx) {
@@ -47,11 +64,9 @@ function matchesHook(def, ctx) {
47
64
  }
48
65
  return true;
49
66
  }
50
- /**
51
- * Run a single hook command asynchronously.
52
- * Returns a promise that resolves with the exit code (0 = success).
53
- */
54
- function runHookAsync(command, env, timeoutMs = 10_000) {
67
+ // ── Hook Executors ──
68
+ /** Run a command hook. Returns exit code (0 = success/allowed). */
69
+ function runCommandHookAsync(command, env, timeoutMs = 10_000) {
55
70
  return new Promise((resolve) => {
56
71
  const proc = spawn(command, {
57
72
  shell: true,
@@ -64,7 +79,7 @@ function runHookAsync(command, env, timeoutMs = 10_000) {
64
79
  if (!settled) {
65
80
  settled = true;
66
81
  proc.kill();
67
- resolve(1); // timeout = failure
82
+ resolve(1);
68
83
  }
69
84
  }, timeoutMs);
70
85
  proc.on("exit", (code) => {
@@ -83,6 +98,50 @@ function runHookAsync(command, env, timeoutMs = 10_000) {
83
98
  });
84
99
  });
85
100
  }
101
+ /** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
102
+ async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
103
+ try {
104
+ const body = JSON.stringify({ event, ...ctx });
105
+ const res = await fetch(url, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body,
109
+ signal: AbortSignal.timeout(timeoutMs),
110
+ });
111
+ if (!res.ok)
112
+ return false;
113
+ const data = await res.json();
114
+ return data.allowed !== false;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ /** Run a prompt hook. Uses LLM to make a yes/no decision. */
121
+ async function runPromptHook(promptText, ctx) {
122
+ // Prompt hooks require a provider — skip if not available
123
+ // This is a lightweight check; full LLM call would need provider injection
124
+ // For now, prompt hooks evaluate the prompt text as a simple template
125
+ // TODO: inject provider for full LLM-based prompt hooks
126
+ return true; // Default allow if no LLM available
127
+ }
128
+ // ── Hook Execution ──
129
+ /** Execute a single hook definition. Returns true if allowed. */
130
+ async function executeHookDef(def, event, ctx) {
131
+ const timeout = def.timeout ?? 10_000;
132
+ if (def.command) {
133
+ const env = buildEnv(event, ctx);
134
+ const code = await runCommandHookAsync(def.command, env, timeout);
135
+ return code === 0;
136
+ }
137
+ if (def.http) {
138
+ return runHttpHook(def.http, event, ctx, timeout);
139
+ }
140
+ if (def.prompt) {
141
+ return runPromptHook(def.prompt, ctx);
142
+ }
143
+ return true; // No handler = allow
144
+ }
86
145
  /**
87
146
  * Emit a hook event. For preToolUse, returns false if any hook blocks the call.
88
147
  *
@@ -96,19 +155,21 @@ export function emitHook(event, ctx = {}) {
96
155
  const defs = hooks[event] ?? [];
97
156
  const env = buildEnv(event, ctx);
98
157
  if (event === "preToolUse") {
99
- // preToolUse must be synchronous — it gates tool execution
158
+ // preToolUse command hooks must be synchronous — they gate tool execution
100
159
  for (const def of defs) {
101
160
  if (!matchesHook(def, ctx))
102
161
  continue;
103
- const result = spawnSync(def.command, {
104
- shell: true,
105
- timeout: 10_000,
106
- stdio: "pipe",
107
- env,
108
- });
109
- if (result.status !== 0 || result.error) {
110
- return false;
162
+ if (def.command) {
163
+ const result = spawnSync(def.command, {
164
+ shell: true,
165
+ timeout: def.timeout ?? 10_000,
166
+ stdio: "pipe",
167
+ env,
168
+ });
169
+ if (result.status !== 0 || result.error)
170
+ return false;
111
171
  }
172
+ // HTTP and prompt hooks for preToolUse are handled in emitHookAsync
112
173
  }
113
174
  return true;
114
175
  }
@@ -116,27 +177,25 @@ export function emitHook(event, ctx = {}) {
116
177
  for (const def of defs) {
117
178
  if (!matchesHook(def, ctx))
118
179
  continue;
119
- runHookAsync(def.command, env).catch(() => { });
180
+ executeHookDef(def, event, ctx).catch(() => { });
120
181
  }
121
182
  return true;
122
183
  }
123
184
  /**
124
185
  * Async version of emitHook that waits for all hooks to complete.
125
- * Useful for sessionEnd where you want to ensure hooks finish.
186
+ * Supports all hook types (command, HTTP, prompt).
126
187
  */
127
188
  export async function emitHookAsync(event, ctx = {}) {
128
189
  const hooks = getHooks();
129
190
  if (!hooks)
130
191
  return true;
131
192
  const defs = hooks[event] ?? [];
132
- const env = buildEnv(event, ctx);
133
193
  for (const def of defs) {
134
194
  if (!matchesHook(def, ctx))
135
195
  continue;
136
- const code = await runHookAsync(def.command, env);
137
- if (event === "preToolUse" && code !== 0) {
196
+ const allowed = await executeHookDef(def, event, ctx);
197
+ if (event === "preToolUse" && !allowed)
138
198
  return false;
139
- }
140
199
  }
141
200
  return true;
142
201
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Plugin Marketplace — discover, install, and manage plugins from curated registries.
3
+ *
4
+ * Marketplaces are JSON files listing available plugins with versions and sources.
5
+ * Plugins are downloaded and cached to ~/.oh/plugins/cache/ for security and versioning.
6
+ *
7
+ * Inspired by Claude Code's marketplace model.
8
+ */
9
+ export type MarketplaceEntry = {
10
+ name: string;
11
+ description: string;
12
+ version: string;
13
+ author?: string;
14
+ source: MarketplaceSource;
15
+ keywords?: string[];
16
+ };
17
+ export type MarketplaceSource = {
18
+ type: 'github';
19
+ repo: string;
20
+ } | {
21
+ type: 'npm';
22
+ package: string;
23
+ } | {
24
+ type: 'url';
25
+ url: string;
26
+ };
27
+ export type Marketplace = {
28
+ name: string;
29
+ version: number;
30
+ description?: string;
31
+ plugins: MarketplaceEntry[];
32
+ };
33
+ export type InstalledPlugin = {
34
+ name: string;
35
+ version: string;
36
+ marketplace: string;
37
+ installedAt: number;
38
+ cachePath: string;
39
+ };
40
+ /** Add a marketplace from a URL, GitHub repo, or local path */
41
+ export declare function addMarketplace(nameOrUrl: string): Marketplace | null;
42
+ /** Remove a marketplace */
43
+ export declare function removeMarketplace(name: string): boolean;
44
+ /** List all configured marketplaces */
45
+ export declare function listMarketplaces(): Marketplace[];
46
+ /** Search all marketplaces for plugins matching a query */
47
+ export declare function searchMarketplace(query: string): Array<MarketplaceEntry & {
48
+ marketplace: string;
49
+ }>;
50
+ /** Install a plugin from a marketplace */
51
+ export declare function installPlugin(pluginName: string, marketplaceName?: string): InstalledPlugin | null;
52
+ /** Uninstall a plugin */
53
+ export declare function uninstallPlugin(name: string): boolean;
54
+ /** Get all installed plugins */
55
+ export declare function getInstalledPlugins(): InstalledPlugin[];
56
+ /** Format marketplace entries for display */
57
+ export declare function formatMarketplaceSearch(results: Array<MarketplaceEntry & {
58
+ marketplace: string;
59
+ }>): string;
60
+ /** Format installed plugins for display */
61
+ export declare function formatInstalledPlugins(plugins: InstalledPlugin[]): string;
62
+ //# sourceMappingURL=marketplace.d.ts.map