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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,18 @@ Tasks are stored in JSON config files:

Global config takes precedence on ID collision. Project configs cannot set `skipPermissions`.

The global state directory (schedules, logs, history — default `~/.claude`) can be
relocated by setting `CLAUDE_SCHEDULER_STATE_DIR`; OS registration (launchd/cron)
always uses the real home. This is primarily used to isolate state in tests.

---

## Development

```bash
npm install
npm test # Unit/integration tests
npm run test:e2e # E2E tests via Claude CLI subprocess (~9 min)
npm run test:e2e # Agentry E2E (local-only; needs claude CLI + sibling ../agentry)
npm run lint # ESLint with typescript-eslint
npm run typecheck # Type checking only
npm run build # TypeScript compilation
Expand All @@ -131,7 +135,7 @@ CI runs automatically on every push and PR to `main` (lint, typecheck, test on N
The test suite has two tiers:

- **Unit/Integration** (`npm test`) — fast tests covering library functions with no external dependencies.
- **E2E** (`npm run test:e2e`) — subprocess tests that invoke each plugin command through `claude --plugin-dir`. Requires the `claude` CLI to be installed and takes ~9 minutes. Skipped automatically if the CLI is not available. E2E tests use temp directories with fixture data and assert on output patterns (not exact strings) to handle Claude's non-deterministic phrasing.
- **E2E** (`npm run test:e2e`) — [agentry](https://github.com/dortort/agentry)-based tests that drive each read command through a real Claude agent (the plugin is loaded via `--plugin-dir`) and assert on output patterns (not exact strings) to handle Claude's non-deterministic phrasing. **Local-only** — not run in CI. Requires the `claude` CLI, `pnpm`, and a sibling `../agentry` checkout (build it once with `pnpm -r build`). The harness lives in [`e2e/`](e2e/README.md) as a self-contained pnpm project so it never touches the npm-managed root. Scenarios are made deterministic by pointing `CLAUDE_SCHEDULER_STATE_DIR` at a per-scenario sandbox (auth-safe — `$HOME` is untouched); mutating commands are excluded since their OS registration can't be isolated.

### Releasing

Expand Down
9 changes: 6 additions & 3 deletions commands/history.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
allowed-tools:
- Bash(cat ~/.claude/execution-history.jsonl *)
- Bash(cat *)
- Bash(tail *)
---

Expand All @@ -10,9 +10,12 @@ View execution history for scheduled tasks.

## Data Sources

The state directory is `$CLAUDE_SCHEDULER_STATE_DIR` when set, otherwise
`~/.claude`; in Bash read it as `"${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}"`.

| Data | Path |
|------|------|
| Execution history | `~/.claude/execution-history.jsonl` |
| Execution history | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/execution-history.jsonl` |

History record format (one JSON object per line):
```json
Expand All @@ -26,7 +29,7 @@ Fields: `taskId`, `taskName`, `status` (`success`|`failure`|`timeout`), `started
### Step 1 — Read history

```bash
cat ~/.claude/execution-history.jsonl 2>/dev/null || echo NO_HISTORY
cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/execution-history.jsonl" 2>/dev/null || echo NO_HISTORY
```

### Step 2 — Empty state
Expand Down
21 changes: 14 additions & 7 deletions commands/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
allowed-tools:
- Read
- Glob
- Bash(cat *)
- Bash(tail *)
- Bash(~/.claude/bin/claude-scheduler-cli *)
---

Expand All @@ -11,13 +13,15 @@ List all scheduled tasks with their status, schedule, and next run time.

## Data Sources

All data comes from these exact paths — do NOT read any source files:
All data comes from these exact paths — do NOT read any source files. The state
directory is `$CLAUDE_SCHEDULER_STATE_DIR` when set, otherwise `~/.claude`; in
Bash always read it as `"${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}"`.

| Data | Path |
|------|------|
| Global config | `~/.claude/schedules.json` |
| Global config | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/schedules.json` |
| Project config | `.claude/schedules.json` (optional) |
| Execution history | `~/.claude/execution-history.jsonl` |
| Execution history | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/execution-history.jsonl` |

Config format:
```json
Expand All @@ -31,9 +35,9 @@ History format: one JSON object per line — fields: `taskId`, `taskName`, `stat
### Step 1 — Load all data in parallel

Issue simultaneously:
1. `Read ~/.claude/schedules.json`
2. `Read .claude/schedules.json` (ignore if missing)
3. `Bash: tail -50 ~/.claude/execution-history.jsonl 2>/dev/null || echo NO_HISTORY`
1. `Bash: cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/schedules.json" 2>/dev/null || echo NO_GLOBAL_CONFIG`
2. `Bash: cat .claude/schedules.json 2>/dev/null || echo NO_PROJECT_CONFIG` (ignore if missing)
3. `Bash: tail -50 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/execution-history.jsonl" 2>/dev/null || echo NO_HISTORY`

### Step 2 — Derive merged task list

Expand All @@ -52,12 +56,15 @@ Then stop.

### Step 4 — Humanize cron expressions and compute next runs

Use a single `node -e` call with the compiled modules (never `src/`):
Use a single call to the compiled CLI (never `src/`):

```bash
~/.claude/bin/claude-scheduler-cli humanize --tasks '[{"id":"...","cron":"...","timezone":"..."}]'
```

If that binary is not present, fall back to showing each task's raw cron
expression (e.g. `0 9 * * *`) and omit the next-run time.

### Step 5 — Output table

For each task display:
Expand Down
26 changes: 16 additions & 10 deletions commands/logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ View stdout and stderr logs for a scheduled task.

## Data Sources

The state directory is `$CLAUDE_SCHEDULER_STATE_DIR` when set, otherwise
`~/.claude`; in Bash read it as `"${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}"`.

| Data | Path pattern |
|------|--------------|
| Global config | `~/.claude/schedules.json` |
| Global config | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/schedules.json` |
| Project config | `.claude/schedules.json` (optional) |
| stdout log (macOS) | `~/.claude/logs/<taskId>.out.log` |
| stderr log (macOS) | `~/.claude/logs/<taskId>.err.log` |
| combined log (Linux) | `~/.claude/logs/<taskId>.log` |
| status marker | `~/.claude/logs/<taskId>.status` |
| stdout log (macOS) | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/logs/<taskId>.out.log` |
| stderr log (macOS) | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/logs/<taskId>.err.log` |
| combined log (Linux) | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/logs/<taskId>.log` |
| status marker | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/logs/<taskId>.status` |

Config format:
```json
Expand All @@ -31,7 +34,10 @@ Status marker content examples: `success`, `failure:exit-1`, `failure:timeout`

### Step 1 — Find the task

Read `~/.claude/schedules.json` (and `.claude/schedules.json` if present) directly as JSON — no `node -e` needed.
Read the config as JSON — no `node -e` needed:
- `Bash: cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/schedules.json" 2>/dev/null || echo NO_GLOBAL_CONFIG`
- `Bash: cat .claude/schedules.json 2>/dev/null || echo NO_PROJECT_CONFIG` (if present)

Match the user's input against task `id` (exact) or `name` (case-insensitive).

If not found: `Task "<input>" not found. Run /scheduler:list to see available tasks.`
Expand All @@ -41,12 +47,12 @@ If not found: `Task "<input>" not found. Run /scheduler:list to see available ta
With the resolved `<taskId>`, issue all reads simultaneously:

**macOS:**
1. `Bash: tail -50 ~/.claude/logs/<taskId>.out.log 2>/dev/null || echo NO_STDOUT`
2. `Bash: tail -50 ~/.claude/logs/<taskId>.err.log 2>/dev/null || echo NO_STDERR`
3. `Bash: cat ~/.claude/logs/<taskId>.status 2>/dev/null || echo NO_STATUS`
1. `Bash: tail -50 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/logs/<taskId>.out.log" 2>/dev/null || echo NO_STDOUT`
2. `Bash: tail -50 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/logs/<taskId>.err.log" 2>/dev/null || echo NO_STDERR`
3. `Bash: cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/logs/<taskId>.status" 2>/dev/null || echo NO_STATUS`

**Linux:** replace steps 1+2 with:
1. `Bash: tail -50 ~/.claude/logs/<taskId>.log 2>/dev/null || echo NO_LOG`
1. `Bash: tail -50 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/logs/<taskId>.log" 2>/dev/null || echo NO_LOG`

If the user requests "full" or "all", use `cat` instead of `tail -50`.

Expand Down
28 changes: 18 additions & 10 deletions commands/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
allowed-tools:
- Read
- Glob
- Bash(cat *)
- Bash(tail *)
- Bash(launchctl list *)
- Bash(crontab -l)
- Bash(ls ~/Library/LaunchAgents/com.claude-scheduler.*.plist *)
Expand All @@ -14,14 +16,16 @@ Show the health status of the scheduling system and individual tasks.

## Data Sources

All checks use these exact paths — do NOT read any source files:
All checks use these exact paths — do NOT read any source files. The state
directory is `$CLAUDE_SCHEDULER_STATE_DIR` when set, otherwise `~/.claude`; in
Bash always read it as `"${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}"`.

| Data | Path |
|------|------|
| Global config | `~/.claude/schedules.json` |
| Global config | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/schedules.json` |
| Project config | `.claude/schedules.json` (optional, only if present) |
| Execution history | `~/.claude/execution-history.jsonl` |
| macOS plist files | `~/Library/LaunchAgents/com.claude-scheduler.*.plist` |
| Execution history | `${CLAUDE_SCHEDULER_STATE_DIR:-~/.claude}/execution-history.jsonl` |
| macOS plist files | `~/Library/LaunchAgents/com.claude-scheduler.*.plist` (OS-managed; always real home) |

Config format (`schedules.json`):
```json
Expand All @@ -38,9 +42,9 @@ macOS plist label format: `com.claude-scheduler.<taskId>`

Issue ALL of the following reads/commands simultaneously in one batch:

1. `Read ~/.claude/schedules.json` (global config)
2. `Read .claude/schedules.json` (project config — ignore if missing)
3. `Bash: tail -20 ~/.claude/execution-history.jsonl 2>/dev/null || echo NO_HISTORY`
1. `Bash: cat "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/schedules.json" 2>/dev/null || echo NO_GLOBAL_CONFIG` (global config)
2. `Bash: cat .claude/schedules.json 2>/dev/null || echo NO_PROJECT_CONFIG` (project config — ignore if missing)
3. `Bash: tail -20 "${CLAUDE_SCHEDULER_STATE_DIR:-$HOME/.claude}/execution-history.jsonl" 2>/dev/null || echo NO_HISTORY`
4. `Bash: ls ~/Library/LaunchAgents/com.claude-scheduler.*.plist 2>/dev/null || echo NO_PLISTS` (macOS)
OR `Bash: crontab -l 2>/dev/null | grep '#claude-scheduler' || echo NO_ENTRIES` (Linux — check with `uname -s` first if platform is unknown)
5. `Bash: launchctl list 2>/dev/null | grep com.claude-scheduler || echo NO_ENTRIES` (macOS only)
Expand All @@ -49,9 +53,11 @@ Issue ALL of the following reads/commands simultaneously in one batch:

Merge global + project tasks (project tasks override global tasks with the same `id`).

### Step 3 — Empty state (no tasks)
### Step 3 — Empty state (no configured tasks)

If the merged task list is empty or both config files are missing, output:
If the merged task list is empty (or both config files are missing), the block
below is the PRIMARY output and MUST always be shown — even when orphaned OS
registrations exist. Never replace it with an issues-only report.

```
Scheduler Status
Expand All @@ -65,7 +71,9 @@ No tasks are currently scheduled.
Run /scheduler:add to create your first task.
```

Then stop — do not attempt further per-task checks.
You MAY append a brief "Orphaned registrations" note afterward if plist or
launchctl entries exist with no matching config, but the `Tasks: none configured`
line must appear first. Do not perform per-task status checks.

### Step 4 — Per-task status table

Expand Down
1 change: 1 addition & 0 deletions e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
75 changes: 75 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Scheduler E2E (agentry)

End-to-end tests that drive the scheduler's slash commands through a **real Claude
agent** using [agentry](https://github.com/dortort/agentry) ("Playwright for AI
Agents"), then assert on the agent's reply. These replace the old Vitest-subprocess
suite.

This is a **self-contained pnpm project**, deliberately isolated from the repo's
npm-managed root so `npm ci` in CI never has to resolve agentry's `workspace:*`
dependencies. It is **local-only** and never runs in CI.

## Prerequisites

- The `claude` CLI on your `PATH`, authenticated.
- `pnpm`.
- A sibling checkout of agentry at `../agentry`, built once:
```bash
cd ../agentry && pnpm install && pnpm -r build
```
agentry is linked via `file:` in `package.json`; the `pnpm.overrides` there
rewrite its internal `workspace:*` deps to the sibling paths.

## Running

From the repo root:

```bash
npm run test:e2e # installs e2e deps, then runs all scenarios (live)
```

Or from this directory:

```bash
pnpm install
pnpm test # all scenarios
pnpm exec tsx node_modules/agentry/src/bin.ts test --mode dry # $0 discovery check
pnpm exec tsx node_modules/agentry/src/bin.ts test --grep "not found" # one scenario
```

`agentry.config.ts` runs in `live` mode (real agent, ~$0.04/scenario). No cassettes
are recorded or committed. Because a real model varies its phrasing and path run to
run, assertions on free text are inherently a little flaky; `retries: 2` is set so a
flaky scenario re-runs before failing (state isolation, below, is deterministic — only
the model's wording is not).

## How it works

- `commands.agentry.ts` — the scenarios (`test`/`test.describe` from `agentry`).
- `fixtures.ts` — seeds a scenario's isolated state dir with sample scheduler data.
- `helpers.ts` — `SCHEDULER_ROOT` (the `--plugin-dir` target), `runOpts()`, and
tolerant text assertions over the agent's reply.

Each scenario gets a fresh sandbox as its working directory; `agent.run(cmd, opts)`
spawns `claude --plugin-dir <repo-root> -p <cmd>` so the scheduler plugin is loaded.

### State isolation (determinism)

The scheduler otherwise reads **global** state from `~/.claude` and
`~/Library/LaunchAgents`, which a cwd sandbox can't isolate. To make the scenarios
deterministic, each run sets **`CLAUDE_SCHEDULER_STATE_DIR`** (via agentry's per-run
`env`) to a `state/` subdir of the sandbox — the read commands (`status`, `list`,
`history`, `logs`) resolve their paths from it, and the fixtures seed into it. This
is auth-safe: it does **not** touch `$HOME`, so the `claude` CLI keeps its normal
credentials (unlike a `$HOME` remap, which drops OAuth auth).

Not isolated by the state dir: OS registration (`~/Library/LaunchAgents`, launchctl).
The read commands only consult those cosmetically, and the assertions don't depend on
them.

### Not covered: mutating commands

`/scheduler:add` (and the other mutating commands) are intentionally **not** exercised
here: they perform real OS registration (launchd/cron) and executor installation that
`CLAUDE_SCHEDULER_STATE_DIR` can't relocate, so running them would mutate the host.
They remain covered by the unit/integration suite (`npm test`).
11 changes: 11 additions & 0 deletions e2e/agentry.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from 'agentry';

export default defineConfig({
testDir: '.',
mode: 'live',
use: { agent: 'claude', model: 'claude-haiku-4-5' },
timeout: 120_000,
// Live agent runs vary in phrasing/path; retry a flaky scenario before failing.
retries: 2,
budget: { perTest: { usd: 1 } },
});
Loading
Loading