@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.
- package/dist/agents/roles.d.ts +4 -2
- package/dist/agents/roles.js +69 -5
- package/dist/commands/index.js +110 -34
- package/dist/harness/config.d.ts +12 -1
- package/dist/harness/config.js +5 -0
- package/dist/harness/hooks.d.ts +19 -4
- package/dist/harness/hooks.js +82 -23
- package/dist/harness/marketplace.d.ts +62 -0
- package/dist/harness/marketplace.js +242 -0
- package/dist/harness/plugins.d.ts +1 -1
- package/dist/harness/plugins.js +15 -1
- package/dist/harness/rules.js +32 -4
- package/dist/harness/submit-handler.js +18 -2
- package/dist/main.js +1 -0
- package/dist/query/compress.js +5 -1
- package/dist/query/tools.js +7 -0
- package/dist/tools/AgentTool/index.js +5 -1
- package/dist/tools/DiagnosticsTool/index.d.ts +3 -3
- package/dist/tools/DiagnosticsTool/index.js +37 -8
- package/dist/tools/MonitorTool/index.d.ts +21 -0
- package/dist/tools/MonitorTool/index.js +114 -0
- package/dist/tools/PowerShellTool/index.d.ts +15 -0
- package/dist/tools/PowerShellTool/index.js +32 -0
- package/dist/tools.js +4 -0
- package/dist/types/permissions.js +42 -2
- package/package.json +1 -1
package/dist/agents/roles.d.ts
CHANGED
|
@@ -16,9 +16,11 @@ export type AgentRole = {
|
|
|
16
16
|
/** Suggested tools to include (empty = all tools) */
|
|
17
17
|
suggestedTools?: string[];
|
|
18
18
|
};
|
|
19
|
-
/**
|
|
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[];
|
package/dist/agents/roles.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
234
|
+
return listRoles().map(r => r.id);
|
|
171
235
|
}
|
|
172
236
|
//# sourceMappingURL=roles.js.map
|
package/dist/commands/index.js
CHANGED
|
@@ -100,15 +100,54 @@ register("undo", "Undo last AI commit", () => {
|
|
|
100
100
|
handled: true,
|
|
101
101
|
};
|
|
102
102
|
});
|
|
103
|
-
register("rewind", "Restore files from
|
|
104
|
-
const { rewindLastCheckpoint, checkpointCount } = require("../harness/checkpoints.js");
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
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
|
|
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
|
|
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", "
|
|
677
|
+
register("plugins", "Manage plugins: list, search, install, uninstall, marketplace", (args) => {
|
|
639
678
|
const { discoverPlugins, discoverSkills } = require('../harness/plugins.js');
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
//
|
|
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(`
|
|
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(`
|
|
666
|
-
const bySource = {};
|
|
743
|
+
lines.push(`Skills (${skills.length}):`);
|
|
667
744
|
for (const s of skills) {
|
|
668
|
-
(
|
|
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
|
-
|
|
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 ──
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -11,14 +11,25 @@ export type McpServerConfig = {
|
|
|
11
11
|
timeout?: number;
|
|
12
12
|
};
|
|
13
13
|
export type HookDef = {
|
|
14
|
-
command
|
|
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;
|
package/dist/harness/config.js
CHANGED
|
@@ -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") {
|
package/dist/harness/hooks.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hooks system — run
|
|
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
|
|
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
|
-
*
|
|
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
|
package/dist/harness/hooks.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hooks system — run
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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);
|
|
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 —
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
137
|
-
if (event === "preToolUse" &&
|
|
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
|