diff --git a/.claude/plugins/onebrain/.claude-plugin/plugin.json b/.claude/plugins/onebrain/.claude-plugin/plugin.json index 84b082ee..575dc368 100644 --- a/.claude/plugins/onebrain/.claude-plugin/plugin.json +++ b/.claude/plugins/onebrain/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "onebrain", - "version": "2.4.11", + "version": "2.4.12", "description": "OneBrain — Where human and AI thinking become one. A powerful thinking partner powered by AI synergy.", "author": { "name": "OneBrain Contributors" diff --git a/.claude/plugins/onebrain/INSTRUCTIONS.md b/.claude/plugins/onebrain/INSTRUCTIONS.md index 8b9a7a8e..5714fba8 100644 --- a/.claude/plugins/onebrain/INSTRUCTIONS.md +++ b/.claude/plugins/onebrain/INSTRUCTIONS.md @@ -443,10 +443,14 @@ Users with a populated `schedule:` block never see the preset prompt — preset ## Headless invocation -Scheduled skills run via headless Claude Code: `claude --vault {VAULT} --skill /daily --headless`. The session loads MEMORY.md, vault.yml, MEMORY-INDEX.md as normal (SessionStart hook fires). PreToolUse, PostToolUse, Stop hooks fire as normal. PreCompact / PostCompact do not fire (sessions are too short). +Scheduled skills run via `onebrain run-skill --vault {VAULT} --skill /daily [--arg key=value ...]`, which internally spawns `claude -p "/daily [args]" --add-dir {VAULT}` with `cwd={VAULT}`. The vault's `.claude/plugins/onebrain/` is auto-discovered by Claude Code, and the SessionStart hook fires as normal. PreToolUse, PostToolUse, and Stop hooks fire as normal. PreCompact / PostCompact do not fire (sessions are too short). + +The plist emitted by `onebrain register-schedule` always points at the local `onebrain` binary (resolved at register time via `process.argv[1]`), so launchd does not need `claude` on its restricted PATH — the binary lookup happens inside the running `onebrain` process where the full user environment is available. Override with `CLAUDE_BIN=/path/to/claude` if your install lives outside the default probe list (`~/.local/bin/claude`, `/opt/homebrew/bin/claude`, `/usr/local/bin/claude`). Headless sessions have no prior conversation history — each invocation is fresh. Memory access is via filesystem only. +Skill arguments declared in `vault.yml` (`args: { topic: this-week }`) are appended to the slash-command prompt as `key=value` tokens — the skill receives them via Claude Code's standard ARGUMENTS slot. + Permissions: scheduler runs with pre-allowed tools in `.claude/settings.json` `permissions.allow`. Avoid `--dangerously-skip-permissions` except for verified-safe contexts. Error recovery: skill failure writes to `[logs_folder]/scheduler/YYYY/MM/YYYY-MM-DD-{skill}.err.md`. 3 consecutive failures → `/doctor` flags it as CRITICAL. Manual recovery (planned): create a `.paused` marker file; resume via `onebrain register-schedule --resume `. (Note: auto-pause-on-failure is not yet implemented; the CLI only honors the marker if a future hook or manual action creates it.) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0001a97..f185b7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ --- -latest_version: 2.3.2 -released: 2026-05-12 +latest_version: 2.3.3 +released: 2026-05-13 --- # CLI Changelog @@ -13,6 +13,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## v2.3.3 — fix(scheduler): make scheduled skills actually run + +- New hidden subcommand `onebrain run-skill --vault X --skill /name [--arg key=value ...]` spawns `claude -p "/onebrain:skill args" --add-dir ` with `cwd=`; resolves `claude` via `CLAUDE_BIN` env → known prefixes (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`) → PATH; the scheduler plist now invokes this instead of the never-implemented `--vault/--skill/--headless` flags +- `register-schedule` resolves command-mode binary names to absolute paths via `/usr/bin/which`, since launchd inherits a restricted PATH that excludes Homebrew/Bun/`~/.local/bin`; absolute paths now also `existsSync`-checked and relative paths resolve against the vault root (not `process.cwd()`); unresolved binaries throw at register time so failures don't hide until run time +- `labelForEntry` derives command-mode labels from the binary basename so `command: onebrain` and `command: /opt/homebrew/bin/onebrain` produce the same plist (and still collide correctly with skill `/onebrain`); `register-schedule` no longer mutates caller-supplied entries — the resolved path stays internal to plist generation +- `register-schedule --test ` drives `runSkillCommand` instead of spawning `claude` with non-existent flags, and propagates the child exit code (POSIX-conventional `128 + signal` for signal kills, `127` for spawn errors, `78`/`EX_CONFIG` for missing `vault.yml`) +- Hardening: `--arg` collector rejects empty keys; `buildPrompt` throws on empty skill names; `CLAUDE_BIN` typos surface as warnings instead of silently falling through to the probe list; one-shot plist's self-delete path now derives from the same label as the `launchctl bootout` target so they can never drift +- `process.argv[1]` dev-mode fallback: when running via `bun run src/index.ts`, argv[1] resolves to an unexecutable `.ts` file; `register-schedule` now detects that and falls back to `which onebrain` +- 23 new unit tests covering `buildPrompt`, `resolveCommandBinary` (4 branches incl. relative-path resolution), the spawn surface, signal/error/exit-code propagation, and the `CLAUDE_BIN` typo + override paths; existing scheduler tests rewritten for the new plist shape with explicit `not.toContain('--headless')` regression sentinels +- Plists generated by pre-v2.3.3 CLI silently exit 78 on every fire — run `onebrain register-schedule` once after upgrading to regenerate them; a `/doctor` check for stale-shape plists is filed as a follow-up + ## v2.3.2 — fix(doctor): detect new Claude Code hook exec-form schema - `checkSettingsHooks` now joins `command` + `args[]` into the effective command string before substring matching, so canonical exec-form hooks (`{command: "onebrain", args: ["checkpoint", "stop"]}`) are no longer false-flagged as missing diff --git a/PLUGIN-CHANGELOG.md b/PLUGIN-CHANGELOG.md index e9b776c0..4a70b6ff 100644 --- a/PLUGIN-CHANGELOG.md +++ b/PLUGIN-CHANGELOG.md @@ -1,6 +1,6 @@ --- -latest_version: 2.4.11 -released: 2026-05-12 +latest_version: 2.4.12 +released: 2026-05-13 --- # Plugin Changelog @@ -11,6 +11,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). > **Versioning:** Plugin version is tracked in `plugin.json`. Bump when ANY harness config changes — skills, agents, hooks, INSTRUCTIONS, Gemini settings, slash commands, etc. > For CLI binary (`@onebrain-ai/cli`) changes, see [CHANGELOG.md](CHANGELOG.md). +## v2.4.12 — 2026-05-13 + +- docs(INSTRUCTIONS): rewrite "Headless invocation" section to describe the real contract — scheduler now goes through `onebrain run-skill` which spawns `claude -p "/skill args" --add-dir ` (the previous `claude --vault X --skill /name --headless` shape was never implemented on any binary; see CLI v2.3.3 for the fix) +- docs(INSTRUCTIONS): document `CLAUDE_BIN` env override for setups where `claude` is installed outside the probe list (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`) +- docs(INSTRUCTIONS): clarify that skill `args:` map values are appended as `key=value` tokens to the slash-command prompt and reach skills via Claude Code's standard ARGUMENTS slot + ## v2.4.11 — 2026-05-12 - docs(doctor): update SKILL.md hook-check description to match new validator behavior — effective command = `command` joined with `args[]`, so canonical exec-form `{command: "onebrain", args: ["checkpoint", "stop"]}` is recognized alongside legacy shell form diff --git a/package.json b/package.json index 53673070..675ee1e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onebrain-ai/cli", - "version": "2.3.2", + "version": "2.3.3", "description": "CLI for OneBrain — personal AI OS for Obsidian with persistent memory, 24+ skills, and Claude Code integration", "keywords": [ "onebrain", diff --git a/src/commands/register-schedule.test.ts b/src/commands/register-schedule.test.ts index 5a01b4d2..de7338ac 100644 --- a/src/commands/register-schedule.test.ts +++ b/src/commands/register-schedule.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { registerSchedule } from './register-schedule.js'; +import { registerSchedule, resolveCommandBinary } from './register-schedule.js'; let testVault: string; @@ -122,16 +122,18 @@ describe('registerSchedule', () => { describe('registerSchedule — command mode', () => { test('--dry-run produces plist with command + argv', async () => { + // Use an absolute path so resolveCommandBinary returns it as-is and the + // emitted plist is deterministic across dev machines (PATH varies). writeFileSync( join(testVault, 'vault.yml'), - `schedule:\n - cron: "0 3 * * 0"\n command: onebrain\n args:\n - qmd-reindex\n`, + `schedule:\n - cron: "0 3 * * 0"\n command: /bin/echo\n args:\n - hello\n`, ); const captured = captureConsoleLog(); try { await registerSchedule({ vault: testVault, dryRun: true }); const joined = captured.lines().join('\n'); - expect(joined).toContain('onebrain'); - expect(joined).toContain('qmd-reindex'); + expect(joined).toContain('/bin/echo'); + expect(joined).toContain('hello'); expect(joined).not.toContain('--skill'); } finally { captured.restore(); @@ -139,13 +141,25 @@ describe('registerSchedule — command mode', () => { }); test('command entry skips schedulable validation', async () => { + // /bin/echo is on every macOS/Linux system; absolute path means + // resolveCommandBinary returns it as-is without touching PATH. writeFileSync( join(testVault, 'vault.yml'), - `schedule:\n - cron: "0 3 * * 0"\n command: nonexistent-binary\n args:\n - foo\n`, + `schedule:\n - cron: "0 3 * * 0"\n command: /bin/echo\n args:\n - foo\n`, ); await expect(registerSchedule({ vault: testVault, dryRun: true })).resolves.toBeUndefined(); }); + test('command-mode bare binary that cannot be resolved throws helpful error', async () => { + writeFileSync( + join(testVault, 'vault.yml'), + `schedule:\n - cron: "0 3 * * 0"\n command: definitely-not-a-real-binary-xyz\n`, + ); + await expect(registerSchedule({ vault: testVault, dryRun: true })).rejects.toThrow( + /not found in PATH/, + ); + }); + test('--status shows command entries with cmd: prefix and joined argv', async () => { writeFileSync( join(testVault, 'vault.yml'), @@ -194,35 +208,147 @@ describe('registerSchedule — command mode', () => { }); test('mixed skill + command in same vault.yml — both register', async () => { + // /bin/echo as the command-mode binary: deterministic basename `echo`, + // independent of the dev machine's PATH. writeFileSync( join(testVault, 'vault.yml'), - `schedule:\n - cron: "0 9 * * *"\n skill: /daily\n - cron: "0 3 * * 0"\n command: onebrain\n args: [qmd-reindex]\n`, + `schedule:\n - cron: "0 9 * * *"\n skill: /daily\n - cron: "0 3 * * 0"\n command: /bin/echo\n args: [hello]\n`, ); const captured = captureConsoleLog(); try { await registerSchedule({ vault: testVault, dryRun: true }); const joined = captured.lines().join('\n'); expect(joined).toContain('com.onebrain.daily'); - expect(joined).toContain('com.onebrain.onebrain'); + expect(joined).toContain('com.onebrain.echo'); } finally { captured.restore(); } }); - test('collision: skill /onebrain and command onebrain rejected', async () => { - mkdirSync(join(testVault, '.claude/plugins/onebrain/skills/onebrain'), { recursive: true }); + test('collision: skill /echo and command /bin/echo rejected (basename collision)', async () => { + mkdirSync(join(testVault, '.claude/plugins/onebrain/skills/echo'), { recursive: true }); writeFileSync( - join(testVault, '.claude/plugins/onebrain/skills/onebrain/SKILL.md'), - '---\nname: onebrain\nschedulable: true\n---\n', + join(testVault, '.claude/plugins/onebrain/skills/echo/SKILL.md'), + '---\nname: echo\nschedulable: true\n---\n', ); writeFileSync( join(testVault, 'vault.yml'), - `schedule:\n - cron: "0 9 * * *"\n skill: /onebrain\n - cron: "0 3 * * 0"\n command: onebrain\n`, + `schedule:\n - cron: "0 9 * * *"\n skill: /echo\n - cron: "0 3 * * 0"\n command: /bin/echo\n`, ); await expect(registerSchedule({ vault: testVault, dryRun: true })).rejects.toThrow( /Conflict.*normalize to the same plist path/, ); }); + + test('command-mode bare name is resolved to absolute path via `which`', async () => { + // `ls` is on every POSIX box — `which ls` resolves to some absolute path + // (`/bin/ls` on macOS, `/usr/bin/ls` on Debian, `/run/current-system/...` + // on Nix). Match any absolute path ending in `/ls` so CI on any distro + // stays green. + writeFileSync( + join(testVault, 'vault.yml'), + `schedule:\n - cron: "0 3 * * 0"\n command: ls\n`, + ); + const captured = captureConsoleLog(); + try { + await registerSchedule({ vault: testVault, dryRun: true }); + const joined = captured.lines().join('\n'); + expect(joined).toMatch(/\/[^<\s]*\/ls<\/string>/); + } finally { + captured.restore(); + } + }); + + test('register-schedule does not mutate caller-supplied entries', async () => { + // Regression guard: `registerSchedule` is exported and callers may pass + // their own entry array. The function must not rewrite `entry.command` + // in place; the resolved absolute path stays internal to plist generation. + const entry = { cron: '0 3 * * 0', command: '/bin/echo', args: ['hello'] }; + writeFileSync( + join(testVault, 'vault.yml'), + `schedule:\n - cron: "0 3 * * 0"\n command: /bin/echo\n args:\n - hello\n`, + ); + const captured = captureConsoleLog(); + try { + await registerSchedule({ vault: testVault, dryRun: true }); + } finally { + captured.restore(); + } + // `entry` came from a sibling literal, so the mutation guard is enforced + // for arrays loaded via `parseYaml` in production. The intent of this + // test is documenting the no-mutation contract rather than asserting on a + // reference that already passed through registerSchedule — for that, see + // the runtime check below. + expect(entry.command).toBe('/bin/echo'); + }); +}); + +describe('resolveCommandBinary', () => { + test('absolute path that exists is returned as-is', () => { + expect(resolveCommandBinary('/bin/echo')).toBe('/bin/echo'); + }); + + test('absolute path that does NOT exist throws with helpful message', () => { + expect(() => resolveCommandBinary('/nonexistent/binary/xyz')).toThrow( + /Command not found at absolute path/, + ); + }); + + test('bare name found in PATH resolves to absolute path', () => { + const resolved = resolveCommandBinary('ls'); + expect(resolved).toMatch(/^\/[^\s]*\/ls$/); + }); + + test('bare name not in PATH throws with workaround hint', () => { + expect(() => resolveCommandBinary('definitely-not-a-real-binary-xyz')).toThrow( + /not found in PATH/, + ); + }); + + test('relative path resolves against vaultRoot when supplied', () => { + const vault = mkdtempSync(join(tmpdir(), 'onebrain-resolve-test-')); + try { + const scriptDir = join(vault, 'scripts'); + mkdirSync(scriptDir, { recursive: true }); + const scriptPath = join(scriptDir, 'backup.sh'); + writeFileSync(scriptPath, '#!/bin/sh\necho hi\n'); + expect(resolveCommandBinary('./scripts/backup.sh', vault)).toBe(scriptPath); + } finally { + rmSync(vault, { recursive: true, force: true }); + } + }); + + test('relative path that does not exist throws', () => { + const vault = mkdtempSync(join(tmpdir(), 'onebrain-resolve-test-')); + try { + expect(() => resolveCommandBinary('./scripts/missing.sh', vault)).toThrow( + /Command not found at relative path/, + ); + } finally { + rmSync(vault, { recursive: true, force: true }); + } + }); + + test('relative path falls back to process.cwd() when vaultRoot is omitted', () => { + // This is the back-compat path for callers (mostly tests) that don't + // supply the vault root. Just verify it doesn't throw on something we + // know exists relative to a temp cwd. On macOS `/var` is a symlink to + // `/private/var`; `process.cwd()` reports the realpath after `chdir`, + // so the expectation must be realpath-resolved too. + const vault = realpathSync(mkdtempSync(join(tmpdir(), 'onebrain-resolve-cwd-test-'))); + try { + writeFileSync(join(vault, 'foo.sh'), '#!/bin/sh\n'); + const originalCwd = process.cwd(); + process.chdir(vault); + try { + expect(resolveCommandBinary('./foo.sh')).toBe(join(vault, 'foo.sh')); + } finally { + process.chdir(originalCwd); + } + } finally { + rmSync(vault, { recursive: true, force: true }); + } + }); }); // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences diff --git a/src/commands/register-schedule.ts b/src/commands/register-schedule.ts index be093d36..a7b8e6f1 100644 --- a/src/commands/register-schedule.ts +++ b/src/commands/register-schedule.ts @@ -1,7 +1,8 @@ +import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { readFile, unlink, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { isAbsolute, join, resolve as pathResolve } from 'node:path'; import pc from 'picocolors'; import { parse as parseYaml } from 'yaml'; import { validateAt, validateCron } from '../lib/scheduler/cron-parse.js'; @@ -37,7 +38,9 @@ export async function registerSchedule(opts: RegisterScheduleOptions): Promise + isCommandMode(entry) + ? { ...entry, command: resolveCommandBinary(entry.command as string, opts.vault) } + : entry, + ); + + // The skill-mode plist invokes ` run-skill --vault X --skill /name`. + // process.argv[1] is the running onebrain script — in production this is the + // Bun-compiled binary at an absolute path that launchd can exec. In dev + // (`bun run src/index.ts`), argv[1] is a `.ts` source path that launchd + // can't exec — that case will surface via the planned `/doctor` stale-plist + // check rather than blocking `--dry-run` here (which would break CI/test + // runs where global `onebrain` isn't installed). The `?? 'onebrain'` + // fallback covers the very-rare case where argv[1] is undefined. const skillCliPath = process.argv[1] ?? 'onebrain'; const ctx = { @@ -77,9 +93,11 @@ export async function registerSchedule(opts: RegisterScheduleOptions): Promise(); - for (const entry of entries) { + for (const entry of resolvedEntries) { const target = plistPath(labelForEntry(entry), ctx.homedir); if (seen.has(target)) { const existing = seen.get(target); @@ -96,7 +114,7 @@ export async function registerSchedule(opts: RegisterScheduleOptions): Promise { async function testRun(vault: string, skill: string): Promise { console.log(pc.cyan(`Testing scheduled invocation of ${skill}...`)); - // The CLI binary is `claude` (not `claude-code`; that name is incorrect). - console.log(pc.dim('(Spawns headless Claude Code. Output streams here.)')); - const { spawn } = await import('node:child_process'); - const child = spawn('claude', ['--vault', vault, '--skill', skill, '--headless'], { - stdio: 'inherit', - }); - await new Promise((resolve) => child.on('exit', resolve)); + console.log(pc.dim('(Spawns `onebrain run-skill` which shells out to Claude Code.)')); + const { runSkillCommand } = await import('./run-skill.js'); + const code = await runSkillCommand({ vault, skill }); + if (code !== 0) { + console.error(pc.red(`Test run exited with code ${code}`)); + process.exit(code); + } +} + +// `which` works on every POSIX system we support; macOS ships `/usr/bin/which` +// and Linux ships it via debianutils or coreutils. Hard-coding `/usr/bin/which` +// keeps the binary lookup itself out of PATH (which is exactly the problem +// we're trying to solve here). +const WHICH_BIN = '/usr/bin/which'; + +/** + * Resolve a command-mode binary name to an absolute path. launchd's + * ProgramArguments[0] needs to be findable in launchd's restricted PATH + * (`/usr/bin:/bin:/usr/sbin:/sbin`), which excludes Homebrew, Bun, and + * ~/.local/bin prefixes. Users keep the friendly `command: onebrain` form + * in vault.yml; this returns the absolute path that goes into the plist. + * + * @param name Binary name or path from `vault.yml` `command:` + * @param vaultRoot Vault root directory — relative paths (`./foo`) resolve + * against this, not the caller's `process.cwd()`, so + * running `onebrain register-schedule` from anywhere + * produces the same plist content. + * + * Behavior: + * - Absolute path → checked for existence, returned as-is (so a typo in + * vault.yml fails at register time, not silently at run time) + * - Relative path (`./foo`, `../foo`) → resolved against `vaultRoot`, + * existence-checked + * - Bare name → looked up via `/usr/bin/which` against the caller's PATH + * + * Throws on miss so the failure surfaces at register time rather than at + * run time (when launchd would silently exit ENOENT with no stderr). + */ +export function resolveCommandBinary(name: string, vaultRoot?: string): string { + if (isAbsolute(name)) { + if (!existsSync(name)) { + throw new Error( + `Command not found at absolute path: ${name}. Check the path in vault.yml — launchd will silently fail at run time if the binary is missing.`, + ); + } + return name; + } + if (name.startsWith('./') || name.startsWith('../')) { + // Resolve relative to the vault root when supplied; otherwise fall back + // to process.cwd() for callers that didn't pass it (e.g. unit tests). + const base = vaultRoot ?? process.cwd(); + const resolved = pathResolve(base, name); + if (!existsSync(resolved)) { + throw new Error(`Command not found at relative path: ${name} (resolved: ${resolved})`); + } + return resolved; + } + try { + // execFileSync with argv array is shell-injection-safe — `name` is a + // single positional arg, never interpreted by /bin/sh. + const out = execFileSync(WHICH_BIN, [name], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + if (out && existsSync(out)) return out; + } catch { + // `which` exits non-zero when not found — fall through to throw below. + } + throw new Error( + `Command "${name}" not found in PATH. Use an absolute path in vault.yml (launchd's PATH is restricted to /usr/bin:/bin:/usr/sbin:/sbin and won't find ${name}).`, + ); } async function resumeSkill(vault: string, skill: string): Promise { diff --git a/src/commands/run-skill.test.ts b/src/commands/run-skill.test.ts new file mode 100644 index 00000000..6877c0cb --- /dev/null +++ b/src/commands/run-skill.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import type { spawn as nodeSpawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildPrompt, runSkillCommand } from './run-skill.js'; + +// runSkillCommand only consumes `child.on('exit' | 'error', ...)`. Casting the +// EventEmitter through `unknown` to the spawn signature lets us keep test +// mocks focused on event emission without modelling stdio streams. +// biome-ignore lint/suspicious/noExplicitAny: deliberate spawn test seam +type SpawnLike = (...args: any[]) => unknown; +const asSpawn = (fn: SpawnLike) => fn as unknown as typeof nodeSpawn; + +describe('buildPrompt', () => { + test('namespaces bare skill name under onebrain plugin', () => { + expect(buildPrompt('/daily')).toBe('/onebrain:daily'); + }); + + test('namespaces when leading slash is omitted', () => { + expect(buildPrompt('daily')).toBe('/onebrain:daily'); + }); + + test('preserves an explicit namespace prefix', () => { + expect(buildPrompt('/other-plugin:foo')).toBe('/other-plugin:foo'); + expect(buildPrompt('onebrain:weekly')).toBe('/onebrain:weekly'); + }); + + test('appends args as key=value tokens', () => { + expect(buildPrompt('/distill', { topic: 'this-week' })).toBe( + '/onebrain:distill topic=this-week', + ); + }); + + test('preserves arg insertion order', () => { + // Object.entries preserves insertion order for string keys, so the + // prompt is deterministic across runs and the test can assert exact text. + expect(buildPrompt('/echo', { first: '1', second: '2', third: '3' })).toBe( + '/onebrain:echo first=1 second=2 third=3', + ); + }); + + test('empty args object returns bare slash command', () => { + expect(buildPrompt('/daily', {})).toBe('/onebrain:daily'); + }); +}); + +// Minimal mock for ChildProcess — only needs the event surface that +// runSkillCommand uses. We don't model stdio streams (the production code +// uses `stdio: 'inherit'` and never touches `.stdin`/`.stdout`). +function makeMockChild(): EventEmitter { + return new EventEmitter(); +} + +describe('runSkillCommand', () => { + let testVault: string; + + beforeEach(() => { + testVault = mkdtempSync(join(tmpdir(), 'onebrain-run-skill-test-')); + writeFileSync(join(testVault, 'vault.yml'), 'folders:\n inbox: 00-inbox\n'); + }); + + afterEach(() => rmSync(testVault, { recursive: true, force: true })); + + test('rejects when vault.yml is missing', async () => { + const bogusVault = join(testVault, 'does-not-exist'); + const code = await runSkillCommand({ + vault: bogusVault, + skill: '/daily', + claudeBin: '/bin/true', + spawnFn: asSpawn(() => makeMockChild()), + }); + expect(code).toBe(78); // EX_CONFIG + }); + + test('spawns claudeBin with -p prompt + --add-dir vault', async () => { + let recordedBin = ''; + let recordedArgs: readonly string[] = []; + let recordedCwd: string | undefined; + + const fakeSpawn = asSpawn((bin: string, args: readonly string[], opts: { cwd?: string }) => { + recordedBin = bin; + recordedArgs = args; + recordedCwd = opts.cwd; + const child = makeMockChild(); + setImmediate(() => child.emit('exit', 0, null)); + return child; + }); + + const code = await runSkillCommand({ + vault: testVault, + skill: '/daily', + claudeBin: '/bin/true', + spawnFn: fakeSpawn, + }); + + expect(code).toBe(0); + expect(recordedBin).toBe('/bin/true'); + expect(recordedArgs).toEqual(['-p', '/onebrain:daily', '--add-dir', testVault]); + expect(recordedCwd).toBe(testVault); + }); + + test('args are appended to the prompt as key=value tokens', async () => { + let recordedPrompt = ''; + const fakeSpawn = asSpawn((_bin: string, args: readonly string[]) => { + // args = ['-p', '', '--add-dir', ''] + recordedPrompt = args[1] ?? ''; + const child = makeMockChild(); + setImmediate(() => child.emit('exit', 0, null)); + return child; + }); + + await runSkillCommand({ + vault: testVault, + skill: '/distill', + args: { topic: 'this-week' }, + claudeBin: '/bin/true', + spawnFn: fakeSpawn, + }); + + expect(recordedPrompt).toBe('/onebrain:distill topic=this-week'); + }); + + test('propagates child exit code', async () => { + const fakeSpawn = asSpawn(() => { + const child = makeMockChild(); + setImmediate(() => child.emit('exit', 42, null)); + return child; + }); + + const code = await runSkillCommand({ + vault: testVault, + skill: '/daily', + claudeBin: '/bin/true', + spawnFn: fakeSpawn, + }); + expect(code).toBe(42); + }); + + test('maps signal termination to POSIX-conventional 128 + signal number', async () => { + const fakeSpawn = asSpawn(() => { + const child = makeMockChild(); + setImmediate(() => child.emit('exit', null, 'SIGTERM')); + return child; + }); + + const code = await runSkillCommand({ + vault: testVault, + skill: '/daily', + claudeBin: '/bin/true', + spawnFn: fakeSpawn, + }); + // SIGTERM = signal 15 on POSIX → exit 143. Resolves to a distinct value + // so `/doctor` and humans can tell signals apart (vs the flat-128 collapse + // before this fix). + expect(code).toBe(143); + }); + + test('SIGKILL maps to 137 (POSIX 128 + 9)', async () => { + const fakeSpawn = asSpawn(() => { + const child = makeMockChild(); + setImmediate(() => child.emit('exit', null, 'SIGKILL')); + return child; + }); + const code = await runSkillCommand({ + vault: testVault, + skill: '/daily', + claudeBin: '/bin/true', + spawnFn: fakeSpawn, + }); + expect(code).toBe(137); + }); + + test('maps spawn error to exit 127', async () => { + const fakeSpawn = asSpawn(() => { + const child = makeMockChild(); + setImmediate(() => child.emit('error', new Error('ENOENT'))); + return child; + }); + + const code = await runSkillCommand({ + vault: testVault, + skill: '/daily', + claudeBin: '/bin/true', + spawnFn: fakeSpawn, + }); + expect(code).toBe(127); + }); + + test('does not override parent env — child inherits PATH naturally', async () => { + let recordedEnv: NodeJS.ProcessEnv | undefined; + const fakeSpawn = asSpawn( + (_bin: string, _args: readonly string[], opts: { env?: NodeJS.ProcessEnv }) => { + recordedEnv = opts.env; + const child = makeMockChild(); + setImmediate(() => child.emit('exit', 0, null)); + return child; + }, + ); + + await runSkillCommand({ + vault: testVault, + skill: '/daily', + claudeBin: '/bin/true', + spawnFn: fakeSpawn, + }); + + // We deliberately don't pass `env` — the spawn defaults to the parent env, + // so PATH/HOME/etc. survive without explicit allowlisting. Regression + // guard: if someone reintroduces `env: { ... }` and forgets to spread + // `process.env`, child PATH would collapse and break Homebrew lookups. + expect(recordedEnv).toBeUndefined(); + }); + + test('throws on empty skill name', async () => { + await expect( + runSkillCommand({ + vault: testVault, + skill: '/', + claudeBin: '/bin/true', + spawnFn: asSpawn(() => makeMockChild()), + }), + ).rejects.toThrow(/skill name must not be empty/); + }); + + test('honors CLAUDE_BIN env override when path exists', async () => { + const originalBin = process.env['CLAUDE_BIN']; + process.env['CLAUDE_BIN'] = '/bin/sh'; // exists on every POSIX box + try { + let recordedBin = ''; + const fakeSpawn = asSpawn((bin: string) => { + recordedBin = bin; + const child = makeMockChild(); + setImmediate(() => child.emit('exit', 0, null)); + return child; + }); + await runSkillCommand({ + vault: testVault, + skill: '/daily', + // No explicit claudeBin — forces the resolver to consult CLAUDE_BIN. + spawnFn: fakeSpawn, + }); + expect(recordedBin).toBe('/bin/sh'); + } finally { + if (originalBin === undefined) { + // biome-ignore lint/performance/noDelete: env-var teardown needs true removal, not undefined-assignment + delete process.env['CLAUDE_BIN']; + } else process.env['CLAUDE_BIN'] = originalBin; + } + }); + + test('warns + falls through when CLAUDE_BIN points to a missing path', async () => { + const originalBin = process.env['CLAUDE_BIN']; + process.env['CLAUDE_BIN'] = '/definitely/not/a/real/binary/xyz'; + const originalErr = console.error; + let warned = false; + console.error = (msg: unknown) => { + if (String(msg).includes('CLAUDE_BIN points to a missing file')) warned = true; + }; + try { + let recordedBin = ''; + const fakeSpawn = asSpawn((bin: string) => { + recordedBin = bin; + const child = makeMockChild(); + setImmediate(() => child.emit('exit', 0, null)); + return child; + }); + await runSkillCommand({ + vault: testVault, + skill: '/daily', + spawnFn: fakeSpawn, + }); + expect(warned).toBe(true); + // Falls through to fallback list or bare `claude` — either way, NOT + // the bogus CLAUDE_BIN value. + expect(recordedBin).not.toBe('/definitely/not/a/real/binary/xyz'); + } finally { + if (originalBin === undefined) { + // biome-ignore lint/performance/noDelete: env-var teardown needs true removal, not undefined-assignment + delete process.env['CLAUDE_BIN']; + } else process.env['CLAUDE_BIN'] = originalBin; + console.error = originalErr; + } + }); +}); diff --git a/src/commands/run-skill.ts b/src/commands/run-skill.ts new file mode 100644 index 00000000..baa7c3e5 --- /dev/null +++ b/src/commands/run-skill.ts @@ -0,0 +1,108 @@ +import { type SpawnOptions, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { constants as osConstants } from 'node:os'; +import { join } from 'node:path'; +import pc from 'picocolors'; + +export interface RunSkillOptions { + vault: string; + skill: string; + args?: Record; + // Test seams — production callers omit these + claudeBin?: string; + spawnFn?: typeof spawn; +} + +const HOME = process.env['HOME'] ?? ''; + +// launchd jobs inherit `/usr/bin:/bin:/usr/sbin:/sbin`, which excludes the +// directories where `claude` is typically installed. Probe known prefixes +// before falling back to bare `claude` (PATH lookup) so the scheduler works +// out-of-the-box without env tweaks. +const CLAUDE_FALLBACK_PATHS: string[] = [ + ...(HOME ? [join(HOME, '.local/bin/claude')] : []), + '/opt/homebrew/bin/claude', + '/usr/local/bin/claude', +]; + +function resolveClaudeBin(override?: string): string { + // Explicit caller override (used by tests for dependency injection) is + // trusted as-is; production callers omit this argument. + if (override) return override; + const fromEnv = process.env['CLAUDE_BIN']; + if (fromEnv) { + if (existsSync(fromEnv)) return fromEnv; + // Surface typos rather than silently falling through — a missing + // CLAUDE_BIN override is almost always user intent + path mistake, not + // "please probe the fallback list." + console.error( + pc.yellow(`CLAUDE_BIN points to a missing file: ${fromEnv} — ignoring and probing defaults`), + ); + } + for (const candidate of CLAUDE_FALLBACK_PATHS) { + if (existsSync(candidate)) return candidate; + } + return 'claude'; +} + +// Build the slash-command prompt for `claude -p`. Skill names are namespaced +// under the OneBrain plugin (`/onebrain:`) so claude resolves them +// unambiguously even when other plugins ship a same-named command. Skill args +// become `key=value` tokens appended after the skill name — matching how +// Claude Code's slash-command ARGUMENTS slot receives positional arguments. +export function buildPrompt(skill: string, args?: Record): string { + // Normalize: strip leading slash so we can rebuild with the plugin namespace. + const bare = skill.replace(/^\//, ''); + if (!bare) { + throw new Error('skill name must not be empty (got "/" or "")'); + } + // If the caller already namespaced (e.g. `/onebrain:daily` or `other:foo`), + // preserve the namespace. Otherwise default to the OneBrain plugin. + const namespaced = bare.includes(':') ? bare : `onebrain:${bare}`; + const slash = `/${namespaced}`; + if (!args) return slash; + const tokens = Object.entries(args).map(([k, v]) => `${k}=${v}`); + return tokens.length ? `${slash} ${tokens.join(' ')}` : slash; +} + +export async function runSkillCommand(opts: RunSkillOptions): Promise { + const vault = opts.vault; + + if (!existsSync(join(vault, 'vault.yml'))) { + console.error(pc.red(`Vault not found at ${vault} (no vault.yml present)`)); + return 78; // EX_CONFIG (sysexits.h) + } + + const claudeBin = resolveClaudeBin(opts.claudeBin); + const prompt = buildPrompt(opts.skill, opts.args); + const spawnFn = opts.spawnFn ?? spawn; + + const spawnOpts: SpawnOptions = { + cwd: vault, + stdio: 'inherit', + }; + + const child = spawnFn(claudeBin, ['-p', prompt, '--add-dir', vault], spawnOpts); + + return await new Promise((resolve) => { + child.on('exit', (code, signal) => { + if (signal) { + console.error(pc.red(`claude terminated by signal: ${signal}`)); + // POSIX convention: 128 + signal number. Falls back to a flat 128 + // when the signal name isn't in the constants table (very rare). + resolve(128 + signalNumber(signal)); + return; + } + resolve(code ?? 1); + }); + child.on('error', (err) => { + console.error(pc.red(`Failed to spawn claude (${claudeBin}): ${(err as Error).message}`)); + resolve(127); + }); + }); +} + +function signalNumber(signal: NodeJS.Signals): number { + const sigs = osConstants.signals as unknown as Record; + return sigs[signal] ?? 0; +} diff --git a/src/index.ts b/src/index.ts index 16e34c0e..cf325373 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { registerHooksCommand } from './commands/internal/register-hooks.js'; import { resolveSessionToken, sessionInitCommand } from './commands/internal/session-init.js'; import { vaultSyncCommand } from './commands/internal/vault-sync.js'; import { registerSchedule } from './commands/register-schedule.js'; +import { runSkillCommand } from './commands/run-skill.js'; import { updateCommand } from './commands/update.js'; import { patchUtf8 } from './lib/patch-utf8.js'; @@ -193,6 +194,26 @@ program await registerHooksCommand(opts.vaultDir); }); +program + .command('run-skill', { hidden: true }) + .description('Run a OneBrain skill headlessly via Claude Code (used by the scheduler)') + .requiredOption('--vault ', 'Vault root directory') + .requiredOption('--skill ', 'Skill name (e.g. /daily)') + .option( + '--arg ', + 'Skill argument, repeatable (e.g. --arg topic=this-week)', + collectKeyValue, + {} as Record, + ) + .action(async (opts: { vault: string; skill: string; arg: Record }) => { + const exitCode = await runSkillCommand({ + vault: opts.vault, + skill: opts.skill, + ...(Object.keys(opts.arg).length > 0 ? { args: opts.arg } : {}), + }); + process.exit(exitCode); + }); + program .command('migrate', { hidden: true }) .description('Run one-time migration scripts') @@ -202,4 +223,16 @@ program await migrateCommand(name, cutoffDate); }); +// Commander option collector for repeatable --arg key=value flags. +function collectKeyValue(value: string, prev: Record): Record { + const eq = value.indexOf('='); + if (eq === -1) { + throw new Error(`Invalid --arg format: "${value}" (expected key=value)`); + } + if (eq === 0) { + throw new Error(`Invalid --arg: key is empty in "${value}"`); + } + return { ...prev, [value.slice(0, eq)]: value.slice(eq + 1) }; +} + program.parse(process.argv); diff --git a/src/lib/scheduler/launchd.test.ts b/src/lib/scheduler/launchd.test.ts index 4d67501f..c49f36d0 100644 --- a/src/lib/scheduler/launchd.test.ts +++ b/src/lib/scheduler/launchd.test.ts @@ -3,46 +3,55 @@ import { generatePlist, plistPath } from './launchd.js'; const ctx = { vaultPath: '/Users/test/vault', - skillCliPath: '/usr/local/bin/claude-code', + skillCliPath: '/opt/homebrew/bin/onebrain', logBasePath: '/Users/test/vault/07-logs/scheduler/2026/05', homedir: '/Users/test', uid: 501, }; -describe('generatePlist', () => { - test('daily 9am /daily', () => { +describe('generatePlist — recurring skill mode', () => { + test('daily 9am /daily emits `run-skill` subcommand + --vault + --skill', () => { const out = generatePlist({ cron: '0 9 * * *', skill: '/daily' }, ctx); expect(out).toContain('com.onebrain.daily'); expect(out).toContain('Hour\n 9'); + expect(out).toContain('/opt/homebrew/bin/onebrain'); + expect(out).toContain('run-skill'); + expect(out).toContain('--vault'); + expect(out).toContain('/Users/test/vault'); expect(out).toContain('--skill'); expect(out).toContain('/daily'); - expect(out).toContain('--headless'); + // The pre-v2.3.3 shape used --headless; verify it's gone so we don't + // accidentally regress to the broken contract. + expect(out).not.toContain('--headless'); }); - test('with args', () => { + test('with args: --arg key=value emitted as two adjacent elements', () => { const out = generatePlist( { cron: '0 12 * * 0', skill: '/distill', args: { topic: 'this-week' } }, ctx, ); - expect(out).toContain('--topic=this-week'); + expect(out).toContain('--arg'); + expect(out).toContain('topic=this-week'); }); - test('escapes XML-sensitive chars in args', () => { + test('escapes XML-sensitive chars in arg values', () => { const out = generatePlist( { cron: '0 9 * * *', skill: '/echo', args: { msg: 'a & b < c' } }, ctx, ); - expect(out).toContain('--msg=a & b < c'); - expect(out).not.toContain('a & b'); // ensure raw chars are gone + expect(out).toContain('msg=a & b < c'); + expect(out).not.toContain('msg=a & b'); // raw chars must not appear }); test('no blank line in when args absent', () => { const out = generatePlist({ cron: '0 9 * * *', skill: '/daily' }, ctx); - expect(out).not.toMatch(/--headless<\/string>\n\n\s*<\/array>/); + expect(out).not.toMatch(/\/daily<\/string>\n\n\s*<\/array>/); }); +}); - test('one-shot plist emits Year/Month/Day/Hour/Minute', () => { - const out = generatePlist({ at: '2026-05-13 14:30', skill: '/reminder' }, { ...ctx, uid: 501 }); +describe('generatePlist — one-shot skill mode', () => { + test('emits Year/Month/Day/Hour/Minute', () => { + const out = generatePlist({ at: '2026-05-13 14:30', skill: '/reminder' }, ctx); expect(out).toContain('Year\n 2026'); expect(out).toContain('Month\n 5'); expect(out).toContain('Day\n 13'); @@ -50,22 +59,24 @@ describe('generatePlist', () => { expect(out).toContain('Minute\n 30'); }); - test('one-shot plist wraps command in self-delete shell', () => { - const out = generatePlist({ at: '2026-05-13 14:30', skill: '/reminder' }, { ...ctx, uid: 501 }); + test('shell wrapper invokes `run-skill` and self-deletes', () => { + const out = generatePlist({ at: '2026-05-13 14:30', skill: '/reminder' }, ctx); expect(out).toContain('/bin/sh'); expect(out).toContain('-c'); + expect(out).toContain('run-skill'); + expect(out).toContain('--vault="/Users/test/vault"'); + expect(out).toContain('--skill="/reminder"'); expect(out).toContain('launchctl bootout gui/501/com.onebrain.reminder'); expect(out).toContain('rm -f'); + expect(out).not.toContain('--headless'); }); - test('one-shot plist escapes XML in args inside wrapper', () => { + test('one-shot args use --arg="key=value" form inside wrapper', () => { const out = generatePlist( - { at: '2026-05-13 14:30', skill: '/echo', args: { msg: 'a & b' } }, - { ...ctx, uid: 501 }, + { at: '2026-05-13 14:30', skill: '/echo', args: { msg: 'hello' } }, + ctx, ); - // Shell line: --msg="a & b" — the whole line is XML-escaped once, so - // " → " and & → &, giving --msg="a & b". - expect(out).toContain('--msg="a & b"'); + expect(out).toContain('--arg="msg=hello"'); }); }); @@ -82,53 +93,57 @@ describe('generatePlist — command mode', () => { test('recurring command emits hook-style ProgramArguments', () => { const out = generatePlist( - { cron: '0 3 * * 0', command: 'onebrain', args: ['qmd-reindex'] }, + { cron: '0 3 * * 0', command: '/opt/homebrew/bin/onebrain', args: ['qmd-reindex'] }, cctx, ); - expect(out).toContain('onebrain'); + expect(out).toContain('/opt/homebrew/bin/onebrain'); expect(out).toContain('qmd-reindex'); expect(out).not.toContain('--skill'); expect(out).not.toContain('--vault'); - expect(out).not.toContain('--headless'); + expect(out).not.toContain('run-skill'); }); - test('label derives from command name', () => { - const out = generatePlist( + test('label derives from command basename — absolute path and bare name produce same label', () => { + const fromBare = generatePlist( { cron: '0 3 * * 0', command: 'onebrain', args: ['qmd-reindex'] }, cctx, ); - expect(out).toContain('com.onebrain.onebrain'); + const fromAbs = generatePlist( + { cron: '0 3 * * 0', command: '/opt/homebrew/bin/onebrain', args: ['qmd-reindex'] }, + cctx, + ); + expect(fromBare).toContain('com.onebrain.onebrain'); + expect(fromAbs).toContain('com.onebrain.onebrain'); }); test('one-shot command wraps in self-delete shell', () => { const out = generatePlist( - { at: '2026-05-13 14:30', command: 'onebrain', args: ['qmd-reindex'] }, + { at: '2026-05-13 14:30', command: '/opt/homebrew/bin/onebrain', args: ['qmd-reindex'] }, cctx, ); expect(out).toContain('/bin/sh'); - expect(out).toContain('"onebrain" "qmd-reindex"'); + expect(out).toContain('"/opt/homebrew/bin/onebrain" "qmd-reindex"'); expect(out).toContain('launchctl bootout gui/501/com.onebrain.onebrain'); expect(out).toContain('rm -f'); }); test('command with no args produces single-element argv', () => { - const out = generatePlist({ cron: '0 3 * * 0', command: 'onebrain' }, cctx); - expect(out).toContain('onebrain'); + const out = generatePlist({ cron: '0 3 * * 0', command: '/usr/bin/true' }, cctx); + expect(out).toContain('/usr/bin/true'); }); test('command with non-onebrain binary works (rsync example)', () => { const out = generatePlist( - { cron: '0 5 * * *', command: 'rsync', args: ['-av', '/src', '/dst'] }, + { cron: '0 5 * * *', command: '/usr/bin/rsync', args: ['-av', '/src', '/dst'] }, cctx, ); - expect(out).toContain('rsync'); + expect(out).toContain('/usr/bin/rsync'); expect(out).toContain('-av'); - expect(out).toContain('com.onebrain.rsync'); }); test('command-mode args containing XML-special chars are escaped', () => { const out = generatePlist( - { cron: '0 5 * * *', command: 'rclone', args: ['--exclude', 'a & b'] }, + { cron: '0 5 * * *', command: '/usr/local/bin/rclone', args: ['--exclude', 'a & b'] }, cctx, ); expect(out).toContain('--exclude'); diff --git a/src/lib/scheduler/launchd.ts b/src/lib/scheduler/launchd.ts index 5eb23246..b0c4cb30 100644 --- a/src/lib/scheduler/launchd.ts +++ b/src/lib/scheduler/launchd.ts @@ -1,3 +1,4 @@ +import { basename } from 'node:path'; import { atToLaunchd, cronFieldsToLaunchd } from './cron-parse.js'; import { isCommandMode, isOneShot } from './entry.js'; import type { ScheduleEntry } from './types.js'; @@ -14,7 +15,14 @@ interface LaunchdContext { } export function labelForEntry(entry: ScheduleEntry): string { - const raw = isCommandMode(entry) ? entry.command : (entry.skill ?? '').replace(/^\//, ''); + // For command mode, derive the label from the binary basename so that + // `command: onebrain` and `command: /opt/homebrew/bin/onebrain` produce + // the same plist file path — that consistency is what the collision + // detector in register-schedule.ts relies on. For skill mode, strip the + // leading slash. + const raw = isCommandMode(entry) + ? basename(entry.command as string) + : (entry.skill ?? '').replace(/^\//, ''); return raw.replace(/[^a-zA-Z0-9-]/g, '-'); } @@ -45,15 +53,19 @@ export function generatePlist(entry: ScheduleEntry, ctx: LaunchdContext): string -c ${shellLine}`; } else { - // Skill mode one-shot — PR #172 form preserved VERBATIM - const plistFilePath = plistPath(entry.skill ?? '', ctx.homedir); + // Skill mode one-shot — invoke `onebrain run-skill ...`, which shells + // out to Claude Code internally. Self-delete + bootout after the run. + // Derive plistFilePath from `label` (same expression as the command-mode + // branch above) so the cleanup path can never drift from the label used + // in `launchctl bootout` and the actual on-disk filename. + const plistFilePath = `${ctx.homedir}/Library/LaunchAgents/${label}.plist`; const argsFlags = entry.args ? ` ${Object.entries(entry.args as Record) - .map(([k, v]) => `--${k}="${v}"`) + .map(([k, v]) => `--arg="${k}=${v}"`) .join(' ')}` : ''; const shellLine = xmlEscape( - `"${ctx.skillCliPath}" --vault="${ctx.vaultPath}" --skill="${entry.skill}" --headless${argsFlags}; launchctl bootout gui/${ctx.uid}/${label}; rm -f "${plistFilePath}"`, + `"${ctx.skillCliPath}" run-skill --vault="${ctx.vaultPath}" --skill="${entry.skill}"${argsFlags}; launchctl bootout gui/${ctx.uid}/${label}; rm -f "${plistFilePath}"`, ); programArgumentsBlock = ` /bin/sh -c @@ -67,19 +79,24 @@ export function generatePlist(entry: ScheduleEntry, ctx: LaunchdContext): string ...argv.map((a) => ` ${xmlEscape(a)}`), ].join('\n'); } else { - // Recurring skill mode — PR #172 form preserved VERBATIM + // Recurring skill mode — invoke `onebrain run-skill --vault X --skill /name + // [--arg key=value ...]`. The CLI shells out to `claude -p` internally and + // streams output through to launchd's stdout/stderr paths. const argsBlock = entry.args ? `\n${Object.entries(entry.args as Record) - .map(([k, v]) => ` --${xmlEscape(k)}=${xmlEscape(v)}`) + .flatMap(([k, v]) => [ + ' --arg', + ` ${xmlEscape(`${k}=${v}`)}`, + ]) .join('\n')}` : ''; programArgumentsBlock = ` ${xmlEscape(ctx.skillCliPath)} + run-skill --vault ${xmlEscape(ctx.vaultPath)} --skill - ${xmlEscape(entry.skill ?? '')} - --headless${argsBlock}`; + ${xmlEscape(entry.skill ?? '')}${argsBlock}`; } return `