mirror of
https://github.com/blamouche/obsidian-any-ai-code.git
synced 2026-07-22 06:53:38 +00:00
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:
parent
366733dadd
commit
9577a02278
14 changed files with 11327 additions and 39 deletions
|
|
@ -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`. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -1 +1 @@
|
|||
0.2.0
|
||||
0.2.1
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -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
267
automation.ts
Normal 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
269
automations-modal.ts
Normal 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}`;
|
||||
}
|
||||
345
main.ts
345
main.ts
|
|
@ -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
31
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
221
styles.css
221
styles.css
|
|
@ -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
276
tests/automation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue