diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..198d634 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [develop] + pull_request: + branches: [develop, main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun test + + - name: Build + run: bun run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index fc17cc0..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Publish to npm - -on: - push: - tags: - - "v*" - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - uses: actions/setup-node@v4 - with: - node-version: "20" - registry-url: "https://registry.npmjs.org" - - - name: Publish - run: npm publish --access public --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a23542..8ad009b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,39 +2,72 @@ name: Release on: push: - tags: - - "v*" - workflow_dispatch: + branches: [main] jobs: - publish: + release: runs-on: ubuntu-latest permissions: - contents: read + contents: write + id-token: write steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Bun - uses: oven-sh/setup-bun@v2 + - name: Get version from package.json + id: version + run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" + + - name: Check if tag exists + id: tag_check + run: | + if git ls-remote --tags origin "v${{ steps.version.outputs.version }}" | grep -q .; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Skip if already released + if: steps.tag_check.outputs.exists == 'true' + run: echo "v${{ steps.version.outputs.version }} already released, skipping." + + - uses: oven-sh/setup-bun@v2 + if: steps.tag_check.outputs.exists == 'false' with: bun-version: latest - name: Install dependencies + if: steps.tag_check.outputs.exists == 'false' run: bun install - - name: Run tests - run: bun test - - - name: Run typecheck + - name: Typecheck + if: steps.tag_check.outputs.exists == 'false' run: bun run typecheck + - name: Test + if: steps.tag_check.outputs.exists == 'false' + run: bun test + - name: Build + if: steps.tag_check.outputs.exists == 'false' run: bun run build - - name: Upload package to npm + - name: Create GitHub Release + if: steps.tag_check.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + generate_release_notes: true + target_commitish: main + + - uses: actions/setup-node@v4 + if: steps.tag_check.outputs.exists == 'false' + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Publish to npm + if: steps.tag_check.outputs.exists == 'false' + run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - npm config set //registry.npmjs.org/:_authToken "${NODE_AUTH_TOKEN}" - npm publish --access public diff --git a/bun.lock b/bun.lock index f493f64..a96d952 100644 --- a/bun.lock +++ b/bun.lock @@ -3,6 +3,9 @@ "workspaces": { "": { "name": "agentlog", + "dependencies": { + "commander": "^14.0.3", + }, "devDependencies": { "@types/bun": "latest", "typescript": "^5.4.0", @@ -16,6 +19,8 @@ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/docs/plans/2026-03-14-omx-discord-bridge-design.md b/docs/plans/2026-03-14-omx-discord-bridge-design.md new file mode 100644 index 0000000..017f930 --- /dev/null +++ b/docs/plans/2026-03-14-omx-discord-bridge-design.md @@ -0,0 +1,196 @@ +# OMX Discord Bridge Design + +**Date:** 2026-03-14 +**Status:** Approved + +## Goal + +Use existing Discord credentials from `~/work/js/kw-chat/.env` as the secret source, create a fresh Discord test channel, and validate a bidirectional OMX workflow where Discord messages can drive an OMX/tmux session without adopting `kw-chat` as the long-term runtime surface. + +## Problem Statement + +The original idea was to piggyback on `kw-chat` because it already has Discord bot credentials and a mature Discord integration surface. That reduces bootstrap work, but it does not reduce long-term maintenance for Discord-driven agent workflows because the runtime would remain coupled to `kw-chat` behavior, routing, and session abstractions. + +At the same time, the installed `oh-my-codex` runtime already contains: +- lifecycle notifications to Discord, +- reply-listener code that correlates Discord bot replies to tmux panes, +- rate limiting, authorized-user checks, and input sanitization. + +The missing piece is an operator-friendly bootstrap path that: +1. reuses existing Discord secrets, +2. creates an isolated channel for testing, +3. writes OMX notification config in an isolated Codex home, +4. starts reply-listener reliably, +5. launches an OMX session inside tmux, +6. verifies the full roundtrip from Discord to tmux and back. + +## Chosen Approach + +### Approach A — `kw-chat` runtime reuse +Use `kw-chat` as the main Discord runtime and route Discord messages into Codex/App Server or Claude sessions. + +**Pros** +- Existing Discord bot runtime and channel/thread abstractions. +- Existing approval/session UI already implemented. +- Fewer unknowns short-term. + +**Cons** +- Does not reduce `kw-chat` maintenance burden. +- Preserves a parallel runtime instead of proving OMX as the operational surface. +- Harder to isolate OMX-specific failures from `kw-chat` behavior. + +### Approach B — OMX-native thin bridge using `kw-chat` env as secret source **(chosen)** +Build a small local harness around the installed OMX runtime. The harness reads Discord credentials from `~/work/js/kw-chat/.env`, creates a dedicated test channel, writes `.omx-config.json` into an isolated `CODEX_HOME`, boots OMX reply-listener directly from installed OMX exports, and launches an OMX tmux session for Discord-driven interaction. + +**Pros** +- Keeps `kw-chat` out of the runtime path while still reusing trusted credentials. +- Proves the OMX-native experience directly. +- Small maintenance surface: a few scripts plus docs. +- Easy to tear down and rerun in isolation. + +**Cons** +- Requires a bootstrap wrapper because reply-listener is not exposed as a first-class CLI command in the current OMX build. +- Must create explicit verification for Discord channel lifecycle and session correlation. + +### Approach C — Full custom Discord daemon +Write a new dedicated Discord service for OMX control. + +**Pros** +- Maximum flexibility. + +**Cons** +- Highest maintenance cost. +- Reinvents large parts of existing OMX/Discord behavior. +- Not justified for the current goal. + +## Architecture + +### High-level components + +1. **kw-chat env loader** + - Read `~/work/js/kw-chat/.env`. + - Extract only Discord values needed for OMX test mode. + - Never copy secrets into repo files. + +2. **Discord test channel operator script** + - Use Discord REST/Bot API with existing bot token + guild id. + - Create a dedicated text channel for OMX testing. + - Optionally delete/archive it during cleanup. + +3. **Isolated OMX home/config generator** + - Create an isolated Codex home directory under the current repo (or temp workspace). + - Write `.omx-config.json` with: + - `notifications.enabled = true` + - `discord-bot` target set to the new channel + - `notifications.reply.enabled = true` + - `authorizedDiscordUserIds` set to the operator user id + - event selection for `session-start`, `session-idle`, `ask-user-question`, `session-stop`, `session-end` + - Preserve the global `~/.codex/config.toml` unless a later step explicitly needs a throwaway override. + +4. **OMX reply-listener bootstrap** + - Resolve installed `oh-my-codex` package root. + - Import `getReplyConfig()` and `startReplyListener()` from OMX notification exports. + - Start the daemon after config generation. + - Surface PID/state/log paths for troubleshooting. + +5. **OMX launch wrapper** + - Launch `omx` inside tmux using the isolated `CODEX_HOME`. + - Prefer `--notify-temp --discord` only if needed for temporary routing; otherwise use persistent `.omx-config.json` in the isolated home. + - Record session metadata (tmux pane, channel id, codex home, log paths). + +6. **Playwright MCP validation lane** + - Use Playwright MCP against Discord Web. + - Reuse a logged-in browser profile or storage state. + - Validate bot message arrival, reply input, confirmation ack, and follow-up OMX lifecycle message. + +## Data Flow + +1. Operator runs bootstrap. +2. Bootstrap reads `kw-chat` env and creates a Discord test channel. +3. Bootstrap writes isolated OMX config and starts reply-listener. +4. Bootstrap launches OMX in tmux. +5. OMX sends Discord lifecycle message via Discord bot. +6. User replies to that bot message in Discord. +7. OMX reply-listener polls Discord, verifies user id, finds message correlation, and injects reply text into tmux pane. +8. OMX session processes the message and eventually emits another lifecycle event back to Discord. + +## Security / Safety Rules + +- Never write Discord secrets into committed files. +- Redact secrets in logs and summaries. +- Restrict reply injection to explicit `authorizedDiscordUserIds`. +- Use a dedicated test channel rather than the main `kw-chat` channel. +- Use an isolated `CODEX_HOME` so OMX config changes are reversible and local. +- Keep `kw-chat` as a read-only secret source for this experiment. + +## Testing Strategy + +### Layer 1 — Local unit tests +- Env parsing for `kw-chat` `.env` extraction. +- OMX config generation. +- Channel name generation / metadata handling. +- Installed OMX module resolution logic. + +### Layer 2 — Local smoke tests +- Create channel successfully. +- Write isolated `.omx-config.json`. +- Start reply-listener and verify daemon state. +- Launch OMX in tmux with isolated `CODEX_HOME`. + +### Layer 3 — Playwright MCP E2E +- Open Discord Web. +- Navigate to the created test channel. +- Observe bot lifecycle message. +- Reply to that message. +- Observe injection confirmation (`✅` reaction and/or “Injected into Codex CLI session.” reply). +- Observe follow-up OMX lifecycle message. + +## Team Execution Plan + +### Team 1 — Bridge / bootstrap lane +Owns: +- `scripts/omx-discord/*.ts` +- isolated config generation +- reply-listener bootstrap +- tmux/launch wrapper + +### Team 2 — Discord ops lane +Owns: +- Discord test channel creation / cleanup +- env loading from `kw-chat` +- operator output / metadata capture + +### Team 3 — Verification lane +Owns: +- daemon health checks +- session/log correlation +- failure triage and fallback guidance + +### Team 4 — Playwright lane +Owns: +- Playwright MCP setup +- Discord Web scenario automation +- evidence capture (screenshots / traces / assertions) + +### Team 5 — Final verifier lane +Owns: +- end-to-end acceptance evidence +- rollback / cleanup confirmation +- final operator runbook + +## Open Questions Resolved + +- **Use `kw-chat` runtime?** No. Only reuse env secrets. +- **Need a new Discord channel?** Yes. Dedicated test channel. +- **Need full bidirectional Discord → tmux?** Yes. That is the acceptance target. +- **Need Playwright-based validation?** Yes. Via Playwright MCP if available; local Playwright fallback only if MCP setup blocks. + +## Acceptance Criteria + +1. Bootstrap can create a dedicated Discord test channel using secrets from `~/work/js/kw-chat/.env`. +2. Bootstrap can produce an isolated OMX config without mutating the main `kw-chat` runtime. +3. Reply-listener can start successfully against the isolated config. +4. OMX can emit at least one Discord lifecycle message into the created test channel. +5. A reply from the authorized Discord user can be injected into the active tmux OMX session. +6. Playwright-based validation can prove the roundtrip with captured evidence. +7. Cleanup can stop the reply-listener and remove or clearly retire the test channel. diff --git a/docs/plans/2026-03-14-omx-discord-bridge.md b/docs/plans/2026-03-14-omx-discord-bridge.md new file mode 100644 index 0000000..cd5489f --- /dev/null +++ b/docs/plans/2026-03-14-omx-discord-bridge.md @@ -0,0 +1,436 @@ +# OMX Discord Bridge Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build an OMX-native Discord bridge harness that reuses Discord credentials from `~/work/js/kw-chat/.env`, creates a fresh Discord test channel, enables bidirectional Discord↔tmux OMX interaction, and validates the flow with Playwright MCP. + +**Architecture:** Add a small script-based harness in this repo rather than extending `kw-chat` runtime behavior. The harness will (1) load Discord secrets from `kw-chat`, (2) create/manage a dedicated test channel, (3) generate an isolated OMX config home, (4) start the installed OMX reply-listener via exported runtime functions, (5) launch OMX in tmux with that isolated config, and (6) run Playwright MCP-based Discord Web verification. + +**Tech Stack:** Bun, TypeScript, Node 20, installed `oh-my-codex` runtime, Discord REST/Bot API, tmux, Playwright MCP, existing local `kw-chat` `.env` file. + +--- + +## Scope + +포함: +- `kw-chat` `.env`에서 Discord secret 읽기 +- 테스트용 Discord 채널 생성/정리 +- 격리된 `CODEX_HOME` + `.omx-config.json` 생성 +- OMX reply-listener 부트스트랩 +- tmux 안에서 OMX 세션 실행 +- Discord reply → tmux input injection 검증 +- Playwright MCP로 Discord Web 시나리오 검증 +- operator runbook / cleanup 문서화 + +제외: +- `kw-chat` 메인 런타임 리팩터링 +- 글로벌 `~/.codex/config.toml` 영구 수정 +- `oh-my-codex` upstream 자체 패치 +- production-wide Discord channel migration + +## Design Decisions + +### Decision 1: `kw-chat`는 secret source만 사용 +`~/work/js/kw-chat/.env`는 신뢰된 비밀 저장 위치로 취급하고, 실행 경로는 본 repo의 harness + installed OMX로 제한한다. + +### Decision 2: 격리된 `CODEX_HOME` 사용 +글로벌 `~/.codex` 대신 repo-local disposable home을 사용해 실험 side effect를 제어한다. + +### Decision 3: reply-listener는 installed OMX export를 직접 호출 +현재 `omx --help`에 reply-listener 관리 CLI가 드러나지 않으므로 bootstrap wrapper에서 runtime export를 직접 사용한다. + +### Decision 4: Discord 검증은 새 테스트 채널에서만 수행 +기존 `DISCORD_CHANNEL_ID`를 재사용하지 않고, 별도 채널을 생성해 실험 흔적을 격리한다. + +### Decision 5: Playwright MCP를 우선 사용 +Discord Web roundtrip은 브라우저 기반 검증이 가장 명확하므로 Playwright MCP를 1순위로 사용한다. + +## Task Breakdown + +### Task 1: Baseline context + operator metadata 정리 + +**Files:** +- Create: `.omx/context/omx-discord-bridge-20260314T000000Z.md` +- Modify: `docs/plans/2026-03-14-omx-discord-bridge.md` + +**Step 1: Write the context snapshot** + +Document: +- 목적: Discord↔OMX/tmux bidirectional bridge +- secret source: `~/work/js/kw-chat/.env` +- constraints: isolated `CODEX_HOME`, dedicated Discord channel, tmux required +- evidence: installed OMX exports, kw-chat Discord env availability, Playwright MCP target + +**Step 2: Verify context file exists** + +Run: `test -f .omx/context/omx-discord-bridge-20260314T000000Z.md` +Expected: exit code 0 + +**Step 3: Commit** + +```bash +git add .omx/context/omx-discord-bridge-20260314T000000Z.md docs/plans/2026-03-14-omx-discord-bridge.md +git commit -m "docs: add omx discord bridge context" +``` + +### Task 2: kw-chat env reader + redacted summary + +**Files:** +- Create: `scripts/omx-discord/kw-chat-env.ts` +- Create: `src/__tests__/kw-chat-env.test.ts` +- Modify: `tsconfig.json` + +**Step 1: Write the failing test** + +Test cases: +- loads `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, `DISCORD_CHANNEL_ID`, `DISCORD_OWNER_ID` from a sample `.env` +- ignores unrelated keys +- prints redacted summary without exposing token values + +**Step 2: Run test to verify it fails** + +Run: `bun test src/__tests__/kw-chat-env.test.ts` +Expected: FAIL because loader module does not exist + +**Step 3: Write minimal implementation** + +Implement: +- `loadKwChatDiscordEnv(path: string)` +- `redactDiscordEnvSummary(env)` +- strict error when required keys are absent (`DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`) + +**Step 4: Run test to verify it passes** + +Run: `bun test src/__tests__/kw-chat-env.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add scripts/omx-discord/kw-chat-env.ts src/__tests__/kw-chat-env.test.ts tsconfig.json +git commit -m "feat: add kw-chat discord env loader" +``` + +### Task 3: Discord test channel create/list/cleanup helper + +**Files:** +- Create: `scripts/omx-discord/discord-channel.ts` +- Create: `src/__tests__/discord-channel.test.ts` +- Modify: `package.json` + +**Step 1: Write the failing test** + +Test cases: +- builds valid create payload for a test channel name +- validates required guild/token inputs +- builds cleanup target metadata without needing live Discord I/O + +**Step 2: Run test to verify it fails** + +Run: `bun test src/__tests__/discord-channel.test.ts` +Expected: FAIL because helper module does not exist + +**Step 3: Write minimal implementation** + +Implement: +- Discord REST helper using `fetch` +- `createTestChannel({ token, guildId, name, topic })` +- `archiveOrDeleteTestChannel({ token, channelId })` +- stdout JSON output with redacted token handling + +**Step 4: Add operator scripts** + +Add npm/bun scripts: +- `bun run omx:discord:create-channel` +- `bun run omx:discord:cleanup-channel` + +**Step 5: Run tests** + +Run: `bun test src/__tests__/discord-channel.test.ts` +Expected: PASS + +**Step 6: Commit** + +```bash +git add scripts/omx-discord/discord-channel.ts src/__tests__/discord-channel.test.ts package.json +git commit -m "feat: add discord test channel helper" +``` + +### Task 4: Isolated OMX home/config generator + +**Files:** +- Create: `scripts/omx-discord/omx-home.ts` +- Create: `src/__tests__/omx-home.test.ts` +- Modify: `scripts/omx-discord/kw-chat-env.ts` + +**Step 1: Write the failing test** + +Test cases: +- creates isolated codex home path under repo-local workspace +- writes `.omx-config.json` with `discord-bot` and `reply` blocks +- stores `authorizedDiscordUserIds` and selected events +- never writes secrets into committed repo locations outside the generated workspace + +**Step 2: Run test to verify it fails** + +Run: `bun test src/__tests__/omx-home.test.ts` +Expected: FAIL because generator module does not exist + +**Step 3: Write minimal implementation** + +Implement: +- `prepareOmxDiscordHome({ rootDir, discordChannelId, userId, token? })` +- create local workspace dir like `.omx/discord-bridge//codex-home` +- write `.omx-config.json` using env or inline values as agreed by tests +- persist metadata JSON for cleanup/debug + +**Step 4: Run tests** + +Run: `bun test src/__tests__/omx-home.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add scripts/omx-discord/omx-home.ts src/__tests__/omx-home.test.ts scripts/omx-discord/kw-chat-env.ts +git commit -m "feat: generate isolated omx discord home" +``` + +### Task 5: Installed OMX reply-listener bootstrap wrapper + +**Files:** +- Create: `scripts/omx-discord/omx-reply-listener.ts` +- Create: `src/__tests__/omx-reply-listener.test.ts` +- Modify: `package.json` + +**Step 1: Write the failing test** + +Test cases: +- resolves installed `oh-my-codex` package root +- imports notification module entrypoint path safely +- returns actionable error when OMX package is missing +- builds start/status/stop wrapper calls without touching the real daemon during unit tests + +**Step 2: Run test to verify it fails** + +Run: `bun test src/__tests__/omx-reply-listener.test.ts` +Expected: FAIL because wrapper does not exist + +**Step 3: Write minimal implementation** + +Implement wrappers: +- `resolveInstalledOmxPackageRoot()` +- `loadOmxNotificationModule()` +- `startOmxReplyListener(config)` +- `getOmxReplyListenerStatus()` +- `stopOmxReplyListener()` + +Expose CLI entrypoints: +- `bun run omx:discord:start-listener` +- `bun run omx:discord:listener-status` +- `bun run omx:discord:stop-listener` + +**Step 4: Run tests** + +Run: `bun test src/__tests__/omx-reply-listener.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add scripts/omx-discord/omx-reply-listener.ts src/__tests__/omx-reply-listener.test.ts package.json +git commit -m "feat: add omx reply listener wrapper" +``` + +### Task 6: OMX tmux launch wrapper + run manifest + +**Files:** +- Create: `scripts/omx-discord/launch.ts` +- Create: `src/__tests__/omx-launch.test.ts` +- Create: `.omx/context/omx-discord-runbook.md` +- Modify: `package.json` + +**Step 1: Write the failing test** + +Test cases: +- generates launch env with isolated `CODEX_HOME` +- rejects launch when not inside tmux +- records tmux pane/session metadata into a run manifest + +**Step 2: Run test to verify it fails** + +Run: `bun test src/__tests__/omx-launch.test.ts` +Expected: FAIL because launch wrapper does not exist + +**Step 3: Write minimal implementation** + +Implement: +- `launchOmxDiscordSession({ codexHome, prompt, reasoning })` +- verify `$TMUX` +- spawn `omx` with isolated env +- write run manifest containing tmux pane/session, channel id, codex home path, listener pid, and timestamps + +Add operator script: +- `bun run omx:discord:launch -- ""` + +**Step 4: Run tests** + +Run: `bun test src/__tests__/omx-launch.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add scripts/omx-discord/launch.ts src/__tests__/omx-launch.test.ts .omx/context/omx-discord-runbook.md package.json +git commit -m "feat: add omx discord launch wrapper" +``` + +### Task 7: Bootstrap orchestrator end-to-end smoke + +**Files:** +- Create: `scripts/omx-discord/bootstrap.ts` +- Create: `src/__tests__/omx-bootstrap.test.ts` +- Modify: `package.json` + +**Step 1: Write the failing test** + +Test cases: +- bootstrap calls env loader, channel create, home generator, listener start, and launch in the correct order +- bootstrap emits machine-readable manifest path +- bootstrap supports dry-run mode + +**Step 2: Run test to verify it fails** + +Run: `bun test src/__tests__/omx-bootstrap.test.ts` +Expected: FAIL because bootstrap orchestrator does not exist + +**Step 3: Write minimal implementation** + +Implement a single entrypoint: +- `bun run omx:discord:bootstrap -- --dry-run` +- `bun run omx:discord:bootstrap -- --prompt "hello from discord bridge"` + +Output: +- channel metadata +- codex home path +- listener status +- tmux launch manifest path + +**Step 4: Run tests** + +Run: `bun test src/__tests__/omx-bootstrap.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add scripts/omx-discord/bootstrap.ts src/__tests__/omx-bootstrap.test.ts package.json +git commit -m "feat: add omx discord bootstrap orchestrator" +``` + +### Task 8: Playwright MCP setup + Discord Web validation plan + +**Files:** +- Create: `docs/plans/2026-03-14-omx-discord-playwright-e2e.md` +- Create: `docs/ops/omx-discord-playwright.md` +- Modify: `.codex/config.toml` (or documented local MCP add command only; do not commit secrets) + +**Step 1: Write the test scenario document** + +Document: +- Discord Web login strategy +- channel navigation +- bot lifecycle message detection +- reply interaction +- inject confirmation assertion +- follow-up lifecycle assertion +- screenshot evidence checkpoints + +**Step 2: Register Playwright MCP locally** + +Run: `codex mcp add playwright npx "@playwright/mcp@latest"` +Expected: Playwright MCP server registered locally + +**Step 3: Verify MCP availability** + +Run: `codex mcp list` +Expected: `playwright` entry visible + +**Step 4: Commit docs only** + +```bash +git add docs/plans/2026-03-14-omx-discord-playwright-e2e.md docs/ops/omx-discord-playwright.md +git commit -m "docs: add omx discord playwright verification plan" +``` + +### Task 9: Team execution lane assignment + +**Files:** +- Modify: `docs/plans/2026-03-14-omx-discord-bridge.md` +- Create: `.omx/context/omx-discord-team-launch.md` + +**Step 1: Write team task split** + +Document exact lane ownership: +- Lane A: env + channel ops +- Lane B: omx config + listener wrapper +- Lane C: tmux launch + manifests +- Lane D: Playwright MCP validation +- Lane E: verifier / cleanup + +**Step 2: Define team launch hints** + +Add concrete launch examples: +```bash +omx team 3:executor "Implement Task 2-4 from docs/plans/2026-03-14-omx-discord-bridge.md" +omx team 2:test-engineer "Implement Task 8 from docs/plans/2026-03-14-omx-discord-bridge.md" +``` + +**Step 3: Commit** + +```bash +git add docs/plans/2026-03-14-omx-discord-bridge.md .omx/context/omx-discord-team-launch.md +git commit -m "docs: add omx discord team execution split" +``` + +## Verification Checklist + +- `bun test` for all newly added focused tests +- `bun run omx:discord:bootstrap -- --dry-run` +- live bootstrap against Discord test channel +- listener status confirms running daemon +- tmux pane receives injected reply +- Discord shows confirmation reply/reaction +- Playwright MCP captures the roundtrip on Discord Web +- cleanup stops listener and retires test channel + +## Risks and Mitigations + +### Risk 1: installed OMX package path varies +Mitigation: resolve with `npm root -g` + explicit existence checks; fail with actionable path diagnostics. + +### Risk 2: reply-listener daemon start path is unstable +Mitigation: wrap exported runtime functions rather than shelling into undocumented internals. + +### Risk 3: Discord Web login blocks Playwright MCP +Mitigation: use a pre-authenticated browser profile or storage-state and keep MCP scenario focused on the test channel only. + +### Risk 4: accidental mutation of main Codex config +Mitigation: require isolated `CODEX_HOME` in all bootstrap/launch paths and assert it in tests. + +### Risk 5: Discord rate-limit / stale message correlation +Mitigation: dedicated test channel, authorized user filter, run manifest + listener logs + bounded smoke loops. + +## Team Roster Recommendation + +- `architect` — validate bridge boundaries and isolation rules +- `executor` x2 — build env/channel + listener/launch lanes in parallel +- `test-engineer` — Playwright MCP and smoke verification +- `verifier` — final end-to-end proof and cleanup confirmation + +## Suggested Team Launch + +```bash +omx team 4:executor "Implement docs/plans/2026-03-14-omx-discord-bridge.md Tasks 2-7 in parallel lanes, preserving isolated CODEX_HOME and kw-chat env reuse" +omx team 2:test-engineer "Implement docs/plans/2026-03-14-omx-discord-bridge.md Task 8 and collect Discord Web evidence" +``` diff --git a/package.json b/package.json index ebae76e..0e260e4 100644 --- a/package.json +++ b/package.json @@ -48,5 +48,8 @@ "devDependencies": { "@types/bun": "latest", "typescript": "^5.4.0" + }, + "dependencies": { + "commander": "^14.0.3" } } diff --git a/src/__tests__/cli-codex.test.ts b/src/__tests__/cli-codex.test.ts index 836018c..5307a0e 100644 --- a/src/__tests__/cli-codex.test.ts +++ b/src/__tests__/cli-codex.test.ts @@ -41,10 +41,12 @@ function fixture(name: string): string { function makeFakeCodexPath(home: string): string { const binDir = join(home, "bin"); - const codexBin = join(binDir, "codex"); mkdirSync(binDir, { recursive: true }); - writeFileSync(codexBin, "#!/bin/sh\nexit 0\n", "utf-8"); - chmodSync(codexBin, 0o755); + for (const name of ["codex", "agentlog"]) { + const bin = join(binDir, name); + writeFileSync(bin, "#!/bin/sh\nexit 0\n", "utf-8"); + chmodSync(bin, 0o755); + } return `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`; } diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 91735d5..d6ebf6c 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -363,6 +363,171 @@ printf '%s\n' "$@" > "${argsFile}" }); }); +describe("cli init --dry-run", () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = makeTmpHome(); + }); + + afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("exits 0 and prints dry-run output without writing config or settings", async () => { + const vault = join(tmpHome, "Obsidian"); + mkdirSync(join(vault, ".obsidian"), { recursive: true }); + + const { stdout, exitCode } = await runCli(["init", "--dry-run", vault], { HOME: tmpHome }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("[dry-run]"); + expect(stdout).toContain("No changes were made"); + + const configFile = join(tmpHome, ".agentlog", "config.json"); + const settingsFile = join(tmpHome, ".claude", "settings.json"); + expect(existsSync(configFile)).toBe(false); + expect(existsSync(settingsFile)).toBe(false); + }); + + it("exits 1 with error when vault path does not exist", async () => { + const { stderr, exitCode } = await runCli( + ["init", "--dry-run", join(tmpHome, "nonexistent")], + { HOME: tmpHome } + ); + + expect(exitCode).toBe(1); + expect(stderr).toBeTruthy(); + }); + + it("exits 1 with 'requires a vault path' when no vault arg", async () => { + const { stderr, exitCode } = await runCli(["init", "--dry-run"], { HOME: tmpHome }); + + expect(exitCode).toBe(1); + expect(stderr).toContain("requires a vault path"); + }); + + it("exits 0 and mentions plain mode with --plain flag", async () => { + const notes = join(tmpHome, "notes"); + mkdirSync(notes, { recursive: true }); + + const { stdout, exitCode } = await runCli( + ["init", "--plain", "--dry-run", notes], + { HOME: tmpHome } + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("[dry-run]"); + expect(stdout).toContain("No changes were made"); + }); +}); + +describe("cli uninstall --dry-run", () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = makeTmpHome(); + }); + + afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("exits 0 and prints Would remove without modifying config or settings", async () => { + const vault = join(tmpHome, "Obsidian"); + mkdirSync(join(vault, ".obsidian"), { recursive: true }); + mkdirSync(join(tmpHome, ".agentlog"), { recursive: true }); + writeFileSync( + join(tmpHome, ".agentlog", "config.json"), + JSON.stringify({ vault }), + "utf-8" + ); + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + const settingsContent = JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { matcher: "", hooks: [{ type: "command", command: "agentlog hook" }] }, + ], + }, + }); + writeFileSync(join(tmpHome, ".claude", "settings.json"), settingsContent, "utf-8"); + + const { stdout, exitCode } = await runCli(["uninstall", "--dry-run"], { HOME: tmpHome }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Would remove"); + + expect(existsSync(join(tmpHome, ".agentlog", "config.json"))).toBe(true); + expect(existsSync(join(tmpHome, ".claude", "settings.json"))).toBe(true); + const settings = JSON.parse(readFileSync(join(tmpHome, ".claude", "settings.json"), "utf-8")); + expect(settings.hooks).toBeDefined(); + }); +}); + +describe("cli validate", () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = makeTmpHome(); + }); + + afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("exits 0 with config: ok and hook: ok when fully configured", async () => { + const vault = join(tmpHome, "Obsidian"); + mkdirSync(join(vault, ".obsidian"), { recursive: true }); + mkdirSync(join(tmpHome, ".agentlog"), { recursive: true }); + writeFileSync( + join(tmpHome, ".agentlog", "config.json"), + JSON.stringify({ vault }), + "utf-8" + ); + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude", "settings.json"), + JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { matcher: "", hooks: [{ type: "command", command: "agentlog hook" }] }, + ], + }, + }), + "utf-8" + ); + + const { stdout, exitCode } = await runCli(["validate"], { HOME: tmpHome }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("config: ok"); + expect(stdout).toContain("hook: ok"); + }); + + it("exits 1 with config: fail when no config present", async () => { + const { stdout, exitCode } = await runCli(["validate"], { HOME: tmpHome }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("config: fail"); + }); + + it("exits 1 with hook: fail when config present but hook not registered", async () => { + const vault = join(tmpHome, "Obsidian"); + mkdirSync(join(vault, ".obsidian"), { recursive: true }); + mkdirSync(join(tmpHome, ".agentlog"), { recursive: true }); + writeFileSync( + join(tmpHome, ".agentlog", "config.json"), + JSON.stringify({ vault }), + "utf-8" + ); + + const { stdout, exitCode } = await runCli(["validate"], { HOME: tmpHome }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("hook: fail"); + }); +}); + describe("cli usage", () => { it("prints only the headline in prod for the version command", async () => { const { stdout, exitCode } = await runCli(["version"], { AGENTLOG_PHASE: "prod" }); diff --git a/src/__tests__/daily-note.test.ts b/src/__tests__/daily-note.test.ts index e298fff..064b939 100644 --- a/src/__tests__/daily-note.test.ts +++ b/src/__tests__/daily-note.test.ts @@ -69,12 +69,20 @@ describe("buildAgentLogEntry", () => { }); describe("buildSessionDivider", () => { - it("returns divider with first 8 chars of sessionId", () => { - expect(buildSessionDivider("abc12345-def6-7890-abcd-ef1234567890")).toBe("- - - - [[ses_abc12345]]"); + it("returns divider with claude prefix by default", () => { + expect(buildSessionDivider("abc12345-def6-7890-abcd-ef1234567890")).toBe("- - - - [[claude_abc12345]]"); + }); + + it("returns divider with claude prefix when source is claude", () => { + expect(buildSessionDivider("abc12345-def6-7890-abcd-ef1234567890", "claude")).toBe("- - - - [[claude_abc12345]]"); + }); + + it("returns divider with codex prefix when source is codex", () => { + expect(buildSessionDivider("abc12345-def6-7890-abcd-ef1234567890", "codex")).toBe("- - - - [[codex_abc12345]]"); }); it("uses full sessionId when shorter than 8 chars", () => { - expect(buildSessionDivider("abc")).toBe("- - - - [[ses_abc]]"); + expect(buildSessionDivider("abc", "claude")).toBe("- - - - [[claude_abc]]"); }); }); diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts new file mode 100644 index 0000000..4ebd327 --- /dev/null +++ b/src/__tests__/errors.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "bun:test"; +import { Errors, formatError, toJsonError } from "../errors.js"; + +describe("Errors", () => { + it("VAULT_NOT_FOUND includes path in message", () => { + const err = Errors.VAULT_NOT_FOUND("/some/path"); + expect(err.code).toBe("VAULT_NOT_FOUND"); + expect(err.message).toContain("/some/path"); + expect(err.fix).toBeTruthy(); + }); + + it("VAULT_NOT_OBSIDIAN includes path in message", () => { + const err = Errors.VAULT_NOT_OBSIDIAN("/my/vault"); + expect(err.code).toBe("VAULT_NOT_OBSIDIAN"); + expect(err.message).toContain("/my/vault"); + expect(err.fix).toContain("agentlog init"); + expect(err.docs).toBeTruthy(); + }); + + it("CONFIG_NOT_FOUND has correct code", () => { + const err = Errors.CONFIG_NOT_FOUND(); + expect(err.code).toBe("CONFIG_NOT_FOUND"); + expect(err.fix).toContain("agentlog init"); + }); + + it("HOOK_NOT_REGISTERED has correct code", () => { + const err = Errors.HOOK_NOT_REGISTERED(); + expect(err.code).toBe("HOOK_NOT_REGISTERED"); + expect(err.fix).toContain("agentlog init"); + }); + + it("CLI_NOT_FOUND has correct code", () => { + const err = Errors.CLI_NOT_FOUND(); + expect(err.code).toBe("CLI_NOT_FOUND"); + }); + + it("APP_NOT_RESPONDING has correct code", () => { + const err = Errors.APP_NOT_RESPONDING(); + expect(err.code).toBe("APP_NOT_RESPONDING"); + }); + + it("PROMPT_REQUIRED has correct code", () => { + const err = Errors.PROMPT_REQUIRED(); + expect(err.code).toBe("PROMPT_REQUIRED"); + expect(err.fix).toContain("agentlog codex-debug"); + }); +}); + +describe("formatError", () => { + it("returns human-readable string with Error and Fix prefix", () => { + const err = Errors.CONFIG_NOT_FOUND(); + const out = formatError(err); + expect(out).toMatch(/^Error: /); + expect(out).toContain("Fix:"); + }); + + it("includes the message and fix", () => { + const err = Errors.VAULT_NOT_FOUND("/test/path"); + const out = formatError(err); + expect(out).toContain(err.message); + expect(out).toContain(err.fix); + }); +}); + +describe("toJsonError", () => { + it("returns object with status error and all error fields", () => { + const err = Errors.HOOK_NOT_REGISTERED(); + const json = toJsonError(err) as unknown as Record; + expect(json.status).toBe("error"); + expect(json.code).toBe("HOOK_NOT_REGISTERED"); + expect(json.message).toBe(err.message); + expect(json.fix).toBe(err.fix); + }); +}); diff --git a/src/__tests__/note-writer.test.ts b/src/__tests__/note-writer.test.ts index 672c9ce..aa12e1f 100644 --- a/src/__tests__/note-writer.test.ts +++ b/src/__tests__/note-writer.test.ts @@ -22,6 +22,7 @@ function makeEntry(overrides: Partial = {}): LogEntry { sessionId: "abc12345-def6-7890-abcd-ef1234567890", project: "js/agentlog", cwd: "/Users/pray/work/js/agentlog", + source: "claude", ...overrides, }; } @@ -125,7 +126,7 @@ describe("appendEntry — session-grouped AgentLog section", () => { expect(content).toContain("> 🕐 10:53 — js/agentlog › 테스트 작업"); expect(content).toContain("#### 10:53 · js/agentlog"); expect(content).toContain(""); - expect(content).toContain("- - - - [[ses_abc12345]]"); + expect(content).toContain("- - - - [[claude_abc12345]]"); expect(content).toContain("- 10:53 테스트 작업"); }); @@ -194,12 +195,28 @@ describe("appendEntry — session-grouped AgentLog section", () => { appendEntry(config, entry2, TEST_DATE); const content = readFileSync(filePath, "utf-8"); - expect(content).toContain("- - - - [[ses_session1]]"); - expect(content).toContain("- - - - [[ses_session2]]"); + expect(content).toContain("- - - - [[claude_session1]]"); + expect(content).toContain("- - - - [[claude_session2]]"); expect(content).toContain("- 10:53 테스트 작업"); expect(content).toContain("- 15:00 테스트 작업"); }); + // N5b: codex source emits [[codex_...]] dividers + it("emits codex-prefixed divider when source is codex", () => { + const filePath = join(tmpDir, "Daily", "2026-03-01-일.md"); + writeFileSync(filePath, FIXTURE_NO_TIMEBLOCKS, "utf-8"); + + const entry1 = makeEntry({ time: "10:53", source: "codex", sessionId: "codex111-aaaa-bbbb-cccc-dddddddddddd" }); + const entry2 = makeEntry({ time: "15:00", source: "codex", sessionId: "codex222-xxxx-yyyy-zzzz-111111111111" }); + appendEntry(config, entry1, TEST_DATE); + appendEntry(config, entry2, TEST_DATE); + + const content = readFileSync(filePath, "utf-8"); + expect(content).toContain("- - - - [[codex_codex111]]"); + expect(content).toContain("- - - - [[codex_codex222]]"); + expect(content).not.toContain("claude_"); + }); + // N6: different projects → separate #### sections it("creates separate sections for different projects", () => { const filePath = join(tmpDir, "Daily", "2026-03-01-일.md"); diff --git a/src/cli.ts b/src/cli.ts index d3d20ff..79f3a2f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,7 @@ import type { AgentLogConfig } from "./types.js"; import { detectVaults, detectCli } from "./detect.js"; import { isVersionAtLeast, MIN_CLI_VERSION, resolveCliBin, parseCliVersion } from "./obsidian-cli.js"; import { registerHook, unregisterHook, isHookRegistered, CLAUDE_SETTINGS_PATH } from "./claude-settings.js"; +import { Errors, formatError } from "./errors.js"; import { CODEX_CONFIG_PATH, readCodexNotifyState, @@ -29,27 +30,7 @@ import { } from "./codex-settings.js"; import { formatVersionHeadline, formatVersionOutput, getRuntimeInfo, readVersion, resolvePackageRoot } from "./version-info.js"; import * as readline from "readline"; - -function usage(): void { - console.log(`Usage: - agentlog init [vault] [--plain] [--claude | --codex | --all] - Configure Claude hook, Codex notify, or both - agentlog detect List detected Obsidian vaults - agentlog codex-debug Run codex exec with a test prompt - agentlog codex-notify Handle Codex notify callback (internal) - agentlog doctor Check installation health - agentlog open Open today's Daily Note in Obsidian (CLI) - agentlog uninstall [-y] [--codex | --all] - Remove Claude hook, Codex notify, or both - agentlog version Print version and build identity - agentlog hook Run hook (called by Claude Code) - agentlog codex-notify Run notify handler (called by Codex) - -Options: - --plain Write to plain folder without Obsidian timeblock parsing - -y Skip confirmation prompt (for uninstall) -`); -} +import { Command } from "commander"; function ask(prompt: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -61,29 +42,29 @@ function ask(prompt: string): Promise { }); } -function validateVaultOrExit(vaultArg: string, plain: boolean): string { +function validateVault(vaultArg: string, plain: boolean): { vault: string; error?: string } { const vault = resolve(expandHome(vaultArg)); + if (!existsSync(vault)) { + return { vault, error: formatError(Errors.VAULT_NOT_FOUND(vault)) }; + } + if (!plain) { const obsidianDir = join(vault, ".obsidian"); if (!existsSync(obsidianDir)) { - console.error(` -Warning: Obsidian vault not detected at: ${vault} + return { vault, error: formatError(Errors.VAULT_NOT_OBSIDIAN(vault)) }; + } + } -1. Install Obsidian: https://obsidian.md/download -2. Open the folder as a vault, then run: - agentlog init /path/to/your/vault + return { vault }; +} -Or to write to a plain folder: - agentlog init --plain ~/notes -`); - process.exit(1); - } - } else if (!existsSync(vault)) { - console.error(`Error: directory not found: ${vault}`); +function validateVaultOrExit(vaultArg: string, plain: boolean): string { + const { vault, error } = validateVault(vaultArg, plain); + if (error) { + console.error(error); process.exit(1); } - return vault; } @@ -127,16 +108,6 @@ function printObsidianCliStatus(plain: boolean): void { } } -function printVersion(): void { - try { - const pkgPath = join(import.meta.dir ?? new URL(".", import.meta.url).pathname, "..", "package.json"); - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string }; - if (pkg.version) console.log(`agentlog v${pkg.version}\n`); - } catch { - // version display is best-effort - } -} - function detectBinary(bin: "agentlog" | "codex"): string { const result = spawnSync("/bin/sh", ["-lc", `command -v ${bin}`], { encoding: "utf-8", @@ -148,38 +119,6 @@ function detectBinary(bin: "agentlog" | "codex"): string { type InitTarget = "claude" | "codex" | "all"; type UninstallTarget = "claude" | "codex" | "all"; -function parseInitArgs(args: string[]): { plain: boolean; target: InitTarget; vaultArg: string } { - const plain = args.includes("--plain"); - const hasClaude = args.includes("--claude"); - const hasCodex = args.includes("--codex"); - const hasAll = args.includes("--all"); - - if ((hasAll && (hasClaude || hasCodex)) || (hasClaude && hasCodex)) { - console.error("Error: choose exactly one target: --claude, --codex, or --all"); - process.exit(1); - } - - const target: InitTarget = hasAll ? "all" : hasCodex ? "codex" : "claude"; - const filteredArgs = args.filter( - (arg) => !["--plain", "--claude", "--codex", "--all"].includes(arg) - ); - - return { plain, target, vaultArg: filteredArgs[0] ?? "" }; -} - -function parseUninstallArgs(args: string[]): { skipConfirm: boolean; target: UninstallTarget } { - const skipConfirm = args.includes("-y"); - const hasCodex = args.includes("--codex"); - const hasAll = args.includes("--all"); - - if (hasCodex && hasAll) { - console.error("Error: choose at most one uninstall target: --codex or --all"); - process.exit(1); - } - - return { skipConfirm, target: hasAll ? "all" : hasCodex ? "codex" : "claude" }; -} - async function runInit(vaultArg: string, plain: boolean): Promise { const vault = validateVaultOrExit(vaultArg, plain); @@ -318,44 +257,6 @@ async function interactiveInit( await runner(selected.path, false); } -async function cmdInit(args: string[]): Promise { - const { plain, target, vaultArg } = parseInitArgs(args); - const runner = target === "all" ? runAllInit : target === "codex" ? runCodexInit : runInit; - - if (!vaultArg) { - await interactiveInit(plain, runner); - return; - } - - await runner(vaultArg, plain); -} - -/** agentlog detect — list detected Obsidian vaults */ -async function cmdDetect(): Promise { - const vaults = detectVaults(); - if (vaults.length === 0) { - console.log("No Obsidian vaults detected."); - console.log("\nOptions:"); - console.log(" Install Obsidian: https://obsidian.md"); - console.log(" Or use plain mode: agentlog init --plain ~/path/to/folder"); - return; - } - console.log("Detected Obsidian vaults:"); - vaults.forEach((v, i) => { - console.log(` ${i + 1}) ${v.path}`); - }); - - // CLI detection - const cli = detectCli(); - console.log(""); - if (cli.installed) { - console.log(`Obsidian CLI: ${cli.binPath} (${cli.version ?? "version unknown"})`); - } else { - console.log("Obsidian CLI: not detected"); - console.log(" Enable in Obsidian 1.12+ Settings > General > Command line interface"); - } -} - function uninstallClaude(configDirPath: string): void { // Remove hook from ~/.claude/settings.json const hookRemoved = unregisterHook(); @@ -394,43 +295,115 @@ function uninstallCodex(clearRestoreMetadata: boolean): void { console.log("\nCodex integration uninstalled."); } -async function cmdUninstall(args: string[]): Promise { - const { skipConfirm, target } = parseUninstallArgs(args); - const cfgDir = configDir(); - if (!skipConfirm && process.stdin.isTTY) { - const prompt = target === "all" - ? "Remove AgentLog Claude hook, Codex notify, and config? [y/N]: " - : target === "codex" - ? "Remove AgentLog Codex notify integration? [y/N]: " - : "Remove AgentLog hook and config? [y/N]: "; - const answer = await ask(prompt); - if (answer.toLowerCase() !== "y") { - console.log("Aborted."); - return; +// --- Command implementations --- + +async function cmdInitDryRun(vaultArg: string, plain: boolean, target: string): Promise { + if (!vaultArg && target !== "codex") { + console.error("Error: --dry-run requires a vault path argument"); + process.exit(1); + } + + if (vaultArg) { + const { vault, error } = validateVault(vaultArg, plain); + if (error) { + console.error(error); + process.exit(1); } } - if (target === "codex") { - uninstallCodex(true); + const cfgPath = configPath(); + const claudeSettingsPath = CLAUDE_SETTINGS_PATH; + + console.log("[dry-run] Validation passed. No changes were made."); + if (target === "hook" || target === "all") { + console.log(` Would save config to: ${cfgPath}`); + if (vaultArg) console.log(` vault: ${resolve(expandHome(vaultArg))}${plain ? " (plain mode)" : ""}`); + console.log(` Would register hook in: ${claudeSettingsPath}`); + } + if (target === "codex" || target === "all") { + console.log(` Would register codex notify integration`); + } +} + +async function cmdInit(vaultArg: string | undefined, opts: { plain: boolean; claude: boolean; codex: boolean; all: boolean; dryRun: boolean }): Promise { + const { plain, dryRun } = opts; + + if ((opts.all && (opts.claude || opts.codex)) || (opts.claude && opts.codex)) { + console.error("Error: choose exactly one target: --claude, --codex, or --all"); + process.exit(1); + } + + const target: InitTarget = opts.all ? "all" : opts.codex ? "codex" : "claude"; + + if (dryRun) { + await cmdInitDryRun(vaultArg ?? "", plain, target === "claude" ? "hook" : target); return; } - if (target === "all") { - uninstallCodex(false); - } else { - // Check if Codex notify is still registered - const config = loadConfig(); - if (config?.codexNotifyRestore) { - console.warn( - "⚠️ Codex notify is still registered. Run `agentlog uninstall --all` to also remove it." - ); + const runner = target === "all" ? runAllInit : target === "codex" ? runCodexInit : runInit; + + if (!vaultArg) { + await interactiveInit(plain, runner); + return; + } + + await runner(vaultArg, plain); +} + +async function cmdDetect(opts: { format: string; fields?: string }): Promise { + const vaults = detectVaults(); + const cli = detectCli(); + + if (opts.format === "json") { + let vaultData: Array> = vaults.map((v) => ({ path: v.path })); + const cliData: Record = { + installed: cli.installed, + binPath: cli.binPath ?? null, + version: cli.version ?? null, + }; + + if (opts.fields) { + const fields = opts.fields.split(",").map((f) => f.trim()); + vaultData = vaultData.map((v) => { + const filtered: Record = {}; + for (const f of fields) { + if (f in v) filtered[f] = v[f]; + } + return filtered; + }); + const filteredCli: Record = {}; + for (const f of fields) { + if (f in cliData) filteredCli[f] = cliData[f]; + } + console.log(JSON.stringify({ status: "success", data: { vaults: vaultData, cli: filteredCli } })); + return; } + + console.log(JSON.stringify({ status: "success", data: { vaults: vaultData, cli: cliData } })); + return; } - uninstallClaude(cfgDir); + if (vaults.length === 0) { + console.log("No Obsidian vaults detected."); + console.log("\nOptions:"); + console.log(" Install Obsidian: https://obsidian.md"); + console.log(" Or use plain mode: agentlog init --plain ~/path/to/folder"); + return; + } + console.log("Detected Obsidian vaults:"); + vaults.forEach((v, i) => { + console.log(` ${i + 1}) ${v.path}`); + }); + + console.log(""); + if (cli.installed) { + console.log(`Obsidian CLI: ${cli.binPath} (${cli.version ?? "version unknown"})`); + } else { + console.log("Obsidian CLI: not detected"); + console.log(" Enable in Obsidian 1.12+ Settings > General > Command line interface"); + } } -/** agentlog doctor — check installation health */ async function cmdDoctor(): Promise { const version = readVersion(resolvePackageRoot()); if (version) console.log(`${formatVersionHeadline({ version })}\n`); @@ -597,7 +570,6 @@ async function cmdDoctor(): Promise { } } -/** agentlog open — open today's Daily Note in Obsidian via CLI */ async function cmdOpen(): Promise { const proc = spawnSync("obsidian", ["daily"], { encoding: "utf-8", @@ -612,26 +584,34 @@ async function cmdOpen(): Promise { } } -async function cmdCodexDebug(args: string[]): Promise { - const prompt = args.join(" ").trim(); - if (!prompt) { +async function cmdHook(): Promise { + // Dynamically import hook to avoid loading it unless needed + await import("./hook.js"); +} + +async function cmdCodexNotify(outputFile: string | undefined): Promise { + const { runCodexNotify } = await import("./codex-notify.js"); + await runCodexNotify(outputFile); +} + +async function cmdCodexDebug(prompt: string[]): Promise { + const text = prompt.join(" ").trim(); + if (!text) { console.error("Error: prompt is required"); process.exit(1); } // Ensure codex notify is registered so logging works - const { registerCodexNotify } = await import("./codex-settings.js"); const config = loadConfig(); const result = registerCodexNotify(config?.codexNotifyRestore ?? null); if (result.changed) { console.log("[agentlog] codex notify registered"); - // Persist restore state so uninstall can undo it if (config) { saveConfig({ ...config, codexNotifyRestore: result.restoreNotify }); } } - const proc = spawnSync("codex", ["exec", "--", prompt], { + const proc = spawnSync("codex", ["exec", "--", text], { stdio: "inherit", }); @@ -643,54 +623,336 @@ async function cmdCodexDebug(args: string[]): Promise { process.exit(proc.status ?? 1); } -async function cmdCodexNotify(args: string[]): Promise { - const { runCodexNotify } = await import("./codex-notify.js"); - const rawArg = args.length > 0 ? args.join(" ") : undefined; - await runCodexNotify(rawArg); +async function cmdVersion(): Promise { + console.log(formatVersionOutput(getRuntimeInfo())); } -async function cmdHook(): Promise { - // Dynamically import hook to avoid loading it unless needed - await import("./hook.js"); +async function cmdUninstall(opts: { y: boolean; codex: boolean; all: boolean; dryRun: boolean }): Promise { + if (opts.codex && opts.all) { + console.error("Error: choose at most one uninstall target: --codex or --all"); + process.exit(1); + } + + const target: UninstallTarget = opts.all ? "all" : opts.codex ? "codex" : "claude"; + const cfgDir = configDir(); + + if (opts.dryRun) { + console.log("[dry-run] Would remove the following:"); + if (target === "claude" || target === "all") { + console.log(` Would remove hook from: ${CLAUDE_SETTINGS_PATH}`); + console.log(` Would remove config dir: ${cfgDir}`); + } + if (target === "codex" || target === "all") { + console.log(` Would restore codex notify: ${CODEX_CONFIG_PATH}`); + } + return; + } + + if (!opts.y && process.stdin.isTTY) { + const prompt = target === "all" + ? "Remove AgentLog Claude hook, Codex notify, and config? [y/N]: " + : target === "codex" + ? "Remove AgentLog Codex notify integration? [y/N]: " + : "Remove AgentLog hook and config? [y/N]: "; + const answer = await ask(prompt); + if (answer.toLowerCase() !== "y") { + console.log("Aborted."); + return; + } + } + + if (target === "codex") { + uninstallCodex(true); + return; + } + + if (target === "all") { + uninstallCodex(false); + } else { + // Check if Codex notify is still registered + const config = loadConfig(); + if (config?.codexNotifyRestore) { + console.warn( + "⚠️ Codex notify is still registered. Run `agentlog uninstall --all` to also remove it." + ); + } + } + + uninstallClaude(cfgDir); } -async function cmdVersion(): Promise { - console.log(formatVersionOutput(getRuntimeInfo())); +async function cmdValidate(): Promise { + let allOk = true; + + // 1. Config check + const config = loadConfig(); + if (!config) { + console.log("config: fail — not configured"); + allOk = false; + } else { + const vaultExists = config.plain + ? existsSync(config.vault) + : existsSync(join(config.vault, ".obsidian")); + if (vaultExists) { + console.log(`config: ok — ${config.vault}${config.plain ? " (plain)" : ""}`); + } else { + console.log(`config: fail — vault not found: ${config.vault}`); + allOk = false; + } + } + + // 2. Hook check + const hookOk = isHookRegistered(); + if (hookOk) { + console.log(`hook: ok — ${CLAUDE_SETTINGS_PATH}`); + } else { + console.log("hook: fail — not registered"); + allOk = false; + } + + if (!allOk) { + process.exit(1); + } } -// --- Main dispatch --- +// --- Schema command data --- + +const SCHEMA_DATA = { + commands: [ + { + name: "init", + description: "Configure Claude hook, Codex notify, or both", + arguments: [{ name: "vault", description: "Path to Obsidian vault or plain folder", required: false }], + options: [ + { flags: "--plain", description: "Write to plain folder without Obsidian timeblock parsing" }, + { flags: "--claude", description: "Register Claude Code hook only (default)" }, + { flags: "--codex", description: "Register Codex notify only" }, + { flags: "--all", description: "Register both Claude hook and Codex notify" }, + { flags: "--dry-run", description: "Show what would happen without making changes" }, + { flags: "--format ", description: "Output format: text or json" }, + ], + }, + { + name: "detect", + description: "List detected Obsidian vaults", + arguments: [], + options: [ + { flags: "--format ", description: "Output format: text or json" }, + { flags: "--fields ", description: "Comma-separated fields to include in JSON output" }, + ], + }, + { + name: "doctor", + description: "Check installation health", + arguments: [], + options: [ + { flags: "--format ", description: "Output format: text or json" }, + ], + }, + { + name: "open", + description: "Open today's Daily Note in Obsidian (CLI)", + arguments: [], + options: [ + { flags: "--format ", description: "Output format: text or json" }, + ], + }, + { + name: "uninstall", + description: "Remove Claude hook, Codex notify, or both", + arguments: [], + options: [ + { flags: "-y", description: "Skip confirmation prompt" }, + { flags: "--codex", description: "Remove Codex notify only" }, + { flags: "--all", description: "Remove both Claude hook and Codex notify" }, + { flags: "--dry-run", description: "Show what would happen without making changes" }, + { flags: "--format ", description: "Output format: text or json" }, + ], + }, + { + name: "validate", + description: "Validate installation (machine-readable pass/fail)", + arguments: [], + options: [], + }, + { + name: "version", + description: "Print version and build identity", + arguments: [], + options: [ + { flags: "--format ", description: "Output format: text or json" }, + ], + }, + { + name: "codex-debug", + description: "Run codex exec with a test prompt", + arguments: [{ name: "prompt", description: "Prompt text to send to codex exec", required: true }], + options: [], + }, + { + name: "hook", + description: "Run hook (called by Claude Code UserPromptSubmit)", + arguments: [], + options: [], + }, + { + name: "codex-notify", + description: "Run notify handler (called by Codex on agent-turn-complete)", + arguments: [{ name: "output-file", description: "Output file path", required: false }], + options: [], + }, + { + name: "schema", + description: "List all commands with their options and descriptions", + arguments: [{ name: "command", description: "Command name to show schema for", required: false }], + options: [], + }, + ], +}; + +async function cmdSchema(commandName: string | undefined): Promise { + if (commandName) { + const cmd = SCHEMA_DATA.commands.find((c) => c.name === commandName); + if (!cmd) { + console.log(JSON.stringify({ status: "error", error: `Unknown command: ${commandName}` })); + process.exit(1); + } + console.log(JSON.stringify({ status: "success", data: cmd })); + return; + } + console.log(JSON.stringify({ status: "success", data: SCHEMA_DATA })); +} -const [, , command, ...rest] = process.argv; +// --- Commander program setup --- -switch (command) { - case "init": - await cmdInit(rest); - break; - case "detect": - await cmdDetect(); - break; - case "doctor": +const program = new Command(); + +program + .name("agentlog") + .description("Auto-log Claude Code prompts to Obsidian Daily Notes") + .allowUnknownOption(true) + .configureOutput({ + writeOut: (str) => process.stdout.write(str), + writeErr: (str) => process.stdout.write(str), + }) + .addHelpText("after", ` +Examples: + agentlog init ~/path/to/vault + agentlog detect + agentlog doctor + +Options: + --plain Write to plain folder without Obsidian timeblock parsing + -y Skip confirmation prompt (for uninstall) +`); + +program + .command("init [vault]") + .description("Configure Claude hook, Codex notify, or both") + .option("--plain", "Write to plain folder without Obsidian timeblock parsing", false) + .option("--claude", "Register Claude Code hook only (default)", false) + .option("--codex", "Register Codex notify only", false) + .option("--all", "Register both Claude hook and Codex notify", false) + .option("--dry-run", "Show what would happen without making changes", false) + .option("--format ", "Output format: text or json", "text") + .action(async (vault: string | undefined, opts: { plain: boolean; claude: boolean; codex: boolean; all: boolean; dryRun: boolean; format: string }) => { + await cmdInit(vault, opts); + }); + +program + .command("detect") + .description("List detected Obsidian vaults") + .option("--format ", "Output format: text or json", "text") + .option("--fields ", "Comma-separated fields to include in JSON output") + .action(async (opts: { format: string; fields?: string }) => { + await cmdDetect(opts); + }); + +program + .command("doctor") + .description("Check installation health") + .option("--format ", "Output format: text or json", "text") + .action(async (_opts: { format: string }) => { await cmdDoctor(); - break; - case "codex-debug": - await cmdCodexDebug(rest); - break; - case "codex-notify": - await cmdCodexNotify(rest); - break; - case "open": + }); + +program + .command("open") + .description("Open today's Daily Note in Obsidian (CLI)") + .option("--format ", "Output format: text or json", "text") + .action(async (_opts: { format: string }) => { await cmdOpen(); - break; - case "uninstall": - await cmdUninstall(rest); - break; - case "version": + }); + +program + .command("validate") + .description("Validate installation (machine-readable pass/fail)") + .action(async () => { + await cmdValidate(); + }); + +program + .command("uninstall") + .description("Remove Claude hook, Codex notify, or both") + .option("-y", "Skip confirmation prompt", false) + .option("--codex", "Remove Codex notify only", false) + .option("--all", "Remove both Claude hook and Codex notify", false) + .option("--dry-run", "Show what would happen without making changes", false) + .option("--format ", "Output format: text or json", "text") + .action(async (opts: { y: boolean; codex: boolean; all: boolean; dryRun: boolean; format: string }) => { + await cmdUninstall(opts); + }); + +program + .command("version") + .description("Print version and build identity") + .option("--format ", "Output format: text or json", "text") + .action(async (_opts: { format: string }) => { await cmdVersion(); - break; - case "hook": + }); + +program + .command("codex-debug") + .description("Run codex exec with a test prompt") + .allowUnknownOption(true) + .helpOption(false) + .argument("[prompt...]", "Prompt text to send to codex exec") + .action(async (prompt: string[]) => { + await cmdCodexDebug(prompt); + }); + +program + .command("hook") + .description("Run hook (called by Claude Code UserPromptSubmit)") + .action(async () => { await cmdHook(); - break; - default: - usage(); - process.exit(command ? 1 : 0); + }); + +program + .command("codex-notify [output-file]") + .description("Run notify handler (called by Codex on agent-turn-complete)") + .action(async (outputFile: string | undefined) => { + await cmdCodexNotify(outputFile); + }); + +program + .command("schema [command]") + .description("List all commands with their options and descriptions as JSON") + .action(async (commandName: string | undefined) => { + await cmdSchema(commandName); + }); + +// Handle unknown commands +program.on("command:*", (operands: string[]) => { + console.error(`error: unknown command '${operands[0]}'`); + console.error(`\nUsage: agentlog [command]\nRun 'agentlog --help' for available commands.`); + process.exit(1); +}); + +await program.parseAsync(process.argv); + +// If no subcommand was invoked, print help and exit 0 +if (!process.argv.slice(2).length) { + program.outputHelp(); + process.exit(0); } diff --git a/src/codex-notify.ts b/src/codex-notify.ts index 8f89605..4711680 100644 --- a/src/codex-notify.ts +++ b/src/codex-notify.ts @@ -56,6 +56,7 @@ export async function runCodexNotify(rawArg?: string): Promise { sessionId: parsed.sessionId, project: cwdToProject(parsed.cwd), cwd: parsed.cwd, + source: "codex", }, now ); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..375821c --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,63 @@ +/** + * Structured error definitions for AgentLog CLI. + */ + +export interface AgentLogError { + code: string; + message: string; + fix: string; + docs?: string; +} + +export const Errors = { + VAULT_NOT_FOUND: (vault: string): AgentLogError => ({ + code: "VAULT_NOT_FOUND", + message: `Directory not found: ${vault}`, + fix: `run: agentlog init ~/path/to/vault`, + }), + + VAULT_NOT_OBSIDIAN: (vault: string): AgentLogError => ({ + code: "VAULT_NOT_OBSIDIAN", + message: `Obsidian vault not detected at: ${vault}`, + fix: `Open the folder as a vault in Obsidian, then run: agentlog init ${vault}\nOr to write to a plain folder: agentlog init --plain ~/notes`, + docs: "https://obsidian.md/download", + }), + + CONFIG_NOT_FOUND: (): AgentLogError => ({ + code: "CONFIG_NOT_FOUND", + message: "AgentLog is not configured", + fix: "run: agentlog init ~/path/to/vault", + }), + + HOOK_NOT_REGISTERED: (): AgentLogError => ({ + code: "HOOK_NOT_REGISTERED", + message: "AgentLog hook is not registered in Claude settings", + fix: "run: agentlog init", + }), + + CLI_NOT_FOUND: (): AgentLogError => ({ + code: "CLI_NOT_FOUND", + message: "Obsidian CLI not found in PATH", + fix: "Enable CLI in Obsidian 1.12+ Settings > General > Command line interface", + }), + + APP_NOT_RESPONDING: (): AgentLogError => ({ + code: "APP_NOT_RESPONDING", + message: "Obsidian app is not responding", + fix: "Start the Obsidian app, or check CLI settings", + }), + + PROMPT_REQUIRED: (): AgentLogError => ({ + code: "PROMPT_REQUIRED", + message: "A prompt argument is required for codex-debug", + fix: 'run: agentlog codex-debug "your prompt text"', + }), +} as const; + +export function formatError(err: AgentLogError): string { + return `Error: ${err.message}\n Fix: ${err.fix}`; +} + +export function toJsonError(err: AgentLogError): AgentLogError & { status: "error" } { + return { status: "error", ...err }; +} diff --git a/src/hook.ts b/src/hook.ts index 07a47ad..efca1a8 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -60,6 +60,7 @@ async function main(): Promise { sessionId: parsed.sessionId, project: cwdToProject(parsed.cwd), cwd: parsed.cwd, + source: "claude" as const, }; try { diff --git a/src/note-writer.ts b/src/note-writer.ts index faaa2f8..8bd96d8 100644 --- a/src/note-writer.ts +++ b/src/note-writer.ts @@ -119,8 +119,8 @@ function insertIntoAgentLogSection(content: string, entry: LogEntry): string { const metaRe = /^$/; const legacyMetaRe = /^$/; const legacyHeaderRe = /^#### .+ $/; - // Match both new [[ses_...]] and legacy (ses_...) formats for backward compat - const dividerRe = /^- - - - (?:\[\[ses_([\w-]+)\]\]|\(ses_([\w-]+)\))$/; + // Match current [[claude_...]]/[[codex_...]] and legacy [[ses_...]]/(ses_...) dividers + const dividerRe = /^- - - - (?:\[\[(?:claude|codex|ses)_([\w-]+)\]\]|\(ses_([\w-]+)\))$/; let projectIdx = -1; let projectMetaIdx = -1; // -1 means legacy inline format (no separate metadata line) @@ -180,7 +180,7 @@ function insertIntoAgentLogSection(content: string, entry: LogEntry): string { if (prevLine !== "" && prevLine !== "## AgentLog") { newSection.push(""); } - newSection.push(header, meta, buildSessionDivider(entry.sessionId), entryLine); + newSection.push(header, meta, buildSessionDivider(entry.sessionId, entry.source), entryLine); lines.splice(agentLogEnd, 0, ...newSection); } else { // Existing project: find end of this subsection @@ -203,13 +203,13 @@ function insertIntoAgentLogSection(content: string, entry: LogEntry): string { let currentSes = ""; for (let i = firstContentIdx; i < subsectionEnd; i++) { const m = lines[i].match(dividerRe); - if (m) currentSes = m[1] ?? m[2]; // group 1: new [[ses_...]], group 2: legacy (ses_...) + if (m) currentSes = m[1] ?? m[2]; } if (!currentSes) currentSes = legacySes; if (currentSes !== sessionShort) { // Session changed: insert divider + entry. - lines.splice(insertAt, 0, buildSessionDivider(entry.sessionId), entryLine); + lines.splice(insertAt, 0, buildSessionDivider(entry.sessionId, entry.source), entryLine); // Migrate legacy metadata format to new format (remove ses=). if (projectMetaIdx !== -1 && lines[projectMetaIdx].match(legacyMetaRe)) { lines[projectMetaIdx] = buildProjectMetadata(entry.cwd); diff --git a/src/schema/daily-note.ts b/src/schema/daily-note.ts index 3cbe35d..1ec710b 100644 --- a/src/schema/daily-note.ts +++ b/src/schema/daily-note.ts @@ -5,6 +5,8 @@ * appends log entries into. */ +import type { SourceType } from "../types.js"; + /** Korean day names indexed by JS getDay() (0=Sunday) */ export const KO_DAYS = ["일", "월", "화", "수", "목", "금", "토"] as const; @@ -45,10 +47,10 @@ export function buildAgentLogEntry(time: string, prompt: string): string { /** * Session divider line inserted when session_id changes within a project section. * Uses Obsidian wiki-link format so the session ID becomes a navigable link. - * Format: "- - - - [[ses_XXXXXXXX]]" + * Format: "- - - - [[claude_XXXXXXXX]]" or "- - - - [[codex_XXXXXXXX]]" */ -export function buildSessionDivider(sessionId: string): string { - return `- - - - [[ses_${sessionId.slice(0, 8)}]]`; +export function buildSessionDivider(sessionId: string, source: SourceType = "claude"): string { + return `- - - - [[${source}_${sessionId.slice(0, 8)}]]`; } /** diff --git a/src/types.ts b/src/types.ts index e55f4fd..a8c66c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,9 @@ export interface AgentLogConfig { codexNotifyRestore?: string[] | null; } +/** Source of the log entry — which AI tool produced it */ +export type SourceType = "claude" | "codex"; + /** A single log entry to be written into a Daily Note */ export interface LogEntry { time: string; // "HH:MM" @@ -14,6 +17,7 @@ export interface LogEntry { sessionId: string; // from hook session_id project: string; // derived from cwd: parent/basename cwd: string; // full cwd path, used as section matching key + source: SourceType; } /** Result of writing a log entry */