@zhijiewang/openharness 2.22.1 → 2.24.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 +61 -1
- package/README.zh-CN.md +61 -1
- package/dist/commands/index.d.ts +7 -0
- package/dist/commands/index.js +19 -11
- package/dist/commands/session.js +5 -2
- package/dist/commands/settings.d.ts +1 -1
- package/dist/commands/settings.js +47 -6
- package/dist/harness/approvals.d.ts +45 -0
- package/dist/harness/approvals.js +100 -0
- package/dist/harness/config.d.ts +34 -0
- package/dist/harness/config.js +24 -0
- package/dist/harness/hooks.js +25 -1
- package/dist/harness/status-line-script.d.ts +52 -0
- package/dist/harness/status-line-script.js +88 -0
- package/dist/harness/trust.d.ts +42 -0
- package/dist/harness/trust.js +99 -0
- package/dist/query/tools.js +36 -0
- package/dist/renderer/cells.d.ts +6 -0
- package/dist/renderer/cells.js +48 -2
- package/dist/renderer/differ.js +18 -1
- package/dist/renderer/index.d.ts +11 -2
- package/dist/renderer/index.js +37 -4
- package/dist/renderer/input.js +7 -0
- package/dist/renderer/layout-sections.js +27 -5
- package/dist/renderer/layout.d.ts +8 -0
- package/dist/renderer/layout.js +5 -1
- package/dist/renderer/markdown.js +4 -1
- package/dist/repl.js +115 -11
- package/dist/tools/ExaSearchTool/index.d.ts +101 -0
- package/dist/tools/ExaSearchTool/index.js +165 -0
- package/dist/tools.js +2 -0
- package/dist/utils/fuzzy.d.ts +39 -0
- package/dist/utils/fuzzy.js +70 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,8 @@ AI coding agent in your terminal. Works with any LLM -- free local models or clo
|
|
|
42
42
|
- [Cybergotchi](#cybergotchi)
|
|
43
43
|
- [MCP Servers](#mcp-servers)
|
|
44
44
|
- [Providers](#providers)
|
|
45
|
+
- [Auth](#auth)
|
|
46
|
+
- [Update](#update)
|
|
45
47
|
- [FAQ](#faq)
|
|
46
48
|
- [Install](#install)
|
|
47
49
|
- [Development](#development)
|
|
@@ -162,6 +164,7 @@ Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XX
|
|
|
162
164
|
| **Web** | | |
|
|
163
165
|
| WebFetch | medium | Fetch URL content (SSRF-protected) |
|
|
164
166
|
| WebSearch | medium | Search the web |
|
|
167
|
+
| ExaSearch | medium | Neural web search via Exa (requires `EXA_API_KEY`) |
|
|
165
168
|
| RemoteTrigger | high | HTTP requests to webhooks/APIs |
|
|
166
169
|
| **Tasks** | | |
|
|
167
170
|
| TaskCreate | low | Create structured tasks |
|
|
@@ -216,6 +219,7 @@ Over 80 commands are registered. The most-used ones are grouped below; see `/hel
|
|
|
216
219
|
| `/clear` | Clear conversation history |
|
|
217
220
|
| `/compact` | Compress conversation to free context |
|
|
218
221
|
| `/export` | Export conversation to markdown |
|
|
222
|
+
| `/copy [n]` | Copy the Nth-last assistant response to the system clipboard |
|
|
219
223
|
| `/history [n]` | List recent sessions; `/history search <term>` to search |
|
|
220
224
|
| `/browse` | Interactive session browser with preview |
|
|
221
225
|
| `/resume <id>` | Resume a saved session |
|
|
@@ -249,12 +253,16 @@ Over 80 commands are registered. The most-used ones are grouped below; see `/hel
|
|
|
249
253
|
| `/theme dark\|light` | Switch theme (saved to config) |
|
|
250
254
|
| `/vim` | Toggle Vim mode |
|
|
251
255
|
| `/companion off\|on` | Toggle companion visibility |
|
|
256
|
+
| `/keys` | Show keyboard shortcuts |
|
|
257
|
+
| `/keybindings` | Open `~/.oh/keybindings.json` in `$EDITOR` (creates a starter file if missing) |
|
|
252
258
|
|
|
253
259
|
**AI:**
|
|
254
260
|
| Command | Description |
|
|
255
261
|
|---------|-------------|
|
|
256
262
|
| `/plan <task>` | Enter plan mode |
|
|
257
263
|
| `/review` | Review recent code changes |
|
|
264
|
+
| `/summarize` | Summarize the current conversation |
|
|
265
|
+
| `/recap` | One-sentence recap of the session (lighter than `/summarize`) |
|
|
258
266
|
|
|
259
267
|
**Pet:**
|
|
260
268
|
| Command | Description |
|
|
@@ -299,7 +307,7 @@ hooks:
|
|
|
299
307
|
command: "scripts/cleanup.sh"
|
|
300
308
|
```
|
|
301
309
|
|
|
302
|
-
**Event types** (
|
|
310
|
+
**Event types** (27 total — matches Claude Code's stable surface):
|
|
303
311
|
|
|
304
312
|
| Event | When it fires | Can block? |
|
|
305
313
|
|-------|---------------|------------|
|
|
@@ -325,8 +333,14 @@ hooks:
|
|
|
325
333
|
| `notification` | A notification is dispatched | — |
|
|
326
334
|
| `taskCreated` | `TaskCreate` persists a new task | — |
|
|
327
335
|
| `taskCompleted` | `TaskUpdate` transitions a task to `completed` | — |
|
|
336
|
+
| `worktreeCreate` | `EnterWorktreeTool` creates an isolated git worktree | — |
|
|
337
|
+
| `worktreeRemove` | `ExitWorktreeTool` removes a git worktree | — |
|
|
338
|
+
| `elicitation` | An MCP server requests user input via `elicitation/create` | yes — `decision: allow\|deny` |
|
|
339
|
+
| `elicitationResult` | After the elicitation response has been decided (audit trail) | — |
|
|
328
340
|
| `instructionsLoaded` | `loadRulesAsPrompt` rebuilt the system prompt with rules in scope | — |
|
|
329
341
|
|
|
342
|
+
Set `disableAllHooks: true` in `.oh/config.yaml` to globally disable hook execution while keeping definitions on disk for auditability.
|
|
343
|
+
|
|
330
344
|
Live introspection: run `/hooks` in-session to see exactly which hooks are loaded, grouped by event.
|
|
331
345
|
|
|
332
346
|
**Environment variables** available to hook scripts:
|
|
@@ -557,6 +571,23 @@ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
|
|
|
557
571
|
oh session --model gpt-4o --max-budget-usd 5
|
|
558
572
|
```
|
|
559
573
|
|
|
574
|
+
### CLI flags for CI / SDK use
|
|
575
|
+
|
|
576
|
+
| Flag | Effect |
|
|
577
|
+
|------|--------|
|
|
578
|
+
| `--bare` | Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline. Faster startup on repos with many CLAUDE.md / RULES.md files. |
|
|
579
|
+
| `--debug [categories]` | Enable categorized debug logs. `--debug` alone enables all; `--debug mcp,hooks` filters. Falls back to `OH_DEBUG` env var. |
|
|
580
|
+
| `--debug-file <path>` | Append debug lines to a file instead of stderr. Falls back to `OH_DEBUG_FILE`. |
|
|
581
|
+
| `--mcp-config <path>` | Load MCP servers from an external JSON file (in addition to `.oh/config.yaml`). |
|
|
582
|
+
| `--strict-mcp-config` | With `--mcp-config`, ignore `.oh/config.yaml` MCP servers entirely. |
|
|
583
|
+
| `--system-prompt-file <path>` / `--append-system-prompt-file <path>` | File-path variants of `--system-prompt` / `--append-system-prompt`. |
|
|
584
|
+
| `--no-session-persistence` | Skip writing the session record to `~/.oh/sessions/` for ephemeral CI runs. |
|
|
585
|
+
| `--fallback-model <model>` | Fallback used when the primary fails with a retriable error. REPLACES `.oh/config.yaml` `fallbackProviders` for this run. |
|
|
586
|
+
| `--permission-prompt-tool <mcp_tool>` | Delegate tool-permission decisions to a configured MCP tool (e.g. `mcp__myperm__check`). |
|
|
587
|
+
| `--init` / `--init-only` | Run the interactive setup wizard before / instead of the command. |
|
|
588
|
+
|
|
589
|
+
All flags work on both `oh run` and `oh session`. See `oh run --help` and `oh session --help` for the full surface.
|
|
590
|
+
|
|
560
591
|
### Structured output with `--json-schema`
|
|
561
592
|
|
|
562
593
|
Constrain the model's output to a JSON Schema. Useful for CI scripts that
|
|
@@ -650,6 +681,35 @@ oh --model llamacpp/my-model
|
|
|
650
681
|
oh models # list available models
|
|
651
682
|
```
|
|
652
683
|
|
|
684
|
+
## Auth
|
|
685
|
+
|
|
686
|
+
Provider-agnostic credential management. Local LLMs (Ollama / llama.cpp / LM Studio) need no auth — configure them via `oh init`.
|
|
687
|
+
|
|
688
|
+
```bash
|
|
689
|
+
oh auth login [provider] [--key <value>] # store API key for a provider
|
|
690
|
+
oh auth logout [provider] # clear stored API key
|
|
691
|
+
oh auth status # show stored providers + env-var overrides
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
`[provider]` defaults to your configured default. `--key` supplies the value inline; otherwise OH prompts (TTY) or reads from stdin (piped).
|
|
695
|
+
|
|
696
|
+
### Script-based key resolution (`apiKeyHelper`)
|
|
697
|
+
|
|
698
|
+
Avoid storing keys in plaintext / the encrypted store by plugging in a helper script (1Password, `pass`, vault, cloud secret manager). The configured command runs at credential-fetch time with `OH_PROVIDER` set, and its trimmed stdout becomes the key.
|
|
699
|
+
|
|
700
|
+
```yaml
|
|
701
|
+
# .oh/config.yaml
|
|
702
|
+
apiKeyHelper: 'op read "op://Personal/Anthropic/key"'
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
Resolution priority: env var → encrypted store → `apiKeyHelper` → legacy plaintext config.
|
|
706
|
+
|
|
707
|
+
## Update
|
|
708
|
+
|
|
709
|
+
```bash
|
|
710
|
+
oh update # detects how OH was installed (npm-global / npx / local clone) and prints the right upgrade command
|
|
711
|
+
```
|
|
712
|
+
|
|
653
713
|
## Configuration Hierarchy
|
|
654
714
|
|
|
655
715
|
Config is loaded in layers (later overrides earlier):
|
package/README.zh-CN.md
CHANGED
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
- [电子宠物 Cybergotchi](#电子宠物-cybergotchi)
|
|
43
43
|
- [MCP 服务器](#mcp-服务器)
|
|
44
44
|
- [模型提供商](#模型提供商)
|
|
45
|
+
- [鉴权(Auth)](#鉴权auth)
|
|
46
|
+
- [自动更新(Update)](#自动更新update)
|
|
45
47
|
- [常见问题](#常见问题)
|
|
46
48
|
- [安装](#安装)
|
|
47
49
|
- [开发](#开发)
|
|
@@ -162,6 +164,7 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
|
|
|
162
164
|
| **Web** | | |
|
|
163
165
|
| WebFetch | 中 | 获取 URL 内容(防 SSRF) |
|
|
164
166
|
| WebSearch | 中 | 网络搜索 |
|
|
167
|
+
| ExaSearch | 中 | 通过 Exa 进行神经网络搜索(需要 `EXA_API_KEY`) |
|
|
165
168
|
| RemoteTrigger | 高 | 向 webhook/API 发送 HTTP 请求 |
|
|
166
169
|
| **任务** | | |
|
|
167
170
|
| TaskCreate | 低 | 创建结构化任务 |
|
|
@@ -216,6 +219,7 @@ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会
|
|
|
216
219
|
| `/clear` | 清空对话历史 |
|
|
217
220
|
| `/compact` | 压缩对话以释放上下文 |
|
|
218
221
|
| `/export` | 将对话导出为 markdown |
|
|
222
|
+
| `/copy [n]` | 复制倒数第 N 条助手回复到系统剪贴板 |
|
|
219
223
|
| `/history [n]` | 列出最近的会话;`/history search <term>` 搜索 |
|
|
220
224
|
| `/browse` | 带预览的交互式会话浏览器 |
|
|
221
225
|
| `/resume <id>` | 恢复已保存的会话 |
|
|
@@ -249,12 +253,16 @@ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会
|
|
|
249
253
|
| `/theme dark\|light` | 切换主题(自动保存到配置) |
|
|
250
254
|
| `/vim` | 切换 Vim 模式 |
|
|
251
255
|
| `/companion off\|on` | 切换电子宠物可见性 |
|
|
256
|
+
| `/keys` | 显示键盘快捷键 |
|
|
257
|
+
| `/keybindings` | 在 `$EDITOR` 中打开 `~/.oh/keybindings.json`(首次运行会创建初始文件) |
|
|
252
258
|
|
|
253
259
|
**AI:**
|
|
254
260
|
| 命令 | 描述 |
|
|
255
261
|
|---------|-------------|
|
|
256
262
|
| `/plan <task>` | 进入规划模式 |
|
|
257
263
|
| `/review` | 审查最近的代码变更 |
|
|
264
|
+
| `/summarize` | 总结当前对话 |
|
|
265
|
+
| `/recap` | 一句话回顾本次会话(比 `/summarize` 更轻量) |
|
|
258
266
|
|
|
259
267
|
**宠物:**
|
|
260
268
|
| 命令 | 描述 |
|
|
@@ -299,7 +307,7 @@ hooks:
|
|
|
299
307
|
command: "scripts/cleanup.sh"
|
|
300
308
|
```
|
|
301
309
|
|
|
302
|
-
**事件类型**(共
|
|
310
|
+
**事件类型**(共 27 个 —— 与 Claude Code 稳定版一致):
|
|
303
311
|
|
|
304
312
|
| 事件 | 触发时机 | 是否可阻止 |
|
|
305
313
|
|-------|---------------|------------|
|
|
@@ -325,8 +333,14 @@ hooks:
|
|
|
325
333
|
| `notification` | 通知被派发 | — |
|
|
326
334
|
| `taskCreated` | `TaskCreate` 持久化新任务后 | — |
|
|
327
335
|
| `taskCompleted` | `TaskUpdate` 将任务状态切换为 `completed` 时 | — |
|
|
336
|
+
| `worktreeCreate` | `EnterWorktreeTool` 创建隔离的 git worktree 时 | — |
|
|
337
|
+
| `worktreeRemove` | `ExitWorktreeTool` 移除 git worktree 时 | — |
|
|
338
|
+
| `elicitation` | MCP 服务器通过 `elicitation/create` 请求用户输入 | 是 —— `decision: allow\|deny` |
|
|
339
|
+
| `elicitationResult` | elicitation 响应决定之后(用于审计追踪) | — |
|
|
328
340
|
| `instructionsLoaded` | `loadRulesAsPrompt` 重新构建系统提示并加载规则之后 | — |
|
|
329
341
|
|
|
342
|
+
在 `.oh/config.yaml` 中设置 `disableAllHooks: true` 可全局禁用钩子执行,同时保留磁盘上的定义以便审计。
|
|
343
|
+
|
|
330
344
|
实时查看:在会话中运行 `/hooks` 可以按事件分组查看当前已加载的钩子。
|
|
331
345
|
|
|
332
346
|
**环境变量**(钩子脚本可用):
|
|
@@ -557,6 +571,23 @@ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
|
|
|
557
571
|
oh session --model gpt-4o --max-budget-usd 5
|
|
558
572
|
```
|
|
559
573
|
|
|
574
|
+
### CI / SDK 常用 CLI 标志
|
|
575
|
+
|
|
576
|
+
| 标志 | 作用 |
|
|
577
|
+
|------|------|
|
|
578
|
+
| `--bare` | 跳过启动时的可选工作(项目检测、插件、记忆、技能、MCP)。系统提示仅保留工具使用基线,对包含大量 CLAUDE.md / RULES.md 的仓库启动更快。 |
|
|
579
|
+
| `--debug [类别]` | 启用分类调试日志。`--debug` 启用全部;`--debug mcp,hooks` 仅启用指定类别。也读取 `OH_DEBUG` 环境变量。 |
|
|
580
|
+
| `--debug-file <path>` | 把调试日志追加到文件而非 stderr。也读取 `OH_DEBUG_FILE`。 |
|
|
581
|
+
| `--mcp-config <path>` | 从外部 JSON 文件加载 MCP 服务器(叠加在 `.oh/config.yaml` 之上)。 |
|
|
582
|
+
| `--strict-mcp-config` | 配合 `--mcp-config`,完全忽略 `.oh/config.yaml` 中的 MCP 服务器。 |
|
|
583
|
+
| `--system-prompt-file <path>` / `--append-system-prompt-file <path>` | `--system-prompt` / `--append-system-prompt` 的文件路径变体。 |
|
|
584
|
+
| `--no-session-persistence` | 跳过会话写入 `~/.oh/sessions/`,适合一次性 CI 运行。 |
|
|
585
|
+
| `--fallback-model <model>` | 主模型遇到可重试错误时使用的回退模型。本次运行内会替代 `.oh/config.yaml` 的 `fallbackProviders`。 |
|
|
586
|
+
| `--permission-prompt-tool <mcp_tool>` | 把工具授权决策委托给指定的 MCP 工具(例如 `mcp__myperm__check`)。 |
|
|
587
|
+
| `--init` / `--init-only` | 在执行命令前 / 替代执行命令运行交互式安装向导。 |
|
|
588
|
+
|
|
589
|
+
所有标志在 `oh run` 与 `oh session` 上都可用。完整列表见 `oh run --help` 与 `oh session --help`。
|
|
590
|
+
|
|
560
591
|
### 使用 `--json-schema` 约束结构化输出
|
|
561
592
|
|
|
562
593
|
按 JSON Schema 约束模型输出。适用于需要以编程方式解析模型输出、避免正则启发式的 CI 脚本:
|
|
@@ -649,6 +680,35 @@ oh --model llamacpp/my-model
|
|
|
649
680
|
oh models # 列出可用模型
|
|
650
681
|
```
|
|
651
682
|
|
|
683
|
+
## 鉴权(Auth)
|
|
684
|
+
|
|
685
|
+
提供商无关的凭据管理。本地 LLM(Ollama / llama.cpp / LM Studio)无需鉴权 —— 通过 `oh init` 配置即可。
|
|
686
|
+
|
|
687
|
+
```bash
|
|
688
|
+
oh auth login [provider] [--key <value>] # 存储某个提供商的 API key
|
|
689
|
+
oh auth logout [provider] # 清除已存储的 API key
|
|
690
|
+
oh auth status # 显示已存储的提供商及环境变量覆盖情况
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
`[provider]` 默认使用配置好的默认提供商。`--key` 可直接传入;否则 OH 会在 TTY 下交互询问,在管道输入下读到 EOF。
|
|
694
|
+
|
|
695
|
+
### 脚本化 key 解析(`apiKeyHelper`)
|
|
696
|
+
|
|
697
|
+
通过插入辅助脚本(1Password、`pass`、vault、云端密钥管理器等)避免把 key 写入纯文本或加密存储。配置好的命令在取 key 时执行,环境变量带 `OH_PROVIDER`,去掉首尾空白的 stdout 即为 key。
|
|
698
|
+
|
|
699
|
+
```yaml
|
|
700
|
+
# .oh/config.yaml
|
|
701
|
+
apiKeyHelper: 'op read "op://Personal/Anthropic/key"'
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
解析优先级:环境变量 → 加密存储 → `apiKeyHelper` → 旧版纯文本配置。
|
|
705
|
+
|
|
706
|
+
## 自动更新(Update)
|
|
707
|
+
|
|
708
|
+
```bash
|
|
709
|
+
oh update # 检测安装方式(npm 全局 / npx / 本地克隆),打印对应的升级命令
|
|
710
|
+
```
|
|
711
|
+
|
|
652
712
|
## 配置层级
|
|
653
713
|
|
|
654
714
|
配置按层加载(后者覆盖前者):
|
package/dist/commands/index.d.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export type { CommandContext, CommandHandler, CommandResult } from "./types.js";
|
|
16
16
|
import type { CommandContext, CommandResult } from "./types.js";
|
|
17
|
+
/**
|
|
18
|
+
* Slash command categories shown in the autocomplete picker (audit U-A3).
|
|
19
|
+
* Names are user-facing — keep them short. Order here is the grouping
|
|
20
|
+
* order in the picker: most-frequently-used first.
|
|
21
|
+
*/
|
|
22
|
+
export type CommandCategory = "Session" | "Git" | "Info" | "Settings" | "AI" | "Skills" | "MCP" | "Other";
|
|
17
23
|
/**
|
|
18
24
|
* Check if input is a slash command. If so, execute it.
|
|
19
25
|
*/
|
|
@@ -25,6 +31,7 @@ export declare function getCommandNames(): string[];
|
|
|
25
31
|
export declare function getCommandEntries(): Array<{
|
|
26
32
|
name: string;
|
|
27
33
|
description: string;
|
|
34
|
+
category: CommandCategory;
|
|
28
35
|
}>;
|
|
29
36
|
/**
|
|
30
37
|
* Register MCP-server prompts as `/server:prompt` slash commands. Called from
|
package/dist/commands/index.js
CHANGED
|
@@ -18,18 +18,25 @@ import { registerInfoCommands } from "./info.js";
|
|
|
18
18
|
import { registerSessionCommands } from "./session.js";
|
|
19
19
|
import { registerSettingsCommands } from "./settings.js";
|
|
20
20
|
import { registerSkillCommands } from "./skills.js";
|
|
21
|
-
// ── Command Registry ──
|
|
22
21
|
const commands = new Map();
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Adapter: each `registerXxx(register)` call is wrapped with a category-bound
|
|
24
|
+
* register so per-domain command files don't need a 4th argument. Adding a
|
|
25
|
+
* new category is a one-line change here, not a sweep across the command
|
|
26
|
+
* files.
|
|
27
|
+
*/
|
|
28
|
+
function registerFor(category) {
|
|
29
|
+
return (name, description, handler) => {
|
|
30
|
+
commands.set(name, { description, handler, category });
|
|
31
|
+
};
|
|
25
32
|
}
|
|
26
|
-
// Register all command groups
|
|
27
|
-
registerSessionCommands(
|
|
28
|
-
registerGitCommands(
|
|
29
|
-
registerInfoCommands(
|
|
30
|
-
registerSettingsCommands(
|
|
31
|
-
registerAICommands(
|
|
32
|
-
registerSkillCommands(
|
|
33
|
+
// Register all command groups — category derived from the source file split.
|
|
34
|
+
registerSessionCommands(registerFor("Session"));
|
|
35
|
+
registerGitCommands(registerFor("Git"));
|
|
36
|
+
registerInfoCommands(registerFor("Info"), () => commands);
|
|
37
|
+
registerSettingsCommands(registerFor("Settings"));
|
|
38
|
+
registerAICommands(registerFor("AI"));
|
|
39
|
+
registerSkillCommands(registerFor("Skills"));
|
|
33
40
|
// ── Command Parser ──
|
|
34
41
|
/**
|
|
35
42
|
* Check if input is a slash command. If so, execute it.
|
|
@@ -65,7 +72,7 @@ export function getCommandNames() {
|
|
|
65
72
|
return [...commands.keys()];
|
|
66
73
|
}
|
|
67
74
|
export function getCommandEntries() {
|
|
68
|
-
return [...commands.entries()].map(([name, { description }]) => ({ name, description }));
|
|
75
|
+
return [...commands.entries()].map(([name, { description, category }]) => ({ name, description, category }));
|
|
69
76
|
}
|
|
70
77
|
let mcpPromptKeys = [];
|
|
71
78
|
export function registerMcpPromptCommands(prompts) {
|
|
@@ -79,6 +86,7 @@ export function registerMcpPromptCommands(prompts) {
|
|
|
79
86
|
const usageBits = [...required.map((n) => `${n}=<value>`), ...optional.map((n) => `[${n}=<value>]`)].join(" ");
|
|
80
87
|
commands.set(key, {
|
|
81
88
|
description: handle.description,
|
|
89
|
+
category: "MCP",
|
|
82
90
|
handler: async (args) => {
|
|
83
91
|
const parsed = parseMcpPromptArgs(args);
|
|
84
92
|
const missing = required.filter((n) => !(n in parsed));
|
package/dist/commands/session.js
CHANGED
|
@@ -181,10 +181,13 @@ export function registerSessionCommands(register) {
|
|
|
181
181
|
register("browse", "Open interactive session browser", () => {
|
|
182
182
|
return { output: "__OPEN_SESSION_BROWSER__", handled: true };
|
|
183
183
|
});
|
|
184
|
-
register("resume", "Resume a saved session
|
|
184
|
+
register("resume", "Resume a saved session — opens a picker if no ID given", (args) => {
|
|
185
185
|
const id = args.trim();
|
|
186
|
+
// Audit U-A6: with no id, open the interactive session browser instead
|
|
187
|
+
// of erroring with a usage hint. Mirrors Claude Code's `/resume`
|
|
188
|
+
// picker. The browser already supports Enter-to-resume.
|
|
186
189
|
if (!id)
|
|
187
|
-
return { output: "
|
|
190
|
+
return { output: "__OPEN_SESSION_BROWSER__", handled: true };
|
|
188
191
|
const sessionDir = join(homedir(), ".oh", "sessions");
|
|
189
192
|
try {
|
|
190
193
|
loadSession(id, sessionDir);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools
|
|
2
|
+
* Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools, /trust
|
|
3
3
|
*/
|
|
4
4
|
import type { CommandHandler } from "./types.js";
|
|
5
5
|
export declare function registerSettingsCommands(register: (name: string, description: string, handler: CommandHandler) => void): void;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools
|
|
2
|
+
* Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools, /trust
|
|
3
3
|
*/
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir, platform } from "node:os";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
|
+
import { readApprovalLog } from "../harness/approvals.js";
|
|
8
9
|
import { readOhConfig } from "../harness/config.js";
|
|
9
10
|
import { loadKeybindings } from "../harness/keybindings.js";
|
|
11
|
+
import { isTrusted, listTrusted, trust } from "../harness/trust.js";
|
|
10
12
|
const KEYBINDINGS_TEMPLATE = `[
|
|
11
13
|
{ "key": "ctrl+d", "action": "/diff" },
|
|
12
14
|
{ "key": "ctrl+l", "action": "/clear" },
|
|
@@ -137,17 +139,37 @@ export function registerSettingsCommands(register) {
|
|
|
137
139
|
const { sandboxStatus } = require("../harness/sandbox.js");
|
|
138
140
|
return { output: `${sandboxStatus()}\n\nConfigure in .oh/config.yaml under sandbox:`, handled: true };
|
|
139
141
|
});
|
|
140
|
-
register("permissions", "View or change permission mode", (args, ctx) => {
|
|
141
|
-
const
|
|
142
|
-
if (!
|
|
142
|
+
register("permissions", "View or change permission mode (or 'log' for approval history)", (args, ctx) => {
|
|
143
|
+
const trimmed = args.trim();
|
|
144
|
+
if (!trimmed) {
|
|
143
145
|
return {
|
|
144
|
-
output: `Current permission mode: ${ctx.permissionMode}\n\nAvailable modes:\n ask Prompt for medium/high risk (default)\n trust Auto-approve everything\n deny Only low-risk read-only\n acceptEdits Auto-approve file edits\n plan Read-only mode\n auto Auto-approve, block dangerous bash\n bypassPermissions CI/CD only`,
|
|
146
|
+
output: `Current permission mode: ${ctx.permissionMode}\n\nAvailable modes:\n ask Prompt for medium/high risk (default)\n trust Auto-approve everything\n deny Only low-risk read-only\n acceptEdits Auto-approve file edits\n plan Read-only mode\n auto Auto-approve, block dangerous bash\n bypassPermissions CI/CD only\n\nApproval history:\n /permissions log [n] Show last n approval decisions (default 50)`,
|
|
145
147
|
handled: true,
|
|
146
148
|
};
|
|
147
149
|
}
|
|
150
|
+
// Audit U-B5: /permissions log [n] — show approval history from
|
|
151
|
+
// ~/.oh/approvals.log. Subcommand check happens before the mode-name
|
|
152
|
+
// validation so "log" doesn't collide with the mode list.
|
|
153
|
+
const [head, ...tail] = trimmed.split(/\s+/);
|
|
154
|
+
if (head?.toLowerCase() === "log") {
|
|
155
|
+
const n = Math.max(1, Math.min(500, Number.parseInt(tail[0] ?? "50", 10) || 50));
|
|
156
|
+
const records = readApprovalLog(n);
|
|
157
|
+
if (records.length === 0) {
|
|
158
|
+
return { output: "No approval decisions logged yet.", handled: true };
|
|
159
|
+
}
|
|
160
|
+
const lines = records.map((r) => {
|
|
161
|
+
const time = r.ts.slice(11, 19); // HH:MM:SS from ISO
|
|
162
|
+
const date = r.ts.slice(0, 10);
|
|
163
|
+
const decision = r.decision === "allow" ? "✓" : r.decision === "always" ? "★" : "✗";
|
|
164
|
+
const reason = r.reason ? ` (${r.reason})` : "";
|
|
165
|
+
return `${date} ${time} ${decision} ${r.decision.padEnd(7)} ${r.tool.padEnd(14)} ${r.source}${reason}`;
|
|
166
|
+
});
|
|
167
|
+
return { output: `Last ${records.length} approval decisions:\n${lines.join("\n")}`, handled: true };
|
|
168
|
+
}
|
|
169
|
+
const mode = trimmed.toLowerCase();
|
|
148
170
|
const valid = ["ask", "trust", "deny", "acceptedits", "plan", "auto", "bypasspermissions"];
|
|
149
171
|
if (!valid.includes(mode)) {
|
|
150
|
-
return { output: `Unknown mode: ${mode}. Valid: ${valid.join(", ")}`, handled: true };
|
|
172
|
+
return { output: `Unknown mode: ${mode}. Valid: ${valid.join(", ")} | log`, handled: true };
|
|
151
173
|
}
|
|
152
174
|
return {
|
|
153
175
|
output: `Permission mode set to: ${mode}\n(Note: takes effect for new tool calls in this session)`,
|
|
@@ -174,6 +196,25 @@ export function registerSettingsCommands(register) {
|
|
|
174
196
|
register("vim", "Toggle Vim mode", () => {
|
|
175
197
|
return { output: "__TOGGLE_VIM__", handled: true };
|
|
176
198
|
});
|
|
199
|
+
register("trust", "Trust this workspace for shell hooks / status-line scripts (or list / add a path)", (args) => {
|
|
200
|
+
const arg = args.trim();
|
|
201
|
+
if (arg === "list") {
|
|
202
|
+
const trusted = listTrusted();
|
|
203
|
+
if (trusted.length === 0) {
|
|
204
|
+
return { output: "No trusted workspaces yet.\nRun `/trust` to add the current directory.", handled: true };
|
|
205
|
+
}
|
|
206
|
+
return { output: `Trusted workspaces:\n${trusted.map((d) => ` ${d}`).join("\n")}`, handled: true };
|
|
207
|
+
}
|
|
208
|
+
const target = arg || process.cwd();
|
|
209
|
+
if (isTrusted(target)) {
|
|
210
|
+
return { output: `Already trusted: ${target}`, handled: true };
|
|
211
|
+
}
|
|
212
|
+
trust(target);
|
|
213
|
+
return {
|
|
214
|
+
output: `Trusted: ${target}\nShell hooks and status-line scripts will now execute in this directory.`,
|
|
215
|
+
handled: true,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
177
218
|
register("login", "Set API key for current provider", (args, ctx) => {
|
|
178
219
|
const key = args.trim();
|
|
179
220
|
if (!key) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval log (audit U-B5).
|
|
3
|
+
*
|
|
4
|
+
* Append-only JSONL at `~/.oh/approvals.log` recording every permission
|
|
5
|
+
* resolution OH makes during a session — the source (user / hook / rule /
|
|
6
|
+
* permission-prompt-tool / headless / policy), the tool name, the decision
|
|
7
|
+
* (allow / deny / always), a redacted args preview, and a timestamp.
|
|
8
|
+
*
|
|
9
|
+
* Rotated to a `.1` sibling once the file exceeds ~2 MiB so the log doesn't
|
|
10
|
+
* grow unbounded. The slash command `/permissions log` reads the tail.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors Claude Code's session approval log. Genuinely new — no prior art
|
|
13
|
+
* in OH (grep-verified during the audit refresh).
|
|
14
|
+
*/
|
|
15
|
+
export type ApprovalSource = "user" | "hook" | "rule" | "permission-prompt-tool" | "policy" | "headless";
|
|
16
|
+
export type ApprovalDecision = "allow" | "deny" | "always";
|
|
17
|
+
export interface ApprovalRecord {
|
|
18
|
+
ts: string;
|
|
19
|
+
tool: string;
|
|
20
|
+
decision: ApprovalDecision;
|
|
21
|
+
source: ApprovalSource;
|
|
22
|
+
/** Tool args as JSON, truncated to ~500 chars to keep the log compact. */
|
|
23
|
+
argsPreview?: string;
|
|
24
|
+
/** Optional human-readable reason (hook reason, headless reason, etc.). */
|
|
25
|
+
reason?: string;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Override the log file path for tests, or `null` to silence the writer.
|
|
30
|
+
* Calling without arguments resets to the real `~/.oh/approvals.log`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function setApprovalLogPathForTests(path: string | null | undefined): void;
|
|
33
|
+
/**
|
|
34
|
+
* Append a single approval decision to the log. Errors are swallowed: a
|
|
35
|
+
* disk-full or permission error must not block the agent loop.
|
|
36
|
+
*/
|
|
37
|
+
export declare function recordApproval(rec: Omit<ApprovalRecord, "ts">): void;
|
|
38
|
+
/**
|
|
39
|
+
* Read the most recent `n` records from the log. Skips malformed lines.
|
|
40
|
+
* Used by the `/permissions log` slash command.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readApprovalLog(n?: number): ApprovalRecord[];
|
|
43
|
+
/** Truncate an args string to roughly N chars without breaking JSON brackets. */
|
|
44
|
+
export declare function previewArgs(argsJson: string, max?: number): string;
|
|
45
|
+
//# sourceMappingURL=approvals.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval log (audit U-B5).
|
|
3
|
+
*
|
|
4
|
+
* Append-only JSONL at `~/.oh/approvals.log` recording every permission
|
|
5
|
+
* resolution OH makes during a session — the source (user / hook / rule /
|
|
6
|
+
* permission-prompt-tool / headless / policy), the tool name, the decision
|
|
7
|
+
* (allow / deny / always), a redacted args preview, and a timestamp.
|
|
8
|
+
*
|
|
9
|
+
* Rotated to a `.1` sibling once the file exceeds ~2 MiB so the log doesn't
|
|
10
|
+
* grow unbounded. The slash command `/permissions log` reads the tail.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors Claude Code's session approval log. Genuinely new — no prior art
|
|
13
|
+
* in OH (grep-verified during the audit refresh).
|
|
14
|
+
*/
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
const LOG_FILE = join(homedir(), ".oh", "approvals.log");
|
|
19
|
+
const ROTATE_BYTES = 2 * 1024 * 1024;
|
|
20
|
+
/** Test seam — flip to `null` to disable logging in test runs. */
|
|
21
|
+
let _logFileOverride;
|
|
22
|
+
/**
|
|
23
|
+
* Override the log file path for tests, or `null` to silence the writer.
|
|
24
|
+
* Calling without arguments resets to the real `~/.oh/approvals.log`.
|
|
25
|
+
*/
|
|
26
|
+
export function setApprovalLogPathForTests(path) {
|
|
27
|
+
_logFileOverride = path;
|
|
28
|
+
}
|
|
29
|
+
function logPath() {
|
|
30
|
+
if (_logFileOverride === null)
|
|
31
|
+
return null;
|
|
32
|
+
return _logFileOverride ?? LOG_FILE;
|
|
33
|
+
}
|
|
34
|
+
function rotateIfNeeded(file) {
|
|
35
|
+
try {
|
|
36
|
+
const st = statSync(file);
|
|
37
|
+
if (st.size >= ROTATE_BYTES) {
|
|
38
|
+
renameSync(file, `${file}.1`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* file does not exist yet — no rotate needed */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Append a single approval decision to the log. Errors are swallowed: a
|
|
47
|
+
* disk-full or permission error must not block the agent loop.
|
|
48
|
+
*/
|
|
49
|
+
export function recordApproval(rec) {
|
|
50
|
+
const file = logPath();
|
|
51
|
+
if (!file)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
55
|
+
rotateIfNeeded(file);
|
|
56
|
+
const line = `${JSON.stringify({ ts: new Date().toISOString(), ...rec })}\n`;
|
|
57
|
+
appendFileSync(file, line, "utf8");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* logging must not throw into caller */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read the most recent `n` records from the log. Skips malformed lines.
|
|
65
|
+
* Used by the `/permissions log` slash command.
|
|
66
|
+
*/
|
|
67
|
+
export function readApprovalLog(n = 50) {
|
|
68
|
+
const file = logPath();
|
|
69
|
+
if (!file || !existsSync(file))
|
|
70
|
+
return [];
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = readFileSync(file, "utf8");
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
79
|
+
const tail = lines.slice(Math.max(0, lines.length - n));
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const line of tail) {
|
|
82
|
+
try {
|
|
83
|
+
const obj = JSON.parse(line);
|
|
84
|
+
if (obj && typeof obj.tool === "string" && typeof obj.decision === "string") {
|
|
85
|
+
out.push(obj);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* skip malformed line */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/** Truncate an args string to roughly N chars without breaking JSON brackets. */
|
|
95
|
+
export function previewArgs(argsJson, max = 500) {
|
|
96
|
+
if (argsJson.length <= max)
|
|
97
|
+
return argsJson;
|
|
98
|
+
return `${argsJson.slice(0, max)}…`;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=approvals.js.map
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -151,6 +151,27 @@ export type OhConfig = {
|
|
|
151
151
|
apiKeyHelper?: string;
|
|
152
152
|
toolPermissions?: ToolPermissionRule[];
|
|
153
153
|
statusLineFormat?: string;
|
|
154
|
+
/**
|
|
155
|
+
* JSON-envelope status line script (audit U-B1). When set, OH spawns
|
|
156
|
+
* `command` through the user's shell on each refresh, pipes a JSON
|
|
157
|
+
* envelope `{ model, tokens, cost, ctx, sessionId, cwd, gitBranch }` to
|
|
158
|
+
* stdin, and uses the trimmed stdout as the status line. Mirrors Claude
|
|
159
|
+
* Code's `statusLine` config. Gated through the workspace-trust system —
|
|
160
|
+
* scripts only run in trusted dirs.
|
|
161
|
+
*
|
|
162
|
+
* Output is cached for `refreshMs` (default 1000) so the script doesn't
|
|
163
|
+
* re-spawn on every keypress. Multi-line output is truncated to the
|
|
164
|
+
* first line.
|
|
165
|
+
*
|
|
166
|
+
* Coexists with `statusLineFormat` — when both are set, the script wins.
|
|
167
|
+
*/
|
|
168
|
+
statusLine?: {
|
|
169
|
+
command: string;
|
|
170
|
+
/** Cache window in ms. Default: 1000. Min: 100. */
|
|
171
|
+
refreshMs?: number;
|
|
172
|
+
/** Spawn timeout in ms. Default: 2000. */
|
|
173
|
+
timeoutMs?: number;
|
|
174
|
+
};
|
|
154
175
|
/** Verification loops — auto-run lint/typecheck after file edits */
|
|
155
176
|
verification?: {
|
|
156
177
|
enabled?: boolean;
|
|
@@ -223,5 +244,18 @@ export declare function readOhConfig(root?: string, sources?: readonly SettingSo
|
|
|
223
244
|
* Unknown names are silently dropped.
|
|
224
245
|
*/
|
|
225
246
|
export declare function parseSettingSources(raw: string | undefined): SettingSource[] | undefined;
|
|
247
|
+
/**
|
|
248
|
+
* Persist a single tool-allow rule (audit U-A2). Used by the "[A]lways"
|
|
249
|
+
* keypress in the permission prompt — the user has just approved a tool
|
|
250
|
+
* call and wants future calls to that tool to skip the prompt.
|
|
251
|
+
*
|
|
252
|
+
* No-ops when no project config exists (user is running on auto-detected
|
|
253
|
+
* settings; we don't auto-create `.oh/config.yaml` just to add a rule).
|
|
254
|
+
* De-dupes against an exact-tool rule with no pattern.
|
|
255
|
+
*
|
|
256
|
+
* Returns `true` if a rule was written, `false` if already present or
|
|
257
|
+
* skipped because no config exists.
|
|
258
|
+
*/
|
|
259
|
+
export declare function appendToolPermission(toolName: string, action?: "allow" | "deny", root?: string): boolean;
|
|
226
260
|
export declare function writeOhConfig(cfg: OhConfig, root?: string): void;
|
|
227
261
|
//# sourceMappingURL=config.d.ts.map
|
package/dist/harness/config.js
CHANGED
|
@@ -101,6 +101,30 @@ export function parseSettingSources(raw) {
|
|
|
101
101
|
.filter((s) => valid.has(s));
|
|
102
102
|
return out.length > 0 ? out : undefined;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Persist a single tool-allow rule (audit U-A2). Used by the "[A]lways"
|
|
106
|
+
* keypress in the permission prompt — the user has just approved a tool
|
|
107
|
+
* call and wants future calls to that tool to skip the prompt.
|
|
108
|
+
*
|
|
109
|
+
* No-ops when no project config exists (user is running on auto-detected
|
|
110
|
+
* settings; we don't auto-create `.oh/config.yaml` just to add a rule).
|
|
111
|
+
* De-dupes against an exact-tool rule with no pattern.
|
|
112
|
+
*
|
|
113
|
+
* Returns `true` if a rule was written, `false` if already present or
|
|
114
|
+
* skipped because no config exists.
|
|
115
|
+
*/
|
|
116
|
+
export function appendToolPermission(toolName, action = "allow", root) {
|
|
117
|
+
const cfg = readOhConfig(root);
|
|
118
|
+
if (!cfg)
|
|
119
|
+
return false;
|
|
120
|
+
const existing = cfg.toolPermissions ?? [];
|
|
121
|
+
if (existing.some((r) => r.tool === toolName && !r.pattern && r.action === action)) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
cfg.toolPermissions = [...existing, { tool: toolName, action }];
|
|
125
|
+
writeOhConfig(cfg, root);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
104
128
|
export function writeOhConfig(cfg, root) {
|
|
105
129
|
invalidateConfigCache();
|
|
106
130
|
// Emit configChange hook (lazy import to avoid circular dependency)
|