diff --git a/cli/selftune/cron/setup.ts b/cli/selftune/cron/setup.ts index 5bbb403..ef9a4fb 100644 --- a/cli/selftune/cron/setup.ts +++ b/cli/selftune/cron/setup.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * selftune cron — Manage OpenClaw cron jobs for selftune automation. + * selftune cron — OpenClaw cron integration for selftune automation. * * Subcommands: * setup Register default selftune cron jobs with OpenClaw @@ -249,7 +249,11 @@ export async function cliMain(): Promise { await removeCronJobs(values["dry-run"] ?? false); break; default: - console.log(`selftune cron — Manage OpenClaw cron jobs + console.log(`selftune cron — OpenClaw cron integration + +Registers selftune automation jobs with OpenClaw's Gateway Scheduler. +This is an optional convenience for OpenClaw users. For generic scheduling +with system cron, launchd, or systemd, see: selftune schedule Usage: selftune cron setup [--dry-run] [--tz ] diff --git a/cli/selftune/index.ts b/cli/selftune/index.ts index 9565924..4e8b479 100644 --- a/cli/selftune/index.ts +++ b/cli/selftune/index.ts @@ -22,7 +22,8 @@ * selftune status — Show skill health summary * selftune last — Show last session details * selftune dashboard [options] — Open visual data dashboard - * selftune cron [options] — Manage OpenClaw cron jobs (setup, list, remove) + * selftune schedule [options] — Generate scheduling examples (cron, launchd, systemd) + * selftune cron [options] — OpenClaw cron integration (setup, list, remove) * selftune baseline [options] — Measure skill value vs. no-skill baseline * selftune composability [options] — Analyze skill co-occurrence conflicts * selftune workflows [options] — Discover multi-skill workflow patterns @@ -63,7 +64,8 @@ Commands: status Show skill health summary last Show last session details dashboard Open visual data dashboard - cron Manage OpenClaw cron jobs (setup, list, remove) + schedule Generate scheduling examples (cron, launchd, systemd) + cron OpenClaw cron integration (setup, list, remove) badge Generate skill health badges for READMEs baseline Measure skill value vs. no-skill baseline composability Analyze skill co-occurrence conflicts @@ -189,6 +191,11 @@ switch (command) { await cliMain(); break; } + case "schedule": { + const { cliMain } = await import("./schedule.js"); + cliMain(); + break; + } case "cron": { const { cliMain } = await import("./cron/setup.js"); await cliMain(); diff --git a/cli/selftune/schedule.ts b/cli/selftune/schedule.ts new file mode 100644 index 0000000..74d401e --- /dev/null +++ b/cli/selftune/schedule.ts @@ -0,0 +1,295 @@ +#!/usr/bin/env bun +/** + * selftune schedule — Generate scheduling examples for automated selftune runs. + * + * Outputs ready-to-use snippets for system cron, macOS launchd, and Linux systemd. + * This is the generic, agent-agnostic way to automate selftune. + * + * For OpenClaw-specific scheduling, see `selftune cron`. + * + * Usage: + * selftune schedule [--format cron|launchd|systemd] + */ + +import { parseArgs } from "node:util"; + +import { DEFAULT_CRON_JOBS } from "./cron/setup.js"; + +// --------------------------------------------------------------------------- +// Schedule definitions — derived from the shared DEFAULT_CRON_JOBS +// --------------------------------------------------------------------------- + +export interface ScheduleEntry { + name: string; + schedule: string; + command: string; + description: string; +} + +/** Map cron job metadata to schedule entries with CLI commands. */ +function commandForJob(jobName: string): string { + switch (jobName) { + case "selftune-sync": + return "selftune sync"; + case "selftune-status": + return "selftune sync && selftune status"; + case "selftune-evolve": + return "selftune evolve --sync-first --skill --skill-path "; + case "selftune-watch": + return "selftune watch --sync-first --skill --skill-path "; + default: + return `selftune ${jobName.replace("selftune-", "")}`; + } +} + +export const SCHEDULE_ENTRIES: ScheduleEntry[] = DEFAULT_CRON_JOBS.map((job) => ({ + name: job.name, + schedule: job.cron, + command: commandForJob(job.name), + description: job.description, +})); + +// --------------------------------------------------------------------------- +// Helpers for launchd/systemd generation +// --------------------------------------------------------------------------- + +/** + * Convert a cron schedule to launchd scheduling XML. + * Uses StartInterval for repeating intervals (e.g. every N minutes/hours), + * and StartCalendarInterval for fixed calendar times (e.g. daily at 8am). + */ +function cronToLaunchdSchedule(cron: string): string { + // Repeating intervals: */N minutes + if (cron.startsWith("*/")) { + const minutes = Number.parseInt(cron.split(" ")[0].replace("*/", ""), 10); + return ` StartInterval\n ${minutes * 60}`; + } + // Repeating intervals: every N hours + if (cron.startsWith("0 */")) { + const hours = Number.parseInt(cron.split(" ")[1].replace("*/", ""), 10); + return ` StartInterval\n ${hours * 3600}`; + } + + // Fixed calendar times use StartCalendarInterval + const parts = cron.split(" "); + const [minute, hour, , , weekday] = parts; + let dict = " StartCalendarInterval\n "; + if (weekday !== "*") { + dict += `\n Weekday\n ${weekday}`; + } + if (hour !== "*") { + dict += `\n Hour\n ${Number.parseInt(hour, 10)}`; + } + if (minute !== "*") { + dict += `\n Minute\n ${Number.parseInt(minute, 10)}`; + } + dict += "\n "; + return dict; +} + +/** Convert a cron schedule to a systemd OnCalendar value. */ +function cronToOnCalendar(cron: string): string { + if (cron === "*/30 * * * *") return "*:0/30"; + if (cron === "0 8 * * *") return "*-*-* 08:00:00"; + if (cron === "0 3 * * 0") return "Sun *-*-* 03:00:00"; + if (cron === "0 */6 * * *") return "*-*-* 0/6:00:00"; + return cron; +} + +/** Build launchd ProgramArguments, using /bin/sh -c for chained commands. */ +function toLaunchdArgs(command: string): string { + if (command.includes(" && ")) { + return ["/bin/sh", "-c", command].map((a) => ` ${a}`).join("\n"); + } + return command + .split(" ") + .map((a) => ` ${a}`) + .join("\n"); +} + +/** Build systemd ExecStart, using /bin/sh -c for chained commands. */ +function toSystemdExecStart(command: string): string { + if (command.includes(" && ")) { + return `/bin/sh -c "${command}"`; + } + return command; +} + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +export function generateCrontab(): string { + const lines = [ + "# selftune automation — add to your crontab with: crontab -e", + "#", + "# The core loop: sync → status → evolve → watch", + "# Adjust paths and skill names for your setup.", + "#", + ]; + for (const entry of SCHEDULE_ENTRIES) { + lines.push(`# ${entry.description}`); + lines.push(`${entry.schedule} ${entry.command}`); + lines.push(""); + } + return lines.join("\n"); +} + +export function generateLaunchd(): string { + const plists: string[] = []; + + for (const entry of SCHEDULE_ENTRIES) { + const label = `com.selftune.${entry.name.replace("selftune-", "")}`; + const args = toLaunchdArgs(entry.command); + const schedule = cronToLaunchdSchedule(entry.schedule); + + plists.push(` + + + + + Label + ${label} + ProgramArguments + +${args} + +${schedule} + StandardOutPath + /tmp/${entry.name}.log + StandardErrorPath + /tmp/${entry.name}.err + +`); + } + + return plists.join("\n\n"); +} + +export function generateSystemd(): string { + const units: string[] = []; + + for (const entry of SCHEDULE_ENTRIES) { + const unitName = entry.name; + const calendar = cronToOnCalendar(entry.schedule); + const execStart = toSystemdExecStart(entry.command); + + units.push(`# --- ${unitName}.timer --- +# ${entry.description} +# +# Install: +# cp ${unitName}.service ${unitName}.timer ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now ${unitName}.timer + +[Unit] +Description=${entry.description} + +[Timer] +OnCalendar=${calendar} +Persistent=true + +[Install] +WantedBy=timers.target + +# --- ${unitName}.service --- +[Unit] +Description=${entry.description} + +[Service] +Type=oneshot +ExecStart=${execStart}`); + } + + return units.join("\n\n"); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const VALID_FORMATS = ["cron", "launchd", "systemd"] as const; +export type ScheduleFormat = (typeof VALID_FORMATS)[number]; + +function isValidFormat(value: string): value is ScheduleFormat { + return (VALID_FORMATS as readonly string[]).includes(value); +} + +export function formatOutput( + format?: string, +): { ok: true; data: string } | { ok: false; error: string } { + if (format && !isValidFormat(format)) { + return { + ok: false, + error: `Unknown format "${format}". Valid formats: ${VALID_FORMATS.join(", ")}`, + }; + } + + const sections: string[] = []; + + if (!format || format === "cron") { + sections.push("## System cron\n"); + sections.push(generateCrontab()); + } + + if (!format || format === "launchd") { + sections.push("## macOS launchd\n"); + sections.push(generateLaunchd()); + } + + if (!format || format === "systemd") { + sections.push("## Linux systemd\n"); + sections.push(generateSystemd()); + } + + return { ok: true, data: sections.join("\n\n") }; +} + +export function cliMain(): void { + const { values } = parseArgs({ + options: { + format: { type: "string", short: "f" }, + help: { type: "boolean", default: false }, + }, + strict: false, + allowPositionals: true, + }); + + if (values.help) { + console.log(`selftune schedule — Generate scheduling examples for automation + +Usage: + selftune schedule [--format cron|launchd|systemd] + +Flags: + --format, -f Output only one format (cron, launchd, or systemd) + --help Show this help message + +The selftune automation loop is: + sync → status → evolve --sync-first → watch --sync-first + +This command generates ready-to-use snippets for running that loop +with standard system scheduling tools. No agent runtime required. + +For OpenClaw-specific scheduling, see: selftune cron`); + process.exit(0); + } + + const result = formatOutput(values.format); + if (!result.ok) { + console.error(result.error); + process.exit(1); + } + console.log(result.data); +} + +if (import.meta.main) { + cliMain(); +} diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 495ccd0..4a2a2f9 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -279,67 +279,11 @@ against known skill names from OpenClaw's skill directories. **Multi-agent support:** If you run multiple OpenClaw agents, selftune scans all directories under `~/.openclaw/agents/` automatically. -**Setup (autonomous cron loop):** +**OpenClaw autonomous cron loop (optional):** -This is the unique OpenClaw feature — skills that improve while you sleep. -OpenClaw's built-in Gateway Scheduler runs selftune autonomously on a schedule. - -1. Ensure OpenClaw is installed (`which openclaw`). -2. Register default cron jobs: - -```bash -selftune cron setup -``` - -This registers 4 jobs with OpenClaw: - -| Job | Schedule | Purpose | -|-----|----------|---------| -| `selftune-sync` | Every 30 min | Sync source-truth telemetry and rebuild repaired overlay | -| `selftune-status` | Daily 8am | Sync first, then flag skills below 80% | -| `selftune-evolve` | Weekly Sunday 3am | Full evolution pipeline on undertriggering skills | -| `selftune-watch` | Every 6 hours | Sync first, then monitor recently evolved skills | - -1. Customize timezone: `selftune cron setup --tz America/New_York` -2. Preview without registering: `selftune cron setup --dry-run` -3. View registered jobs: `selftune cron list` -4. Remove all jobs: `selftune cron remove` - -**How the autonomous loop works:** - -```text -Cron fires (isolated session) - ↓ -OpenClaw agent reads selftune skill instructions - ↓ -Runs: selftune sync → selftune status - ↓ -For each skill below 80% pass rate: - selftune evals → selftune evolve → selftune watch - ↓ -Evolved SKILL.md written to disk - ↓ -OpenClaw hot-reloads the changed SKILL.md (250ms) - ↓ -Next agent turn uses improved skill description -``` - -Each cron run uses an **isolated session** — no context pollution between runs. - -**Safety controls:** -- `--dry-run` before real deploys -- <5% regression threshold on existing triggers -- Auto-rollback via `selftune watch --auto-rollback` -- Full audit trail in `evolution_audit_log.jsonl` -- `SKILL.md.bak` backup before every deploy -- Manual override: `selftune rollback --skill ` at any time - -**Limitations:** -- Each cron run costs tokens (full LLM session, ~5K tokens estimated) -- Cron tools may be blocked in Docker sandbox mode (OpenClaw issue #29921) -- Newly created cron jobs may not fire until Gateway restart (known OpenClaw bug) - -See `skill/Workflows/Cron.md` for the full cron workflow reference. +OpenClaw users can also register selftune jobs with OpenClaw's Gateway Scheduler +for fully autonomous evolution. See the [Automation](#automation) section below +and `skill/Workflows/Cron.md` for details. --- @@ -441,6 +385,80 @@ in your hook configuration, which resolves the correct script path at runtime. --- +## Automation + +selftune is designed to run unattended on any machine. The core automation +loop is four commands: + +```text +sync → status → evolve --sync-first → watch --sync-first +``` + +Run `selftune schedule` to generate ready-to-use snippets for your platform. + +### System cron (Linux/macOS — recommended default) + +The simplest path. Add to your crontab with `crontab -e`: + +```cron +# Sync source-truth telemetry every 30 minutes +*/30 * * * * selftune sync + +# Daily health check at 8am (syncs first) +0 8 * * * selftune sync && selftune status + +# Weekly evolution at 3am Sunday +0 3 * * 0 selftune evolve --sync-first --skill --skill-path + +# Monitor regressions every 6 hours +0 */6 * * * selftune watch --sync-first --skill --skill-path +``` + +Replace `` and `` with your skill name and SKILL.md path. + +### macOS launchd + +For macOS machines that need scheduling to survive reboots and sleep/wake: + +```bash +selftune schedule --format launchd > ~/Library/LaunchAgents/com.selftune.sync.plist +launchctl load ~/Library/LaunchAgents/com.selftune.sync.plist +``` + +Run `selftune schedule --format launchd` for a full example plist. + +### Linux systemd timer + +For systemd-based servers: + +```bash +selftune schedule --format systemd +``` + +Save the output as `~/.config/systemd/user/selftune-sync.timer` and +`selftune-sync.service`, then: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now selftune-sync.timer +``` + +### OpenClaw integration (optional) + +If you use OpenClaw, `selftune cron setup` registers jobs directly with +OpenClaw's Gateway Scheduler. This provides isolated sessions per cron run +and automatic hot-reloading of evolved skills. + +```bash +selftune cron setup # register 4 default jobs +selftune cron setup --dry-run # preview first +selftune cron list # check registered jobs +``` + +See `skill/Workflows/Cron.md` for the full OpenClaw cron reference. + +--- + ## Troubleshooting ### `selftune doctor` reports failing checks @@ -493,8 +511,14 @@ Run `selftune doctor` and address each failing check: 4. Use `--force` to re-ingest all sessions if the marker file is stale. 5. If using a custom agents directory: `selftune ingest-openclaw --agents-dir /custom/path` -### Cron jobs not firing +### Scheduled automation not running + +**System cron / launchd / systemd:** +1. Verify `selftune` is on PATH in the cron environment (cron has a minimal PATH). +2. Use absolute paths if needed: `which selftune` to find the binary. +3. Check cron logs: `grep selftune /var/log/syslog` (Linux) or Console.app (macOS). +**OpenClaw cron:** 1. Verify jobs are registered: `selftune cron list` 2. Check OpenClaw cron status: `openclaw cron list` 3. Newly created jobs may require a Gateway restart (known OpenClaw bug). diff --git a/skill/Workflows/Cron.md b/skill/Workflows/Cron.md index 02d32ca..8d74249 100644 --- a/skill/Workflows/Cron.md +++ b/skill/Workflows/Cron.md @@ -1,15 +1,19 @@ -# selftune Cron Workflow +# selftune Cron Workflow (OpenClaw Integration) Manage OpenClaw cron jobs that run the selftune pipeline on a schedule. -The scheduled architecture is now explicitly **source-truth first**: every +This is the **OpenClaw-specific** scheduling path. For generic scheduling +with system cron, launchd, or systemd, see `Workflows/Schedule.md` or +run `selftune schedule`. + +The scheduled architecture is **source-truth first**: every status/evolve/watch pass should sync raw agent data before making decisions. ## When to Use -- Setting up selftune automation for the first time on OpenClaw -- Checking which cron jobs are registered -- Removing selftune cron jobs (cleanup or reconfiguration) -- Enabling the autonomous observe-grade-evolve-deploy loop +- Setting up selftune automation specifically on OpenClaw +- Checking which OpenClaw cron jobs are registered +- Removing selftune cron jobs from OpenClaw +- Enabling the autonomous observe-grade-evolve-deploy loop via OpenClaw ## Prerequisites diff --git a/skill/Workflows/Schedule.md b/skill/Workflows/Schedule.md new file mode 100644 index 0000000..6ce93e0 --- /dev/null +++ b/skill/Workflows/Schedule.md @@ -0,0 +1,65 @@ +# selftune Schedule Workflow + +Generate ready-to-use scheduling examples for automating selftune with +standard system tools. This is the **primary automation path** — it works +on any machine without requiring a specific agent runtime. + +For OpenClaw-specific scheduling, see `Workflows/Cron.md`. + +## When to Use + +- Setting up selftune automation for the first time +- Generating crontab entries for a Linux/macOS server +- Creating a launchd plist for a macOS machine +- Creating a systemd timer for a Linux server +- Understanding the selftune automation loop + +## The Automation Loop + +The core selftune automation loop is four commands: + +``` +sync → status → evolve --sync-first → watch --sync-first +``` + +1. **sync** refreshes source-truth telemetry from all agent sources +2. **status** reports skill health (run after sync) +3. **evolve --sync-first** improves underperforming skills (syncs before analyzing) +4. **watch --sync-first** monitors recently evolved skills for regressions + +## Default Command + +```bash +selftune schedule +``` + +Outputs examples for all three scheduling systems (cron, launchd, systemd). + +## Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--format ` | Output only one format: `cron`, `launchd`, or `systemd` | All formats | +| `--help` | Show help message | — | + +## Steps + +1. Run `selftune schedule` to see all examples +2. Pick the scheduling system for your platform +3. Customize the snippets (skill names, paths, timezone) +4. Install using the instructions in the output + +## Common Patterns + +**"Quick setup on a Linux server"** +> Run `selftune schedule --format cron`, paste the output into `crontab -e`. + +**"Set up on macOS"** +> Run `selftune schedule --format launchd`, save as a `.plist` file, load with `launchctl`. + +**"Set up on a systemd-based server"** +> Run `selftune schedule --format systemd`, save as `.timer` and `.service` files, enable with `systemctl`. + +**"I use OpenClaw"** +> Use `selftune cron setup` instead — it registers jobs directly with OpenClaw's scheduler. +> See `Workflows/Cron.md`. diff --git a/tests/schedule/schedule.test.ts b/tests/schedule/schedule.test.ts new file mode 100644 index 0000000..c38ccd8 --- /dev/null +++ b/tests/schedule/schedule.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatOutput, + generateCrontab, + generateLaunchd, + generateSystemd, + SCHEDULE_ENTRIES, +} from "../../cli/selftune/schedule.js"; + +// --------------------------------------------------------------------------- +// 1. SCHEDULE_ENTRIES structure +// --------------------------------------------------------------------------- +describe("SCHEDULE_ENTRIES", () => { + test("has exactly 4 entries", () => { + expect(SCHEDULE_ENTRIES).toHaveLength(4); + }); + + test("all entries have required fields", () => { + for (const entry of SCHEDULE_ENTRIES) { + expect(typeof entry.name).toBe("string"); + expect(entry.name.length).toBeGreaterThan(0); + expect(typeof entry.schedule).toBe("string"); + expect(entry.schedule.length).toBeGreaterThan(0); + expect(typeof entry.command).toBe("string"); + expect(entry.command.length).toBeGreaterThan(0); + expect(typeof entry.description).toBe("string"); + expect(entry.description.length).toBeGreaterThan(0); + } + }); + + test("contains sync, status, evolve, and watch entries", () => { + const names = SCHEDULE_ENTRIES.map((e) => e.name); + expect(names).toContain("selftune-sync"); + expect(names).toContain("selftune-status"); + expect(names).toContain("selftune-evolve"); + expect(names).toContain("selftune-watch"); + }); + + test("evolve entry uses --sync-first", () => { + const evolve = SCHEDULE_ENTRIES.find((e) => e.name === "selftune-evolve"); + expect(evolve?.command).toContain("--sync-first"); + }); + + test("watch entry uses --sync-first", () => { + const watch = SCHEDULE_ENTRIES.find((e) => e.name === "selftune-watch"); + expect(watch?.command).toContain("--sync-first"); + }); + + test("derives from DEFAULT_CRON_JOBS (shared source of truth)", () => { + // Schedules should match cron expressions from DEFAULT_CRON_JOBS + const sync = SCHEDULE_ENTRIES.find((e) => e.name === "selftune-sync"); + expect(sync?.schedule).toBe("*/30 * * * *"); + const status = SCHEDULE_ENTRIES.find((e) => e.name === "selftune-status"); + expect(status?.schedule).toBe("0 8 * * *"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Crontab generation +// --------------------------------------------------------------------------- +describe("generateCrontab", () => { + test("includes crontab header comment", () => { + const output = generateCrontab(); + expect(output).toContain("crontab -e"); + }); + + test("includes all schedule entries", () => { + const output = generateCrontab(); + for (const entry of SCHEDULE_ENTRIES) { + expect(output).toContain(entry.schedule); + expect(output).toContain(entry.command); + } + }); +}); + +// --------------------------------------------------------------------------- +// 3. Launchd generation +// --------------------------------------------------------------------------- +describe("generateLaunchd", () => { + test("outputs valid plist structure", () => { + const output = generateLaunchd(); + expect(output).toContain(""); + }); + + test("uses StartInterval for repeating schedules", () => { + const output = generateLaunchd(); + // sync runs every 30 min — should use StartInterval + expect(output).toContain("StartInterval"); + }); + + test("uses StartCalendarInterval for fixed-time schedules", () => { + const output = generateLaunchd(); + // status runs daily at 8am — should use StartCalendarInterval + expect(output).toContain("StartCalendarInterval"); + expect(output).toContain("Hour"); + }); + + test("uses /bin/sh -c for chained commands", () => { + const output = generateLaunchd(); + // status command has && — should use shell wrapper + expect(output).toContain("/bin/sh"); + expect(output).toContain("-c"); + expect(output).toContain("selftune sync && selftune status"); + }); + + test("includes install instructions", () => { + const output = generateLaunchd(); + expect(output).toContain("launchctl load"); + }); + + test("generates plists for all schedule entries", () => { + const output = generateLaunchd(); + expect(output).toContain("com.selftune.sync"); + expect(output).toContain("com.selftune.status"); + expect(output).toContain("com.selftune.evolve"); + expect(output).toContain("com.selftune.watch"); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Systemd generation +// --------------------------------------------------------------------------- +describe("generateSystemd", () => { + test("outputs timer and service sections", () => { + const output = generateSystemd(); + expect(output).toContain("[Timer]"); + expect(output).toContain("[Service]"); + }); + + test("uses /bin/sh -c for chained commands", () => { + const output = generateSystemd(); + // status has && — should wrap in shell + expect(output).toContain('ExecStart=/bin/sh -c "selftune sync && selftune status"'); + }); + + test("uses bare command for simple entries", () => { + const output = generateSystemd(); + expect(output).toContain("ExecStart=selftune sync\n"); + }); + + test("includes install instructions", () => { + const output = generateSystemd(); + expect(output).toContain("systemctl --user"); + }); + + test("generates units for all schedule entries", () => { + const output = generateSystemd(); + expect(output).toContain("selftune-sync.timer"); + expect(output).toContain("selftune-status.timer"); + expect(output).toContain("selftune-evolve.timer"); + expect(output).toContain("selftune-watch.timer"); + }); +}); + +// --------------------------------------------------------------------------- +// 5. formatOutput (default and filtered) +// --------------------------------------------------------------------------- +describe("formatOutput", () => { + test("default output includes all three sections", () => { + const result = formatOutput(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toContain("## System cron"); + expect(result.data).toContain("## macOS launchd"); + expect(result.data).toContain("## Linux systemd"); + } + }); + + test("--format cron outputs only cron section", () => { + const result = formatOutput("cron"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toContain("## System cron"); + expect(result.data).not.toContain("## macOS launchd"); + expect(result.data).not.toContain("## Linux systemd"); + } + }); + + test("--format launchd outputs only launchd section", () => { + const result = formatOutput("launchd"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).not.toContain("## System cron"); + expect(result.data).toContain("## macOS launchd"); + expect(result.data).not.toContain("## Linux systemd"); + } + }); + + test("--format systemd outputs only systemd section", () => { + const result = formatOutput("systemd"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).not.toContain("## System cron"); + expect(result.data).not.toContain("## macOS launchd"); + expect(result.data).toContain("## Linux systemd"); + } + }); + + test("unknown format returns error result", () => { + const result = formatOutput("docker"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("docker"); + } + }); +});