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
42 changes: 31 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ cc-timer "2h10m" "Explore if this is this the real life? "
- Keep pending jobs in a JSON state file so they can be listed or canceled later.
- Work the same way on macOS, Linux, and Windows without shell-specific tricks.

## Glossary

`cc-timer` uses three related terms consistently:

- **Task** — one unit of work you provide (a positional argument, a `--task`, or a line from `--file`). Each task becomes a single `claude --bg <task>` invocation.
- **Job** — the scheduled record that groups one or more tasks under a single dispatch time. Jobs are what you `list` and `cancel`; each has an id like `ct_9f2aXY`.
- **Agent** — the Claude Code background agent a task turns into once the job is dispatched. After dispatch, agents appear in `claude agents` and are managed there, not by `cc-timer`.

## Install

Install globally from npm:
Expand Down Expand Up @@ -53,7 +61,7 @@ cc-timer "45m" "Investigate this landslide, why no escape from reality"
```

```text
[Success] Scheduled 1 task to run in 45 minutes (2700 seconds).
[Success] Scheduled 1 task to run in 45 minutes (2,700 seconds).
Dispatch time: 2026-05-18 15:42:00 local time
Tasks:
- Investigate this landslide, why no escape from reality
Expand Down Expand Up @@ -174,6 +182,18 @@ cc-timer "2h" \
--dry-run
```

```text
[Dry-run] Would schedule 2 tasks to run in 2 hours (7,200 seconds).
Dispatch time: 2026-05-18 17:42:00 local time
Commands:
- claude --bg "Ping mama, ooh (any way the wind blows)"
- claude --bg "Respawn I don't wanna die"

No job was created.
```

The dry run prints the exact shell-quoted `claude` invocations under `Commands:`. Add `--json` to get the same preview as a `commands` array.

## Listing pending jobs

```bash
Expand Down Expand Up @@ -214,16 +234,16 @@ Cancellation marks the job state before terminating the worker, so a wake-up tha

`cc-timer [delay] [tasks...]` — schedule new agents.

| Option | Description |
| ----------------------------- | ---------------------------------------------------------------- |
| `--task <text>` | Add one task. Repeatable. |
| `--file <path>` | Read tasks from a text file, one per line. |
| `--at <time>` | Use an exact local time instead of `delay`. |
| `--dry-run` | Print the plan without creating a job. |
| `--claude-bin <path-or-name>` | Override the `claude` executable. Default: `claude`. |
| `--cwd <path>` | Working directory for dispatched commands. Default: current dir. |
| `--log-file <path>` | Explicit log output file. |
| `--json` | Machine-readable JSON output. |
| Option | Description |
| ----------------------------- | -------------------------------------------------------------------- |
| `--task <text>` | Add one task. Repeatable. |
| `--file <path>` | Read tasks from a text file, one per line. |
| `--at <time>` | Schedule at an exact local time, e.g. `17:30` or `2026-05-19 09:00`. |
| `--dry-run` | Show the planned schedule without creating a job. |
| `--claude-bin <path-or-name>` | Override the `claude` executable. Default: `claude`. |
| `--cwd <path>` | Working directory for dispatched commands. Default: current dir. |
| `--log-file <path>` | Explicit log output file. |
| `--json` | Machine-readable JSON output. |

Anything after a standalone `--` is forwarded verbatim to `claude`.

Expand Down
3 changes: 2 additions & 1 deletion src/claude/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* without affecting the rest of the codebase.
*/
import { CcTimerError } from "../errors.js";
import { Label } from "../labels.js";

export interface ClaudeCommand {
args: string[];
Expand All @@ -26,7 +27,7 @@ export function validateClaudeArgs(args: readonly string[]): void {
const reason = RESERVED_FLAGS[head];
if (reason) {
throw new CcTimerError(
`[Error] Cannot forward ${head} to claude — ${reason} Remove it from the args after \`--\`.`,
`${Label.Error} Cannot forward ${head} to claude — ${reason} Remove it from the args after \`--\`.`,
);
}
}
Expand Down
99 changes: 78 additions & 21 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,65 @@
#!/usr/bin/env node
import { Command, Option } from "commander";
import { Command, CommanderError, Option } from "commander";
import { createRequire } from "node:module";
import { runCancel, runCancelAll } from "./commands/cancel.js";
import { runList } from "./commands/list.js";
import { runSchedule } from "./commands/schedule.js";
import { CcTimerError, formatCliError } from "./errors.js";
import { Label } from "./labels.js";

const require = createRequire(import.meta.url);
const packageJson = require("../package.json") as { version: string };

const SUBCOMMANDS = ["list", "cancel"] as const;

function die(err: unknown): never {
process.stderr.write(`${formatCliError(err)}\n`);
process.exit(1);
}

/**
* Route commander's own parse errors (unknown option, invalid argument, …)
* through the same `[Error]` + prelude rendering as everything else, instead of
* commander's lowercase `error: …` + usage dump. Help/version output has already
* been printed at this point and carries exit code 0, so we exit cleanly.
*/
function handleParseError(err: unknown): never {
if (err instanceof CommanderError) {
if (err.exitCode === 0) process.exit(0);
const message = err.message.replace(/^error:\s*/i, "");
process.stderr.write(`${formatCliError(message)}\n`);
process.exit(err.exitCode || 1);
}
die(err);
}

/** Levenshtein distance, used to spot mistyped subcommands. */
function editDistance(a: string, b: string): number {
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
let diag = prev[0];
prev[0] = i;
for (let j = 1; j <= b.length; j++) {
const tmp = prev[j];
prev[j] =
a[i - 1] === b[j - 1] ? diag : 1 + Math.min(prev[j], prev[j - 1], diag);
diag = tmp;
}
}
return prev[b.length];
}

/**
* The default action owns `[items...]`, so a mistyped subcommand like `lst` is
* parsed as a delay and fails with a confusing "Invalid delay". Detect a
* near-miss against the real subcommands and suggest it instead.
*/
function suggestSubcommand(token: string): string | undefined {
const word = token.toLowerCase();
if (word.includes(" ")) return undefined;
return SUBCOMMANDS.find((cmd) => editDistance(word, cmd) <= 2);
}

/**
* Split argv at the first standalone `--` separator. Everything after is
* collected as passthrough args for the underlying `claude` invocation.
Expand All @@ -31,54 +77,65 @@ function splitPassthrough(argv: string[]): {

function buildProgram(claudeArgs: string[]): Command {
const program = new Command();
// Set before adding subcommands so they inherit these settings and their
// parse errors flow through handleParseError too.
program.exitOverride();
program.configureOutput({ writeErr: () => {} });
program
.name("cc-timer")
.description(
"Schedule Claude Code background agents after a delay or at a specific time.",
"Schedule Claude Code background agents after a delay or at a specific local time.",
)
.version(packageJson.version)
.showHelpAfterError();
.version(packageJson.version);

// Default action: schedule new agents.
program
.argument(
"[items...]",
"Delay followed by tasks, or just tasks when using --at",
"Delay followed by tasks, or just tasks when using --at.",
)
.option(
"--task <text>",
"Add one task (repeatable)",
"Add one task. Repeatable.",
(val: string, prev: string[]) => {
prev.push(val);
return prev;
},
[] as string[],
)
.option("--file <path>", "Read tasks from a text file")
.option("--file <path>", "Read tasks from a text file, one per line.")
.option(
"--at <time>",
'Schedule at an exact local time, e.g. "17:30" or "2026-05-19 09:00"',
'Schedule at an exact local time, e.g. "17:30" or "2026-05-19 09:00".',
)
.option(
"--dry-run",
"Show the planned schedule without creating a job",
"Show the planned schedule without creating a job.",
false,
)
.option(
"--claude-bin <path-or-name>",
"Claude CLI executable name or path",
"Override the claude executable. Default: claude.",
"claude",
)
.option(
"--cwd <path>",
"Working directory for dispatched commands",
"Working directory for dispatched commands. Default: current dir.",
process.cwd(),
)
.option("--log-file <path>", "Explicit log output file")
.option("--json", "Machine-readable JSON output", false)
.option("--log-file <path>", "Explicit log output file.")
.option("--json", "Machine-readable JSON output.", false)
.action(async (items: string[], options) => {
try {
const scheduleItems = items ?? [];
if (!options.at && scheduleItems.length > 0) {
const suggestion = suggestSubcommand(scheduleItems[0]);
if (suggestion) {
throw new CcTimerError(
`${Label.Error} Unknown command "${scheduleItems[0]}". Did you mean "${suggestion}"?`,
);
}
}
const delay = options.at ? undefined : scheduleItems[0];
const positional = options.at ? scheduleItems : scheduleItems.slice(1);
const res = await runSchedule({
Expand All @@ -103,9 +160,9 @@ function buildProgram(claudeArgs: string[]): Command {
program
.command("list")
.description("List pending jobs.")
.option("--verbose", "Show task text, PID, and dispatch details", false)
.option("--json", "Machine-readable JSON output", false)
.option("--all", "Include dispatched/canceled history", false)
.option("--verbose", "Show task text, PID, and dispatch details.", false)
.option("--json", "Machine-readable JSON output.", false)
.option("--all", "Include dispatched/canceled history.", false)
.action(async (options) => {
try {
const out = await runList({
Expand All @@ -122,19 +179,19 @@ function buildProgram(claudeArgs: string[]): Command {
program
.command("cancel [id]")
.description("Cancel a pending job, or all pending jobs with --all.")
.option("--all", "Cancel every pending job", false)
.option("--json", "Machine-readable JSON output", false)
.option("--all", "Cancel every pending job.", false)
.option("--json", "Machine-readable JSON output.", false)
.action(async (id: string | undefined, options) => {
try {
const all = Boolean(options.all);
if (all && id) {
throw new CcTimerError(
"[Error] Pass either an id or --all, not both.",
`${Label.Error} Pass either an id or --all, not both.`,
);
}
if (!all && !id) {
throw new CcTimerError(
"[Error] Provide a job id or use --all to cancel every pending job.",
`${Label.Error} Provide a job id or use --all to cancel every pending job.`,
);
}
const res = all
Expand All @@ -153,4 +210,4 @@ function buildProgram(claudeArgs: string[]): Command {

const { head, claudeArgs } = splitPassthrough(process.argv);
const program = buildProgram(claudeArgs);
program.parseAsync(head).catch(die);
program.parseAsync(head).catch(handleParseError);
48 changes: 18 additions & 30 deletions src/commands/cancel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CcTimerError } from "../errors.js";
import { isProcessAlive, terminateProcess } from "../scheduler/process.js";
import { getJob, listPending, updateJob } from "../state/jobStore.js";
import { JobRecord } from "../state/schema.js";
import { Label } from "../labels.js";

export interface CancelOptions {
id: string;
Expand Down Expand Up @@ -46,11 +47,22 @@ async function cancelJob(job: JobRecord): Promise<void> {
export async function runCancel(opts: CancelOptions): Promise<CancelResult> {
const job = await getJob(opts.id);
if (!job) {
throw new CcTimerError(`[Error] Unknown job id "${opts.id}".`);
throw new CcTimerError(`${Label.Error} Unknown job id "${opts.id}".`);
}

if (job.status === "dispatched") {
const msg = `[Info] Job ${job.id} already dispatched. Any created Claude agents must be managed separately.`;
// Terminal states share one structurally parallel phrasing:
// "Job <id> already <state>; nothing to cancel."
const terminal: Record<string, CancelResult["status"]> = {
dispatched: "already-dispatched",
canceled: "already-canceled",
failed: "already-failed",
};
if (job.status in terminal) {
const suffix =
job.status === "dispatched"
? " Any created Claude agents must be managed separately."
: "";
const msg = `${Label.Info} Job ${job.id} already ${job.status}; nothing to cancel.${suffix}`;
return {
output: opts.json
? JSON.stringify(
Expand All @@ -59,38 +71,14 @@ export async function runCancel(opts: CancelOptions): Promise<CancelResult> {
2,
)
: msg,
status: "already-dispatched",
};
}
if (job.status === "canceled") {
return {
output: opts.json
? JSON.stringify(
{ id: job.id, status: job.status, action: "noop" },
null,
2,
)
: `[Info] Job ${job.id} was already canceled.`,
status: "already-canceled",
};
}
if (job.status === "failed") {
return {
output: opts.json
? JSON.stringify(
{ id: job.id, status: job.status, action: "noop" },
null,
2,
)
: `[Info] Job ${job.id} already failed; nothing to cancel.`,
status: "already-failed",
status: terminal[job.status],
};
}

// Mark canceled before attempting termination so a racing wake-up sees the new state.
await cancelJob(job);

const msg = `[Success] Canceled job ${job.id}.`;
const msg = `${Label.Success} Canceled job ${job.id}.`;
return {
output: opts.json
? JSON.stringify(
Expand All @@ -113,7 +101,7 @@ export async function runCancelAll(
return {
output: opts.json
? JSON.stringify({ canceled: 0, ids: [], action: "noop" }, null, 2)
: "[Info] No pending jobs to cancel.",
: `${Label.Info} No pending jobs to cancel.`,
canceledIds: [],
};
}
Expand Down
Loading
Loading