Add Automations feature: scheduled prompts from a vault folder

A new "Automations" affordance reads markdown files from a configurable
vault folder, parses a frontmatter schedule (`interval` minutes or 5-field
`cron`) plus optional `enabled`/`runtime`/`appendNewline`/`name`, and fires
the file body into the running CLI on schedule or manually from a modal.
Each fire (or skip / error) is logged to a ring-buffer history capped at
200 entries; the modal exposes the entries with status, last/next run, a
per-row Run now button, and a History tab with Clear / Export-as-markdown.

The scheduler runs at the plugin level (`activeWindow.setInterval`, 30 s
tick) and stays alive whether the panel is open or not. The folder cache
live-updates on vault create/modify/delete/rename. Runs skip cleanly when
no CLI is running — the plugin never auto-starts a runtime.

Implementation: pure logic in automation.ts (28 vitest cases), wiring in
main.ts, AutomationsModal in automations-modal.ts. Adds `cron-parser`
(runtime) and `yaml` (dev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
blamouche 2026-05-19 23:08:28 +02:00
parent 366733dadd
commit 9577a02278
14 changed files with 11327 additions and 39 deletions

View file

@ -66,3 +66,4 @@
| 2026-04-27 23:09:00 CEST | agent | User reported the community-store auto-install only ships `main.js`, `manifest.json`, `styles.css`, which doesn't include the `pty-proxy.js` / `pty-bridge.py` files the plugin spawns. Fix: embedded both auxiliary file contents into `main.js` at build time via esbuild `define` (`PTY_PROXY_SOURCE`, `PTY_BRIDGE_SOURCE` constants declared in main.ts), and added `ensureProxyFiles(pluginDir)` that writes them lazily before each `spawnPtyProxy` call (only when missing or content drift). Verified embedded markers (`spawnPythonBridge`, `decode_payload`, `TIOCSWINSZ`, `pty.fork`) present in built main.js. README `Required files` reworked accordingly. Bumped to 0.1.46. | `esbuild.config.mjs`, `main.ts`, `main.js`, `manifest.json`, `versions.json`, `package.json`, `package-lock.json`, `README.md`, `.prompt-hub/version.md`, `.prompt-hub/releases.md` | success | Commit, push, annotated tag 0.1.46. User republishes draft so community-store install works for new users. |
| 2026-05-12 23:12:00 CEST | agent | Trimmed the release zip staging step to the 3 canonical Obsidian plugin files (`manifest.json`, `main.js`, `styles.css`). The auxiliary `pty-proxy.js` / `pty-bridge.py` are now embedded in main.js so they no longer need to ship; `versions.json`, `package.json`, `package-lock.json` also dropped from the zip (`versions.json` stays as a standalone release asset since Obsidian fetches it directly from the release URL). Updated release notes body + README `Release` and `Required files` sections. Dry-run zip listing confirmed 3 files in the `any-ai-cli/` folder. Bumped to 0.1.47. | `.github/workflows/release.yml`, `README.md`, `manifest.json`, `versions.json`, `package.json`, `package-lock.json`, `.prompt-hub/version.md`, `.prompt-hub/releases.md` | success | Commit, push, annotated tag 0.1.47. |
| 2026-05-19 22:34:08 CEST | agent | Created new branch `feature/automation` from `main` for upcoming automation work; bumped minor version 0.1.48 → 0.2.0 per Prompt Hub branch protocol and logged the release entry. | `git checkout -b feature/automation`, `.prompt-hub/version.md`, `.prompt-hub/releases.md`, `.prompt-hub/todo/todo-20260519-223408-create-feature-automation-branch.md` | success | Commit the version/releases bump on `feature/automation` and push the new branch upstream. |
| 2026-05-19 23:08:00 CEST | agent | Implemented the Automations feature on `feature/automation`: new `automation.ts` (pure parser/scheduler logic with 28 vitest cases), plugin-level scheduler in `main.ts` (ticks every 30 s, vault `create/modify/delete/rename` listeners, `triggerAutomation` with `scheduler` vs `manual` source, history ring-buffer capped at 200), `sendAutomationPrompt` method on `ClaudeCliView`, new "Automations" toolbar button, new `AutomationsModal` with **Automations** + **History** tabs (parse errors surfaced, Run now per row, Clear history, Export as markdown), settings section with `Automations folder` + `Reload now`, modal styles in `styles.css`, README updated with feature + format docs. Added `cron-parser` (runtime) and `yaml` (dev). Validated `npm run lint`, `npm run build`, `npm test` (55 tests pass). Bumped 0.2.0 → 0.2.1. | `automation.ts`, `automations-modal.ts`, `tests/automation.test.ts`, `main.ts`, `styles.css`, `README.md`, `package.json`, `package-lock.json`, `.prompt-hub/version.md`, `.prompt-hub/releases.md`, `.prompt-hub/todo/todo-20260519-225317-automations-recurring-prompts.md` | success | Commit and push to `feature/automation`. |

View file

@ -1,5 +1,12 @@
# Releases
## 0.2.1 - 2026-05-19
- Added the Automations feature: pick a vault folder of markdown files (frontmatter `interval` in minutes or 5-field `cron`, optional `enabled`/`runtime`/`appendNewline`/`name`; body = prompt) and the plugin fires them into the running CLI on schedule. Skips runs when no CLI is running (logged, never auto-starts).
- Added a new `Automations` toolbar button that opens a modal with two tabs: **Automations** (list with schedule, last run, next run, status, per-row Run now + Open file; surfaces parse errors) and **History** (chronological log capped at 200 entries with status badges, Clear history, Export as markdown).
- Added plugin-level scheduler (ticks every 30 s) wired via `registerInterval` + vault `create/modify/delete/rename` events so the folder cache live-updates.
- Added new module `automation.ts` (pure logic: frontmatter parser, `computeNextRun`, `isDue`, `pushHistory`, `describeSchedule`, `buildPromptPreview`) with 28 vitest cases covering parsing, scheduling, ring-buffer truncation and preview formatting.
- Added `cron-parser` runtime dep and `yaml` devDep; bumped `package-lock.json`.
## 0.2.0 - 2026-05-19
- Opened branch `feature/automation` off `main` for upcoming automation work; bumped minor version per Prompt Hub branch protocol.

View file

@ -13,4 +13,8 @@ Create a new git branch `feature/automation` off `main` for upcoming automation
7. Push the branch to `origin` with upstream tracking.
## Review
- _to be filled in after execution_
- Created branch `feature/automation` from `main` (commit `366733d`) and switched to it.
- Bumped `.prompt-hub/version.md` `0.1.48``0.2.0`; added a `0.2.0` entry to `.prompt-hub/releases.md`.
- Logged the action in `.prompt-hub/memory.md`.
- Pushed the branch upstream (`origin/feature/automation`, tracking set up).
- Status: **completed**.

View file

@ -0,0 +1,41 @@
# Task: Automations — prompts récurrents depuis un dossier vault
Branch: `feature/automation`
Plan: `/Users/benoitlamouche/.claude/plans/j-aimerais-ajouter-une-option-magical-river.md`
## Objective
Permettre à l'utilisateur de définir un dossier de prompts (markdown + frontmatter) qui sont auto-envoyés au CLI selon une récurrence (interval ou cron) ou manuellement depuis un bouton de toolbar. Historique des exécutions tracké et consultable.
## Plan d'exécution
1. Ajouter `cron-parser` aux dépendances.
2. Créer `automation.ts` — parser frontmatter, types, `computeNextRun`, `isDue`, `pushHistory` (logique pure).
3. Créer `tests/automation.test.ts` — couverture parser + scheduling + ring buffer.
4. Étendre `ClaudeCliPluginSettings` + `DEFAULT_SETTINGS` + `loadSettings` dans `main.ts` (champs automations).
5. Ajouter scheduler + `triggerAutomation` + `recordHistory` + `loadAutomations` à la classe `ClaudeCliPlugin`.
6. Ajouter méthode publique `sendAutomationPrompt` sur `ClaudeCliView`.
7. Ajouter section "Automations" au settings tab.
8. Ajouter bouton "Automations" dans la toolbar du panel + wire vers modal.
9. Créer `automations-modal.ts` (onglets Automations / History).
10. Ajouter styles modal dans `styles.css`.
11. Lint + build + typecheck + tests.
12. Mettre à jour README.
13. Bumper version + releases + memory + review du todo.
14. Commit + push.
## Review
- Implémenté la feature complète selon le plan :
- `automation.ts` (parser frontmatter + `computeNextRun` / `isDue` / `pushHistory` / `buildPromptPreview` / `describeSchedule`).
- `tests/automation.test.ts` — 28 cas (parsing OK + erreurs, scheduling interval/cron, ring-buffer truncation, preview formatting).
- `main.ts` — étendu `ClaudeCliPluginSettings` + `loadSettings` (avec `sanitizeLastRun` / `sanitizeHistory`), scheduler plugin-level via `activeWindow.setInterval` + 30 s tick + `onLayoutReady` initial tick, vault `create/modify/delete/rename` events filtrés sur le préfixe de dossier, `triggerAutomation` avec `scheduler`/`manual` source, `recordHistory` + `clearAutomationHistory`, méthodes publiques `isProcessRunning` / `getRunningRuntimeId` / `matchesRuntime` / `sendAutomationPrompt` sur `ClaudeCliView`.
- Nouveau bouton "Automations" dans la toolbar (`secondaryRowEl`) avec icône `calendar-clock`, ouvre `AutomationsModal`.
- Section settings "Automations" (champ `automationsFolder` + bouton `Reload now`).
- `automations-modal.ts` — onglets **Automations** (tableau Name/Schedule/Last run/Next run/Status/Actions, Run now désactivé si CLI off, Open file, erreurs de parsing en haut) et **History** (liste capée, badges colorés par status, `Clear history`, `Export as markdown`).
- `styles.css` — styles modal complets (tabs, table, badges, history list).
- `README.md` — entrée feature + section **Automations** (setup, format, manual runs + history).
- Deps : `cron-parser` (runtime), `yaml` (dev).
- Validations :
- `npm run lint` → clean (corrections : `activeWindow.setInterval`, suppression `console.info/error`, sentence-case "Markdown"/"readme", typage `unknown` du parser YAML dans les tests).
- `npm run build` → OK.
- `npm test` → 55 tests pass (28 nouveaux + 27 existants).
- Bumped version 0.2.0 → 0.2.1, log mémoire ajouté.
- Statut : **completed** (reste : commit + push, et test manuel utilisateur dans un vault de dev).

View file

@ -1 +1 @@
0.2.0
0.2.1

View file

@ -11,6 +11,7 @@ The plugin embeds a real PTY-backed terminal in the sidebar and lets you declare
- Dedicated sidebar view with an embedded `xterm` terminal.
- **Customizable runtime list** — declare any number of CLI runtimes from settings (Claude and Codex are pre-populated; add Aider, custom wrappers, anything on `PATH`) and switch between them via a sidebar dropdown.
- One-click `@active file` and `@active folder` buttons that insert the current note path (or its parent folder) as a mention in the running CLI's stdin.
- **Automations** — drop markdown files (prompt + frontmatter schedule) in a vault folder and have them fired into the running CLI on an `interval` or `cron`, or run them manually from a modal with a per-run history log.
- Process controls in the toolbar: `Start`, `Stop`, `Restart`, `Clear`.
- Launches the selected runtime in the **current active vault folder** so the AI sees your notes as the working tree.
- Resilient PTY stack with multi-tier fallbacks (`node-pty` → Python PTY bridge → direct pipe → `script`) so it works on macOS, Linux, and Windows.
@ -88,6 +89,49 @@ The community-store auto-install and the release zip both ship only the three ca
- Click `@active file` or `@active folder` (second toolbar row) to insert the current note path or its parent folder as a mention.
- Click `Restart` to relaunch, `Stop` to terminate, `Clear` to wipe the terminal output.
5. Switching the dropdown to another runtime while a process is running automatically restarts it on the new CLI (configurable in settings).
6. Click `Automations` (second toolbar row) to open the Automations modal: run any prompt manually with **Run now**, or browse the **History** tab to see what fired and when.
## Automations
Automations let you store reusable prompts as markdown files in your vault and have the plugin send them to the running CLI either on a schedule (`interval` or `cron`) or manually from the **Automations** modal.
### Setup
1. Pick a folder in your vault to hold the prompts (e.g. `Automations`).
2. In plugin settings → **Automations folder**, set that path. Leave empty to disable the feature.
3. Drop one markdown file per automation in that folder. The plugin scans the folder on startup and live-updates on vault changes (create / modify / delete / rename).
### File format
Each automation is a regular markdown file with YAML frontmatter that sets the schedule, plus a body containing the prompt that will be sent to the CLI verbatim.
```markdown
---
name: Daily summary # optional, defaults to the filename
enabled: true # optional, defaults to true
interval: 60 # minutes — exclusive with `cron`
# cron: "0 9 * * 1-5" # standard 5-field cron — exclusive with `interval`
runtime: claude # optional — only fire if this runtime id or display name is running
appendNewline: true # optional, defaults to true (adds "\n" so the CLI executes the prompt)
---
Summarize my notes from the last 24h and propose three priorities for today.
```
Rules:
- Exactly one of `interval` or `cron` must be set. `interval` is in whole minutes (>= 1). `cron` uses standard 5-field syntax (`cron-parser`).
- `enabled: false` keeps the entry visible in the modal but skips scheduling and disables the **Run now** button.
- The prompt is everything after the closing `---` (trimmed).
- If the configured `runtime` does not match the currently running CLI, the run is skipped and logged in History.
- If no CLI is running when a scheduled run is due, the run is skipped (the plugin never auto-starts a CLI for you).
### Manual runs and history
The **Automations** toolbar button opens a modal with two tabs:
- **Automations** — list of parsed entries with schedule, last run, next run, status badge, and a **Run now** button per row (disabled when no CLI is running). Parse errors are shown at the top with file paths and reasons.
- **History** — chronological log of every fired run (or skip / error), capped at 200 entries by default. You can **Clear history** or **Export as markdown** to create a snapshot note in the vault.
## Plugin Settings
@ -106,6 +150,11 @@ Runtimes section (the customizable list of CLIs shown in the sidebar dropdown):
- Add as many entries as you need with **Add runtime**. Remove unused ones via the trash icon (the list must keep at least one entry).
- Claude and Codex are pre-populated on first install. Old `command` / `codexCommand` settings from earlier versions are migrated automatically.
Automations section:
- **Automations folder** — vault-relative path to the folder holding automation markdown files. Leave empty to disable. See the [Automations](#automations) section above for the file format.
- **Reload automations** — force a re-scan (otherwise the plugin already refreshes on any vault change inside the folder).
Advanced:
- **Node executable** — path to the Node binary used to run the PTY proxy. Leave as `auto` for automatic detection, or override with an explicit path (`/opt/homebrew/bin/node`, `C:\Program Files\nodejs\node.exe`, etc.).

267
automation.ts Normal file
View file

@ -0,0 +1,267 @@
import { CronExpressionParser } from "cron-parser";
export interface AutomationFrontmatter {
name?: string;
enabled?: boolean;
interval?: number;
cron?: string;
runtime?: string;
appendNewline?: boolean;
}
export interface ParsedAutomation {
path: string;
name: string;
enabled: boolean;
interval: number | null;
cron: string | null;
runtime: string | null;
appendNewline: boolean;
body: string;
}
export interface AutomationParseError {
path: string;
name: string;
reason: string;
}
export type ParseResult =
| { ok: true; entry: ParsedAutomation }
| { ok: false; error: AutomationParseError };
export interface AutomationRunRecord {
ts: number;
path: string;
name: string;
source: "scheduler" | "manual";
status: "ran" | "skipped" | "error";
reason?: string;
runtimeId?: string | null;
promptPreview?: string;
}
const FRONTMATTER_RE = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/;
export interface FrontmatterParser {
(yaml: string): unknown;
}
export function splitFrontmatter(content: string): { yaml: string | null; body: string } {
const match = FRONTMATTER_RE.exec(content);
if (!match) {
return { yaml: null, body: content };
}
return { yaml: match[1], body: match[2] ?? "" };
}
export function basenameWithoutExt(filePath: string): string {
const lastSlash = filePath.lastIndexOf("/");
const file = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath;
const dot = file.lastIndexOf(".");
return dot > 0 ? file.slice(0, dot) : file;
}
export function parseAutomationFile(
content: string,
filePath: string,
parseYaml: FrontmatterParser
): ParseResult {
const fallbackName = basenameWithoutExt(filePath);
const { yaml, body } = splitFrontmatter(content);
if (yaml === null) {
return {
ok: false,
error: {
path: filePath,
name: fallbackName,
reason: "Missing frontmatter block (expected `---` fenced YAML at top of file)."
}
};
}
let parsed: unknown;
try {
parsed = parseYaml(yaml);
} catch (err) {
return {
ok: false,
error: {
path: filePath,
name: fallbackName,
reason: `YAML parse error: ${(err as Error).message}`
}
};
}
if (!parsed || typeof parsed !== "object") {
return {
ok: false,
error: {
path: filePath,
name: fallbackName,
reason: "Frontmatter must be a YAML mapping."
}
};
}
const fm = parsed as AutomationFrontmatter;
const hasInterval = fm.interval !== undefined && fm.interval !== null;
const hasCron = typeof fm.cron === "string" && fm.cron.trim().length > 0;
if (hasInterval && hasCron) {
return {
ok: false,
error: {
path: filePath,
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
reason: "`interval` and `cron` are mutually exclusive — set only one."
}
};
}
if (!hasInterval && !hasCron) {
return {
ok: false,
error: {
path: filePath,
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
reason: "Missing schedule: set either `interval` (minutes) or `cron`."
}
};
}
let interval: number | null = null;
if (hasInterval) {
const raw = fm.interval;
if (typeof raw !== "number" || !Number.isFinite(raw) || !Number.isInteger(raw) || raw < 1) {
return {
ok: false,
error: {
path: filePath,
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
reason: "`interval` must be an integer >= 1 (minutes)."
}
};
}
interval = raw;
}
let cron: string | null = null;
if (hasCron) {
const expr = (fm.cron as string).trim();
try {
CronExpressionParser.parse(expr);
} catch (err) {
return {
ok: false,
error: {
path: filePath,
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
reason: `Invalid cron expression: ${(err as Error).message}`
}
};
}
cron = expr;
}
const trimmedBody = body.trim();
if (!trimmedBody) {
return {
ok: false,
error: {
path: filePath,
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
reason: "Prompt body is empty (write the prompt below the frontmatter)."
}
};
}
return {
ok: true,
entry: {
path: filePath,
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
enabled: typeof fm.enabled === "boolean" ? fm.enabled : true,
interval,
cron,
runtime: typeof fm.runtime === "string" && fm.runtime.trim() ? fm.runtime.trim() : null,
appendNewline: typeof fm.appendNewline === "boolean" ? fm.appendNewline : true,
body: trimmedBody
}
};
}
/**
* Returns the epoch ms of the next scheduled run, or null if the entry has no
* usable schedule. Cron schedules are advanced from `lastRun` (or `now` if never
* run); interval schedules add `interval * 60_000` to `lastRun`, or fire immediately
* when never run.
*/
export function computeNextRun(
entry: ParsedAutomation,
lastRun: number | null,
now: number
): number | null {
if (entry.interval !== null) {
if (lastRun === null) {
return now;
}
return lastRun + entry.interval * 60_000;
}
if (entry.cron !== null) {
try {
const reference = lastRun ?? now - 1;
const it = CronExpressionParser.parse(entry.cron, {
currentDate: new Date(reference)
});
return it.next().getTime();
} catch {
return null;
}
}
return null;
}
export function isDue(
entry: ParsedAutomation,
lastRun: number | null,
now: number
): boolean {
if (!entry.enabled) {
return false;
}
const next = computeNextRun(entry, lastRun, now);
return next !== null && next <= now;
}
export function describeSchedule(entry: ParsedAutomation): string {
if (entry.interval !== null) {
return entry.interval === 1 ? "every 1 min" : `every ${entry.interval} min`;
}
if (entry.cron !== null) {
return `cron: ${entry.cron}`;
}
return "no schedule";
}
export function pushHistory(
history: AutomationRunRecord[],
record: AutomationRunRecord,
limit: number
): AutomationRunRecord[] {
const next = [record, ...history];
if (next.length > limit) {
next.length = limit;
}
return next;
}
export function buildPromptPreview(body: string, maxLen = 120): string {
const oneLine = body.replace(/\s+/g, " ").trim();
if (oneLine.length <= maxLen) {
return oneLine;
}
return `${oneLine.slice(0, maxLen - 1)}`;
}

269
automations-modal.ts Normal file
View file

@ -0,0 +1,269 @@
import { App, Modal, Notice, setIcon } from "obsidian";
import type ClaudeCliPlugin from "./main";
import {
computeNextRun,
describeSchedule,
type AutomationRunRecord,
type ParsedAutomation
} from "./automation";
type Tab = "automations" | "history";
export class AutomationsModal extends Modal {
private plugin: ClaudeCliPlugin;
private currentTab: Tab = "automations";
private tabsHostEl: HTMLDivElement | null = null;
private bodyEl: HTMLDivElement | null = null;
private unsubscribe: (() => void) | null = null;
constructor(app: App, plugin: ClaudeCliPlugin) {
super(app);
this.plugin = plugin;
}
onOpen(): void {
this.modalEl.addClass("any-ai-cli-automations-modal");
this.titleEl.setText("Automations");
this.contentEl.empty();
this.tabsHostEl = this.contentEl.createDiv({ cls: "any-ai-cli-am-tabs" });
this.bodyEl = this.contentEl.createDiv({ cls: "any-ai-cli-am-body" });
this.renderTabs();
this.renderBody();
this.unsubscribe = this.plugin.onAutomationsChanged(() => {
this.renderBody();
});
}
onClose(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.contentEl.empty();
}
private renderTabs(): void {
if (!this.tabsHostEl) return;
this.tabsHostEl.empty();
const tabs: { id: Tab; label: string }[] = [
{ id: "automations", label: "Automations" },
{ id: "history", label: "History" }
];
for (const tab of tabs) {
const btn = this.tabsHostEl.createEl("button", {
text: tab.label,
cls: `any-ai-cli-am-tab${this.currentTab === tab.id ? " is-active" : ""}`
});
btn.addEventListener("click", () => {
if (this.currentTab !== tab.id) {
this.currentTab = tab.id;
this.renderTabs();
this.renderBody();
}
});
}
}
private renderBody(): void {
if (!this.bodyEl) return;
this.bodyEl.empty();
if (this.currentTab === "automations") {
this.renderAutomationsTab(this.bodyEl);
} else {
this.renderHistoryTab(this.bodyEl);
}
}
private renderAutomationsTab(host: HTMLDivElement): void {
const folder = this.plugin.settings.automationsFolder.trim();
if (!folder) {
host.createEl("p", {
text: "No automations folder configured. Set one in plugin settings to get started.",
cls: "any-ai-cli-am-empty"
});
return;
}
const entries = this.plugin.getAutomations();
const errors = this.plugin.getAutomationErrors();
const enabledCount = entries.filter((e) => e.enabled).length;
const disabledCount = entries.length - enabledCount;
const info = host.createEl("p", { cls: "any-ai-cli-am-info" });
info.setText(
`${entries.length} automation${entries.length === 1 ? "" : "s"} (${enabledCount} enabled, ${disabledCount} disabled) — folder: ${folder}`
);
if (errors.length > 0) {
const errBox = host.createDiv({ cls: "any-ai-cli-am-errors" });
errBox.createEl("strong", { text: `${errors.length} file${errors.length === 1 ? "" : "s"} could not be parsed:` });
const list = errBox.createEl("ul");
for (const err of errors) {
const item = list.createEl("li");
item.createSpan({ text: err.path, cls: "any-ai-cli-am-error-path" });
item.createSpan({ text: `${err.reason}` });
}
}
if (entries.length === 0) {
host.createEl("p", {
text: "No automations found in the configured folder.",
cls: "any-ai-cli-am-empty"
});
return;
}
const runnable = this.isAnyViewRunning();
const table = host.createEl("table", { cls: "any-ai-cli-am-table" });
const thead = table.createEl("thead").createEl("tr");
for (const label of ["Name", "Schedule", "Last run", "Next run", "Status", "Actions"]) {
thead.createEl("th", { text: label });
}
const tbody = table.createEl("tbody");
const now = Date.now();
for (const entry of entries) {
const tr = tbody.createEl("tr");
if (!entry.enabled) tr.addClass("is-disabled");
tr.createEl("td", { text: entry.name });
tr.createEl("td", { text: describeSchedule(entry) });
const lastRun = this.plugin.settings.automationsLastRun[entry.path] ?? null;
tr.createEl("td", { text: lastRun ? formatRelative(lastRun, now) : "never" });
const nextRun = entry.enabled ? computeNextRun(entry, lastRun, now) : null;
tr.createEl("td", {
text: nextRun === null ? "—" : nextRun <= now ? "due now" : formatRelative(nextRun, now)
});
const statusCell = tr.createEl("td");
const badge = statusCell.createSpan({
text: entry.enabled ? "enabled" : "disabled",
cls: `any-ai-cli-am-badge ${entry.enabled ? "is-enabled" : "is-disabled"}`
});
if (entry.runtime) {
badge.setAttribute("title", `Requires runtime "${entry.runtime}"`);
}
const actionsCell = tr.createEl("td", { cls: "any-ai-cli-am-actions" });
const runBtn = actionsCell.createEl("button", { text: "Run now" });
runBtn.disabled = !runnable;
if (!runnable) {
runBtn.setAttribute("title", "Start a CLI in the sidebar panel first.");
}
runBtn.addEventListener("click", () => {
void this.plugin.triggerAutomation(entry, "manual");
});
const openBtn = actionsCell.createEl("button", { text: "Open" });
setIcon(openBtn.createSpan(), "external-link");
openBtn.addEventListener("click", () => {
void this.app.workspace.openLinkText(entry.path, "", true);
this.close();
});
}
}
private renderHistoryTab(host: HTMLDivElement): void {
const history = this.plugin.settings.automationsHistory;
const toolbar = host.createDiv({ cls: "any-ai-cli-am-history-toolbar" });
toolbar.createEl("p", {
text: `${history.length} entr${history.length === 1 ? "y" : "ies"} (most recent first, capped at ${this.plugin.settings.automationsHistoryLimit}).`,
cls: "any-ai-cli-am-info"
});
const actions = toolbar.createDiv({ cls: "any-ai-cli-am-history-actions" });
const clearBtn = actions.createEl("button", { text: "Clear history" });
clearBtn.disabled = history.length === 0;
clearBtn.addEventListener("click", () => {
this.plugin.clearAutomationHistory();
new Notice("Automation history cleared.", 2500);
});
const exportBtn = actions.createEl("button", { text: "Export as markdown" });
exportBtn.disabled = history.length === 0;
exportBtn.addEventListener("click", () => {
void this.exportHistory(history);
});
if (history.length === 0) {
host.createEl("p", { text: "No runs recorded yet.", cls: "any-ai-cli-am-empty" });
return;
}
const list = host.createEl("ul", { cls: "any-ai-cli-am-history-list" });
for (const record of history) {
const li = list.createEl("li", { cls: "any-ai-cli-am-history-item" });
li.createSpan({ text: formatDateTime(record.ts), cls: "any-ai-cli-am-history-ts" });
li.createSpan({ text: record.name, cls: "any-ai-cli-am-history-name" });
li.createSpan({
text: record.status,
cls: `any-ai-cli-am-badge is-${record.status}`
});
li.createSpan({ text: record.source, cls: "any-ai-cli-am-history-source" });
const detail = record.reason || record.promptPreview || "";
if (detail) {
li.createSpan({ text: detail, cls: "any-ai-cli-am-history-detail" });
}
}
}
private isAnyViewRunning(): boolean {
const leaves = this.app.workspace.getLeavesOfType("claude-cli-view");
return leaves.some((leaf) => {
const view = leaf.view as unknown as { isProcessRunning?: () => boolean };
return typeof view?.isProcessRunning === "function" && view.isProcessRunning();
});
}
private async exportHistory(history: AutomationRunRecord[]): Promise<void> {
const lines: string[] = [];
lines.push(`# Automations history — ${new Date().toISOString()}`, "");
lines.push("| Time | Name | Status | Source | Detail | Path |");
lines.push("| --- | --- | --- | --- | --- | --- |");
for (const r of history) {
const detail = (r.reason || r.promptPreview || "").replace(/\|/g, "\\|");
lines.push(
`| ${formatDateTime(r.ts)} | ${r.name.replace(/\|/g, "\\|")} | ${r.status} | ${r.source} | ${detail} | ${r.path} |`
);
}
const fileName = `automations-history-${new Date().toISOString().slice(0, 10)}.md`;
try {
const file = await this.app.vault.create(fileName, lines.join("\n"));
await this.app.workspace.openLinkText(file.path, "", true);
this.close();
} catch (err) {
new Notice(`Export failed: ${(err as Error).message}`, 5000);
}
}
}
function pad(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
function formatDateTime(ts: number): string {
const d = new Date(ts);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatRelative(ts: number, now: number): string {
const diff = ts - now;
const absMs = Math.abs(diff);
const sec = Math.round(absMs / 1000);
const min = Math.round(sec / 60);
const hr = Math.round(min / 60);
const day = Math.round(hr / 24);
const arrow = diff < 0 ? "ago" : "in";
let value: string;
if (sec < 60) value = `${sec}s`;
else if (min < 60) value = `${min}m`;
else if (hr < 24) value = `${hr}h`;
else value = `${day}d`;
return diff < 0 ? `${value} ${arrow}` : `${arrow} ${value}`;
}

9847
main.js

File diff suppressed because it is too large Load diff

345
main.ts
View file

@ -1,4 +1,4 @@
import { App, FileSystemAdapter, ItemView, Notice, Plugin, PluginSettingTab, Setting, WorkspaceLeaf, setIcon } from "obsidian";
import { App, FileSystemAdapter, ItemView, Notice, Plugin, PluginSettingTab, Setting, TFile, TFolder, WorkspaceLeaf, parseYaml, setIcon } from "obsidian";
import { spawn, type ChildProcess } from "child_process";
import * as fs from "fs";
import * as os from "os";
@ -16,6 +16,16 @@ import {
resolvePluginDir as resolvePluginDirWithVault,
type CliRuntimeConfig
} from "./runtime-utils";
import {
buildPromptPreview,
computeNextRun,
parseAutomationFile,
pushHistory,
type AutomationParseError,
type AutomationRunRecord,
type ParsedAutomation
} from "./automation";
import { AutomationsModal } from "./automations-modal";
// Injected at build time by `esbuild.config.mjs` (see `define`). These hold the
// full source of `pty-proxy.js` and `pty-bridge.py` so the plugin can recreate
@ -40,6 +50,10 @@ interface ClaudeCliPluginSettings {
autoRestartOnRuntimeSwitch: boolean;
autoStart: boolean;
nodeExecutable: string;
automationsFolder: string;
automationsLastRun: Record<string, number>;
automationsHistory: AutomationRunRecord[];
automationsHistoryLimit: number;
}
const DEFAULT_SETTINGS: ClaudeCliPluginSettings = {
@ -47,9 +61,15 @@ const DEFAULT_SETTINGS: ClaudeCliPluginSettings = {
selectedRuntimeId: "claude",
autoRestartOnRuntimeSwitch: true,
autoStart: true,
nodeExecutable: "auto"
nodeExecutable: "auto",
automationsFolder: "",
automationsLastRun: {},
automationsHistory: [],
automationsHistoryLimit: 200
};
const AUTOMATION_TICK_MS = 30_000;
function cloneDefaultRuntimes(): CliRuntimeConfig[] {
return DEFAULT_RUNTIMES.map((runtime) => ({ ...runtime }));
}
@ -120,10 +140,13 @@ class ClaudeCliView extends ItemView {
const secondaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" });
const mentionBtn = secondaryRowEl.createEl("button", { text: "@active file" });
const folderMentionBtn = secondaryRowEl.createEl("button", { text: "@active folder" });
const automationsBtn = secondaryRowEl.createEl("button", { text: "Automations" });
this.setButtonIcon(mentionBtn, "file-plus", "@active file");
this.setButtonIcon(folderMentionBtn, "folder-plus", "@active folder");
this.setButtonIcon(automationsBtn, "calendar-clock", "Automations");
mentionBtn.addClass("claude-cli-btn-info");
folderMentionBtn.addClass("claude-cli-btn-info");
automationsBtn.addClass("claude-cli-btn-info");
this.statusEl = this.contentEl.createDiv({ cls: "claude-cli-status" });
@ -133,6 +156,9 @@ class ClaudeCliView extends ItemView {
clearBtn.addEventListener("click", () => this.terminal?.clear());
mentionBtn.addEventListener("click", () => this.insertActiveFileMention());
folderMentionBtn.addEventListener("click", () => this.insertActiveFolderMention());
automationsBtn.addEventListener("click", () => {
new AutomationsModal(this.app, this.plugin).open();
});
this.runtimeSelect.addEventListener("change", () => {
if (this.runtimeSelect) {
this.setRuntime(this.runtimeSelect.value);
@ -184,6 +210,29 @@ class ClaudeCliView extends ItemView {
return Promise.resolve();
}
isProcessRunning(): boolean {
return this.processHandle !== null && this.runningRuntimeId !== null;
}
getRunningRuntimeId(): string | null {
return this.runningRuntimeId;
}
matchesRuntime(target: string): boolean {
if (!this.runningRuntimeId) return false;
if (this.runningRuntimeId === target) return true;
const runtime = this.plugin.settings.runtimes.find((r) => r.id === this.runningRuntimeId);
return runtime ? runtime.name.trim().toLowerCase() === target.trim().toLowerCase() : false;
}
sendAutomationPrompt(text: string): void {
if (!this.processHandle) {
throw new Error("CLI process is not running");
}
this.processHandle.write(text);
this.writeSystemLine(`[Automation prompt injected]`);
}
refreshRuntimeSelect(): void {
if (!this.runtimeSelect) {
return;
@ -501,6 +550,9 @@ class ClaudeCliView extends ItemView {
export default class ClaudeCliPlugin extends Plugin {
settings!: ClaudeCliPluginSettings;
private automationEntries: Map<string, ParsedAutomation> = new Map();
private automationErrors: Map<string, AutomationParseError> = new Map();
private automationListeners: Set<() => void> = new Set();
async onload(): Promise<void> {
await this.loadSettings();
@ -520,6 +572,34 @@ export default class ClaudeCliPlugin extends Plugin {
});
this.addSettingTab(new ClaudeCliSettingTab(this.app, this));
this.app.workspace.onLayoutReady(() => {
this.loadAutomations();
this.runAutomationTick();
});
const refreshOnVaultChange = (file: { path: string } | null) => {
const folder = this.settings.automationsFolder;
if (!folder || !file) return;
if (file.path === folder || file.path.startsWith(`${folder}/`)) {
this.loadAutomations();
}
};
this.registerEvent(this.app.vault.on("create", refreshOnVaultChange));
this.registerEvent(this.app.vault.on("modify", refreshOnVaultChange));
this.registerEvent(this.app.vault.on("delete", refreshOnVaultChange));
this.registerEvent(
this.app.vault.on("rename", (file, oldPath) => {
refreshOnVaultChange(file);
const folder = this.settings.automationsFolder;
if (folder && (oldPath === folder || oldPath.startsWith(`${folder}/`))) {
this.loadAutomations();
}
})
);
this.registerInterval(activeWindow.setInterval(() => this.runAutomationTick(), AUTOMATION_TICK_MS));
}
onunload(): void {
@ -527,6 +607,178 @@ export default class ClaudeCliPlugin extends Plugin {
// Obsidian preserves leaf state across reloads/updates.
}
getAutomations(): ParsedAutomation[] {
return Array.from(this.automationEntries.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}
getAutomationErrors(): AutomationParseError[] {
return Array.from(this.automationErrors.values());
}
onAutomationsChanged(listener: () => void): () => void {
this.automationListeners.add(listener);
return () => this.automationListeners.delete(listener);
}
private notifyAutomationsChanged(): void {
this.automationListeners.forEach((listener) => {
try {
listener();
} catch {
/* ignore listener errors */
}
});
}
loadAutomations(): void {
this.automationEntries.clear();
this.automationErrors.clear();
const folderPath = this.settings.automationsFolder.trim();
if (!folderPath) {
this.notifyAutomationsChanged();
return;
}
const folder = this.app.vault.getFolderByPath(folderPath);
if (!folder) {
this.notifyAutomationsChanged();
return;
}
const files: TFile[] = [];
const walk = (f: TFolder) => {
for (const child of f.children) {
if (child instanceof TFile && child.extension === "md") {
files.push(child);
} else if (child instanceof TFolder) {
walk(child);
}
}
};
walk(folder);
void Promise.all(
files.map(async (file) => {
try {
const content = await this.app.vault.cachedRead(file);
const result = parseAutomationFile(content, file.path, (yaml) => parseYaml(yaml));
if (result.ok) {
this.automationEntries.set(file.path, result.entry);
} else {
this.automationErrors.set(file.path, result.error);
}
} catch (err) {
this.automationErrors.set(file.path, {
path: file.path,
name: file.basename,
reason: `Read error: ${(err as Error).message}`
});
}
})
).then(() => {
this.notifyAutomationsChanged();
});
}
runAutomationTick(): void {
if (this.automationEntries.size === 0) return;
const now = Date.now();
for (const entry of this.automationEntries.values()) {
if (!entry.enabled) continue;
const last = this.settings.automationsLastRun[entry.path] ?? null;
const next = computeNextRun(entry, last, now);
if (next !== null && next <= now) {
void this.triggerAutomation(entry, "scheduler");
}
}
}
async triggerAutomation(
entry: ParsedAutomation,
source: "scheduler" | "manual"
): Promise<void> {
const now = Date.now();
const baseRecord = {
ts: now,
path: entry.path,
name: entry.name,
source
} as const;
const view = this.findRunnableView();
if (!view) {
const reason = "No CLI is running";
this.recordHistory({ ...baseRecord, status: "skipped", reason });
if (source === "manual") {
new Notice(`Automation "${entry.name}" skipped — ${reason.toLowerCase()}.`, 5000);
}
return;
}
if (entry.runtime) {
const runtimeMatches = view.matchesRuntime(entry.runtime);
if (!runtimeMatches) {
const reason = `Runtime mismatch (expected "${entry.runtime}")`;
this.recordHistory({ ...baseRecord, status: "skipped", reason });
if (source === "manual") {
new Notice(`Automation "${entry.name}" skipped — ${reason.toLowerCase()}.`, 5000);
}
return;
}
}
try {
const text = entry.body + (entry.appendNewline ? "\n" : "");
view.sendAutomationPrompt(text);
const runtimeId = view.getRunningRuntimeId();
this.recordHistory({
...baseRecord,
status: "ran",
runtimeId,
promptPreview: buildPromptPreview(entry.body)
});
this.settings.automationsLastRun[entry.path] = now;
await this.saveSettings();
if (source === "manual") {
new Notice(`Automation "${entry.name}" sent.`, 3000);
}
} catch (err) {
this.recordHistory({
...baseRecord,
status: "error",
reason: (err as Error).message
});
new Notice(`Automation "${entry.name}" failed: ${(err as Error).message}`, 6000);
}
}
private findRunnableView(): ClaudeCliView | null {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDE);
for (const leaf of leaves) {
const view = leaf.view;
if (view instanceof ClaudeCliView && view.isProcessRunning()) {
return view;
}
}
return null;
}
recordHistory(record: AutomationRunRecord): void {
this.settings.automationsHistory = pushHistory(
this.settings.automationsHistory,
record,
this.settings.automationsHistoryLimit
);
void this.saveSettings();
this.notifyAutomationsChanged();
}
clearAutomationHistory(): void {
this.settings.automationsHistory = [];
void this.saveSettings();
this.notifyAutomationsChanged();
}
async activateView(): Promise<void> {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = workspace.getLeavesOfType(VIEW_TYPE_CLAUDE)[0] ?? null;
@ -560,7 +812,17 @@ export default class ClaudeCliPlugin extends Plugin {
nodeExecutable:
typeof raw.nodeExecutable === "string" && raw.nodeExecutable.trim()
? raw.nodeExecutable
: DEFAULT_SETTINGS.nodeExecutable
: DEFAULT_SETTINGS.nodeExecutable,
automationsFolder:
typeof raw.automationsFolder === "string" ? raw.automationsFolder : DEFAULT_SETTINGS.automationsFolder,
automationsLastRun: sanitizeLastRun(raw.automationsLastRun),
automationsHistory: sanitizeHistory(raw.automationsHistory),
automationsHistoryLimit:
typeof raw.automationsHistoryLimit === "number" &&
Number.isInteger(raw.automationsHistoryLimit) &&
raw.automationsHistoryLimit > 0
? raw.automationsHistoryLimit
: DEFAULT_SETTINGS.automationsHistoryLimit
};
}
@ -716,6 +978,39 @@ class ClaudeCliSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl).setName("Automations").setHeading();
containerEl.createEl("p", {
text: "Folder containing prompt automations. Each Markdown file is one automation (frontmatter sets the schedule; body is the prompt sent to the running CLI). See readme for the file format.",
cls: "setting-item-description"
});
new Setting(containerEl)
.setName("Automations folder")
.setDesc("Vault-relative path. Leave empty to disable automations.")
.addText((text) =>
text
.setPlaceholder("Automations")
.setValue(this.plugin.settings.automationsFolder)
.onChange(async (value) => {
this.plugin.settings.automationsFolder = value.trim();
await this.plugin.saveSettings();
this.plugin.loadAutomations();
})
);
new Setting(containerEl)
.setName("Reload automations")
.setDesc("Force a re-scan of the automations folder (otherwise scans happen on vault changes).")
.addButton((btn) =>
btn
.setButtonText("Reload now")
.setIcon("refresh-cw")
.onClick(() => {
this.plugin.loadAutomations();
new Notice("Automations reloaded.", 2500);
})
);
new Setting(containerEl).setName("Advanced").setHeading();
new Setting(containerEl)
@ -884,6 +1179,50 @@ function spawnPtyProxy(params: {
});
}
function sanitizeLastRun(raw: unknown): Record<string, number> {
if (!raw || typeof raw !== "object") {
return {};
}
const out: Record<string, number> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof key === "string" && key && typeof value === "number" && Number.isFinite(value)) {
out[key] = value;
}
}
return out;
}
function sanitizeHistory(raw: unknown): AutomationRunRecord[] {
if (!Array.isArray(raw)) {
return [];
}
const out: AutomationRunRecord[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const r = entry as Partial<AutomationRunRecord>;
if (
typeof r.ts !== "number" ||
typeof r.path !== "string" ||
typeof r.name !== "string" ||
(r.source !== "scheduler" && r.source !== "manual") ||
(r.status !== "ran" && r.status !== "skipped" && r.status !== "error")
) {
continue;
}
out.push({
ts: r.ts,
path: r.path,
name: r.name,
source: r.source,
status: r.status,
reason: typeof r.reason === "string" ? r.reason : undefined,
runtimeId: typeof r.runtimeId === "string" ? r.runtimeId : null,
promptPreview: typeof r.promptPreview === "string" ? r.promptPreview : undefined
});
}
return out;
}
function makeProxyAdapter(handle: ChildProcess): ProcessAdapter {
const dataCallbacks: Array<(data: string) => void> = [];
const exitCallbacks: Array<(exitCode: number, signal: string) => void> = [];

31
package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"cron-parser": "^5.5.0",
"node-pty": "^1.1.0"
},
"devDependencies": {
@ -23,7 +24,8 @@
"obsidian": "latest",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"yaml": "^2.9.0"
}
},
"node_modules/@codemirror/state": {
@ -2125,6 +2127,18 @@
"license": "MIT",
"peer": true
},
"node_modules/cron-parser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
"integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
"license": "MIT",
"dependencies": {
"luxon": "^3.7.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4632,6 +4646,15 @@
"loose-envify": "cli.js"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -6944,9 +6967,9 @@
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {

View file

@ -28,11 +28,13 @@
"obsidian": "latest",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"yaml": "^2.9.0"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"cron-parser": "^5.5.0",
"node-pty": "^1.1.0"
}
}

View file

@ -350,3 +350,224 @@
.claude-cli-terminal .xterm-cursor-layer {
position: relative;
}
.any-ai-cli-automations-modal {
max-width: 760px;
width: min(760px, 90vw);
}
.any-ai-cli-am-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--background-modifier-border);
margin-bottom: 12px;
}
.any-ai-cli-am-tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.any-ai-cli-am-tab:hover {
color: var(--text-normal);
}
.any-ai-cli-am-tab.is-active {
color: var(--text-normal);
border-bottom-color: var(--interactive-accent);
}
.any-ai-cli-am-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.any-ai-cli-am-info {
color: var(--text-muted);
font-size: 12px;
margin: 0;
}
.any-ai-cli-am-empty {
color: var(--text-muted);
font-style: italic;
margin: 12px 0;
}
.any-ai-cli-am-errors {
border: 1px solid var(--background-modifier-border);
border-left: 3px solid var(--color-orange, #d97706);
background: var(--background-secondary);
padding: 8px 10px;
border-radius: 4px;
font-size: 12px;
}
.any-ai-cli-am-errors ul {
margin: 6px 0 0 0;
padding-left: 18px;
}
.any-ai-cli-am-error-path {
font-family: var(--font-monospace);
color: var(--text-normal);
}
.any-ai-cli-am-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.any-ai-cli-am-table th,
.any-ai-cli-am-table td {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid var(--background-modifier-border);
vertical-align: middle;
}
.any-ai-cli-am-table th {
font-weight: 600;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.any-ai-cli-am-table tr.is-disabled {
opacity: 0.55;
}
.any-ai-cli-am-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.any-ai-cli-am-actions button {
padding: 3px 8px;
font-size: 11px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
}
.any-ai-cli-am-actions button:hover:not(:disabled) {
background: var(--interactive-hover);
}
.any-ai-cli-am-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.any-ai-cli-am-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
}
.any-ai-cli-am-badge.is-enabled,
.any-ai-cli-am-badge.is-ran {
background: rgba(34, 197, 94, 0.18);
color: var(--color-green, #16a34a);
}
.any-ai-cli-am-badge.is-disabled,
.any-ai-cli-am-badge.is-skipped {
background: rgba(234, 179, 8, 0.18);
color: var(--color-yellow, #ca8a04);
}
.any-ai-cli-am-badge.is-error {
background: rgba(239, 68, 68, 0.18);
color: var(--color-red, #dc2626);
}
.any-ai-cli-am-history-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.any-ai-cli-am-history-actions {
display: flex;
gap: 6px;
}
.any-ai-cli-am-history-actions button {
padding: 4px 10px;
font-size: 11px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
}
.any-ai-cli-am-history-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.any-ai-cli-am-history-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 50vh;
overflow-y: auto;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
}
.any-ai-cli-am-history-item {
display: grid;
grid-template-columns: 140px 1fr auto auto;
gap: 8px;
align-items: center;
padding: 6px 10px;
border-bottom: 1px solid var(--background-modifier-border);
font-size: 12px;
}
.any-ai-cli-am-history-item:last-child {
border-bottom: none;
}
.any-ai-cli-am-history-ts {
font-family: var(--font-monospace);
color: var(--text-muted);
font-size: 11px;
}
.any-ai-cli-am-history-name {
color: var(--text-normal);
font-weight: 500;
}
.any-ai-cli-am-history-source {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.any-ai-cli-am-history-detail {
grid-column: 2 / -1;
color: var(--text-muted);
font-size: 11px;
font-style: italic;
}

276
tests/automation.test.ts Normal file
View file

@ -0,0 +1,276 @@
import { describe, expect, it } from "vitest";
import { parse as parseYaml } from "yaml";
import {
basenameWithoutExt,
buildPromptPreview,
computeNextRun,
describeSchedule,
isDue,
parseAutomationFile,
pushHistory,
splitFrontmatter,
type AutomationRunRecord,
type ParsedAutomation
} from "../automation";
const yamlParser = (text: string): unknown => parseYaml(text) as unknown;
function makeFile(frontmatter: Record<string, unknown>, body: string): string {
const fm = Object.entries(frontmatter)
.map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v) : String(v)}`)
.join("\n");
return `---\n${fm}\n---\n${body}`;
}
describe("splitFrontmatter", () => {
it("splits valid frontmatter from body", () => {
const r = splitFrontmatter("---\nfoo: 1\n---\nhello");
expect(r.yaml).toBe("foo: 1");
expect(r.body).toBe("hello");
});
it("returns null yaml when no frontmatter", () => {
const r = splitFrontmatter("just a body");
expect(r.yaml).toBeNull();
expect(r.body).toBe("just a body");
});
it("handles CRLF line endings", () => {
const r = splitFrontmatter("---\r\nfoo: 1\r\n---\r\nhello");
expect(r.yaml).toBe("foo: 1");
expect(r.body).toBe("hello");
});
});
describe("basenameWithoutExt", () => {
it("strips extension and folders", () => {
expect(basenameWithoutExt("Automations/daily.md")).toBe("daily");
expect(basenameWithoutExt("no-folder.md")).toBe("no-folder");
expect(basenameWithoutExt("noext")).toBe("noext");
});
});
describe("parseAutomationFile", () => {
it("parses an interval automation with defaults", () => {
const file = makeFile({ interval: 60 }, "Summarize my day.");
const r = parseAutomationFile(file, "Automations/summary.md", yamlParser);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.entry.name).toBe("summary");
expect(r.entry.enabled).toBe(true);
expect(r.entry.interval).toBe(60);
expect(r.entry.cron).toBeNull();
expect(r.entry.runtime).toBeNull();
expect(r.entry.appendNewline).toBe(true);
expect(r.entry.body).toBe("Summarize my day.");
});
it("parses a cron automation with explicit name + runtime + disabled", () => {
const file = makeFile(
{ name: "Daily standup", enabled: false, cron: "0 9 * * 1-5", runtime: "claude", appendNewline: false },
"Give me the standup brief."
);
const r = parseAutomationFile(file, "Automations/standup.md", yamlParser);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.entry.name).toBe("Daily standup");
expect(r.entry.enabled).toBe(false);
expect(r.entry.interval).toBeNull();
expect(r.entry.cron).toBe("0 9 * * 1-5");
expect(r.entry.runtime).toBe("claude");
expect(r.entry.appendNewline).toBe(false);
});
it("rejects when both interval and cron are set", () => {
const file = makeFile({ interval: 5, cron: "*/5 * * * *" }, "body");
const r = parseAutomationFile(file, "x.md", yamlParser);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.reason).toMatch(/mutually exclusive/);
});
it("rejects when neither interval nor cron is set", () => {
const file = makeFile({ name: "no sched" }, "body");
const r = parseAutomationFile(file, "x.md", yamlParser);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.reason).toMatch(/Missing schedule/);
});
it("rejects non-integer interval", () => {
const file = makeFile({ interval: 1.5 }, "body");
const r = parseAutomationFile(file, "x.md", yamlParser);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.reason).toMatch(/integer/);
});
it("rejects interval < 1", () => {
const file = makeFile({ interval: 0 }, "body");
const r = parseAutomationFile(file, "x.md", yamlParser);
expect(r.ok).toBe(false);
});
it("rejects invalid cron", () => {
const file = makeFile({ cron: "not a cron" }, "body");
const r = parseAutomationFile(file, "x.md", yamlParser);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.reason).toMatch(/cron/i);
});
it("rejects empty body", () => {
const file = makeFile({ interval: 60 }, " ");
const r = parseAutomationFile(file, "x.md", yamlParser);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.reason).toMatch(/empty/);
});
it("rejects missing frontmatter", () => {
const r = parseAutomationFile("no fences here", "x.md", yamlParser);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.reason).toMatch(/Missing frontmatter/);
});
it("rejects frontmatter parse errors via the provided yaml parser", () => {
const r = parseAutomationFile(
"---\n: : :\n---\nbody",
"x.md",
() => {
throw new Error("boom");
}
);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.reason).toMatch(/boom/);
});
});
function intervalEntry(min: number, enabled = true): ParsedAutomation {
return {
path: "a.md",
name: "a",
enabled,
interval: min,
cron: null,
runtime: null,
appendNewline: true,
body: "x"
};
}
function cronEntry(expr: string, enabled = true): ParsedAutomation {
return {
path: "a.md",
name: "a",
enabled,
interval: null,
cron: expr,
runtime: null,
appendNewline: true,
body: "x"
};
}
describe("computeNextRun", () => {
it("interval: returns now when never run", () => {
const entry = intervalEntry(30);
const now = 1_700_000_000_000;
expect(computeNextRun(entry, null, now)).toBe(now);
});
it("interval: adds minutes to lastRun", () => {
const entry = intervalEntry(15);
const last = 1_700_000_000_000;
expect(computeNextRun(entry, last, last + 1_000)).toBe(last + 15 * 60_000);
});
it("cron: returns next occurrence after lastRun", () => {
const entry = cronEntry("*/5 * * * *");
const last = new Date("2026-05-19T22:00:00Z").getTime();
const now = last + 60_000;
const next = computeNextRun(entry, last, now);
expect(next).toBe(new Date("2026-05-19T22:05:00Z").getTime());
});
it("cron: when never run, schedules from just-before-now", () => {
const entry = cronEntry("*/5 * * * *");
const now = new Date("2026-05-19T22:03:00Z").getTime();
const next = computeNextRun(entry, null, now);
expect(next).toBe(new Date("2026-05-19T22:05:00Z").getTime());
});
});
describe("isDue", () => {
it("disabled entries are never due", () => {
const entry = intervalEntry(1, false);
expect(isDue(entry, null, Date.now())).toBe(false);
});
it("interval entry is due once interval has passed", () => {
const entry = intervalEntry(5);
const last = 1_700_000_000_000;
expect(isDue(entry, last, last + 4 * 60_000)).toBe(false);
expect(isDue(entry, last, last + 5 * 60_000)).toBe(true);
expect(isDue(entry, last, last + 6 * 60_000)).toBe(true);
});
it("interval entry is due immediately when never run", () => {
const entry = intervalEntry(60);
expect(isDue(entry, null, Date.now())).toBe(true);
});
});
describe("describeSchedule", () => {
it("formats interval (plural and singular)", () => {
expect(describeSchedule(intervalEntry(30))).toBe("every 30 min");
expect(describeSchedule(intervalEntry(1))).toBe("every 1 min");
});
it("formats cron", () => {
expect(describeSchedule(cronEntry("0 9 * * 1-5"))).toBe("cron: 0 9 * * 1-5");
});
});
describe("pushHistory", () => {
const makeRec = (ts: number): AutomationRunRecord => ({
ts,
path: "a.md",
name: "a",
source: "manual",
status: "ran"
});
it("prepends new record", () => {
const next = pushHistory([makeRec(1)], makeRec(2), 10);
expect(next.map((r) => r.ts)).toEqual([2, 1]);
});
it("truncates to the limit, dropping oldest", () => {
const history: AutomationRunRecord[] = [3, 2, 1].map((t) => makeRec(t));
const next = pushHistory(history, makeRec(4), 3);
expect(next.map((r) => r.ts)).toEqual([4, 3, 2]);
expect(next.length).toBe(3);
});
it("does not mutate the input array", () => {
const original = [makeRec(1)];
pushHistory(original, makeRec(2), 10);
expect(original.map((r) => r.ts)).toEqual([1]);
});
});
describe("buildPromptPreview", () => {
it("collapses whitespace and trims", () => {
expect(buildPromptPreview(" a b\nc ")).toBe("a b c");
});
it("truncates with ellipsis", () => {
const long = "a".repeat(200);
const preview = buildPromptPreview(long, 50);
expect(preview.length).toBe(50);
expect(preview.endsWith("…")).toBe(true);
});
});