@zhijiewang/openharness 0.12.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -611,6 +611,49 @@ Create `.oh/RULES.md` in any repo (or run `oh init`):
611
611
 
612
612
  Rules load automatically into every session.
613
613
 
614
+ ## Skills & Plugins
615
+
616
+ ### Skills
617
+
618
+ Skills are markdown files with YAML frontmatter that add reusable behaviors:
619
+
620
+ ```markdown
621
+ ---
622
+ name: deploy
623
+ description: Deploy the application to production
624
+ trigger: deploy
625
+ tools: [Bash, Read]
626
+ ---
627
+
628
+ Run the deploy script with health checks...
629
+ ```
630
+
631
+ **Locations** (searched in order):
632
+ 1. `.oh/skills/` — project-level skills
633
+ 2. `~/.oh/skills/` — global skills (available in all projects)
634
+
635
+ Skills auto-trigger when the user's message contains the trigger keyword, or can be invoked explicitly with `/skill deploy`.
636
+
637
+ ### Plugins
638
+
639
+ Plugins are npm packages that bundle skills, hooks, and MCP servers:
640
+
641
+ ```json
642
+ {
643
+ "name": "my-openharness-plugin",
644
+ "version": "1.0.0",
645
+ "skills": ["skills/deploy.md", "skills/review.md"],
646
+ "hooks": {
647
+ "sessionStart": "scripts/setup.sh"
648
+ },
649
+ "mcpServers": [
650
+ { "name": "my-api", "command": "npx", "args": ["-y", "@my-org/mcp-server"] }
651
+ ]
652
+ }
653
+ ```
654
+
655
+ Save as `openharness-plugin.json` in your npm package root. Install with `npm install`, and openHarness discovers it automatically from `node_modules/`.
656
+
614
657
  ## How It Works
615
658
 
616
659
  ```mermaid
@@ -473,18 +473,61 @@ register("doctor", "Run diagnostic health checks", (_args, ctx) => {
473
473
  lines.push(` Session: ${ctx.sessionId}`);
474
474
  lines.push(` Messages: ${ctx.messages.length}`);
475
475
  lines.push(` Cost: $${ctx.totalCost.toFixed(4)}`);
476
- // Disk space (basic check)
476
+ // Disk space & storage
477
477
  try {
478
- const { statSync } = require("node:fs");
479
478
  const ohDir = join(homedir(), ".oh");
480
479
  if (existsSync(ohDir)) {
481
- const sessions = readdirSync(join(ohDir, "sessions")).length;
482
- lines.push(` Sessions: ${sessions} saved`);
483
- if (sessions > 80)
484
- issues.push(`${sessions} saved sessions. Consider cleaning old ones.`);
480
+ const sessionsDir = join(ohDir, "sessions");
481
+ const sessCount = existsSync(sessionsDir) ? readdirSync(sessionsDir).filter(f => f.endsWith('.json')).length : 0;
482
+ lines.push(` Sessions: ${sessCount} saved`);
483
+ if (sessCount > 80)
484
+ issues.push(`${sessCount} saved sessions. Consider cleaning old ones.`);
485
+ // Memory stats
486
+ const memDir = join(ohDir, "memory");
487
+ const memCount = existsSync(memDir) ? readdirSync(memDir).filter(f => f.endsWith('.md')).length : 0;
488
+ lines.push(` Memories: ${memCount} global`);
489
+ // Cron stats
490
+ const cronDir = join(ohDir, "crons");
491
+ const cronCount = existsSync(cronDir) ? readdirSync(cronDir).filter(f => f.endsWith('.json')).length : 0;
492
+ lines.push(` Cron tasks: ${cronCount}`);
485
493
  }
486
494
  }
487
495
  catch { /* ignore */ }
496
+ // Project-level stats
497
+ try {
498
+ const projMemDir = join(".oh", "memory");
499
+ const projMemCount = existsSync(projMemDir) ? readdirSync(projMemDir).filter(f => f.endsWith('.md')).length : 0;
500
+ if (projMemCount > 0)
501
+ lines.push(` Project mems: ${projMemCount}`);
502
+ const skillsDir = join(".oh", "skills");
503
+ const skillCount = existsSync(skillsDir) ? readdirSync(skillsDir).filter(f => f.endsWith('.md')).length : 0;
504
+ if (skillCount > 0)
505
+ lines.push(` Skills: ${skillCount}`);
506
+ }
507
+ catch { /* ignore */ }
508
+ // Global config
509
+ const globalCfg = existsSync(join(homedir(), ".oh", "config.yaml"));
510
+ lines.push(` Global config: ${globalCfg ? "~/.oh/config.yaml ✓" : "not set (optional)"}`);
511
+ // Verification config
512
+ try {
513
+ const { getVerificationConfig } = require('../harness/verification.js');
514
+ const vCfg = getVerificationConfig();
515
+ if (vCfg?.enabled) {
516
+ lines.push(` Verification: ✓ (${vCfg.rules.length} rules, mode: ${vCfg.mode})`);
517
+ }
518
+ else {
519
+ lines.push(` Verification: off (no rules detected)`);
520
+ }
521
+ }
522
+ catch { /* ignore */ }
523
+ // Tools
524
+ lines.push("");
525
+ lines.push(` Tools: ${ctx.messages.length > 0 ? 'ready' : 'loaded'}`);
526
+ // Node.js version
527
+ lines.push(` Node.js: ${process.version}`);
528
+ const [major] = process.version.slice(1).split('.').map(Number);
529
+ if (major && major < 18)
530
+ issues.push(`Node.js ${process.version} is below minimum (18+). Upgrade Node.js.`);
488
531
  // Issues summary
489
532
  if (issues.length > 0) {
490
533
  lines.push("");
@@ -571,6 +614,53 @@ function setPinned(args, ctx, pinned) {
571
614
  }
572
615
  register("pin", "Pin a message (survives /compact)", (args, ctx) => setPinned(args, ctx, true));
573
616
  register("unpin", "Unpin a message", (args, ctx) => setPinned(args, ctx, false));
617
+ register("plugins", "List installed plugins and discover new ones", (args) => {
618
+ const { discoverPlugins, discoverSkills } = require('../harness/plugins.js');
619
+ const query = args.trim();
620
+ if (query === 'search' || query.startsWith('search ')) {
621
+ // npm registry search
622
+ const keyword = query.replace(/^search\s*/, '').trim() || 'openharness-plugin';
623
+ return {
624
+ 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.`,
625
+ handled: true,
626
+ };
627
+ }
628
+ // List installed
629
+ const plugins = discoverPlugins();
630
+ const skills = discoverSkills();
631
+ const lines = [];
632
+ if (plugins.length > 0) {
633
+ lines.push(`Installed Plugins (${plugins.length}):`);
634
+ for (const p of plugins) {
635
+ lines.push(` ${p.name}@${p.version} — ${p.description || 'no description'}`);
636
+ if (p.skills?.length)
637
+ lines.push(` Skills: ${p.skills.length}`);
638
+ if (p.mcpServers?.length)
639
+ lines.push(` MCP servers: ${p.mcpServers.map((s) => s.name).join(', ')}`);
640
+ }
641
+ lines.push('');
642
+ }
643
+ if (skills.length > 0) {
644
+ lines.push(`Available Skills (${skills.length}):`);
645
+ const bySource = {};
646
+ for (const s of skills) {
647
+ (bySource[s.source] ??= []).push(s);
648
+ }
649
+ for (const [source, sourceSkills] of Object.entries(bySource)) {
650
+ lines.push(` ${source}:`);
651
+ for (const s of sourceSkills) {
652
+ lines.push(` ${s.name} — ${s.description}${s.trigger ? ` (trigger: "${s.trigger}")` : ''}`);
653
+ }
654
+ }
655
+ }
656
+ else if (plugins.length === 0) {
657
+ lines.push('No plugins or skills installed.');
658
+ lines.push('');
659
+ lines.push('Create skills in .oh/skills/ or ~/.oh/skills/');
660
+ lines.push('Run /plugins search to find npm packages.');
661
+ }
662
+ return { output: lines.join('\n'), handled: true };
663
+ });
574
664
  // ── Command Parser ──
575
665
  /**
576
666
  * Check if input is a slash command. If so, execute it.
@@ -30,9 +30,30 @@ const PERMISSION_MODES = [
30
30
  { key: "trust", label: "trust — auto-approve everything" },
31
31
  { key: "deny", label: "deny — read-only, block write/run tools" },
32
32
  ];
33
+ /** Auto-detect provider from environment variables */
34
+ function detectProviderFromEnv() {
35
+ if (process.env.ANTHROPIC_API_KEY)
36
+ return PROVIDERS.findIndex(p => p.key === "anthropic");
37
+ if (process.env.OPENAI_API_KEY)
38
+ return PROVIDERS.findIndex(p => p.key === "openai");
39
+ if (process.env.OPENROUTER_API_KEY)
40
+ return PROVIDERS.findIndex(p => p.key === "openrouter");
41
+ return 0; // Default to Ollama
42
+ }
43
+ /** Get the detected API key for a provider */
44
+ function getEnvApiKey(providerKey) {
45
+ const envMap = {
46
+ anthropic: 'ANTHROPIC_API_KEY',
47
+ openai: 'OPENAI_API_KEY',
48
+ openrouter: 'OPENROUTER_API_KEY',
49
+ };
50
+ const envVar = envMap[providerKey];
51
+ return envVar ? (process.env[envVar] ?? '') : '';
52
+ }
33
53
  export default function InitWizard({ onDone }) {
54
+ const detectedIdx = detectProviderFromEnv();
34
55
  const [step, setStep] = useState("provider");
35
- const [providerIdx, setProviderIdx] = useState(0);
56
+ const [providerIdx, setProviderIdx] = useState(detectedIdx);
36
57
  const [apiKey, setApiKey] = useState("");
37
58
  const [baseUrl, setBaseUrl] = useState("");
38
59
  const [model, setModel] = useState("");
@@ -43,6 +64,9 @@ export default function InitWizard({ onDone }) {
43
64
  const [permIdx, setPermIdx] = useState(0);
44
65
  const [hatchGotchi, setHatchGotchi] = useState(false);
45
66
  const [showSetup, setShowSetup] = useState(false);
67
+ const [suggestedMcp, setSuggestedMcp] = useState([]);
68
+ const [selectedMcp, setSelectedMcp] = useState(new Set());
69
+ const [mcpIdx, setMcpIdx] = useState(0);
46
70
  const provider = PROVIDERS[providerIdx];
47
71
  // ── Keyboard navigation ──
48
72
  useInput(useCallback((input, key) => {
@@ -54,11 +78,19 @@ export default function InitWizard({ onDone }) {
54
78
  if (key.return) {
55
79
  setBaseUrl(provider.defaultBaseUrl ?? "");
56
80
  setModel(provider.defaultModel);
81
+ // Auto-fill API key from environment if available
82
+ const envKey = getEnvApiKey(provider.key);
83
+ if (envKey)
84
+ setApiKey(envKey);
57
85
  if (!provider.needsApiKey) {
58
- // Skip API key, go straight to testing
59
86
  runTest(provider, "", provider.defaultBaseUrl ?? "");
60
87
  setStep("testing");
61
88
  }
89
+ else if (envKey) {
90
+ // Have env API key — skip manual entry, go to testing
91
+ runTest(provider, envKey, provider.defaultBaseUrl ?? "");
92
+ setStep("testing");
93
+ }
62
94
  else {
63
95
  setStep("apikey");
64
96
  }
@@ -69,8 +101,37 @@ export default function InitWizard({ onDone }) {
69
101
  setPermIdx(i => Math.max(0, i - 1));
70
102
  if (key.downArrow)
71
103
  setPermIdx(i => Math.min(PERMISSION_MODES.length - 1, i + 1));
104
+ if (key.return) {
105
+ // Suggest popular MCP servers
106
+ setSuggestedMcp(['github', 'memory', 'fetch', 'sequential-thinking', 'brave-search']);
107
+ setMcpIdx(0);
108
+ setSelectedMcp(new Set());
109
+ setStep("mcp");
110
+ }
111
+ }
112
+ if (step === "mcp") {
113
+ if (key.upArrow)
114
+ setMcpIdx(i => Math.max(0, i - 1));
115
+ if (key.downArrow)
116
+ setMcpIdx(i => Math.min(suggestedMcp.length - 1, i + 1));
117
+ if (input === " ") {
118
+ // Toggle selection
119
+ const name = suggestedMcp[mcpIdx];
120
+ if (name) {
121
+ setSelectedMcp(prev => {
122
+ const next = new Set(prev);
123
+ if (next.has(name))
124
+ next.delete(name);
125
+ else
126
+ next.add(name);
127
+ return next;
128
+ });
129
+ }
130
+ }
72
131
  if (key.return)
73
132
  setStep("gotchi");
133
+ if (input === "s" || input === "S")
134
+ setStep("gotchi"); // Skip
74
135
  }
75
136
  if (step === "model" && availableModels.length > 0) {
76
137
  if (key.upArrow)
@@ -120,16 +181,34 @@ export default function InitWizard({ onDone }) {
120
181
  // ── Write final config ──
121
182
  const writeFinal = useCallback(() => {
122
183
  const selectedModel = availableModels.length > 0 ? (availableModels[modelIdx] ?? model) : model;
184
+ // Build MCP server configs from selected registry entries
185
+ let mcpServers;
186
+ if (selectedMcp.size > 0) {
187
+ try {
188
+ const { MCP_REGISTRY } = require('../mcp/registry.js');
189
+ mcpServers = [...selectedMcp]
190
+ .map(name => MCP_REGISTRY.find((e) => e.name === name))
191
+ .filter(Boolean)
192
+ .map((e) => ({
193
+ name: e.name,
194
+ command: 'npx',
195
+ args: ['-y', e.package, ...(e.args ?? [])],
196
+ ...(e.envVars?.length ? { env: Object.fromEntries(e.envVars.map((v) => [v, `YOUR_${v}`])) } : {}),
197
+ }));
198
+ }
199
+ catch { /* ignore */ }
200
+ }
123
201
  writeOhConfig({
124
202
  provider: provider.key,
125
203
  model: selectedModel || provider.defaultModel,
126
204
  permissionMode: PERMISSION_MODES[permIdx].key,
127
205
  ...(apiKey ? { apiKey } : {}),
128
206
  ...(baseUrl ? { baseUrl } : {}),
207
+ ...(mcpServers?.length ? { mcpServers } : {}),
129
208
  });
130
209
  setStep("done");
131
210
  setTimeout(() => onDone?.(), 1500);
132
- }, [provider, model, availableModels, modelIdx, permIdx, apiKey, baseUrl]);
211
+ }, [provider, model, availableModels, modelIdx, permIdx, apiKey, baseUrl, selectedMcp]);
133
212
  // ── Render ──
134
213
  if (showSetup) {
135
214
  return _jsx(CybergotchiSetup, { onComplete: () => { setShowSetup(false); writeFinal(); }, onSkip: () => { setShowSetup(false); writeFinal(); } });
@@ -137,7 +216,7 @@ export default function InitWizard({ onDone }) {
137
216
  if (step === "done") {
138
217
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "green", children: "\u2713 OpenHarness configured!" }), _jsx(Text, { dimColor: true, children: "Config saved to .oh/config.yaml" }), _jsx(Text, { dimColor: true, children: "Run: oh" })] }));
139
218
  }
140
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "OpenHarness Setup" }), _jsx(Text, { children: " " }), step === "provider" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select provider:" }), PROVIDERS.map((p, i) => (_jsxs(Text, { color: i === providerIdx ? "cyan" : undefined, children: [i === providerIdx ? "▶ " : " ", p.label] }, p.key))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "apikey" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["API key for ", _jsx(Text, { color: "cyan", children: provider.label }), ":"] }), _jsx(TextInput, { value: apiKey, onChange: setApiKey, mask: "*", onSubmit: (val) => {
219
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "OpenHarness Setup" }), _jsx(Text, { children: " " }), step === "provider" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Select provider:", detectedIdx > 0 ? _jsx(Text, { dimColor: true, children: " (auto-detected from env)" }) : ''] }), PROVIDERS.map((p, i) => (_jsxs(Text, { color: i === providerIdx ? "cyan" : undefined, children: [i === providerIdx ? "▶ " : " ", p.label] }, p.key))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "apikey" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["API key for ", _jsx(Text, { color: "cyan", children: provider.label }), ":"] }), _jsx(TextInput, { value: apiKey, onChange: setApiKey, mask: "*", onSubmit: (val) => {
141
220
  if (!val.trim())
142
221
  return;
143
222
  if (provider.key === "custom") {
@@ -158,6 +237,6 @@ export default function InitWizard({ onDone }) {
158
237
  const gi = start + vi;
159
238
  return (_jsxs(Text, { color: gi === modelIdx ? "cyan" : undefined, children: [gi === modelIdx ? "▶ " : " ", m] }, m));
160
239
  }), start + WINDOW < availableModels.length && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", availableModels.length - start - WINDOW, " more"] }))] }));
161
- })() : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Could not fetch model list. Enter model name:" }), _jsx(TextInput, { value: model, onChange: setModel, onSubmit: () => setStep("permission") })] })), availableModels.length > 0 && _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "permission" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Permission mode:" }), PERMISSION_MODES.map((p, i) => (_jsxs(Text, { color: i === permIdx ? "cyan" : undefined, children: [i === permIdx ? "▶ " : " ", p.label] }, p.key))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "gotchi" && (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: ["Hatch a cybergotchi companion? ", _jsx(Text, { dimColor: true, children: "(Y/n)" })] }) }))] }));
240
+ })() : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Could not fetch model list. Enter model name:" }), _jsx(TextInput, { value: model, onChange: setModel, onSubmit: () => setStep("permission") })] })), availableModels.length > 0 && _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "permission" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Permission mode:" }), PERMISSION_MODES.map((p, i) => (_jsxs(Text, { color: i === permIdx ? "cyan" : undefined, children: [i === permIdx ? "▶ " : " ", p.label] }, p.key))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Enter select" })] })), step === "mcp" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Add MCP servers? ", _jsx(Text, { dimColor: true, children: "(Space to toggle, Enter to confirm, S to skip)" })] }), _jsx(Text, { children: " " }), suggestedMcp.map((name, i) => (_jsxs(Text, { color: i === mcpIdx ? "cyan" : undefined, children: [i === mcpIdx ? "▶ " : " ", selectedMcp.has(name) ? "[✓] " : "[ ] ", name] }, name))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate Space toggle Enter confirm S skip" })] })), step === "gotchi" && (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: ["Hatch a cybergotchi companion? ", _jsx(Text, { dimColor: true, children: "(Y/n)" })] }) }))] }));
162
241
  }
163
242
  //# sourceMappingURL=InitWizard.js.map
@@ -86,6 +86,13 @@ export declare class TerminalRenderer {
86
86
  private handlePermissionKey;
87
87
  /** Handle question prompt text input. Returns true if key was consumed. */
88
88
  private handleQuestionKey;
89
+ private renderTimer;
90
+ private lastRenderTime;
91
+ private static readonly FRAME_MS;
92
+ /**
93
+ * Schedule a render at ~60fps. Multiple calls within a frame are batched.
94
+ * This prevents excessive re-renders during fast token streaming.
95
+ */
89
96
  private scheduleRender;
90
97
  /** Apply lightweight markdown styling to a line for scrollback output */
91
98
  private styleMarkdownLine;
@@ -108,6 +108,10 @@ export class TerminalRenderer {
108
108
  clearInterval(this.animationTimer);
109
109
  this.animationTimer = null;
110
110
  }
111
+ if (this.renderTimer) {
112
+ clearTimeout(this.renderTimer);
113
+ this.renderTimer = null;
114
+ }
111
115
  if (this.resizeHandler) {
112
116
  process.stdout.off('resize', this.resizeHandler);
113
117
  this.resizeHandler = null;
@@ -363,15 +367,39 @@ export class TerminalRenderer {
363
367
  return true;
364
368
  }
365
369
  // ── Rendering ──
370
+ renderTimer = null;
371
+ lastRenderTime = 0;
372
+ static FRAME_MS = 16; // ~60fps
373
+ /**
374
+ * Schedule a render at ~60fps. Multiple calls within a frame are batched.
375
+ * This prevents excessive re-renders during fast token streaming.
376
+ */
366
377
  scheduleRender() {
367
378
  if (this.renderPending || !this.started)
368
379
  return;
369
380
  this.renderPending = true;
370
- queueMicrotask(() => {
371
- this.renderPending = false;
372
- if (this.started)
373
- this.render();
374
- });
381
+ const now = Date.now();
382
+ const elapsed = now - this.lastRenderTime;
383
+ if (elapsed >= TerminalRenderer.FRAME_MS) {
384
+ // Enough time has passed — render on next microtask (no delay)
385
+ queueMicrotask(() => {
386
+ this.renderPending = false;
387
+ this.lastRenderTime = Date.now();
388
+ if (this.started)
389
+ this.render();
390
+ });
391
+ }
392
+ else {
393
+ // Too soon — debounce to next frame boundary
394
+ const delay = TerminalRenderer.FRAME_MS - elapsed;
395
+ this.renderTimer = setTimeout(() => {
396
+ this.renderTimer = null;
397
+ this.renderPending = false;
398
+ this.lastRenderTime = Date.now();
399
+ if (this.started)
400
+ this.render();
401
+ }, delay);
402
+ }
375
403
  }
376
404
  /** Apply lightweight markdown styling to a line for scrollback output */
377
405
  styleMarkdownLine(line) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "0.12.1",
3
+ "version": "1.0.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {