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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/plugins/onebrain/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 5 additions & 1 deletion .claude/plugins/onebrain/INSTRUCTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <skill>`. (Note: auto-pause-on-failure is not yet implemented; the CLI only honors the marker if a future hook or manual action creates it.)
Expand Down
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
latest_version: 2.3.2
released: 2026-05-12
latest_version: 2.3.3
released: 2026-05-13
---

# CLI Changelog
Expand All @@ -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 <vault>` with `cwd=<vault>`; 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 <skill>` 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
Expand Down
10 changes: 8 additions & 2 deletions PLUGIN-CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
latest_version: 2.4.11
released: 2026-05-12
latest_version: 2.4.12
released: 2026-05-13
---

# Plugin Changelog
Expand All @@ -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 <vault>` (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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
152 changes: 139 additions & 13 deletions src/commands/register-schedule.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -122,30 +122,44 @@ 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('<string>onebrain</string>');
expect(joined).toContain('<string>qmd-reindex</string>');
expect(joined).toContain('<string>/bin/echo</string>');
expect(joined).toContain('<string>hello</string>');
expect(joined).not.toContain('<string>--skill</string>');
} finally {
captured.restore();
}
});

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'),
Expand Down Expand Up @@ -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(/<string>\/[^<\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
Expand Down
Loading
Loading