@zhijiewang/openharness 2.22.1 → 2.23.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 +60 -1
- package/README.zh-CN.md +60 -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 +21 -1
- package/dist/harness/config.d.ts +13 -0
- package/dist/harness/config.js +24 -0
- package/dist/harness/hooks.js +25 -1
- package/dist/harness/trust.d.ts +42 -0
- package/dist/harness/trust.js +99 -0
- package/dist/renderer/index.d.ts +11 -2
- package/dist/renderer/index.js +32 -4
- package/dist/renderer/input.js +7 -0
- package/dist/renderer/layout-sections.js +25 -3
- package/dist/renderer/layout.d.ts +8 -0
- package/dist/renderer/layout.js +5 -1
- package/dist/repl.js +76 -4
- 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)
|
|
@@ -216,6 +218,7 @@ Over 80 commands are registered. The most-used ones are grouped below; see `/hel
|
|
|
216
218
|
| `/clear` | Clear conversation history |
|
|
217
219
|
| `/compact` | Compress conversation to free context |
|
|
218
220
|
| `/export` | Export conversation to markdown |
|
|
221
|
+
| `/copy [n]` | Copy the Nth-last assistant response to the system clipboard |
|
|
219
222
|
| `/history [n]` | List recent sessions; `/history search <term>` to search |
|
|
220
223
|
| `/browse` | Interactive session browser with preview |
|
|
221
224
|
| `/resume <id>` | Resume a saved session |
|
|
@@ -249,12 +252,16 @@ Over 80 commands are registered. The most-used ones are grouped below; see `/hel
|
|
|
249
252
|
| `/theme dark\|light` | Switch theme (saved to config) |
|
|
250
253
|
| `/vim` | Toggle Vim mode |
|
|
251
254
|
| `/companion off\|on` | Toggle companion visibility |
|
|
255
|
+
| `/keys` | Show keyboard shortcuts |
|
|
256
|
+
| `/keybindings` | Open `~/.oh/keybindings.json` in `$EDITOR` (creates a starter file if missing) |
|
|
252
257
|
|
|
253
258
|
**AI:**
|
|
254
259
|
| Command | Description |
|
|
255
260
|
|---------|-------------|
|
|
256
261
|
| `/plan <task>` | Enter plan mode |
|
|
257
262
|
| `/review` | Review recent code changes |
|
|
263
|
+
| `/summarize` | Summarize the current conversation |
|
|
264
|
+
| `/recap` | One-sentence recap of the session (lighter than `/summarize`) |
|
|
258
265
|
|
|
259
266
|
**Pet:**
|
|
260
267
|
| Command | Description |
|
|
@@ -299,7 +306,7 @@ hooks:
|
|
|
299
306
|
command: "scripts/cleanup.sh"
|
|
300
307
|
```
|
|
301
308
|
|
|
302
|
-
**Event types** (
|
|
309
|
+
**Event types** (27 total — matches Claude Code's stable surface):
|
|
303
310
|
|
|
304
311
|
| Event | When it fires | Can block? |
|
|
305
312
|
|-------|---------------|------------|
|
|
@@ -325,8 +332,14 @@ hooks:
|
|
|
325
332
|
| `notification` | A notification is dispatched | — |
|
|
326
333
|
| `taskCreated` | `TaskCreate` persists a new task | — |
|
|
327
334
|
| `taskCompleted` | `TaskUpdate` transitions a task to `completed` | — |
|
|
335
|
+
| `worktreeCreate` | `EnterWorktreeTool` creates an isolated git worktree | — |
|
|
336
|
+
| `worktreeRemove` | `ExitWorktreeTool` removes a git worktree | — |
|
|
337
|
+
| `elicitation` | An MCP server requests user input via `elicitation/create` | yes — `decision: allow\|deny` |
|
|
338
|
+
| `elicitationResult` | After the elicitation response has been decided (audit trail) | — |
|
|
328
339
|
| `instructionsLoaded` | `loadRulesAsPrompt` rebuilt the system prompt with rules in scope | — |
|
|
329
340
|
|
|
341
|
+
Set `disableAllHooks: true` in `.oh/config.yaml` to globally disable hook execution while keeping definitions on disk for auditability.
|
|
342
|
+
|
|
330
343
|
Live introspection: run `/hooks` in-session to see exactly which hooks are loaded, grouped by event.
|
|
331
344
|
|
|
332
345
|
**Environment variables** available to hook scripts:
|
|
@@ -557,6 +570,23 @@ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
|
|
|
557
570
|
oh session --model gpt-4o --max-budget-usd 5
|
|
558
571
|
```
|
|
559
572
|
|
|
573
|
+
### CLI flags for CI / SDK use
|
|
574
|
+
|
|
575
|
+
| Flag | Effect |
|
|
576
|
+
|------|--------|
|
|
577
|
+
| `--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. |
|
|
578
|
+
| `--debug [categories]` | Enable categorized debug logs. `--debug` alone enables all; `--debug mcp,hooks` filters. Falls back to `OH_DEBUG` env var. |
|
|
579
|
+
| `--debug-file <path>` | Append debug lines to a file instead of stderr. Falls back to `OH_DEBUG_FILE`. |
|
|
580
|
+
| `--mcp-config <path>` | Load MCP servers from an external JSON file (in addition to `.oh/config.yaml`). |
|
|
581
|
+
| `--strict-mcp-config` | With `--mcp-config`, ignore `.oh/config.yaml` MCP servers entirely. |
|
|
582
|
+
| `--system-prompt-file <path>` / `--append-system-prompt-file <path>` | File-path variants of `--system-prompt` / `--append-system-prompt`. |
|
|
583
|
+
| `--no-session-persistence` | Skip writing the session record to `~/.oh/sessions/` for ephemeral CI runs. |
|
|
584
|
+
| `--fallback-model <model>` | Fallback used when the primary fails with a retriable error. REPLACES `.oh/config.yaml` `fallbackProviders` for this run. |
|
|
585
|
+
| `--permission-prompt-tool <mcp_tool>` | Delegate tool-permission decisions to a configured MCP tool (e.g. `mcp__myperm__check`). |
|
|
586
|
+
| `--init` / `--init-only` | Run the interactive setup wizard before / instead of the command. |
|
|
587
|
+
|
|
588
|
+
All flags work on both `oh run` and `oh session`. See `oh run --help` and `oh session --help` for the full surface.
|
|
589
|
+
|
|
560
590
|
### Structured output with `--json-schema`
|
|
561
591
|
|
|
562
592
|
Constrain the model's output to a JSON Schema. Useful for CI scripts that
|
|
@@ -650,6 +680,35 @@ oh --model llamacpp/my-model
|
|
|
650
680
|
oh models # list available models
|
|
651
681
|
```
|
|
652
682
|
|
|
683
|
+
## Auth
|
|
684
|
+
|
|
685
|
+
Provider-agnostic credential management. Local LLMs (Ollama / llama.cpp / LM Studio) need no auth — configure them via `oh init`.
|
|
686
|
+
|
|
687
|
+
```bash
|
|
688
|
+
oh auth login [provider] [--key <value>] # store API key for a provider
|
|
689
|
+
oh auth logout [provider] # clear stored API key
|
|
690
|
+
oh auth status # show stored providers + env-var overrides
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
`[provider]` defaults to your configured default. `--key` supplies the value inline; otherwise OH prompts (TTY) or reads from stdin (piped).
|
|
694
|
+
|
|
695
|
+
### Script-based key resolution (`apiKeyHelper`)
|
|
696
|
+
|
|
697
|
+
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.
|
|
698
|
+
|
|
699
|
+
```yaml
|
|
700
|
+
# .oh/config.yaml
|
|
701
|
+
apiKeyHelper: 'op read "op://Personal/Anthropic/key"'
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Resolution priority: env var → encrypted store → `apiKeyHelper` → legacy plaintext config.
|
|
705
|
+
|
|
706
|
+
## Update
|
|
707
|
+
|
|
708
|
+
```bash
|
|
709
|
+
oh update # detects how OH was installed (npm-global / npx / local clone) and prints the right upgrade command
|
|
710
|
+
```
|
|
711
|
+
|
|
653
712
|
## Configuration Hierarchy
|
|
654
713
|
|
|
655
714
|
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
|
- [开发](#开发)
|
|
@@ -216,6 +218,7 @@ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会
|
|
|
216
218
|
| `/clear` | 清空对话历史 |
|
|
217
219
|
| `/compact` | 压缩对话以释放上下文 |
|
|
218
220
|
| `/export` | 将对话导出为 markdown |
|
|
221
|
+
| `/copy [n]` | 复制倒数第 N 条助手回复到系统剪贴板 |
|
|
219
222
|
| `/history [n]` | 列出最近的会话;`/history search <term>` 搜索 |
|
|
220
223
|
| `/browse` | 带预览的交互式会话浏览器 |
|
|
221
224
|
| `/resume <id>` | 恢复已保存的会话 |
|
|
@@ -249,12 +252,16 @@ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会
|
|
|
249
252
|
| `/theme dark\|light` | 切换主题(自动保存到配置) |
|
|
250
253
|
| `/vim` | 切换 Vim 模式 |
|
|
251
254
|
| `/companion off\|on` | 切换电子宠物可见性 |
|
|
255
|
+
| `/keys` | 显示键盘快捷键 |
|
|
256
|
+
| `/keybindings` | 在 `$EDITOR` 中打开 `~/.oh/keybindings.json`(首次运行会创建初始文件) |
|
|
252
257
|
|
|
253
258
|
**AI:**
|
|
254
259
|
| 命令 | 描述 |
|
|
255
260
|
|---------|-------------|
|
|
256
261
|
| `/plan <task>` | 进入规划模式 |
|
|
257
262
|
| `/review` | 审查最近的代码变更 |
|
|
263
|
+
| `/summarize` | 总结当前对话 |
|
|
264
|
+
| `/recap` | 一句话回顾本次会话(比 `/summarize` 更轻量) |
|
|
258
265
|
|
|
259
266
|
**宠物:**
|
|
260
267
|
| 命令 | 描述 |
|
|
@@ -299,7 +306,7 @@ hooks:
|
|
|
299
306
|
command: "scripts/cleanup.sh"
|
|
300
307
|
```
|
|
301
308
|
|
|
302
|
-
**事件类型**(共
|
|
309
|
+
**事件类型**(共 27 个 —— 与 Claude Code 稳定版一致):
|
|
303
310
|
|
|
304
311
|
| 事件 | 触发时机 | 是否可阻止 |
|
|
305
312
|
|-------|---------------|------------|
|
|
@@ -325,8 +332,14 @@ hooks:
|
|
|
325
332
|
| `notification` | 通知被派发 | — |
|
|
326
333
|
| `taskCreated` | `TaskCreate` 持久化新任务后 | — |
|
|
327
334
|
| `taskCompleted` | `TaskUpdate` 将任务状态切换为 `completed` 时 | — |
|
|
335
|
+
| `worktreeCreate` | `EnterWorktreeTool` 创建隔离的 git worktree 时 | — |
|
|
336
|
+
| `worktreeRemove` | `ExitWorktreeTool` 移除 git worktree 时 | — |
|
|
337
|
+
| `elicitation` | MCP 服务器通过 `elicitation/create` 请求用户输入 | 是 —— `decision: allow\|deny` |
|
|
338
|
+
| `elicitationResult` | elicitation 响应决定之后(用于审计追踪) | — |
|
|
328
339
|
| `instructionsLoaded` | `loadRulesAsPrompt` 重新构建系统提示并加载规则之后 | — |
|
|
329
340
|
|
|
341
|
+
在 `.oh/config.yaml` 中设置 `disableAllHooks: true` 可全局禁用钩子执行,同时保留磁盘上的定义以便审计。
|
|
342
|
+
|
|
330
343
|
实时查看:在会话中运行 `/hooks` 可以按事件分组查看当前已加载的钩子。
|
|
331
344
|
|
|
332
345
|
**环境变量**(钩子脚本可用):
|
|
@@ -557,6 +570,23 @@ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
|
|
|
557
570
|
oh session --model gpt-4o --max-budget-usd 5
|
|
558
571
|
```
|
|
559
572
|
|
|
573
|
+
### CI / SDK 常用 CLI 标志
|
|
574
|
+
|
|
575
|
+
| 标志 | 作用 |
|
|
576
|
+
|------|------|
|
|
577
|
+
| `--bare` | 跳过启动时的可选工作(项目检测、插件、记忆、技能、MCP)。系统提示仅保留工具使用基线,对包含大量 CLAUDE.md / RULES.md 的仓库启动更快。 |
|
|
578
|
+
| `--debug [类别]` | 启用分类调试日志。`--debug` 启用全部;`--debug mcp,hooks` 仅启用指定类别。也读取 `OH_DEBUG` 环境变量。 |
|
|
579
|
+
| `--debug-file <path>` | 把调试日志追加到文件而非 stderr。也读取 `OH_DEBUG_FILE`。 |
|
|
580
|
+
| `--mcp-config <path>` | 从外部 JSON 文件加载 MCP 服务器(叠加在 `.oh/config.yaml` 之上)。 |
|
|
581
|
+
| `--strict-mcp-config` | 配合 `--mcp-config`,完全忽略 `.oh/config.yaml` 中的 MCP 服务器。 |
|
|
582
|
+
| `--system-prompt-file <path>` / `--append-system-prompt-file <path>` | `--system-prompt` / `--append-system-prompt` 的文件路径变体。 |
|
|
583
|
+
| `--no-session-persistence` | 跳过会话写入 `~/.oh/sessions/`,适合一次性 CI 运行。 |
|
|
584
|
+
| `--fallback-model <model>` | 主模型遇到可重试错误时使用的回退模型。本次运行内会替代 `.oh/config.yaml` 的 `fallbackProviders`。 |
|
|
585
|
+
| `--permission-prompt-tool <mcp_tool>` | 把工具授权决策委托给指定的 MCP 工具(例如 `mcp__myperm__check`)。 |
|
|
586
|
+
| `--init` / `--init-only` | 在执行命令前 / 替代执行命令运行交互式安装向导。 |
|
|
587
|
+
|
|
588
|
+
所有标志在 `oh run` 与 `oh session` 上都可用。完整列表见 `oh run --help` 与 `oh session --help`。
|
|
589
|
+
|
|
560
590
|
### 使用 `--json-schema` 约束结构化输出
|
|
561
591
|
|
|
562
592
|
按 JSON Schema 约束模型输出。适用于需要以编程方式解析模型输出、避免正则启发式的 CI 脚本:
|
|
@@ -649,6 +679,35 @@ oh --model llamacpp/my-model
|
|
|
649
679
|
oh models # 列出可用模型
|
|
650
680
|
```
|
|
651
681
|
|
|
682
|
+
## 鉴权(Auth)
|
|
683
|
+
|
|
684
|
+
提供商无关的凭据管理。本地 LLM(Ollama / llama.cpp / LM Studio)无需鉴权 —— 通过 `oh init` 配置即可。
|
|
685
|
+
|
|
686
|
+
```bash
|
|
687
|
+
oh auth login [provider] [--key <value>] # 存储某个提供商的 API key
|
|
688
|
+
oh auth logout [provider] # 清除已存储的 API key
|
|
689
|
+
oh auth status # 显示已存储的提供商及环境变量覆盖情况
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
`[provider]` 默认使用配置好的默认提供商。`--key` 可直接传入;否则 OH 会在 TTY 下交互询问,在管道输入下读到 EOF。
|
|
693
|
+
|
|
694
|
+
### 脚本化 key 解析(`apiKeyHelper`)
|
|
695
|
+
|
|
696
|
+
通过插入辅助脚本(1Password、`pass`、vault、云端密钥管理器等)避免把 key 写入纯文本或加密存储。配置好的命令在取 key 时执行,环境变量带 `OH_PROVIDER`,去掉首尾空白的 stdout 即为 key。
|
|
697
|
+
|
|
698
|
+
```yaml
|
|
699
|
+
# .oh/config.yaml
|
|
700
|
+
apiKeyHelper: 'op read "op://Personal/Anthropic/key"'
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
解析优先级:环境变量 → 加密存储 → `apiKeyHelper` → 旧版纯文本配置。
|
|
704
|
+
|
|
705
|
+
## 自动更新(Update)
|
|
706
|
+
|
|
707
|
+
```bash
|
|
708
|
+
oh update # 检测安装方式(npm 全局 / npx / 本地克隆),打印对应的升级命令
|
|
709
|
+
```
|
|
710
|
+
|
|
652
711
|
## 配置层级
|
|
653
712
|
|
|
654
713
|
配置按层加载(后者覆盖前者):
|
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,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 { spawn } from "node:child_process";
|
|
5
5
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
@@ -7,6 +7,7 @@ import { homedir, platform } from "node:os";
|
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
import { readOhConfig } from "../harness/config.js";
|
|
9
9
|
import { loadKeybindings } from "../harness/keybindings.js";
|
|
10
|
+
import { isTrusted, listTrusted, trust } from "../harness/trust.js";
|
|
10
11
|
const KEYBINDINGS_TEMPLATE = `[
|
|
11
12
|
{ "key": "ctrl+d", "action": "/diff" },
|
|
12
13
|
{ "key": "ctrl+l", "action": "/clear" },
|
|
@@ -174,6 +175,25 @@ export function registerSettingsCommands(register) {
|
|
|
174
175
|
register("vim", "Toggle Vim mode", () => {
|
|
175
176
|
return { output: "__TOGGLE_VIM__", handled: true };
|
|
176
177
|
});
|
|
178
|
+
register("trust", "Trust this workspace for shell hooks / status-line scripts (or list / add a path)", (args) => {
|
|
179
|
+
const arg = args.trim();
|
|
180
|
+
if (arg === "list") {
|
|
181
|
+
const trusted = listTrusted();
|
|
182
|
+
if (trusted.length === 0) {
|
|
183
|
+
return { output: "No trusted workspaces yet.\nRun `/trust` to add the current directory.", handled: true };
|
|
184
|
+
}
|
|
185
|
+
return { output: `Trusted workspaces:\n${trusted.map((d) => ` ${d}`).join("\n")}`, handled: true };
|
|
186
|
+
}
|
|
187
|
+
const target = arg || process.cwd();
|
|
188
|
+
if (isTrusted(target)) {
|
|
189
|
+
return { output: `Already trusted: ${target}`, handled: true };
|
|
190
|
+
}
|
|
191
|
+
trust(target);
|
|
192
|
+
return {
|
|
193
|
+
output: `Trusted: ${target}\nShell hooks and status-line scripts will now execute in this directory.`,
|
|
194
|
+
handled: true,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
177
197
|
register("login", "Set API key for current provider", (args, ctx) => {
|
|
178
198
|
const key = args.trim();
|
|
179
199
|
if (!key) {
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -223,5 +223,18 @@ export declare function readOhConfig(root?: string, sources?: readonly SettingSo
|
|
|
223
223
|
* Unknown names are silently dropped.
|
|
224
224
|
*/
|
|
225
225
|
export declare function parseSettingSources(raw: string | undefined): SettingSource[] | undefined;
|
|
226
|
+
/**
|
|
227
|
+
* Persist a single tool-allow rule (audit U-A2). Used by the "[A]lways"
|
|
228
|
+
* keypress in the permission prompt — the user has just approved a tool
|
|
229
|
+
* call and wants future calls to that tool to skip the prompt.
|
|
230
|
+
*
|
|
231
|
+
* No-ops when no project config exists (user is running on auto-detected
|
|
232
|
+
* settings; we don't auto-create `.oh/config.yaml` just to add a rule).
|
|
233
|
+
* De-dupes against an exact-tool rule with no pattern.
|
|
234
|
+
*
|
|
235
|
+
* Returns `true` if a rule was written, `false` if already present or
|
|
236
|
+
* skipped because no config exists.
|
|
237
|
+
*/
|
|
238
|
+
export declare function appendToolPermission(toolName: string, action?: "allow" | "deny", root?: string): boolean;
|
|
226
239
|
export declare function writeOhConfig(cfg: OhConfig, root?: string): void;
|
|
227
240
|
//# 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)
|
package/dist/harness/hooks.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { spawn, spawnSync } from "node:child_process";
|
|
13
13
|
import { debug } from "../utils/debug.js";
|
|
14
14
|
import { readOhConfig } from "./config.js";
|
|
15
|
+
import { isTrusted, trustSystemActive } from "./trust.js";
|
|
15
16
|
let cachedHooks;
|
|
16
17
|
export function getHooks() {
|
|
17
18
|
if (cachedHooks !== undefined)
|
|
@@ -403,6 +404,22 @@ async function runPromptHook(promptText, ctx, timeoutMs = 10_000) {
|
|
|
403
404
|
/** Execute a single hook definition. Returns true if allowed. */
|
|
404
405
|
async function executeHookDef(def, event, ctx) {
|
|
405
406
|
const timeout = def.timeout ?? 10_000;
|
|
407
|
+
// Workspace-trust gate (audit U-A4). Shell-executing hook types
|
|
408
|
+
// (`command`, `http`) require the cwd to be on the trust list — a fresh
|
|
409
|
+
// clone of a hostile repo can't auto-execute on first launch. Allowed by
|
|
410
|
+
// default for `prompt` hooks (LLM-only, no shell).
|
|
411
|
+
//
|
|
412
|
+
// Soft rollout: the gate is only enforced once the user has interacted
|
|
413
|
+
// with the trust system (i.e., `~/.oh/trusted-dirs.json` exists). Until
|
|
414
|
+
// then, existing behavior is preserved. The first session in a hooked
|
|
415
|
+
// workspace fires a startup prompt that creates the file — from that
|
|
416
|
+
// point on every other dir requires explicit trust.
|
|
417
|
+
if ((def.command || def.http) && trustSystemActive() && !isTrusted(process.cwd())) {
|
|
418
|
+
// Allow as if the hook didn't exist. The REPL surfaces a one-time
|
|
419
|
+
// prompt at session start when hooks are configured but the dir is
|
|
420
|
+
// untrusted; the user can also grant trust via `/trust`.
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
406
423
|
if (def.command) {
|
|
407
424
|
const env = buildEnv(event, ctx);
|
|
408
425
|
// JSON-mode (Claude Code convention): send `{event, ...ctx}` on stdin,
|
|
@@ -439,10 +456,17 @@ export function emitHook(event, ctx = {}) {
|
|
|
439
456
|
debug("hooks", "fire", { event, count: defs.length, tool: ctx.toolName });
|
|
440
457
|
const env = buildEnv(event, ctx);
|
|
441
458
|
if (event === "preToolUse") {
|
|
442
|
-
// preToolUse command hooks must be synchronous — they gate tool execution
|
|
459
|
+
// preToolUse command hooks must be synchronous — they gate tool execution.
|
|
460
|
+
// Workspace-trust gate (audit U-A4): once the trust system is active
|
|
461
|
+
// (file exists), shell-executing hooks in untrusted dirs act as absent.
|
|
462
|
+
// Soft rollout: when no trust file exists at all, treat as legacy mode
|
|
463
|
+
// and run all hooks normally.
|
|
464
|
+
const enforceTrust = trustSystemActive() && !isTrusted(process.cwd());
|
|
443
465
|
for (const def of defs) {
|
|
444
466
|
if (!matchesHook(def, ctx))
|
|
445
467
|
continue;
|
|
468
|
+
if ((def.command || def.http) && enforceTrust)
|
|
469
|
+
continue;
|
|
446
470
|
if (def.command) {
|
|
447
471
|
const input = def.jsonIO ? JSON.stringify({ event, ...ctx }) : undefined;
|
|
448
472
|
const result = spawnSync(def.command, {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-trust store (audit U-A4).
|
|
3
|
+
*
|
|
4
|
+
* OH lets users configure shell hooks (`.oh/config.yaml` `hooks:`) and
|
|
5
|
+
* arbitrary status-line scripts (Tier U-B1) that auto-execute as part of
|
|
6
|
+
* the session loop. That's a footgun for fresh-cloned projects — a hostile
|
|
7
|
+
* `.oh/config.yaml` could run shell on first launch.
|
|
8
|
+
*
|
|
9
|
+
* This module gates user-defined-shell execution on a one-time
|
|
10
|
+
* "trust this directory" prompt, persisted in `~/.oh/trusted-dirs.json`.
|
|
11
|
+
* The first time a hook or status-line script tries to run in an untrusted
|
|
12
|
+
* directory, the REPL pops a question; trusted dirs skip the prompt forever.
|
|
13
|
+
*
|
|
14
|
+
* Mirrors Claude Code's workspace-trust model. Per the prior audit's
|
|
15
|
+
* already-built check, OH had zero `trustedDirectories` matches anywhere —
|
|
16
|
+
* this is genuinely new.
|
|
17
|
+
*/
|
|
18
|
+
/** Check whether `dir` is trusted. Pure read — never prompts. */
|
|
19
|
+
export declare function isTrusted(dir: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Whether the user has ever interacted with the trust system. Used by the
|
|
22
|
+
* hook gate as a soft-rollout switch: before the file exists, we treat all
|
|
23
|
+
* dirs as trusted (legacy behavior — existing users not affected). Once
|
|
24
|
+
* the user grants trust to even one workspace, the gate switches on for
|
|
25
|
+
* every other dir. Mirrors the design pattern of "explicit opt-in once,
|
|
26
|
+
* enforce always after."
|
|
27
|
+
*
|
|
28
|
+
* Bypasses the in-memory cache so it picks up writes from a parallel
|
|
29
|
+
* process (e.g., `oh trust` run from another shell while a session is up).
|
|
30
|
+
*/
|
|
31
|
+
export declare function trustSystemActive(): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Mark `dir` as trusted. Idempotent — a second call is a no-op. Persists
|
|
34
|
+
* immediately so a process crash before the next prompt doesn't lose the
|
|
35
|
+
* grant.
|
|
36
|
+
*/
|
|
37
|
+
export declare function trust(dir: string): void;
|
|
38
|
+
/** List currently-trusted dirs. For diagnostics / `oh status`. */
|
|
39
|
+
export declare function listTrusted(): readonly string[];
|
|
40
|
+
/** @internal Test-only reset. */
|
|
41
|
+
export declare function _resetTrustForTest(): void;
|
|
42
|
+
//# sourceMappingURL=trust.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-trust store (audit U-A4).
|
|
3
|
+
*
|
|
4
|
+
* OH lets users configure shell hooks (`.oh/config.yaml` `hooks:`) and
|
|
5
|
+
* arbitrary status-line scripts (Tier U-B1) that auto-execute as part of
|
|
6
|
+
* the session loop. That's a footgun for fresh-cloned projects — a hostile
|
|
7
|
+
* `.oh/config.yaml` could run shell on first launch.
|
|
8
|
+
*
|
|
9
|
+
* This module gates user-defined-shell execution on a one-time
|
|
10
|
+
* "trust this directory" prompt, persisted in `~/.oh/trusted-dirs.json`.
|
|
11
|
+
* The first time a hook or status-line script tries to run in an untrusted
|
|
12
|
+
* directory, the REPL pops a question; trusted dirs skip the prompt forever.
|
|
13
|
+
*
|
|
14
|
+
* Mirrors Claude Code's workspace-trust model. Per the prior audit's
|
|
15
|
+
* already-built check, OH had zero `trustedDirectories` matches anywhere —
|
|
16
|
+
* this is genuinely new.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
const TRUST_FILE = join(homedir(), ".oh", "trusted-dirs.json");
|
|
22
|
+
let cached;
|
|
23
|
+
function loadStore() {
|
|
24
|
+
if (cached)
|
|
25
|
+
return cached;
|
|
26
|
+
if (!existsSync(TRUST_FILE)) {
|
|
27
|
+
cached = { trusted: [] };
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync(TRUST_FILE, "utf8");
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
if (Array.isArray(parsed.trusted)) {
|
|
34
|
+
cached = { trusted: parsed.trusted.filter((p) => typeof p === "string") };
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* malformed file — treat as empty so the user can re-grant */
|
|
40
|
+
}
|
|
41
|
+
cached = { trusted: [] };
|
|
42
|
+
return cached;
|
|
43
|
+
}
|
|
44
|
+
function saveStore(store) {
|
|
45
|
+
cached = store;
|
|
46
|
+
mkdirSync(dirname(TRUST_FILE), { recursive: true });
|
|
47
|
+
writeFileSync(TRUST_FILE, JSON.stringify({ trusted: store.trusted }, null, 2));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Normalize a directory for comparison. Resolves to an absolute path and
|
|
51
|
+
* lowercases on Windows (which is case-insensitive for paths). Other
|
|
52
|
+
* platforms keep case so distinct dirs that differ only in case are treated
|
|
53
|
+
* as distinct.
|
|
54
|
+
*/
|
|
55
|
+
function normalize(dir) {
|
|
56
|
+
const abs = resolve(dir);
|
|
57
|
+
return process.platform === "win32" ? abs.toLowerCase() : abs;
|
|
58
|
+
}
|
|
59
|
+
/** Check whether `dir` is trusted. Pure read — never prompts. */
|
|
60
|
+
export function isTrusted(dir) {
|
|
61
|
+
const store = loadStore();
|
|
62
|
+
const target = normalize(dir);
|
|
63
|
+
return store.trusted.some((t) => normalize(t) === target);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether the user has ever interacted with the trust system. Used by the
|
|
67
|
+
* hook gate as a soft-rollout switch: before the file exists, we treat all
|
|
68
|
+
* dirs as trusted (legacy behavior — existing users not affected). Once
|
|
69
|
+
* the user grants trust to even one workspace, the gate switches on for
|
|
70
|
+
* every other dir. Mirrors the design pattern of "explicit opt-in once,
|
|
71
|
+
* enforce always after."
|
|
72
|
+
*
|
|
73
|
+
* Bypasses the in-memory cache so it picks up writes from a parallel
|
|
74
|
+
* process (e.g., `oh trust` run from another shell while a session is up).
|
|
75
|
+
*/
|
|
76
|
+
export function trustSystemActive() {
|
|
77
|
+
return existsSync(TRUST_FILE);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Mark `dir` as trusted. Idempotent — a second call is a no-op. Persists
|
|
81
|
+
* immediately so a process crash before the next prompt doesn't lose the
|
|
82
|
+
* grant.
|
|
83
|
+
*/
|
|
84
|
+
export function trust(dir) {
|
|
85
|
+
const store = loadStore();
|
|
86
|
+
const target = normalize(dir);
|
|
87
|
+
if (store.trusted.some((t) => normalize(t) === target))
|
|
88
|
+
return;
|
|
89
|
+
saveStore({ trusted: [...store.trusted, dir] });
|
|
90
|
+
}
|
|
91
|
+
/** List currently-trusted dirs. For diagnostics / `oh status`. */
|
|
92
|
+
export function listTrusted() {
|
|
93
|
+
return loadStore().trusted;
|
|
94
|
+
}
|
|
95
|
+
/** @internal Test-only reset. */
|
|
96
|
+
export function _resetTrustForTest() {
|
|
97
|
+
cached = undefined;
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=trust.js.map
|
package/dist/renderer/index.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export declare class TerminalRenderer {
|
|
|
45
45
|
setCompanion(lines: string[] | null, color: string): void;
|
|
46
46
|
setStatusHints(text: string): void;
|
|
47
47
|
setBannerLines(lines: string[]): void;
|
|
48
|
-
setAutocomplete(suggestions: string[], index: number, descriptions?: string[]): void;
|
|
48
|
+
setAutocomplete(suggestions: string[], index: number, descriptions?: string[], categories?: string[]): void;
|
|
49
49
|
setStatusLine(text: string): void;
|
|
50
50
|
setContextWarning(warning: {
|
|
51
51
|
text: string;
|
|
@@ -82,7 +82,16 @@ export declare class TerminalRenderer {
|
|
|
82
82
|
clearLiveArea(): void;
|
|
83
83
|
onKeypress(handler: (key: KeyEvent) => void): void;
|
|
84
84
|
onAnimation(handler: (frame: number) => void): void;
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Handle permission prompt keys (Y/N/A/D).
|
|
87
|
+
* - Y / N: approve or deny this single call.
|
|
88
|
+
* - A: approve AND persist a `toolPermissions: { tool, action: "allow" }`
|
|
89
|
+
* rule to `.oh/config.yaml` so future calls to this tool skip the prompt
|
|
90
|
+
* entirely (audit U-A2). Mirrors Claude Code's "yes, don't ask again".
|
|
91
|
+
* - D: toggle inline diff (when available).
|
|
92
|
+
*
|
|
93
|
+
* Returns true if key was consumed.
|
|
94
|
+
*/
|
|
86
95
|
private handlePermissionKey;
|
|
87
96
|
/** Handle question prompt text input. Returns true if key was consumed. */
|
|
88
97
|
private handleQuestionKey;
|
package/dist/renderer/index.js
CHANGED
|
@@ -62,6 +62,7 @@ export class TerminalRenderer {
|
|
|
62
62
|
questionPrompt: null,
|
|
63
63
|
autocomplete: [],
|
|
64
64
|
autocompleteDescriptions: [],
|
|
65
|
+
autocompleteCategories: [],
|
|
65
66
|
autocompleteIndex: -1,
|
|
66
67
|
manualScroll: 0,
|
|
67
68
|
codeBlocksExpanded: false,
|
|
@@ -195,9 +196,10 @@ export class TerminalRenderer {
|
|
|
195
196
|
this.state.bannerLines = lines;
|
|
196
197
|
this.scheduleRender();
|
|
197
198
|
}
|
|
198
|
-
setAutocomplete(suggestions, index, descriptions) {
|
|
199
|
+
setAutocomplete(suggestions, index, descriptions, categories) {
|
|
199
200
|
this.state.autocomplete = suggestions;
|
|
200
201
|
this.state.autocompleteDescriptions = descriptions ?? [];
|
|
202
|
+
this.state.autocompleteCategories = categories ?? [];
|
|
201
203
|
this.state.autocompleteIndex = index;
|
|
202
204
|
this.scheduleRender();
|
|
203
205
|
}
|
|
@@ -366,20 +368,44 @@ export class TerminalRenderer {
|
|
|
366
368
|
this.animationCallback = handler;
|
|
367
369
|
}
|
|
368
370
|
// ── Input routing ──
|
|
369
|
-
/**
|
|
371
|
+
/**
|
|
372
|
+
* Handle permission prompt keys (Y/N/A/D).
|
|
373
|
+
* - Y / N: approve or deny this single call.
|
|
374
|
+
* - A: approve AND persist a `toolPermissions: { tool, action: "allow" }`
|
|
375
|
+
* rule to `.oh/config.yaml` so future calls to this tool skip the prompt
|
|
376
|
+
* entirely (audit U-A2). Mirrors Claude Code's "yes, don't ask again".
|
|
377
|
+
* - D: toggle inline diff (when available).
|
|
378
|
+
*
|
|
379
|
+
* Returns true if key was consumed.
|
|
380
|
+
*/
|
|
370
381
|
handlePermissionKey(key) {
|
|
371
382
|
if (!this.permissionResolve)
|
|
372
383
|
return false;
|
|
373
384
|
const k = key.char.toLowerCase();
|
|
374
|
-
if (k === "y" || k === "n") {
|
|
385
|
+
if (k === "y" || k === "n" || k === "a") {
|
|
375
386
|
const resolve = this.permissionResolve;
|
|
387
|
+
const toolName = this.state.permissionBox?.toolName;
|
|
376
388
|
this.permissionResolve = null;
|
|
377
389
|
this.permissionPrompt = null;
|
|
378
390
|
this.state.permissionBox = null;
|
|
379
391
|
this.state.permissionDiffVisible = false;
|
|
380
392
|
this.state.permissionDiffInfo = null;
|
|
393
|
+
// Persist before resolving — any error in the write should not block
|
|
394
|
+
// the resolution. The persist call itself is no-op when no .oh/config.yaml
|
|
395
|
+
// exists (we don't auto-create on first interaction).
|
|
396
|
+
if (k === "a" && toolName) {
|
|
397
|
+
try {
|
|
398
|
+
// Lazy import to avoid pulling config into the renderer bundle
|
|
399
|
+
// for callers that don't trip the permission path.
|
|
400
|
+
const { appendToolPermission } = require("../harness/config.js");
|
|
401
|
+
appendToolPermission(toolName);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
/* persistence failure must not block the agent */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
381
407
|
this.scheduleRender();
|
|
382
|
-
resolve(k === "y");
|
|
408
|
+
resolve(k === "y" || k === "a");
|
|
383
409
|
}
|
|
384
410
|
else if (k === "d" && this.state.permissionDiffInfo) {
|
|
385
411
|
this.state.permissionDiffVisible = !this.state.permissionDiffVisible;
|
|
@@ -626,6 +652,8 @@ export class TerminalRenderer {
|
|
|
626
652
|
if (this.state.statusLine)
|
|
627
653
|
rows += 1;
|
|
628
654
|
rows += this.state.autocomplete.length;
|
|
655
|
+
// Audit U-A3: one row per distinct category header.
|
|
656
|
+
rows += new Set((this.state.autocompleteCategories ?? []).filter((c) => c && c.length > 0)).size;
|
|
629
657
|
if (this.state.permissionBox) {
|
|
630
658
|
rows += 3;
|
|
631
659
|
if (this.state.permissionDiffVisible && this.state.permissionDiffInfo)
|
package/dist/renderer/input.js
CHANGED
|
@@ -84,6 +84,13 @@ export function parseKey(data, offset) {
|
|
|
84
84
|
return { event: key("", "pageup", seq.slice(0, 4)), consumed: 4 };
|
|
85
85
|
if (seq.startsWith("\x1b[6~"))
|
|
86
86
|
return { event: key("", "pagedown", seq.slice(0, 4)), consumed: 4 };
|
|
87
|
+
// Shift+Tab (xterm "backtab"): ESC [ Z. Used as a quick-toggle for
|
|
88
|
+
// permission mode (mirrors Claude Code's Shift+Tab cycler).
|
|
89
|
+
if (seq.startsWith("\x1b[Z"))
|
|
90
|
+
return {
|
|
91
|
+
event: { char: "", name: "tab", ctrl: false, meta: false, shift: true, sequence: seq.slice(0, 3) },
|
|
92
|
+
consumed: 3,
|
|
93
|
+
};
|
|
87
94
|
// Shift+Arrow: ESC [ 1 ; 2 A/B/C/D
|
|
88
95
|
if (seq.startsWith("\x1b[1;2A"))
|
|
89
96
|
return {
|
|
@@ -280,6 +280,13 @@ export function renderPermissionBoxSection(state, grid, nextRow, h, opts) {
|
|
|
280
280
|
kc += 1;
|
|
281
281
|
grid.writeText(nextRow, kc, "o", S_DIM);
|
|
282
282
|
kc += 1;
|
|
283
|
+
grid.writeText(nextRow, kc, " ", S_DIM);
|
|
284
|
+
kc += 2;
|
|
285
|
+
// Audit U-A2: "always allow this tool" — persists toolPermissions rule.
|
|
286
|
+
grid.writeText(nextRow, kc, "A", S_KEY_GREEN);
|
|
287
|
+
kc += 1;
|
|
288
|
+
grid.writeText(nextRow, kc, "lways", S_DIM);
|
|
289
|
+
kc += 5;
|
|
283
290
|
if (state.permissionDiffInfo) {
|
|
284
291
|
grid.writeText(nextRow, kc, " ", S_DIM);
|
|
285
292
|
kc += 2;
|
|
@@ -300,10 +307,12 @@ export function renderPermissionBoxSection(state, grid, nextRow, h, opts) {
|
|
|
300
307
|
grid.writeText(nextRow, 1, "Y", S_KEY_GREEN);
|
|
301
308
|
grid.writeText(nextRow, 2, "es ", S_DIM);
|
|
302
309
|
grid.writeText(nextRow, 6, "N", S_KEY_RED);
|
|
303
|
-
grid.writeText(nextRow, 7, "o", S_DIM);
|
|
310
|
+
grid.writeText(nextRow, 7, "o ", S_DIM);
|
|
311
|
+
grid.writeText(nextRow, 10, "A", S_KEY_GREEN);
|
|
312
|
+
grid.writeText(nextRow, 11, "lways", S_DIM);
|
|
304
313
|
if (state.permissionDiffInfo) {
|
|
305
|
-
grid.writeText(nextRow,
|
|
306
|
-
grid.writeText(nextRow,
|
|
314
|
+
grid.writeText(nextRow, 18, "D", S_KEY_CYAN);
|
|
315
|
+
grid.writeText(nextRow, 19, "iff", S_DIM);
|
|
307
316
|
}
|
|
308
317
|
nextRow++;
|
|
309
318
|
if (state.permissionDiffVisible && state.permissionDiffInfo && nextRow + 3 < h) {
|
|
@@ -375,7 +384,20 @@ export function renderAutocompleteSection(state, grid, nextRow, limit, promptWid
|
|
|
375
384
|
if (state.autocomplete.length === 0)
|
|
376
385
|
return nextRow;
|
|
377
386
|
const w = grid.width;
|
|
387
|
+
let lastCategory = "";
|
|
378
388
|
for (let ai = 0; ai < state.autocomplete.length; ai++) {
|
|
389
|
+
if (nextRow >= limit)
|
|
390
|
+
break;
|
|
391
|
+
// Category header — draw whenever the category changes between entries.
|
|
392
|
+
// First-entry header is drawn when the category is non-empty (audit U-A3).
|
|
393
|
+
const cat = state.autocompleteCategories?.[ai] ?? "";
|
|
394
|
+
if (cat && cat !== lastCategory) {
|
|
395
|
+
if (nextRow >= limit)
|
|
396
|
+
break;
|
|
397
|
+
grid.writeText(nextRow, promptWidth, `── ${cat} ──`, S_DIM);
|
|
398
|
+
nextRow++;
|
|
399
|
+
lastCategory = cat;
|
|
400
|
+
}
|
|
379
401
|
if (nextRow >= limit)
|
|
380
402
|
break;
|
|
381
403
|
const cmd = state.autocomplete[ai];
|
|
@@ -57,6 +57,14 @@ export type LayoutState = {
|
|
|
57
57
|
} | null;
|
|
58
58
|
autocomplete: string[];
|
|
59
59
|
autocompleteDescriptions: string[];
|
|
60
|
+
/**
|
|
61
|
+
* Optional category label per autocomplete entry (audit U-A3). When two
|
|
62
|
+
* adjacent entries differ in category, the renderer draws a header line
|
|
63
|
+
* before the second. Empty / missing category strings render flat (the
|
|
64
|
+
* pre-A3 behavior). Optional so older test fixtures + non-REPL callers
|
|
65
|
+
* don't need to thread an empty array.
|
|
66
|
+
*/
|
|
67
|
+
autocompleteCategories?: string[];
|
|
60
68
|
autocompleteIndex: number;
|
|
61
69
|
manualScroll: number;
|
|
62
70
|
codeBlocksExpanded: boolean;
|
package/dist/renderer/layout.js
CHANGED
|
@@ -28,7 +28,11 @@ export function rasterize(state, grid) {
|
|
|
28
28
|
const questionHeight = state.questionPrompt ? 4 + (state.questionPrompt.options?.length ?? 0) : 0;
|
|
29
29
|
const statusLineHeight = state.statusLine ? 1 : 0;
|
|
30
30
|
const contextWarningHeight = state.contextWarning ? 1 : 0;
|
|
31
|
-
|
|
31
|
+
// Autocomplete height — each entry is one row, plus one extra row per
|
|
32
|
+
// distinct category (audit U-A3 header lines). Distinct-category count
|
|
33
|
+
// is bounded by entry count so this stays cheap.
|
|
34
|
+
const distinctCategories = new Set((state.autocompleteCategories ?? []).filter((c) => c && c.length > 0));
|
|
35
|
+
const autocompleteHeight = state.autocomplete.length + distinctCategories.size;
|
|
32
36
|
const inputLineCount = Math.min(5, (state.inputText.match(/\n/g)?.length ?? 0) + 1);
|
|
33
37
|
const rawFooterHeight = Math.max(2 + inputLineCount + statusLineHeight + autocompleteHeight, companionHeight + 1) +
|
|
34
38
|
permissionHeight +
|
package/dist/repl.js
CHANGED
|
@@ -105,6 +105,9 @@ export async function startREPL(config) {
|
|
|
105
105
|
let fastMode = s().fastMode;
|
|
106
106
|
let acSuggestions = s().acSuggestions;
|
|
107
107
|
let acDescriptions = s().acDescriptions;
|
|
108
|
+
// Audit U-A3: parallel category array for the picker. Local-only — no
|
|
109
|
+
// need to round-trip through `store` since no other consumer reads it.
|
|
110
|
+
let acCategories = [];
|
|
108
111
|
let acIndex = s().acIndex;
|
|
109
112
|
let acTokenStart = s().acTokenStart;
|
|
110
113
|
let acIsPath = s().acIsPath;
|
|
@@ -128,13 +131,16 @@ export async function startREPL(config) {
|
|
|
128
131
|
function updateAutocomplete() {
|
|
129
132
|
acIsPath = false;
|
|
130
133
|
if (inputText.startsWith("/") && inputText.length > 1 && !inputText.includes(" ")) {
|
|
131
|
-
// Slash command autocomplete
|
|
134
|
+
// Slash command autocomplete — entries arrive in registration order
|
|
135
|
+
// (Session → Git → Info → Settings → AI → Skills → MCP), so categories
|
|
136
|
+
// are naturally contiguous after a startsWith filter (audit U-A3).
|
|
132
137
|
const prefix = inputText.slice(1).toLowerCase();
|
|
133
138
|
const entries = getCommandEntries()
|
|
134
139
|
.filter((e) => e.name.startsWith(prefix))
|
|
135
|
-
.slice(0,
|
|
140
|
+
.slice(0, 8);
|
|
136
141
|
acSuggestions = entries.map((e) => e.name);
|
|
137
142
|
acDescriptions = entries.map((e) => e.description);
|
|
143
|
+
acCategories = entries.map((e) => e.category);
|
|
138
144
|
acTokenStart = 0;
|
|
139
145
|
acIndex = -1;
|
|
140
146
|
}
|
|
@@ -176,26 +182,30 @@ export async function startREPL(config) {
|
|
|
176
182
|
return "";
|
|
177
183
|
}
|
|
178
184
|
});
|
|
185
|
+
acCategories = [];
|
|
179
186
|
acIsPath = acSuggestions.length > 0;
|
|
180
187
|
}
|
|
181
188
|
catch {
|
|
182
189
|
acSuggestions = [];
|
|
183
190
|
acDescriptions = [];
|
|
191
|
+
acCategories = [];
|
|
184
192
|
}
|
|
185
193
|
acIndex = -1;
|
|
186
194
|
}
|
|
187
195
|
else {
|
|
188
196
|
acSuggestions = [];
|
|
189
197
|
acDescriptions = [];
|
|
198
|
+
acCategories = [];
|
|
190
199
|
acIndex = -1;
|
|
191
200
|
}
|
|
192
201
|
}
|
|
193
202
|
else {
|
|
194
203
|
acSuggestions = [];
|
|
195
204
|
acDescriptions = [];
|
|
205
|
+
acCategories = [];
|
|
196
206
|
acIndex = -1;
|
|
197
207
|
}
|
|
198
|
-
renderer.setAutocomplete(acSuggestions, acIndex, acDescriptions);
|
|
208
|
+
renderer.setAutocomplete(acSuggestions, acIndex, acDescriptions, acCategories);
|
|
199
209
|
}
|
|
200
210
|
// Companion
|
|
201
211
|
let companionVisible = true;
|
|
@@ -516,6 +526,14 @@ export async function startREPL(config) {
|
|
|
516
526
|
}
|
|
517
527
|
if (key.name === "pageup" || key.name === "pagedown" || key.name === "mouse")
|
|
518
528
|
return;
|
|
529
|
+
// Shift+Tab: cycle permission mode (audit U-A1). Mirrors Claude Code's
|
|
530
|
+
// quick-toggle. Cycles ask → acceptEdits → plan → trust → ask. The
|
|
531
|
+
// session-level mode is mutated on `config` so all downstream callers
|
|
532
|
+
// (`query()`, `cronExecutor`, status line) read the new value.
|
|
533
|
+
if (key.name === "tab" && key.shift) {
|
|
534
|
+
cyclePermissionMode();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
519
537
|
// Tab: autocomplete slash commands or file paths, or cycle tool call expansion
|
|
520
538
|
if (key.name === "tab" && !loading) {
|
|
521
539
|
if (acSuggestions.length > 0) {
|
|
@@ -533,7 +551,7 @@ export async function startREPL(config) {
|
|
|
533
551
|
}
|
|
534
552
|
renderer.setInputText(inputText);
|
|
535
553
|
renderer.setInputCursor(inputCursor);
|
|
536
|
-
renderer.setAutocomplete(acSuggestions, acIndex, acDescriptions);
|
|
554
|
+
renderer.setAutocomplete(acSuggestions, acIndex, acDescriptions, acCategories);
|
|
537
555
|
return;
|
|
538
556
|
}
|
|
539
557
|
renderer.cycleToolCallExpansion();
|
|
@@ -621,6 +639,26 @@ export async function startREPL(config) {
|
|
|
621
639
|
acIsPath,
|
|
622
640
|
});
|
|
623
641
|
});
|
|
642
|
+
/**
|
|
643
|
+
* Cycle the session permission mode (audit U-A1, Shift+Tab). The cycle
|
|
644
|
+
* intentionally covers the four interactive modes a user is likely to
|
|
645
|
+
* toggle between — `ask`, `acceptEdits`, `plan`, `trust`. The other modes
|
|
646
|
+
* (`deny`, `auto`, `bypassPermissions`) stay reachable via `/permissions
|
|
647
|
+
* <mode>` but aren't on the quick-cycle path because they're either
|
|
648
|
+
* destructive (`bypassPermissions`) or seldom-used.
|
|
649
|
+
*
|
|
650
|
+
* Mutates `config.permissionMode` directly so every existing read site
|
|
651
|
+
* (the `query()` call sites, `cronExecutor`, status hints) sees the new
|
|
652
|
+
* value without extra plumbing.
|
|
653
|
+
*/
|
|
654
|
+
function cyclePermissionMode() {
|
|
655
|
+
const cycle = ["ask", "acceptEdits", "plan", "trust"];
|
|
656
|
+
const idx = cycle.indexOf(config.permissionMode);
|
|
657
|
+
const next = cycle[(idx === -1 ? 0 : idx + 1) % cycle.length];
|
|
658
|
+
config.permissionMode = next;
|
|
659
|
+
messages.push(createInfoMessage(`Permission mode → ${next}`));
|
|
660
|
+
syncRenderer();
|
|
661
|
+
}
|
|
624
662
|
function navigateHistory(dir) {
|
|
625
663
|
if (dir < 0 && historyIndex < inputHistory.length - 1) {
|
|
626
664
|
historyIndex++;
|
|
@@ -1063,5 +1101,39 @@ export async function startREPL(config) {
|
|
|
1063
1101
|
renderer.start();
|
|
1064
1102
|
// Banner is already printed to stdout by main.tsx (visible in terminal scrollback)
|
|
1065
1103
|
syncRenderer();
|
|
1104
|
+
// Workspace-trust prompt (audit U-A4). Fires once per session when:
|
|
1105
|
+
// - the cwd isn't already on the trust list, AND
|
|
1106
|
+
// - `.oh/config.yaml` defines at least one shell-executing hook
|
|
1107
|
+
// (command/http) — `prompt` hooks don't trip the gate.
|
|
1108
|
+
// Untrusted cwd silently skips command/http hooks via the gate in
|
|
1109
|
+
// `harness/hooks.ts`. The prompt is non-blocking: we fire-and-forget
|
|
1110
|
+
// the askQuestion so the REPL stays responsive while the question is
|
|
1111
|
+
// displayed.
|
|
1112
|
+
void (async () => {
|
|
1113
|
+
try {
|
|
1114
|
+
const { isTrusted, trust } = await import("./harness/trust.js");
|
|
1115
|
+
if (isTrusted(process.cwd()))
|
|
1116
|
+
return;
|
|
1117
|
+
const cfgWithHooks = readOhConfig();
|
|
1118
|
+
const hooks = cfgWithHooks?.hooks;
|
|
1119
|
+
if (!hooks)
|
|
1120
|
+
return;
|
|
1121
|
+
const hasShellHook = Object.values(hooks).some((defs) => Array.isArray(defs) && defs.some((d) => d.command || d.http));
|
|
1122
|
+
if (!hasShellHook)
|
|
1123
|
+
return;
|
|
1124
|
+
const answer = await renderer.askQuestion(`Trust this workspace? Shell hooks are configured in ${process.cwd()}. (yes/no)`);
|
|
1125
|
+
if (answer.toLowerCase().startsWith("y")) {
|
|
1126
|
+
trust(process.cwd());
|
|
1127
|
+
messages.push(createInfoMessage(`Trusted ${process.cwd()} — shell hooks will now execute.`));
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
messages.push(createInfoMessage(`Workspace not trusted — shell hooks are silently skipped. Run /trust to grant.`));
|
|
1131
|
+
}
|
|
1132
|
+
syncRenderer();
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
/* trust prompt is best-effort; never block the REPL */
|
|
1136
|
+
}
|
|
1137
|
+
})();
|
|
1066
1138
|
}
|
|
1067
1139
|
//# sourceMappingURL=repl.js.map
|