clementine-agent 1.18.128 → 1.18.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Schedule registry — 1.18.129.
3
+ *
4
+ * The Anthropic-pure architectural shift: skills stay vanilla SKILL.md
5
+ * folders, scheduling lives in a separate tiny registry. Today's "fat
6
+ * cron" model in CRON.md duplicated ~70% of the skill schema (prompt,
7
+ * tools, MCP allowlists, work_dir, success criteria); this registry
8
+ * replaces all that with a thin {skillName → schedule} map.
9
+ *
10
+ * Storage: a single JSON file at `~/.clementine/schedules.json` with
11
+ * the shape:
12
+ *
13
+ * {
14
+ * "morning-briefing": {
15
+ * "schedule": "0 7 * * 1-5",
16
+ * "enabled": true,
17
+ * "agentSlug": null,
18
+ * "addedAt": "2026-05-08T...",
19
+ * "lastModifiedAt": "2026-05-08T..."
20
+ * }
21
+ * }
22
+ *
23
+ * The cron scheduler reads this alongside CRON.md and emits one
24
+ * CronJobDefinition per scheduled skill. The runtime path is unchanged
25
+ * — the skill body becomes the prompt via the existing buildSkillContext
26
+ * pipeline, no special case.
27
+ *
28
+ * Coexists with CRON.md indefinitely. Both formats run today; new work
29
+ * goes through this registry. Phase 3 ships the migrator that converts
30
+ * legacy crons to scheduled skills.
31
+ */
32
+ export interface ScheduleEntry {
33
+ /** Skill slug — must match a skill in the catalog. The runtime auto-pins
34
+ * this skill, so its body becomes the prompt at fire-time. */
35
+ skillName: string;
36
+ /** Cron expression. Empty / falsy = no auto-fire (still appears on
37
+ * Tasks page so the user can edit it). */
38
+ schedule: string;
39
+ /** When false the scheduler skips it. Lets users pause without losing
40
+ * the schedule definition. */
41
+ enabled: boolean;
42
+ /** When set, the skill runs as the named hired agent (Sasha, Ross,
43
+ * Nora, etc.). null/undefined = Clementine. Per-agent skills load
44
+ * from `agents/<slug>/skills/` first; the runtime resolves precedence. */
45
+ agentSlug?: string | null;
46
+ /** ISO timestamp of when the schedule was first created. */
47
+ addedAt?: string;
48
+ /** ISO timestamp of the last edit. */
49
+ lastModifiedAt?: string;
50
+ }
51
+ /** On-disk shape — keyed by skill name for O(1) lookup + simple merge. */
52
+ export type ScheduleFile = Record<string, Omit<ScheduleEntry, 'skillName'>>;
53
+ /**
54
+ * Read every schedule entry as a flat array. Each entry includes its
55
+ * skill name so callers don't have to reconstruct it.
56
+ */
57
+ export declare function listSchedules(): ScheduleEntry[];
58
+ /** Read one entry by skill name. Returns null when not scheduled. */
59
+ export declare function getSchedule(skillName: string): ScheduleEntry | null;
60
+ export interface SetScheduleInput {
61
+ schedule: string;
62
+ enabled?: boolean;
63
+ agentSlug?: string | null;
64
+ }
65
+ /**
66
+ * Upsert a schedule for a skill. New entries get `addedAt`; existing
67
+ * entries get `lastModifiedAt` updated. Returns the resulting entry so
68
+ * the dashboard can re-render without a re-fetch.
69
+ */
70
+ export declare function setSchedule(skillName: string, input: SetScheduleInput): ScheduleEntry;
71
+ /** Drop the entry for a skill. No-op when nothing is scheduled. */
72
+ export declare function removeSchedule(skillName: string): void;
73
+ /** Toggle the enabled flag. Skill stays in the registry; just won't
74
+ * fire while disabled. Caller-side convenience to avoid re-passing
75
+ * schedule + agentSlug just to flip the boolean. */
76
+ export declare function enableSchedule(skillName: string, enabled: boolean): ScheduleEntry | null;
77
+ //# sourceMappingURL=schedule-registry.d.ts.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Schedule registry — 1.18.129.
3
+ *
4
+ * The Anthropic-pure architectural shift: skills stay vanilla SKILL.md
5
+ * folders, scheduling lives in a separate tiny registry. Today's "fat
6
+ * cron" model in CRON.md duplicated ~70% of the skill schema (prompt,
7
+ * tools, MCP allowlists, work_dir, success criteria); this registry
8
+ * replaces all that with a thin {skillName → schedule} map.
9
+ *
10
+ * Storage: a single JSON file at `~/.clementine/schedules.json` with
11
+ * the shape:
12
+ *
13
+ * {
14
+ * "morning-briefing": {
15
+ * "schedule": "0 7 * * 1-5",
16
+ * "enabled": true,
17
+ * "agentSlug": null,
18
+ * "addedAt": "2026-05-08T...",
19
+ * "lastModifiedAt": "2026-05-08T..."
20
+ * }
21
+ * }
22
+ *
23
+ * The cron scheduler reads this alongside CRON.md and emits one
24
+ * CronJobDefinition per scheduled skill. The runtime path is unchanged
25
+ * — the skill body becomes the prompt via the existing buildSkillContext
26
+ * pipeline, no special case.
27
+ *
28
+ * Coexists with CRON.md indefinitely. Both formats run today; new work
29
+ * goes through this registry. Phase 3 ships the migrator that converts
30
+ * legacy crons to scheduled skills.
31
+ */
32
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ // Resolve lazily on each call so test environments (which override
36
+ // CLEMENTINE_HOME inside beforeEach) see the fresh value rather than the
37
+ // value snapshot at module-load time. Mirrors skill-suppressions.ts.
38
+ function baseDir() {
39
+ return process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
40
+ }
41
+ function schedulesPath() {
42
+ return path.join(baseDir(), 'schedules.json');
43
+ }
44
+ function readFile() {
45
+ const filePath = schedulesPath();
46
+ if (!existsSync(filePath))
47
+ return {};
48
+ try {
49
+ const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
50
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
51
+ return {};
52
+ const out = {};
53
+ for (const [name, entry] of Object.entries(raw)) {
54
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
55
+ continue;
56
+ const e = entry;
57
+ // Tolerate partially-populated entries (a hand-edited file might
58
+ // have only `schedule` set). Default everything else.
59
+ out[name] = {
60
+ schedule: typeof e.schedule === 'string' ? e.schedule : '',
61
+ enabled: e.enabled !== false, // default true
62
+ agentSlug: typeof e.agentSlug === 'string' ? e.agentSlug : null,
63
+ addedAt: typeof e.addedAt === 'string' ? e.addedAt : undefined,
64
+ lastModifiedAt: typeof e.lastModifiedAt === 'string' ? e.lastModifiedAt : undefined,
65
+ };
66
+ }
67
+ return out;
68
+ }
69
+ catch {
70
+ // Malformed JSON — never throw out of the registry. Worst case the
71
+ // user re-creates entries via the UI; their CRON.md jobs keep firing.
72
+ return {};
73
+ }
74
+ }
75
+ function writeFile(data) {
76
+ const dir = baseDir();
77
+ if (!existsSync(dir))
78
+ mkdirSync(dir, { recursive: true });
79
+ // Sorted keys → deterministic on disk → friendly to git users who
80
+ // version-control their ~/.clementine/.
81
+ const sorted = {};
82
+ for (const k of Object.keys(data).sort())
83
+ sorted[k] = data[k];
84
+ writeFileSync(schedulesPath(), JSON.stringify(sorted, null, 2));
85
+ }
86
+ /**
87
+ * Read every schedule entry as a flat array. Each entry includes its
88
+ * skill name so callers don't have to reconstruct it.
89
+ */
90
+ export function listSchedules() {
91
+ const file = readFile();
92
+ return Object.entries(file).map(([skillName, entry]) => ({ skillName, ...entry }));
93
+ }
94
+ /** Read one entry by skill name. Returns null when not scheduled. */
95
+ export function getSchedule(skillName) {
96
+ const file = readFile();
97
+ const entry = file[skillName];
98
+ if (!entry)
99
+ return null;
100
+ return { skillName, ...entry };
101
+ }
102
+ /**
103
+ * Upsert a schedule for a skill. New entries get `addedAt`; existing
104
+ * entries get `lastModifiedAt` updated. Returns the resulting entry so
105
+ * the dashboard can re-render without a re-fetch.
106
+ */
107
+ export function setSchedule(skillName, input) {
108
+ if (!skillName || typeof skillName !== 'string') {
109
+ throw new Error('setSchedule: skillName required');
110
+ }
111
+ const file = readFile();
112
+ const existing = file[skillName];
113
+ const now = new Date().toISOString();
114
+ const entry = {
115
+ schedule: input.schedule ?? '',
116
+ enabled: input.enabled !== false,
117
+ agentSlug: input.agentSlug ?? null,
118
+ addedAt: existing?.addedAt ?? now,
119
+ lastModifiedAt: now,
120
+ };
121
+ file[skillName] = entry;
122
+ writeFile(file);
123
+ return { skillName, ...entry };
124
+ }
125
+ /** Drop the entry for a skill. No-op when nothing is scheduled. */
126
+ export function removeSchedule(skillName) {
127
+ const file = readFile();
128
+ if (!(skillName in file))
129
+ return;
130
+ delete file[skillName];
131
+ writeFile(file);
132
+ }
133
+ /** Toggle the enabled flag. Skill stays in the registry; just won't
134
+ * fire while disabled. Caller-side convenience to avoid re-passing
135
+ * schedule + agentSlug just to flip the boolean. */
136
+ export function enableSchedule(skillName, enabled) {
137
+ const file = readFile();
138
+ const entry = file[skillName];
139
+ if (!entry)
140
+ return null;
141
+ entry.enabled = enabled;
142
+ entry.lastModifiedAt = new Date().toISOString();
143
+ file[skillName] = entry;
144
+ writeFile(file);
145
+ return { skillName, ...entry };
146
+ }
147
+ //# sourceMappingURL=schedule-registry.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Skill templates — 1.18.130 (Phase 2 / Skill Builder).
3
+ *
4
+ * Each template scaffolds a SKILL.md frontmatter + body skeleton plus
5
+ * (optionally) starter bundled files. Templates encode the five common
6
+ * skill archetypes the user pulled out of the Anthropic skills video:
7
+ *
8
+ * - **Orchestrator** — the meta-skill that routes work to other skills
9
+ * - **Scraper / Poller** — read external data, surface what's new
10
+ * - **Transformer** — mutate input → produce output (no side effects)
11
+ * - **Notifier** — send a message to Discord/Slack/email when X happens
12
+ * - **Conversational** — interactive multi-turn agent for one workflow
13
+ *
14
+ * The Builder asks the user to pick one when creating a new skill;
15
+ * the chosen template defines the starting body + suggested tools.allow.
16
+ * Authors edit from there. Templates aren't a runtime concept — they're
17
+ * just initial content the writeSkill() helper persists like any other
18
+ * skill.
19
+ */
20
+ export interface SkillTemplate {
21
+ /** Stable id used by the picker. */
22
+ id: string;
23
+ /** Display name shown in the picker. */
24
+ label: string;
25
+ /** One-line "use when" hint for the picker subtitle. */
26
+ hint: string;
27
+ /** Emoji shown next to the label — gives the picker visual texture. */
28
+ emoji: string;
29
+ /** Initial frontmatter description filled into the create modal.
30
+ * User can override before save; serves as a writing prompt. */
31
+ defaultDescription: string;
32
+ /** Initial Markdown body. Should follow the Anthropic procedure
33
+ * shape (numbered steps, clear inputs/outputs section) so authors
34
+ * start from a good pattern instead of a blank page. */
35
+ body: string;
36
+ /** Suggested clementine.tools.allow allowlist. The Builder pre-fills
37
+ * the tools chip list so authors see "this archetype usually needs
38
+ * Read + Bash + memory_write" right away. */
39
+ suggestedTools: string[];
40
+ /** Optional bundled files to drop alongside SKILL.md. Each entry is
41
+ * written via the same writeSkill folder as the entry-point file.
42
+ * Example: an Orchestrator template ships a templates/output.md
43
+ * scaffold; a Scraper ships a scripts/fetch.py stub. */
44
+ bundledFiles?: Array<{
45
+ relPath: string;
46
+ content: string;
47
+ }>;
48
+ }
49
+ export declare const SKILL_TEMPLATES: SkillTemplate[];
50
+ /** Lookup by id. */
51
+ export declare function getSkillTemplate(id: string): SkillTemplate | null;
52
+ /** Apply a template to a skill name — substitutes \`{{TITLE}}\` placeholders
53
+ * in the body with the user's display title. */
54
+ export declare function renderTemplateBody(template: SkillTemplate, displayTitle: string): string;
55
+ //# sourceMappingURL=skill-templates.d.ts.map
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Skill templates — 1.18.130 (Phase 2 / Skill Builder).
3
+ *
4
+ * Each template scaffolds a SKILL.md frontmatter + body skeleton plus
5
+ * (optionally) starter bundled files. Templates encode the five common
6
+ * skill archetypes the user pulled out of the Anthropic skills video:
7
+ *
8
+ * - **Orchestrator** — the meta-skill that routes work to other skills
9
+ * - **Scraper / Poller** — read external data, surface what's new
10
+ * - **Transformer** — mutate input → produce output (no side effects)
11
+ * - **Notifier** — send a message to Discord/Slack/email when X happens
12
+ * - **Conversational** — interactive multi-turn agent for one workflow
13
+ *
14
+ * The Builder asks the user to pick one when creating a new skill;
15
+ * the chosen template defines the starting body + suggested tools.allow.
16
+ * Authors edit from there. Templates aren't a runtime concept — they're
17
+ * just initial content the writeSkill() helper persists like any other
18
+ * skill.
19
+ */
20
+ export const SKILL_TEMPLATES = [
21
+ {
22
+ id: 'orchestrator',
23
+ label: 'Orchestrator',
24
+ hint: 'Routes work to other skills based on conditions. The "meta-skill" pattern from the Anthropic agent skills video.',
25
+ emoji: '🎯',
26
+ defaultDescription: 'Route incoming work through a sequence of decisions. For each item, pick the right downstream skill based on the conditions in the body.',
27
+ suggestedTools: ['Agent', 'Read'],
28
+ body: `# {{TITLE}}
29
+
30
+ This skill is an **orchestrator** — it doesn't do the work itself, it routes work to other skills based on conditions.
31
+
32
+ ## Procedure
33
+
34
+ 1. **Gather inputs.** State what you need to know to make routing decisions (e.g., query a data source, read state, check a flag).
35
+
36
+ 2. **For each item, decide the path:**
37
+ - If <condition A> → invoke the **<skill-name-a>** skill with \`{key: value}\`
38
+ - If <condition B> → invoke the **<skill-name-b>** skill with \`{key: value}\`
39
+ - Otherwise → log to the daily note as "no match"
40
+
41
+ 3. **Summarize.** Send a short Discord summary: how many items, which skills fired, any errors.
42
+
43
+ ## Sub-skills referenced
44
+
45
+ - \`<skill-name-a>\` — describe what it does
46
+ - \`<skill-name-b>\` — describe what it does
47
+
48
+ ## Tips for writing orchestrators
49
+
50
+ - Keep the routing logic in this body. Sub-skills should be pure functions that don't know they're being called from here.
51
+ - Pin every sub-skill to the same scheduled task so their bodies are loaded into context together.
52
+ - The Agent tool is what dispatches sub-skills — keep it in your tools.allow.
53
+ `,
54
+ },
55
+ {
56
+ id: 'scraper',
57
+ label: 'Scraper / Poller',
58
+ hint: 'Read an external source, surface what is new since the last run.',
59
+ emoji: '🔍',
60
+ defaultDescription: 'Poll an external source on a schedule and surface what is new since the last run. Tracks state via the cron progress mechanism so it does not re-process old items.',
61
+ suggestedTools: ['Read', 'WebFetch', 'cron_progress_read', 'cron_progress_write'],
62
+ body: `# {{TITLE}}
63
+
64
+ Read an external data source on a schedule, surface only what's changed since the last run.
65
+
66
+ ## Procedure
67
+
68
+ 1. **Read state.** \`cron_progress_read\` to get \`{processed_ids: [...]}\` (default \`[]\`).
69
+
70
+ 2. **Query the source.** Describe exactly what you fetch (URL, MCP tool call, file path).
71
+
72
+ 3. **Filter to new items.** Compare each item's id against \`processed_ids\`. Skip ones already seen.
73
+
74
+ 4. **For each new item:**
75
+ - <do the thing>
76
+
77
+ 5. **Persist state.** \`cron_progress_write({processed_ids: [...]})\` with the union of old + new ids.
78
+
79
+ 6. **Output.** If nothing was new, output \`__NOTHING__\`. Otherwise summarize what was processed.
80
+
81
+ ## Inputs
82
+
83
+ - (declare any \`clementine.inputs\` parameters here so the agent reads them as inputs)
84
+ `,
85
+ bundledFiles: [
86
+ {
87
+ relPath: 'references/state-shape.md',
88
+ content: `# State shape\\n\\nThis skill reads/writes its state via \`cron_progress_*\`. Document the shape here so future-you remembers what each key means.\\n\\n\\\`\\\`\\\`json\\n{\\n "processed_ids": ["id1", "id2"],\\n "last_run_summary": "..."\\n}\\n\\\`\\\`\\\`\\n`,
89
+ },
90
+ ],
91
+ },
92
+ {
93
+ id: 'transformer',
94
+ label: 'Transformer',
95
+ hint: 'Take input → produce output. Pure function, no side effects.',
96
+ emoji: '⚙',
97
+ defaultDescription: 'Pure transformation: receives input, produces output. No external side effects. Useful as a sub-skill called by an orchestrator.',
98
+ suggestedTools: ['Read', 'Write'],
99
+ body: `# {{TITLE}}
100
+
101
+ Transform input into output. No side effects — safe to call from any context.
102
+
103
+ ## Inputs
104
+
105
+ Declare in \`clementine.inputs\`:
106
+
107
+ \\\`\\\`\\\`yaml
108
+ clementine:
109
+ inputs:
110
+ source_text:
111
+ type: string
112
+ description: The raw text to transform
113
+ required: true
114
+ style:
115
+ type: string
116
+ enum: [casual, formal, urgent]
117
+ default: casual
118
+ \\\`\\\`\\\`
119
+
120
+ ## Procedure
121
+
122
+ 1. **Read inputs.** All inputs are interpolated as \`{{ input_name }}\`.
123
+
124
+ 2. **Transform.** Describe the transformation step by step.
125
+
126
+ 3. **Output.** Return the transformed text only — no commentary, no narration.
127
+
128
+ ## Output shape
129
+
130
+ \\\`\\\`\\\`
131
+ <one-line summary>
132
+
133
+ <transformed body>
134
+ \\\`\\\`\\\`
135
+ `,
136
+ },
137
+ {
138
+ id: 'notifier',
139
+ label: 'Notifier',
140
+ hint: 'Send a message somewhere when a condition fires.',
141
+ emoji: '📣',
142
+ defaultDescription: 'Send a message to Discord/Slack/email/SMS when a condition is met. Composes well with a Scraper that detects "something changed."',
143
+ suggestedTools: ['Read', 'discord_channel_send', 'slack_post_message'],
144
+ body: `# {{TITLE}}
145
+
146
+ Notify a destination when a condition fires.
147
+
148
+ ## Procedure
149
+
150
+ 1. **Check the condition.** Describe what triggers the notification (e.g., new audit row, daily digest ready, alert threshold crossed).
151
+
152
+ 2. **Build the message.** Keep it short. Include:
153
+ - What happened
154
+ - Why it matters (one line)
155
+ - Action the recipient should take, if any
156
+
157
+ 3. **Send via the right channel:**
158
+ - Use \`discord_channel_send\` for the team / yourself
159
+ - Use \`slack_post_message\` if the recipient prefers Slack
160
+ - Use \`mcp__gmail__send\` for external recipients
161
+
162
+ 4. **Confirm delivery.** Output a one-liner like "Sent to #ops-alerts at 09:14."
163
+
164
+ ## Inputs
165
+
166
+ \\\`\\\`\\\`yaml
167
+ clementine:
168
+ inputs:
169
+ destination:
170
+ type: string
171
+ enum: [discord-ops, slack-team, email-owner]
172
+ default: discord-ops
173
+ severity:
174
+ type: string
175
+ enum: [info, warning, urgent]
176
+ default: info
177
+ \\\`\\\`\\\`
178
+ `,
179
+ },
180
+ {
181
+ id: 'conversational',
182
+ label: 'Conversational',
183
+ hint: 'Multi-turn interactive agent for one specific workflow.',
184
+ emoji: '💬',
185
+ defaultDescription: 'Multi-turn conversational agent for a specific workflow (e.g., onboarding a new client, debugging a system). Maintains a focused context across the conversation.',
186
+ suggestedTools: ['Read', 'memory_read', 'memory_write', 'note_create'],
187
+ body: `# {{TITLE}}
188
+
189
+ Run a focused multi-turn conversation. The user comes to you with a specific goal; you guide them step by step until it's done.
190
+
191
+ ## Procedure
192
+
193
+ 1. **Open with intent confirmation.** "Sounds like you want to do X. Is that right?" Don't start tools until they confirm.
194
+
195
+ 2. **Gather what you need.** Ask one question at a time, not a wall.
196
+
197
+ 3. **Use tools sparingly.** Each tool call should be obvious and explainable in the next turn.
198
+
199
+ 4. **Save what you learn.** Use \`memory_write\` for facts that should persist beyond this conversation.
200
+
201
+ 5. **Close cleanly.** When done, summarize what was accomplished + any next steps for the user.
202
+
203
+ ## Conversation principles
204
+
205
+ - Match the user's tone (casual / formal / urgent — read their first message)
206
+ - Never start a multi-step process without checking in first
207
+ - If the user changes direction mid-conversation, acknowledge + pivot — don't pretend they asked for what you started
208
+ `,
209
+ },
210
+ ];
211
+ /** Lookup by id. */
212
+ export function getSkillTemplate(id) {
213
+ return SKILL_TEMPLATES.find((t) => t.id === id) ?? null;
214
+ }
215
+ /** Apply a template to a skill name — substitutes \`{{TITLE}}\` placeholders
216
+ * in the body with the user's display title. */
217
+ export function renderTemplateBody(template, displayTitle) {
218
+ return template.body.replace(/\{\{TITLE\}\}/g, displayTitle);
219
+ }
220
+ //# sourceMappingURL=skill-templates.js.map
package/dist/cli/cron.js CHANGED
@@ -62,6 +62,36 @@ export async function cmdCronRun(jobName) {
62
62
  process.env.CLEMENTINE_HOME = BASE_DIR;
63
63
  const jobs = parseCronJobs();
64
64
  let job = jobs.find((j) => j.name === jobName);
65
+ // 1.18.129 — Run-now fallback for skills that aren't on a schedule.
66
+ // The dashboard's "Run now" button on a skill detail pane calls this
67
+ // endpoint with the skill name. If the skill has no registry entry,
68
+ // synthesize a transient CronJobDefinition so the runtime can fire
69
+ // it once. The skill body becomes the prompt via buildSkillContext,
70
+ // same as scheduled-skill jobs.
71
+ if (!job) {
72
+ try {
73
+ const { getSkill } = await import('../agent/skill-store.js');
74
+ const skill = getSkill(jobName);
75
+ if (skill) {
76
+ const ext = (skill.frontmatter.clementine ?? {});
77
+ job = {
78
+ name: skill.frontmatter.name,
79
+ schedule: '0 0 1 1 *', // dummy — never auto-fires; run-now bypasses scheduling
80
+ prompt: '', // buildSkillContext injects the skill body
81
+ tier: 1,
82
+ enabled: true,
83
+ skills: [skill.frontmatter.name],
84
+ agentSlug: ext.agentSlug ?? undefined,
85
+ predictable: true,
86
+ source: 'scheduled-skill',
87
+ };
88
+ console.log(`(running skill "${jobName}" on demand — not yet scheduled)`);
89
+ }
90
+ }
91
+ catch {
92
+ // Fall through to error
93
+ }
94
+ }
65
95
  if (!job) {
66
96
  console.error(`Job not found: ${jobName}`);
67
97
  console.error(`Available jobs: ${jobs.map((j) => j.name).join(', ') || '(none)'}`);