Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .claude/skills/run-wk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ stdout: \t\tgit push

**1 つ目のフィールドは区切り文字そのもの**なので、buffer の前に区切り文字が 2 個並ぶ。`src/run.ts` が `[delimiter, buffer, ...].join(delimiter)` を出しており、1 個目はフィールドの中身、2 個目は join の区切り。受け側の `src/widget.eta` が `${res:2}` で捨てているのはこの 2 つ。末尾に改行が 1 個付く (`console.log` 由来、driver の `stdout:` 行では落としている)。

**`key:value` の並びは YAML の記述順**。`src/run.ts` `...rest` を `Object.entries()` で回すため、`accept` を先に書けば `accept:true` が先に出る。受け側が並び順を前提にすると壊れる。**`false` も省略されない** — 出るかどうかを決めるのは値ではなくキーが YAML にあるかどうかで、`eval: false` は `eval:false` として出る (キーごと省いた場合とは別物)。
**`key:value` の並び順は保証しない。** `src/run.ts` `...rest` を `Object.entries()` で回すが、その `rest` は zod が組み直したオブジェクトなので、schema が名前で持つ `eval` / `accept` が schema の順で先に出て、それ以外の追加フィールドがファイル順で続く (`accept` を先に書いても `eval:true accept:true` の順)。受け側は位置ではなく `key:` で引くこと — `src/widget.eta` の `reply[(rb:2:)eval:*]` がそうしている。**`false` も省略されない** — 出るかどうかを決めるのは値ではなくキーが YAML にあるかどうかで、`eval: false` は `eval:false` として出る (キーごと省いた場合とは別物)。

**区切り文字は、その buffer に出てこない 1 文字にする。** 受け側の `${(@ps:$delimiter:)...}` は buffer 内の同じ文字も境界として split するので、既定のタブのままだと `buffer: "echo a\tb"` は `BUFFER=[echo a]` になる。バインディング単位の `delimiter` (`schemas/bindings.json`、1 文字) はこのためにあるが、**与えれば直るのではなく、衝突しない文字を選んで初めて直る** — 同じ buffer に `delimiter: 'b'` を与えると今度は `BUFFER=[echo a<TAB>]` で切れる。config 全体の `outputDelimiter` ではなくバインディング単位で与えるのが正しい形。
**区切り文字は、その buffer に出てこない 1 文字にする。** 受け側の `${(@ps:$delimiter:)...}` は buffer 内の同じ文字も境界として split するので、既定のタブのままだと `buffer: "echo a\tb"` は `BUFFER=[echo a]` になる。バインディング単位の `delimiter` (`src/schema.ts`、1 文字) はこのためにあるが、**与えれば直るのではなく、衝突しない文字を選んで初めて直る** — 同じ buffer に `delimiter: 'b'` を与えると今度は `BUFFER=[echo a<TAB>]` で切れる。config 全体の `outputDelimiter` ではなくバインディング単位で与えるのが正しい形。

既定フィクスチャの区切り文字はタブなので、`\t\t` を見ても「1 つ目のフィールドだから 2 個」なのか「buffer にタブがある」のか区別できない。撃ち分けるには `delimiter: '|'` を持つバインディングを `$SP` に置く (`stdout: ||echo hi|eval:true` のように先頭 2 文字も追従する)。生バイトは `TMPDIR=$SP WK_KEEP=1` で残るサンドボックスの `out` / `err` / `status` で見る (パスは stderr に出る)。

Expand Down Expand Up @@ -72,7 +72,7 @@ $ WK_BINDINGS=e2e/fixtures/nested.bindings.yaml ./.claude/skills/run-wk/driver.s

パンくずに並ぶのは desc ではなく**押したキー**。**`type: command` を選ぶと `wk run` が終了する = ペインも消える**ので、リーフまで降りると画面は残らない。これは成功で、出力は `keys` で見る。

画面に出る記号はどれも `config.yaml` の `symbols` で差し替えられる (`src/types/Context.ts` の `defaultContext`)。
画面に出る記号はどれも `config.yaml` の `symbols` で差し替えられる (`src/schema.ts` の `defaultContext`)。

| 画面上 | `symbols` のキー | 既定値 | 出る場所 |
|---|---|---|---|
Expand All @@ -88,7 +88,7 @@ $ WK_BINDINGS=e2e/fixtures/nested.bindings.yaml ./.claude/skills/run-wk/driver.s

`timeout` のように**時間で消えるものは、キーごとの待ち (`WK_SETTLE`、既定 0.4 秒) より短ければ写らない。** 消えたのか出なかったのかは `keys` に流せば分かる (exit 4)。

driver が 125 で止まる 2 つのメッセージは意味が正反対で、**ペインが生きているか**と所要時間で見分ける。`wk exited before drawing a menu` はペインが死んでいて即座 — wk のクラッシュか即終了で、原因は下の 3 つと**ポーリング粒度 0.05 秒より短い `timeout`** (この場合は一度描いてから消しているので字面と実態がずれる)。`menu never appeared` はペインが生きたまま 5 秒 — wk は正常にキー待ちで、`keys <key>` を送れば応答する。
driver が 125 で止まる 2 つのメッセージは意味が正反対で、**ペインが生きているか**と所要時間で見分ける。`wk exited before drawing a menu` はペインが死んでいて即座 — wk のクラッシュか即終了で、原因は「弾かれる入力・落ちる入力」の節のものと**ポーリング粒度 0.05 秒より短い `timeout`** (この場合は一度描いてから消しているので字面と実態がずれる)。`menu never appeared` はペインが生きたまま 5 秒 — wk は正常にキー待ちで、`keys <key>` を送れば応答する。

## widget — zsh の BUFFER まで

Expand Down Expand Up @@ -128,13 +128,13 @@ $ WK_LEADER='^O' WK_PRETYPE=',' ./.claude/skills/run-wk/driver.sh widget t
BUFFER=[make test] CURSOR=[9]
```

## 落ちる入力
## 弾かれる入力・落ちる入力

どれも exit 1 で、`screen` では `wk exited before drawing a menu` に見える
**設定ファイルの不備は落ちずに exit 7 になる。** stderr は `<ファイル>: <パス>: <期待>` の 1 行で、パスが悪いフィールドまで案内する (`.../bindings.yaml: [0].bindings[0].key: expected a key name or a digit 0-9`)。`screen` からは `wk exited before drawing a menu` に見えるだけなので、**原因は `keys` に流して stderr を読む。** 配列でない `bindings.yaml`、`desc` の無い `type: bindings`、`bindings:` にスカラを置いたグループ、`buffer: 42`、`colors.prompt: {}`、`outputDelimiter: 42` — 描画時にスタックトレースを吐いていた入力は全部ここに畳まれている。グローバルとローカルで症状が変わることも無い

- **配列でない `bindings.yaml`** (スカラや mapping) — **グローバルとローカルで症状が違う。** `src/run.ts` は `globalBindings.concat(localBindings)` の形なので、グローバル側が配列でなければ `s.concat is not a function` で即死する。ローカル側は `concat()` の引数なので**落ちずに 1 要素として連結され**、描画時に `Cannot read properties of undefined (reading 'replace')` になる。パースは通るため `.catch()` はどちらでも発火しない
- **`desc` の無い `type: bindings`** — `Cannot read properties of undefined (reading 'replace')`。スキーマ違反として弾かれるのではなく**そのグループを描く瞬間に落ちる**ので、最上段なら起動直後、下の階層ならそこへ降りたとき。フィクスチャの必須フィールドは `key` / `type` / `buffer` の 3 つだけ (`schemas/bindings.json`) だが、グループの `desc` はこの通り実質必須。**この `reading 'replace'` は 1 つ上の「配列でないローカル `wk.bindings.yaml`」と同じ字面**なので、どちらか決め打ちせず両方のファイルを見る。
- **`--inputs ' '`** — 空白 split の結果が空文字列 2 つになりキーコードパーサが落ちる。スペースキーは `\x20`。
**`key` はクォート無しの数字でも通る。** YAML が数値にした `0`〜`9` は wk が文字列に戻す。**クォートが要るのは YAML のインジケータ文字だけ** — `.` / `$` / `(` / `)` / `+` / `/` / `;` / `<` / `=` / `\` / `^` / `_` はそのまま通る。クォート無しだと `~` / `!` / `?` / `#` は null になって wk が弾き、`"` / `%` / `&` / `'` / `*` / `,` / `-` / `:` / `@` / `[` / `]` / `` ` `` / `{` / `}` は YAML 側の構文エラー、`>` / `|` はブロックスカラ扱いで空文字列になって wk が弾く。どれも exit 7 だが、構文エラーの文言だけパーサ由来

**exit 1 で落ちるのは `--inputs ' '` だけ。** 空白 split の結果が空文字列 2 つになりキーコードパーサが落ちる。スペースキーは `\x20`。

**空ファイルは落ちない。** 空ドキュメント (0 バイト・改行だけ・空白だけ・コメントだけ・`---`・`null`・`~`) は `src/run.ts` の `loadYaml()` が不在ファイルと同じ扱いに畳む。bindings なら全キー未定義 = exit 5、config なら既定値。

Expand Down
7 changes: 4 additions & 3 deletions .claude/skills/run-wk/driver.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
# WK_SETTLE seconds to wait after each key (default: 0.4 screen, 0.6 widget)
#
# Exit status:
# `keys` exits with wk's own exit code (0/1/2/3/4/5/6, or 124 when wk was
# `keys` exits with wk's own exit code (0/1/2/3/4/5/6/7, or 124 when wk was
# still waiting for a key), so `driver.sh keys g p || ...` works from a
# script. 1 is an uncaught error and is reachable from ordinary fixtures.
# The other subcommands exit 0 on success. A driver-level failure — no binary,
Expand Down Expand Up @@ -249,7 +249,7 @@ wait_for_screen() {

# capture-pane strips trailing whitespace and tmux has already resolved the ANSI
# colours away. The first line looks empty but is not: the default prompt symbol
# in src/types/Context.ts is a Nerd Font glyph (U+F460), which most terminals and
# in src/schema.ts is a Nerd Font glyph (U+F460), which most terminals and
# every grep-based assertion render as nothing useful. Match on a binding row
# instead of the prompt.
# The widget layer's startup marker (below) is scaffolding, not evidence; drop
Expand Down Expand Up @@ -375,12 +375,13 @@ cmd_keys() {
explain_exit() {
case "$1" in
0) echo '(selected a command)' ;;
1) echo '(uncaught error — read stderr; a non-array bindings.yaml and a blank --inputs both land here)' ;;
1) echo '(uncaught error — read stderr; a blank --inputs lands here)' ;;
2) echo '(bad CLI arguments)' ;;
3) echo '(abort: escape / ctrl-c / ctrl-d / backspace at root)' ;;
4) echo '(timeout — config.yaml timeout elapsed)' ;;
5) echo '(undefined key)' ;;
6) echo '(key parse failure)' ;;
7) echo '(bad config.yaml or bindings.yaml — stderr names the file and the field)' ;;
124) echo '(driver timeout: wk was still waiting for a key)' ;;
*) echo '(unknown)' ;;
esac
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ jobs:
- name: Run build
run: mise run build

# Also exercises schema generation and packaging, so that a broken
# release tarball is caught before the tag is pushed.
- name: Run dist
run: mise run dist x86_64-unknown-linux-gnu

e2e:
name: E2E
runs-on: ubuntu-24.04
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ jobs:
- name: Setup project
uses: ./.github/actions/setup-project

- name: Run build
run: VERSION=${{ github.ref_name }} mise run build:internal ${{ matrix.target }}
- name: Run dist
run: VERSION=${{ github.ref_name }} mise run dist ${{ matrix.target }}

- name: Upload binary
- name: Upload tarball
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binary-${{ matrix.target }}
path: dist/wk-${{ matrix.target }}
name: tarball-${{ matrix.target }}
path: dist/wk-${{ matrix.target }}.tar.gz
if-no-files-found: error
retention-days: 1

Expand Down Expand Up @@ -84,10 +84,10 @@ jobs:
permissions:
contents: write
steps:
- name: Download binaries
- name: Download tarballs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binary-*
pattern: tarball-*
path: dist
merge-multiple: true

Expand All @@ -97,6 +97,6 @@ jobs:
name: release-notes

- name: Create draft release
run: gh release create ${{ github.ref_name }} dist/wk-* --repo ${{ github.repository }} --draft --title ${{ github.ref_name }} --notes-file release-notes.md --verify-tag
run: gh release create ${{ github.ref_name }} dist/wk-*.tar.gz --repo ${{ github.repository }} --draft --title ${{ github.ref_name }} --notes-file release-notes.md --verify-tag
env:
GH_TOKEN: ${{ github.token }}
14 changes: 8 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,19 @@ zsh向けのwhich-keyライクメニュー。Deno製CLI (`src/wk.ts`) と、そ
- 終了コード: 0成功/3中断/4タイムアウト/5未定義キー/6キーパース失敗/7設定ファイル不正。`widget.eta` の `case` がこれで分岐し、それ以外は `zle -M` でエラー表示に回る。
- エラー種別を増やすときは `src/errors.ts`・`run.ts` のcatch・`widget.eta` のcaseをセットで触る。

## binding/config のスキーマは 3 箇所にある
## スキーマは `src/schema.ts` が SSoT

`key`/`desc`/`buffer` などのフィールドを増減させたら、次の3箇所を揃える
`key`/`desc`/`buffer` などのフィールドを増減させたら、触るのは `src/schema.ts` と `README.md` の設定例だけ

- `src/types/Binding.ts`, `src/types/Context.ts` (型と既定値)
- `schemas/bindings.json`, `schemas/config.json` (ユーザー向けJSON Schema)
- `README.md` の設定例
- `src/schema.ts` — `@zod/mini` のスキーマ。型 (`Binding`/`Command`/`Context`/`Color`) は `z.infer` で導出、既定値は `z._default`/`z.prefault` が持ち、`defaultContext` は `ContextSchema.parse({})`。手書きの型定義は無い。
- 配布用JSON Schemaはコミットしていない。`mise run generate:schemas` が `dist/schemas/` に吐き、`mise run dist <target>` がリリースtarball (`wk-<target>/{wk,LICENSE,schemas/}`) に詰める。生成は `io: 'input'` — ユーザーが書ける形を出す。
- `@zod/mini` はロケールを積まないので、`error` を渡し忘れた検査は `Invalid input` になる。フィールドを足したら必ず `expected(...)` を渡し、既存の入力を一通り流して `Invalid input` が出ないことを見る。
- 設定エラーは `<ファイル>: <パス>: <期待>` の1行で exit 7。パスはzodのissueの `path` から `[0].bindings[0].key` の形に組む。
- `key` は文字列だが、YAMLがクォート無しの数字を数値にするため `0`〜`9` の整数も受けて `String()` で正規化する。クォートが要るのはYAMLのインジケータ文字だけ (nullになるか構文エラー) で、`.` のような大半の記号はそのまま通る。

## 検証

静的チェックは `mise run check` にまとまっている。`VERSION` はgitignoreされた生成物で `wk.ts` がraw-importしているため、素の `deno check src/wk.ts` は `TS2307` で落ちる。型チェックは `mise run check:type` 経由で走らせる。依存の挙動を単発スクリプトで確かめるときは `deno run --config deno.jsonc <file>` — import map がここにあるので、渡さないと `@cliffy/*` が解決できずに落ちる。`src/main.ts` はキー入力ループを `Dependencies` で注入する形になっているが、まだ差し替え先が存在しない。
静的チェックは `mise run check` にまとまっている。`VERSION` はgitignoreされた生成物で `wk.ts` がraw-importしているため、素の `deno check src/wk.ts` は `TS2307` で落ちる。型チェックは `mise run check:type` 経由で走らせる。依存の挙動を単発スクリプトで確かめるときは `deno run --config deno.jsonc <file>` — import map がここにあるので、渡さないと `@cliffy/*` や `@zod/mini` が解決できずに落ちる。`deno.jsonc` は `lock.frozen` なので、依存を足したら `deno install --frozen=false`。`src/main.ts` はキー入力ループを `Dependencies` で注入する形になっているが、まだ差し替え先が存在しない。

e2eテストが `e2e/` にある。`mise run e2e` でバイナリをビルドしてから走らせ、`mise run e2e:only` は `dist/` の既存バイナリをそのまま使う (パスを渡せば1ファイルだけ — `mise run e2e:only e2e/tests/06_widget.bats`)。`exec format error` は `WK_E2E_TARGET` とバイナリのアーキ不一致。Docker (zsh/tmux/bats-core、Denoは入れない) の中でbats-coreを回し、対象バイナリは `WK_BIN` で受け取る黒箱テスト。将来Denoをやめても受け入れ仕様として使い回せるよう、テスト側からDenoを参照しないこと。層は3つ — `script(1)` でptyを張ってCLIを直叩き (`helpers/common.bash` の `wk_run`)、TTY不要の `wk init`、tmuxで実zshウィジェットを動かす (`helpers/tmux.bash`)。ユニットテストは無い。

Expand Down
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,31 @@

## :package: Installation

1. Download the latest release and put into your `$PATH`:
1. Download the archive for your platform from the latest release and extract it:

<https://github.com/844196/wk/releases/latest>

2. Activate in `$ZDOTDIR/.zshrc`:
```shell
tar xzf wk-x86_64-unknown-linux-gnu.tar.gz
```

It holds the `wk` binary, the JSON Schemas for the configuration files, and the license.

2. Put the binary into your `$PATH`:

```shell
install -m 755 wk-x86_64-unknown-linux-gnu/wk ~/.local/bin/
```

3. Activate in `$ZDOTDIR/.zshrc`:

```shell
# Bind space as the leader key and comma as the major-leader key.
# The major-prefix "m" is used for the major menu.
eval "$(wk init --leader ' ' --major-leader ',' --major-prefix 'm')"
```

3. Restart zsh.
4. Restart zsh.

> [!TIP]
> If you want to register only the widgets, change it as follows:
Expand Down Expand Up @@ -59,7 +71,11 @@ colors:
bindingDescription: 8
```

See [schemas/config.json](./schemas/config.json) for more details.
Point your editor at the schema in the extracted archive to get completion and validation:

```yaml
# yaml-language-server: $schema=/path/to/wk-x86_64-unknown-linux-gnu/schemas/config.json
```

### Global bindings

Expand Down Expand Up @@ -100,7 +116,16 @@ See [schemas/config.json](./schemas/config.json) for more details.
accept: true
```

See [schemas/bindings.json](./schemas/bindings.json) for more details.
Point your editor at the schema in the extracted archive to get completion and validation:

```yaml
# yaml-language-server: $schema=/path/to/wk-x86_64-unknown-linux-gnu/schemas/bindings.json
```

A `key` is the key to press. YAML reads an unquoted digit as a number, and wk reads it back as that
digit, so `key: 1` and `key: '1'` both bind the `1` key. Most punctuation needs no quoting either —
`key: .` above is one. YAML's indicator characters do, though: unquoted they are read as null or
rejected outright, so write `key: '~'` and `key: ':'`.

### Local bindings

Expand Down
1 change: 1 addition & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@std/fmt": "jsr:@std/fmt@1.0.3",
"@std/path": "jsr:@std/path@1.0.8",
"@std/yaml": "jsr:@std/yaml@1.0.5",
"@zod/mini": "jsr:@zod/zod@4.5.4/mini",
"xdg-basedir": "npm:xdg-basedir@5.1.0"
},
"unstable": ["raw-imports"]
Expand Down
Loading