From b0f5b3bab9480aa52a9b2a069f3bb943468a0ef2 Mon Sep 17 00:00:00 2001 From: "pray.yoon" Date: Fri, 13 Mar 2026 23:06:09 +0900 Subject: [PATCH 1/7] ci: add develop branch workflow with auto-release on main merge - Create ci.yml: test/typecheck/build on PR and develop push - Rewrite release.yml: auto GitHub Release + npm publish on main push - Remove duplicate publish.yml - Default branch changed to develop Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 29 ++++++++++++++++++ .github/workflows/publish.yml | 32 ------------------- .github/workflows/release.yml | 58 +++++++++++++++++++++++++---------- 3 files changed, 70 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/publish.yml 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..abbf815 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,39 +2,63 @@ 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 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: bun install - - name: Run tests - run: bun test - - - name: Run typecheck + - name: Typecheck run: bun run typecheck + - name: Test + run: bun test + - name: Build run: bun run build - - name: Upload package to npm + - 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: 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 From df9198a7627e8bfd06f53683e9ed562a66939b81 Mon Sep 17 00:00:00 2001 From: "pray.yoon" Date: Fri, 13 Mar 2026 23:07:52 +0900 Subject: [PATCH 2/7] =?UTF-8?q?ci:=20auto=20version=20bump=20on=20develop?= =?UTF-8?q?=E2=86=92main=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Determine bump type from conventional commits (feat→minor, fix→patch, breaking→major) - Auto-bump package.json version before release - Skip release commits to prevent infinite loop - Commit version bump back to main Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 61 ++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abbf815..cc4e9a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,11 +7,16 @@ on: jobs: release: runs-on: ubuntu-latest + # Skip release commits to avoid infinite loop + if: "!startsWith(github.event.head_commit.message, 'chore(release):')" permissions: contents: write id-token: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - uses: oven-sh/setup-bun@v2 with: @@ -29,21 +34,55 @@ jobs: - name: Build run: bun run build - - 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 + - name: Determine version bump type + id: bump run: | - if git ls-remote --tags origin "v${{ steps.version.outputs.version }}" | grep -q .; then - echo "exists=true" >> "$GITHUB_OUTPUT" + # Get commits since last tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$LAST_TAG" ]; then + COMMITS=$(git log --oneline --format="%s") else - echo "exists=false" >> "$GITHUB_OUTPUT" + COMMITS=$(git log --oneline --format="%s" "${LAST_TAG}..HEAD") fi + # Determine bump type from conventional commits + if echo "$COMMITS" | grep -qiE "^(feat|feature)(\(.+\))?!:|^BREAKING CHANGE"; then + echo "type=major" >> "$GITHUB_OUTPUT" + elif echo "$COMMITS" | grep -qiE "^(feat|feature)(\(.+\))?:"; then + echo "type=minor" >> "$GITHUB_OUTPUT" + else + echo "type=patch" >> "$GITHUB_OUTPUT" + fi + + - name: Bump version + id: version + run: | + CURRENT=$(jq -r .version package.json) + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + + case "${{ steps.bump.outputs.type }}" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + echo "version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" + echo "Bumping $CURRENT -> $NEW_VERSION (${{ steps.bump.outputs.type }})" + + # Update package.json + jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + - name: Commit version bump + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json + git commit -m "chore(release): v${{ steps.version.outputs.version }}" + git push origin main + - 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 }} @@ -52,13 +91,11 @@ jobs: 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 }} From 45584648a008611135b348bcd0d005085bec2d99 Mon Sep 17 00:00:00 2001 From: "pray.yoon" Date: Fri, 13 Mar 2026 23:10:43 +0900 Subject: [PATCH 3/7] ci: simplify release to use manual version from package.json - Remove auto version bump logic, use package.json version directly - Developer bumps version on develop before merging to main - Skip release if tag already exists (no duplicate releases) - Skip build/test steps early when no release needed Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 78 +++++++++++------------------------ 1 file changed, 25 insertions(+), 53 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc4e9a8..8ad009b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,82 +7,52 @@ on: jobs: release: runs-on: ubuntu-latest - # Skip release commits to avoid infinite loop - if: "!startsWith(github.event.head_commit.message, 'chore(release):')" permissions: contents: write id-token: write steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + + - 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: 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: Determine version bump type - id: bump - run: | - # Get commits since last tag - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -z "$LAST_TAG" ]; then - COMMITS=$(git log --oneline --format="%s") - else - COMMITS=$(git log --oneline --format="%s" "${LAST_TAG}..HEAD") - fi - - # Determine bump type from conventional commits - if echo "$COMMITS" | grep -qiE "^(feat|feature)(\(.+\))?!:|^BREAKING CHANGE"; then - echo "type=major" >> "$GITHUB_OUTPUT" - elif echo "$COMMITS" | grep -qiE "^(feat|feature)(\(.+\))?:"; then - echo "type=minor" >> "$GITHUB_OUTPUT" - else - echo "type=patch" >> "$GITHUB_OUTPUT" - fi - - - name: Bump version - id: version - run: | - CURRENT=$(jq -r .version package.json) - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" - - case "${{ steps.bump.outputs.type }}" in - major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - minor) MINOR=$((MINOR + 1)); PATCH=0 ;; - patch) PATCH=$((PATCH + 1)) ;; - esac - - NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" - echo "version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" - echo "Bumping $CURRENT -> $NEW_VERSION (${{ steps.bump.outputs.type }})" - - # Update package.json - jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json - - - name: Commit version bump - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json - git commit -m "chore(release): v${{ steps.version.outputs.version }}" - git push origin main - - 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 }} @@ -91,11 +61,13 @@ jobs: 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 }} From e3df4adb133283817a0d5fc67d8fc75a2bcd7bba Mon Sep 17 00:00:00 2001 From: "pray.yoon" Date: Fri, 13 Mar 2026 23:13:55 +0900 Subject: [PATCH 4/7] fix: add fake agentlog binary to codex test PATH for CI The doctor command checks for agentlog in PATH. On CI (Linux) it's not installed globally, causing the test to fail. Add a fake agentlog binary alongside the fake codex binary in the test helper. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/cli-codex.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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"}`; } From 104e5fd418d3e5035bfcc1d3a7d90cc0438e4dc2 Mon Sep 17 00:00:00 2001 From: Albireo3754 <40790219+albireo3754@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:53:59 +0900 Subject: [PATCH 5/7] feat: add dry-run, validate command, and structured errors (#18) * feat: add dry-run support, validate command, and structured errors - Add src/errors.ts with typed error codes and actionable fix messages - Add --dry-run flag to init and uninstall commands - Add validate command for machine-readable health checks - Replace raw console.error strings with structured error formatting Co-Authored-By: Claude Opus 4.6 * fix: address PR review feedback - Distinguish VAULT_NOT_FOUND vs VAULT_NOT_OBSIDIAN in validateVault - Pass target to cmdInitDryRun so --codex/--all dry-run shows correct actions - Distinguish missing vs corrupted config in cmdValidate - Type toJsonError return as AgentLogError & { status: "error" } Co-Authored-By: Claude Opus 4.6 * test: add tests for --dry-run and validate command - init --dry-run: valid vault, nonexistent path, missing arg, plain mode - uninstall --dry-run: no side effects verification - validate: all-ok, no-config, missing-hook scenarios Co-Authored-By: Claude Opus 4.6 * fix: resolve TS2352 type cast error in errors test Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/__tests__/cli.test.ts | 178 +++++++++++++++++++++++++++++++++++ src/__tests__/errors.test.ts | 74 +++++++++++++++ src/cli.ts | 142 +++++++++++++++++++++++----- src/errors.ts | 63 +++++++++++++ 4 files changed, 434 insertions(+), 23 deletions(-) create mode 100644 src/__tests__/errors.test.ts create mode 100644 src/errors.ts diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 91735d5..32a17bf 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -363,6 +363,184 @@ printf '%s\n' "$@" > "${argsFile}" }); }); +describe("cli init --dry-run", () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = makeTmpHome(); + }); + + afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }); + }); + + // DR1: valid vault → exits 0, prints [dry-run] and Would save config, no side effects + 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"); + + // No side effects + const configFile = join(tmpHome, ".agentlog", "config.json"); + const settingsFile = join(tmpHome, ".claude", "settings.json"); + expect(existsSync(configFile)).toBe(false); + expect(existsSync(settingsFile)).toBe(false); + }); + + // DR2: nonexistent vault → exits 1, stderr contains error + 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(); + }); + + // DR3: no vault arg → exits 1, stderr contains "requires a vault path" + 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"); + }); + + // DR4: --plain --dry-run → exits 0, stdout contains "plain mode" + 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 }); + }); + + // UD1: config + hook present → exits 0, prints "Would remove", no side effects + it("exits 0 and prints Would remove without modifying config or settings", async () => { + // Set up config + 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" + ); + // Set up hook in settings.json + 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"); + + // No side effects — config and settings still exist with original content + 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 }); + }); + + // V1: all configured → exits 0, stdout contains "config: ok" and "hook: ok" + 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"); + }); + + // V2: no config → exits 1, stdout contains "config: fail" + 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"); + }); + + // V3: config present but hook not registered → exits 1, stdout contains "hook: 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" + ); + // No settings.json → hook not registered + + 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__/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/cli.ts b/src/cli.ts index d3d20ff..2376578 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, @@ -32,14 +33,15 @@ import * as readline from "readline"; function usage(): void { console.log(`Usage: - agentlog init [vault] [--plain] [--claude | --codex | --all] + agentlog init [vault] [--plain] [--claude | --codex | --all] [--dry-run] 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 validate Validate installation (machine-readable pass/fail) agentlog open Open today's Daily Note in Obsidian (CLI) - agentlog uninstall [-y] [--codex | --all] + agentlog uninstall [-y] [--codex | --all] [--dry-run] Remove Claude hook, Codex notify, or both agentlog version Print version and build identity agentlog hook Run hook (called by Claude Code) @@ -48,6 +50,7 @@ function usage(): void { Options: --plain Write to plain folder without Obsidian timeblock parsing -y Skip confirmation prompt (for uninstall) + --dry-run Show what would happen without making changes `); } @@ -61,29 +64,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; } @@ -148,11 +151,12 @@ 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 } { +function parseInitArgs(args: string[]): { plain: boolean; target: InitTarget; vaultArg: string; dryRun: boolean } { const plain = args.includes("--plain"); const hasClaude = args.includes("--claude"); const hasCodex = args.includes("--codex"); const hasAll = args.includes("--all"); + const dryRun = args.includes("--dry-run"); if ((hasAll && (hasClaude || hasCodex)) || (hasClaude && hasCodex)) { console.error("Error: choose exactly one target: --claude, --codex, or --all"); @@ -161,23 +165,24 @@ function parseInitArgs(args: string[]): { plain: boolean; target: InitTarget; va const target: InitTarget = hasAll ? "all" : hasCodex ? "codex" : "claude"; const filteredArgs = args.filter( - (arg) => !["--plain", "--claude", "--codex", "--all"].includes(arg) + (arg) => !["--plain", "--claude", "--codex", "--all", "--dry-run"].includes(arg) ); - return { plain, target, vaultArg: filteredArgs[0] ?? "" }; + return { plain, target, vaultArg: filteredArgs[0] ?? "", dryRun }; } -function parseUninstallArgs(args: string[]): { skipConfirm: boolean; target: UninstallTarget } { +function parseUninstallArgs(args: string[]): { skipConfirm: boolean; target: UninstallTarget; dryRun: boolean } { const skipConfirm = args.includes("-y"); const hasCodex = args.includes("--codex"); const hasAll = args.includes("--all"); + const dryRun = args.includes("--dry-run"); 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" }; + return { skipConfirm, target: hasAll ? "all" : hasCodex ? "codex" : "claude", dryRun }; } async function runInit(vaultArg: string, plain: boolean): Promise { @@ -318,8 +323,42 @@ async function interactiveInit( await runner(selected.path, false); } +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); + } + } + + 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(args: string[]): Promise { - const { plain, target, vaultArg } = parseInitArgs(args); + const { plain, target, vaultArg, dryRun } = parseInitArgs(args); + + if (dryRun) { + await cmdInitDryRun(vaultArg, plain, target); + return; + } + const runner = target === "all" ? runAllInit : target === "codex" ? runCodexInit : runInit; if (!vaultArg) { @@ -395,8 +434,21 @@ function uninstallCodex(clearRestoreMetadata: boolean): void { } async function cmdUninstall(args: string[]): Promise { - const { skipConfirm, target } = parseUninstallArgs(args); + const { skipConfirm, target, dryRun } = parseUninstallArgs(args); const cfgDir = configDir(); + + if (dryRun) { + const claudeSettingsPath = CLAUDE_SETTINGS_PATH; + if (target === "codex" || target === "all") { + console.log(`[dry-run] Would restore/remove Codex notify: ${CODEX_CONFIG_PATH}`); + } + if (target === "claude" || target === "all") { + console.log(`[dry-run] Would remove hook from: ${claudeSettingsPath}`); + console.log(`[dry-run] Would delete config at: ${cfgDir}`); + } + console.log("[dry-run] No changes were made."); + return; + } if (!skipConfirm && process.stdin.isTTY) { const prompt = target === "all" ? "Remove AgentLog Claude hook, Codex notify, and config? [y/N]: " @@ -658,6 +710,47 @@ async function cmdVersion(): Promise { console.log(formatVersionOutput(getRuntimeInfo())); } +/** agentlog validate — structured pass/fail checks, exit 0 if all required pass */ +async function cmdValidate(): Promise { + let allOk = true; + const results: Array<{ check: string; status: "ok" | "fail"; detail: string }> = []; + + function check(name: string, ok: boolean, detail: string): void { + results.push({ check: name, status: ok ? "ok" : "fail", detail }); + if (!ok) allOk = false; + } + + // 1. Config present + const config = loadConfig(); + const cfgExists = existsSync(configPath()); + check("config", !!config, config ? configPath() : cfgExists ? "invalid config.json (parse error)" : "no config.json"); + + // 2. Vault exists + if (config) { + if (config.plain) { + const vaultOk = existsSync(config.vault); + check("vault", vaultOk, vaultOk ? config.vault : `not found: ${config.vault}`); + } else { + const vaultOk = existsSync(join(config.vault, ".obsidian")); + check("vault", vaultOk, vaultOk ? config.vault : `.obsidian not found: ${config.vault}`); + } + } else { + check("vault", false, "skipped (no config)"); + } + + // 3. Hook registered + const hookOk = isHookRegistered(); + check("hook", hookOk, hookOk ? CLAUDE_SETTINGS_PATH : "not registered"); + + for (const r of results) { + console.log(`${r.check}: ${r.status} (${r.detail})`); + } + + if (!allOk) { + process.exit(1); + } +} + // --- Main dispatch --- const [, , command, ...rest] = process.argv; @@ -672,6 +765,9 @@ switch (command) { case "doctor": await cmdDoctor(); break; + case "validate": + await cmdValidate(); + break; case "codex-debug": await cmdCodexDebug(rest); break; 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 }; +} From 7af8045e73c024e09608122916f16465a6c55c55 Mon Sep 17 00:00:00 2001 From: Albireo3754 <40790219+albireo3754@users.noreply.github.com> Date: Sat, 14 Mar 2026 16:12:48 +0900 Subject: [PATCH 6/7] refactor: migrate CLI to Commander.js with safe-ops features (#22) - Replace manual arg parsing with Commander.js - Add structured errors (errors.ts with AgentLogError types) - Add --dry-run flag to init and uninstall commands - Add validate command (machine-readable pass/fail) - Add codex-debug command - Add schema command for agent-friendly CLI discovery - Add detect --format json with --fields filtering - Update CI workflow and add publish workflow Co-authored-by: Claude Sonnet 4.6 --- bun.lock | 5 + package.json | 3 + src/__tests__/cli.test.ts | 13 - src/cli.ts | 638 ++++++++++++++++++++++++-------------- 4 files changed, 410 insertions(+), 249 deletions(-) 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/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.test.ts b/src/__tests__/cli.test.ts index 32a17bf..d6ebf6c 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -374,7 +374,6 @@ describe("cli init --dry-run", () => { rmSync(tmpHome, { recursive: true, force: true }); }); - // DR1: valid vault → exits 0, prints [dry-run] and Would save config, no side effects 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 }); @@ -385,14 +384,12 @@ describe("cli init --dry-run", () => { expect(stdout).toContain("[dry-run]"); expect(stdout).toContain("No changes were made"); - // No side effects const configFile = join(tmpHome, ".agentlog", "config.json"); const settingsFile = join(tmpHome, ".claude", "settings.json"); expect(existsSync(configFile)).toBe(false); expect(existsSync(settingsFile)).toBe(false); }); - // DR2: nonexistent vault → exits 1, stderr contains error it("exits 1 with error when vault path does not exist", async () => { const { stderr, exitCode } = await runCli( ["init", "--dry-run", join(tmpHome, "nonexistent")], @@ -403,7 +400,6 @@ describe("cli init --dry-run", () => { expect(stderr).toBeTruthy(); }); - // DR3: no vault arg → exits 1, stderr contains "requires a vault path" it("exits 1 with 'requires a vault path' when no vault arg", async () => { const { stderr, exitCode } = await runCli(["init", "--dry-run"], { HOME: tmpHome }); @@ -411,7 +407,6 @@ describe("cli init --dry-run", () => { expect(stderr).toContain("requires a vault path"); }); - // DR4: --plain --dry-run → exits 0, stdout contains "plain mode" it("exits 0 and mentions plain mode with --plain flag", async () => { const notes = join(tmpHome, "notes"); mkdirSync(notes, { recursive: true }); @@ -438,9 +433,7 @@ describe("cli uninstall --dry-run", () => { rmSync(tmpHome, { recursive: true, force: true }); }); - // UD1: config + hook present → exits 0, prints "Would remove", no side effects it("exits 0 and prints Would remove without modifying config or settings", async () => { - // Set up config const vault = join(tmpHome, "Obsidian"); mkdirSync(join(vault, ".obsidian"), { recursive: true }); mkdirSync(join(tmpHome, ".agentlog"), { recursive: true }); @@ -449,7 +442,6 @@ describe("cli uninstall --dry-run", () => { JSON.stringify({ vault }), "utf-8" ); - // Set up hook in settings.json mkdirSync(join(tmpHome, ".claude"), { recursive: true }); const settingsContent = JSON.stringify({ hooks: { @@ -465,7 +457,6 @@ describe("cli uninstall --dry-run", () => { expect(exitCode).toBe(0); expect(stdout).toContain("Would remove"); - // No side effects — config and settings still exist with original content 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")); @@ -484,7 +475,6 @@ describe("cli validate", () => { rmSync(tmpHome, { recursive: true, force: true }); }); - // V1: all configured → exits 0, stdout contains "config: ok" and "hook: ok" it("exits 0 with config: ok and hook: ok when fully configured", async () => { const vault = join(tmpHome, "Obsidian"); mkdirSync(join(vault, ".obsidian"), { recursive: true }); @@ -514,7 +504,6 @@ describe("cli validate", () => { expect(stdout).toContain("hook: ok"); }); - // V2: no config → exits 1, stdout contains "config: fail" it("exits 1 with config: fail when no config present", async () => { const { stdout, exitCode } = await runCli(["validate"], { HOME: tmpHome }); @@ -522,7 +511,6 @@ describe("cli validate", () => { expect(stdout).toContain("config: fail"); }); - // V3: config present but hook not registered → exits 1, stdout contains "hook: 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 }); @@ -532,7 +520,6 @@ describe("cli validate", () => { JSON.stringify({ vault }), "utf-8" ); - // No settings.json → hook not registered const { stdout, exitCode } = await runCli(["validate"], { HOME: tmpHome }); diff --git a/src/cli.ts b/src/cli.ts index 2376578..79f3a2f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -30,29 +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] [--dry-run] - 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 validate Validate installation (machine-readable pass/fail) - agentlog open Open today's Daily Note in Obsidian (CLI) - agentlog uninstall [-y] [--codex | --all] [--dry-run] - 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) - --dry-run Show what would happen without making changes -`); -} +import { Command } from "commander"; function ask(prompt: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -130,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", @@ -151,40 +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; dryRun: boolean } { - const plain = args.includes("--plain"); - const hasClaude = args.includes("--claude"); - const hasCodex = args.includes("--codex"); - const hasAll = args.includes("--all"); - const dryRun = args.includes("--dry-run"); - - 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", "--dry-run"].includes(arg) - ); - - return { plain, target, vaultArg: filteredArgs[0] ?? "", dryRun }; -} - -function parseUninstallArgs(args: string[]): { skipConfirm: boolean; target: UninstallTarget; dryRun: boolean } { - const skipConfirm = args.includes("-y"); - const hasCodex = args.includes("--codex"); - const hasAll = args.includes("--all"); - const dryRun = args.includes("--dry-run"); - - 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", dryRun }; -} - async function runInit(vaultArg: string, plain: boolean): Promise { const vault = validateVaultOrExit(vaultArg, plain); @@ -323,6 +257,46 @@ async function interactiveInit( await runner(selected.path, false); } +function uninstallClaude(configDirPath: string): void { + // Remove hook from ~/.claude/settings.json + const hookRemoved = unregisterHook(); + if (hookRemoved) { + console.log(`Hook removed: ${CLAUDE_SETTINGS_PATH}`); + } else { + console.log(`Hook not found (already removed or never registered)`); + } + + // Remove config directory + if (existsSync(configDirPath)) { + rmSync(configDirPath, { recursive: true, force: true }); + console.log(`Config removed: ${configDirPath}`); + } else { + console.log(`Config not found (already removed)`); + } + + console.log("\nAgentLog uninstalled."); +} + +function uninstallCodex(clearRestoreMetadata: boolean): void { + const config = loadConfig(); + const result = unregisterCodexNotify(config?.codexNotifyRestore ?? null); + if (result.changed) { + console.log(`Codex notify restored: ${CODEX_CONFIG_PATH}`); + } else { + console.log("Codex notify not found (already removed or never registered)"); + } + + if (clearRestoreMetadata && config) { + const next: AgentLogConfig = { ...config }; + delete next.codexNotifyRestore; + saveConfig(next); + } + + console.log("\nCodex integration uninstalled."); +} + +// --- 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"); @@ -351,11 +325,18 @@ async function cmdInitDryRun(vaultArg: string, plain: boolean, target: string): } } -async function cmdInit(args: string[]): Promise { - const { plain, target, vaultArg, dryRun } = parseInitArgs(args); +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); + await cmdInitDryRun(vaultArg ?? "", plain, target === "claude" ? "hook" : target); return; } @@ -369,9 +350,39 @@ async function cmdInit(args: string[]): Promise { await runner(vaultArg, plain); } -/** agentlog detect — list detected Obsidian vaults */ -async function cmdDetect(): Promise { +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; + } + if (vaults.length === 0) { console.log("No Obsidian vaults detected."); console.log("\nOptions:"); @@ -384,8 +395,6 @@ async function cmdDetect(): Promise { 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"})`); @@ -395,94 +404,6 @@ async function cmdDetect(): Promise { } } -function uninstallClaude(configDirPath: string): void { - // Remove hook from ~/.claude/settings.json - const hookRemoved = unregisterHook(); - if (hookRemoved) { - console.log(`Hook removed: ${CLAUDE_SETTINGS_PATH}`); - } else { - console.log(`Hook not found (already removed or never registered)`); - } - - // Remove config directory - if (existsSync(configDirPath)) { - rmSync(configDirPath, { recursive: true, force: true }); - console.log(`Config removed: ${configDirPath}`); - } else { - console.log(`Config not found (already removed)`); - } - - console.log("\nAgentLog uninstalled."); -} - -function uninstallCodex(clearRestoreMetadata: boolean): void { - const config = loadConfig(); - const result = unregisterCodexNotify(config?.codexNotifyRestore ?? null); - if (result.changed) { - console.log(`Codex notify restored: ${CODEX_CONFIG_PATH}`); - } else { - console.log("Codex notify not found (already removed or never registered)"); - } - - if (clearRestoreMetadata && config) { - const next: AgentLogConfig = { ...config }; - delete next.codexNotifyRestore; - saveConfig(next); - } - - console.log("\nCodex integration uninstalled."); -} - -async function cmdUninstall(args: string[]): Promise { - const { skipConfirm, target, dryRun } = parseUninstallArgs(args); - const cfgDir = configDir(); - - if (dryRun) { - const claudeSettingsPath = CLAUDE_SETTINGS_PATH; - if (target === "codex" || target === "all") { - console.log(`[dry-run] Would restore/remove Codex notify: ${CODEX_CONFIG_PATH}`); - } - if (target === "claude" || target === "all") { - console.log(`[dry-run] Would remove hook from: ${claudeSettingsPath}`); - console.log(`[dry-run] Would delete config at: ${cfgDir}`); - } - console.log("[dry-run] No changes were made."); - return; - } - 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; - } - } - - 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); -} - -/** agentlog doctor — check installation health */ async function cmdDoctor(): Promise { const version = readVersion(resolvePackageRoot()); if (version) console.log(`${formatVersionHeadline({ version })}\n`); @@ -649,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", @@ -664,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", }); @@ -695,55 +623,91 @@ 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); + } -async function cmdVersion(): Promise { - console.log(formatVersionOutput(getRuntimeInfo())); + 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); } -/** agentlog validate — structured pass/fail checks, exit 0 if all required pass */ async function cmdValidate(): Promise { let allOk = true; - const results: Array<{ check: string; status: "ok" | "fail"; detail: string }> = []; - - function check(name: string, ok: boolean, detail: string): void { - results.push({ check: name, status: ok ? "ok" : "fail", detail }); - if (!ok) allOk = false; - } - // 1. Config present + // 1. Config check const config = loadConfig(); - const cfgExists = existsSync(configPath()); - check("config", !!config, config ? configPath() : cfgExists ? "invalid config.json (parse error)" : "no config.json"); - - // 2. Vault exists - if (config) { - if (config.plain) { - const vaultOk = existsSync(config.vault); - check("vault", vaultOk, vaultOk ? config.vault : `not found: ${config.vault}`); + 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 { - const vaultOk = existsSync(join(config.vault, ".obsidian")); - check("vault", vaultOk, vaultOk ? config.vault : `.obsidian not found: ${config.vault}`); + console.log(`config: fail — vault not found: ${config.vault}`); + allOk = false; } - } else { - check("vault", false, "skipped (no config)"); } - // 3. Hook registered + // 2. Hook check const hookOk = isHookRegistered(); - check("hook", hookOk, hookOk ? CLAUDE_SETTINGS_PATH : "not registered"); - - for (const r of results) { - console.log(`${r.check}: ${r.status} (${r.detail})`); + if (hookOk) { + console.log(`hook: ok — ${CLAUDE_SETTINGS_PATH}`); + } else { + console.log("hook: fail — not registered"); + allOk = false; } if (!allOk) { @@ -751,42 +715,244 @@ async function cmdValidate(): Promise { } } -// --- 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 })); +} + +// --- Commander program setup --- -const [, , command, ...rest] = process.argv; +const program = new Command(); -switch (command) { - case "init": - await cmdInit(rest); - break; - case "detect": - await cmdDetect(); - break; - case "doctor": +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 "validate": - await cmdValidate(); - 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); } From 247412fdf48127d2ca197d4d590f2e9bddbd1870 Mon Sep 17 00:00:00 2001 From: Albireo3754 <40790219+albireo3754@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:04:54 +0900 Subject: [PATCH 7/7] feat: replace ses_ with claude_/codex_ source prefix in session dividers (#24) * docs: add omx discord bridge design and plan * feat: replace ses_ prefix with claude_/codex_ source in session dividers Session dividers now show the origin tool (claude or codex) instead of the generic "ses" prefix, making it clear which AI produced each session block in the Daily Note. Co-Authored-By: Claude Opus 4.6 (1M context) * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: unify SourceType in types.ts and fix divider regex pairing - Rename Source to SourceType in types.ts as single source of truth - Remove duplicate Source type from daily-note.ts, import from types.ts - Fix divider regex to properly pair [[...]] and (...) delimiters Co-Authored-By: Claude Opus 4.6 (1M context) * test: add codex source divider test Verify that entries with source: "codex" emit [[codex_...]] dividers and session grouping works correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../2026-03-14-omx-discord-bridge-design.md | 196 ++++++++ docs/plans/2026-03-14-omx-discord-bridge.md | 436 ++++++++++++++++++ src/__tests__/daily-note.test.ts | 14 +- src/__tests__/note-writer.test.ts | 23 +- src/codex-notify.ts | 1 + src/hook.ts | 1 + src/note-writer.ts | 10 +- src/schema/daily-note.ts | 8 +- src/types.ts | 4 + 9 files changed, 679 insertions(+), 14 deletions(-) create mode 100644 docs/plans/2026-03-14-omx-discord-bridge-design.md create mode 100644 docs/plans/2026-03-14-omx-discord-bridge.md 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/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__/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/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/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 */