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
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ CLAUDE.md

# Auto-generated context files
.context/

# Playwright MCP session artifacts
.playwright-mcp/
31 changes: 17 additions & 14 deletions .zsh/configs/pre/devcontainer-env.zsh
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
# Expose selected shared local secrets to CLI tools such as Codex MCP servers.
# NOTE: This list is duplicated in nix/home/zsh.nix (home.file). Keep both in sync.
# Clear previously exported tokens so removed entries in ~/.devcontainer.env don't linger.
unset SUPABASE_ACCESS_TOKEN VERCEL_TOKEN LINEAR_API_KEY DOPPLER_TOKEN
if [[ -r "$HOME/.devcontainer.env" ]]; then
while IFS='=' read -r _codex_env_key _codex_env_value || [[ -n $_codex_env_key ]]; do
# Strip trailing CR so Windows-style CRLF files work correctly.
_codex_env_value="${_codex_env_value%$'\r'}"
case "$_codex_env_key" in
# Allowed keys: SUPABASE_ACCESS_TOKEN | VERCEL_TOKEN | LINEAR_API_KEY | DOPPLER_TOKEN
SUPABASE_ACCESS_TOKEN|VERCEL_TOKEN|LINEAR_API_KEY|DOPPLER_TOKEN)
# NOTE: This loader is duplicated in nix/home/zsh.nix (home.file). Keep both in sync.
# 許可キー一覧は組織名を含むため private-config 管理の外部ファイルに置く
_codex_env_allowlist="$HOME/.config/devcontainer-env-keys.txt"
if [[ -r "$_codex_env_allowlist" ]]; then
# Clear previously exported tokens so removed entries in ~/.devcontainer.env don't linger.
while IFS= read -r _codex_env_key || [[ -n $_codex_env_key ]]; do
[[ -n $_codex_env_key && $_codex_env_key != \#* ]] && unset "$_codex_env_key"
done < "$_codex_env_allowlist"
if [[ -r "$HOME/.devcontainer.env" ]]; then
while IFS='=' read -r _codex_env_key _codex_env_value || [[ -n $_codex_env_key ]]; do
# Strip trailing CR so Windows-style CRLF files work correctly.
_codex_env_value="${_codex_env_value%$'\r'}"
if [[ -n $_codex_env_key && $_codex_env_key != \#* ]] && grep -qxF -- "$_codex_env_key" "$_codex_env_allowlist"; then
export "$_codex_env_key=$_codex_env_value"
;;
esac
done < "$HOME/.devcontainer.env"
unset _codex_env_key _codex_env_value
fi
done < "$HOME/.devcontainer.env"
fi
fi
unset _codex_env_key _codex_env_value _codex_env_allowlist
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Development infrastructure template repository providing DevContainer images, CI
| `ci-check` | PR作成後にCIの結果を確認し、失敗している場合は修正する。PR作成完了後に自動的にこのスキルを適用してCIの状態を監視し、失敗時は修正を行うこと。 |
| `codex-review` | PR作成後にOpenAI Codexによるコードレビューを実行する。Codex CLIがインストール済みの場合、PR作成完了後に自動的にこのスキルを適用してレビューを実行すること。 |
| `gemini-review` | PR作成後にGoogle Gemini CLIによるコードレビューを実行する。Gemini CLIがインストール済みの場合、PR作成完了後に自動的にこのスキルを適用してレビューを実行すること。 |
| `n8n-workflow-pr-review` | keito4-org/n8n_custom_node の n8n ワークフロー/テンプレートPRをレビューする。ワークフロー同期PR(workflow-sync/*)の退行判定、資格情報のMAS... |
| `n8n-workflow-pr-review` | keito4-org/n8n_custom_node の n8n ワークフロー/テンプレートPRをレビューする。ワークフロー同期PR(workflow-sync/\*)の退行判定、資格情報のMAS... |

## CI/CD Workflows

Expand All @@ -149,12 +149,12 @@ Development infrastructure template repository providing DevContainer images, CI

The following scripts are auto-detected and run before git commit/push:

| Script | Command | Purpose |
| ----------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------- |
| `format:check` | `prettier --check .` | Code formatting validation |
| `lint` | `eslint . --ext .js` | Code quality validation |
| `test` | `jest --runInBand` | Unit test execution |
| `shellcheck` | `find script -name '*.sh' -type f | xargs -r shellcheck -x` | Shell script validation |
| Script | Command | Purpose |
| ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | -------------------------- | ----------------------- |
| `format:check` | `prettier --check .` | Code formatting validation |
| `lint` | `eslint . --ext .js` | Code quality validation |
| `test` | `jest --runInBand` | Unit test execution |
| `shellcheck` | `find script -name '\*.sh' -type f | xargs -r shellcheck -x` | Shell script validation |
| Additional test commands: `test:integration` (BATS), `test:coverage` (Jest + coverage), `test:all` (unit + integration) |

## Hooks
Expand Down
25 changes: 23 additions & 2 deletions docs/adr/0016-use-kanary-for-keyboard-remapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,17 @@ Stop using Karabiner Elements for local keyboard remapping.
- Per-app default input modes where useful.
- Use nix-darwin's `services.skhd` for terminal-friendly IME shortcuts without
Karabiner:
- `Ctrl+Shift+J` selects Google Japanese Input hiragana.
- `Ctrl+Shift+;` selects Google Japanese Input alphanumeric.
- `Ctrl+Shift+J` emits the physical かな key to switch to Japanese hiragana.
- `Ctrl+Shift+;` emits the physical 英数 key to switch to alphanumeric.
- Drive these shortcuts by emitting the physical かな/英数 keys (`send-ime-key`,
CGEvent keycodes 104/102) rather than calling `TISSelectInputSource` on an
input mode. Selecting a mode (`base` <-> `Roman`) of the already-active Google
Japanese input method only updates the menu-bar indicator; it does not reliably
notify the running IME to change its conversion mode, so the tooltip shows
Hiragana while typing still produces alphanumeric. The physical keys are
handled by macOS at the HID level and switch the IME reliably — the same
mechanism Kanary's Command taps use. `select-input-source` (TISSelectInputSource)
is kept for programmatic input-source queries and selection by agents.

## Consequences

Expand Down Expand Up @@ -81,3 +90,15 @@ user-level hotkey daemon, so they work in cmux terminals even when the foregroun
process is Claude Code, Codex, zsh, or another TUI instead of tmux. This still
avoids Karabiner and its DriverKit extension, but skhd must be allowed in macOS
Accessibility settings before it can observe keyboard events.

Because the shortcuts now inject synthetic HID events (`send-ime-key`), the
injected events are only delivered when the posting process is permitted in macOS
Accessibility. skhd (the parent) is already granted, and the spawned helper is
expected to run under skhd's responsibility; verify after `darwin-rebuild switch`
by pressing `Ctrl+Shift+J` and confirming typed characters — not just the menu-bar
indicator — become Japanese. Note the switch cannot be validated from a plain
terminal, because a shell that lacks Accessibility silently drops the injected
events. If the shortcuts do not switch after a rebuild, the fallback is to bind
`Ctrl+Shift+J` / `Ctrl+Shift+;` as Kanary app hotkeys that emit かな / 英数, since
Kanary already holds the required permission and uses the same key-injection
mechanism as its Command taps.
2 changes: 2 additions & 0 deletions nix/home/agent-commands.nix
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ in
".local/bin/agent-collect-local-configs" = managedCommand (
configRoot + /script/agent/collect-local-configs.sh
);

".local/bin/claude-lmstudio" = managedCommand (configRoot + /script/agent/claude-lmstudio.sh);
};
}
11 changes: 11 additions & 0 deletions nix/home/input-source.nix
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,15 @@
executable = true;
force = true;
};

home.file.".local/share/input-source/send-ime-key.swift" = {
source = configRoot + /script/macos/send-ime-key.swift;
force = true;
};

home.file.".local/bin/send-ime-key" = {
source = configRoot + /script/macos/send-ime-key.sh;
executable = true;
force = true;
};
}
4 changes: 2 additions & 2 deletions nix/hosts/darwin/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@
services.skhd = {
enable = true;
skhdConfig = ''
ctrl + shift - j : /Users/keito/.local/bin/select-input-source com.google.inputmethod.Japanese.base
ctrl + shift - 0x29 : /Users/keito/.local/bin/select-input-source com.google.inputmethod.Japanese.Roman
ctrl + shift - j : /Users/keito/.local/bin/send-ime-key kana
ctrl + shift - 0x29 : /Users/keito/.local/bin/send-ime-key eisuu
'';
};

Expand Down
100 changes: 100 additions & 0 deletions script/agent/claude-lmstudio.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Launch Claude Code against a local LM Studio server (Anthropic-compatible API).
# LM Studio 0.4.1+ serves POST /v1/messages natively, so no proxy is needed.
#
# IMPORTANT: use an MLX build of the model, not GGUF. Claude Code's tool schemas
# contain JSON-schema `pattern` regexes with `\w`, which llama.cpp's GBNF grammar
# parser rejects ("failed to parse grammar"). The MLX runtime handles these, so
# GGUF models fail on tool use while MLX models work end-to-end (verified 2026-07-15).
#
# The MLX runtime loads a vendored CPython from
# ~/.lmstudio/extensions/backends/vendor/_amphibian/cpython3.11-mac-arm64@*. If that
# directory goes missing, every MLX model fails to load with
# "Library not loaded: @rpath/libpython3.11.dylib"; repair it from the LM Studio app's
# Runtime tab (`lms runtime update`/`get` report it as already installed and do not fix it).

set -euo pipefail

BASE_URL="${LMSTUDIO_BASE_URL:-http://localhost:1234}"
MODEL="${LMSTUDIO_MODEL:-qwen/qwen3-coder-next}" # MLX build; override for another MLX model
AUTH_TOKEN="${LMSTUDIO_AUTH_TOKEN:-lmstudio}"
CONTEXT_LENGTH="${LMSTUDIO_CONTEXT_LENGTH:-262144}"

usage() {
cat <<'EOF'
Usage: claude-lmstudio [claude-args...]

Runs `claude` with ANTHROPIC_BASE_URL pointed at a local LM Studio server.
Any extra arguments are passed through to the claude CLI.

Environment overrides:
LMSTUDIO_BASE_URL LM Studio endpoint (default: http://localhost:1234)
LMSTUDIO_MODEL Model id to request (default: qwen/qwen3-coder-next, MLX build)
LMSTUDIO_AUTH_TOKEN Auth token if LM Studio requires one (default: lmstudio)
LMSTUDIO_CONTEXT_LENGTH Context window to load the model with (default: 262144)

Use an MLX build of the model. GGUF models fail on Claude Code tool use because
llama.cpp's grammar parser rejects the `\w` regex in tool JSON schemas.
EOF
}

if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi

for cli in claude lms; do
if ! command -v "$cli" >/dev/null 2>&1; then
echo "claude-lmstudio: '${cli}' CLI not found in PATH" >&2
exit 1
fi
done
Comment on lines +46 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add node to the required CLI check.

The script uses node to parse the output of lms ps --json (on line 67). Adding node to the fail-fast check ensures the script produces a clear error if Node.js is missing, rather than failing opaquely during execution.

💻 Proposed fix
-for cli in claude lms; do
+for cli in claude lms node; do
   if ! command -v "$cli" >/dev/null 2>&1; then
     echo "claude-lmstudio: '${cli}' CLI not found in PATH" >&2
     exit 1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for cli in claude lms; do
if ! command -v "$cli" >/dev/null 2>&1; then
echo "claude-lmstudio: '${cli}' CLI not found in PATH" >&2
exit 1
fi
done
for cli in claude lms node; do
if ! command -v "$cli" >/dev/null 2>&1; then
echo "claude-lmstudio: '${cli}' CLI not found in PATH" >&2
exit 1
fi
done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/agent/claude-lmstudio.sh` around lines 46 - 51, Update the required
CLI validation loop in the claude-lmstudio script to include node alongside
claude and lms, so the script exits with the existing clear PATH error before
attempting the node-based JSON parsing.


# Fail fast if the local server is unreachable, with a clear hint.
if ! curl -fsS -m 3 "${BASE_URL}/v1/models" >/dev/null 2>&1; then
echo "claude-lmstudio: cannot reach LM Studio at ${BASE_URL}." >&2
echo " Start it with: lms server start --port 1234 (or launch the LM Studio app)." >&2
exit 1
fi

# LM Studio's JIT loader picks an 8k context, which cannot even hold Claude Code's
# system prompt ("number of tokens to keep from the initial prompt is greater than
# the context length"). Report the resident copies of $MODEL, splitting them by
# whether their window is big enough: `usable` on the first line, then one
# too-small identifier per line.
resident_copies() {
lms ps --json 2>/dev/null |
MODEL="$MODEL" CONTEXT_LENGTH="$CONTEXT_LENGTH" node -e '
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
const want = Number(process.env.CONTEXT_LENGTH);
const copies = JSON.parse(raw || "[]").filter((m) => m.modelKey === process.env.MODEL);
console.log(copies.filter((m) => m.contextLength >= want).length);
for (const m of copies.filter((m) => m.contextLength < want)) console.log(m.identifier);
});
'
}

# Loading a second copy alongside a small one is not enough: LM Studio routes by model
# key and keeps serving the small copy, so drop those before loading a usable one.
usable_count=""
while read -r line; do
if [[ -z "$usable_count" ]]; then
usable_count="$line"
else
echo "claude-lmstudio: unloading ${line} (context below ${CONTEXT_LENGTH})..." >&2
lms unload "$line" >&2
fi
done < <(resident_copies)

if [[ "${usable_count:-0}" -eq 0 ]]; then
echo "claude-lmstudio: loading ${MODEL} with a ${CONTEXT_LENGTH}-token context..." >&2
lms load "$MODEL" --context-length "$CONTEXT_LENGTH" -y >&2
fi

export ANTHROPIC_BASE_URL="$BASE_URL"
export ANTHROPIC_AUTH_TOKEN="$AUTH_TOKEN"
export CLAUDE_CODE_ATTRIBUTION_HEADER=0

exec claude --model "$MODEL" "$@"
7 changes: 7 additions & 0 deletions script/macos/send-ime-key.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu

data_home="${XDG_DATA_HOME:-${HOME}/.local/share}"
src="${data_home}/input-source/send-ime-key.swift"

exec /usr/bin/xcrun swift "$src" "$@"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Pre-compile the Swift script to eliminate execution latency.

Invoking xcrun swift compiles the script on the fly on every execution, which typically introduces a noticeable delay (~200–500ms). For a human-facing IME switching shortcut, this latency is highly disruptive—characters typed immediately after pressing the shortcut can be sent to the active application before the input mode finishes switching.

Consider pre-compiling the Swift script into a binary (e.g., using swiftc -O during a build/setup phase or caching it on the first run) and executing the compiled binary directly to ensure instantaneous switching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/macos/send-ime-key.sh` at line 7, Update the send-ime-key.sh execution
flow to avoid invoking xcrun swift on every run; compile the Swift source with
swiftc -O during setup or a first-run cache step, then execute the resulting
binary directly while preserving the existing "$src" "$@" arguments.

44 changes: 44 additions & 0 deletions script/macos/send-ime-key.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import CoreGraphics
import Foundation

// Emit the physical Japanese IME keys (かな / 英数) via CGEvent so the active
// input method reliably switches its conversion mode.
//
// TISSelectInputSource on an input *mode* (base <-> Roman) of the same input
// method only updates the menu-bar indicator; it does not reliably notify the
// already-running Google Japanese IME to change its conversion mode. That leaves
// the tooltip showing Hiragana while typing still produces alphanumeric.
// The physical かな/英数 keys are handled by macOS at the HID level and switch
// the IME (and the input source) reliably, which is exactly what Kanary's
// Command-tap mappings rely on.

func keyCode(for name: String) -> CGKeyCode? {
switch name {
case "kana", "hiragana", "japanese":
return 104 // かな key
case "eisuu", "eisu", "alphanumeric", "roman":
return 102 // 英数 key
default:
if let raw = UInt16(name) {
return CGKeyCode(raw)
}
return nil
}
}

guard CommandLine.arguments.count == 2, let key = keyCode(for: CommandLine.arguments[1]) else {
fputs("usage: send-ime-key <kana|eisuu|keycode>\n", stderr)
exit(64)
}

let source = CGEventSource(stateID: .hidSystemState)
guard
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: true),
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: false)
else {
fputs("failed to create key events\n", stderr)
exit(1)
}

keyDown.post(tap: .cghidEventTap)
keyUp.post(tap: .cghidEventTap)
Loading
Loading