@aion0/forge 0.9.12 → 0.9.14

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.
@@ -2,17 +2,16 @@
2
2
 
3
3
  /**
4
4
  * ScheduleCreateModal — 3-step wizard:
5
- * 1. Pick body (kind: pipeline | skill, then ref)
6
- * 2. Fill input (schema-driven for pipeline; project + user_prompt for skill)
7
- * 3. Trigger (period / cron / once / manual + name)
8
- *
9
- * V2 Phase 2: pipeline + skill body. connector_tool + action types
10
- * (chat / email / telegram) land in phases 3-6.
5
+ * 1. Pick body (kind: pipeline | prompt, then ref)
6
+ * 2. Fill input (schema-driven for pipeline; project + agent + skills for prompt)
7
+ * 3. Trigger (period / cron / once / manual + name) + action (none / chat / email / telegram)
11
8
  */
12
9
 
13
10
  import { useEffect, useState } from 'react';
14
11
 
15
- type BodyKind = 'pipeline' | 'skill' | 'connector_tool';
12
+ type BodyKind = 'pipeline' | 'prompt';
13
+
14
+ interface AgentInfo { id: string; label?: string; backendType?: string; }
16
15
 
17
16
  interface Workflow { name: string; description?: string; }
18
17
  interface SkillItem { name: string; displayName?: string; description?: string; installedGlobal?: boolean; installedProjects?: string[]; }
@@ -28,52 +27,6 @@ interface PipelineInputField {
28
27
  multiline?: boolean;
29
28
  }
30
29
  interface PipelineSchema { name: string; description: string | null; input: PipelineInputField[]; }
31
- interface ConnectorToolParam {
32
- type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | string;
33
- label?: string;
34
- description?: string;
35
- required?: boolean;
36
- default?: unknown;
37
- enum?: unknown[];
38
- }
39
- interface ConnectorTool {
40
- name: string;
41
- description?: string;
42
- parameters?: Record<string, ConnectorToolParam>;
43
- input_schema?: any;
44
- destructive?: boolean;
45
- }
46
- interface ConnectorEntry { id?: string; tools?: Record<string, ConnectorTool>; }
47
- interface ConnectorPayload {
48
- plugin_id: string; name: string; description?: string;
49
- installed: boolean;
50
- entries?: ConnectorEntry[];
51
- }
52
-
53
- /** Flatten connector → list of {ref, tool} for the picker.
54
- * ref = "<plugin_id>.<tool_name>" — matches the body_ref shape the
55
- * scheduler / dispatchTool expect. */
56
- function flattenConnectorTools(connectors: ConnectorPayload[]): Array<{
57
- pluginId: string; pluginName: string; toolName: string; ref: string; tool: ConnectorTool;
58
- }> {
59
- const out: Array<{ pluginId: string; pluginName: string; toolName: string; ref: string; tool: ConnectorTool }> = [];
60
- for (const c of connectors) {
61
- if (!c.installed) continue;
62
- for (const e of c.entries || []) {
63
- const tools = e.tools || {};
64
- for (const [toolName, t] of Object.entries(tools)) {
65
- out.push({
66
- pluginId: c.plugin_id,
67
- pluginName: c.name || c.plugin_id,
68
- toolName,
69
- ref: `${c.plugin_id}.${toolName}`,
70
- tool: { ...t, name: toolName },
71
- });
72
- }
73
- }
74
- }
75
- return out;
76
- }
77
30
 
78
31
  /** Subset of Schedule fields needed to seed the modal in edit mode. */
79
32
  export interface EditableSchedule {
@@ -128,37 +81,37 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
128
81
  const [bodyKind, setBodyKind] = useState<BodyKind>(existing?.body_kind ?? 'pipeline');
129
82
  const [bodyRef, setBodyRef] = useState<string>(existing?.body_ref ?? '');
130
83
  const [workflows, setWorkflows] = useState<Workflow[]>([]);
131
- const [skills, setSkills] = useState<SkillItem[]>([]);
132
84
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
133
- const [connectors, setConnectors] = useState<ConnectorPayload[]>([]);
134
85
 
135
86
  // Step 2 — for pipeline body
136
87
  const [pipelineSchema, setPipelineSchema] = useState<PipelineSchema | null>(null);
137
- // Step 2 — for skill body (free-form)
138
- const [skillProject, setSkillProject] = useState(
139
- existing?.body_kind === 'skill' ? String(existing.input?.project ?? '') : ''
140
- );
141
- const [skillUserPrompt, setSkillUserPrompt] = useState(
142
- existing?.body_kind === 'skill' ? String(existing.input?.user_prompt ?? '') : ''
143
- );
144
- // Step 2 for connector_tool body
145
- const [toolInputJson, setToolInputJson] = useState(
146
- existing?.body_kind === 'connector_tool' ? JSON.stringify(existing.input ?? {}, null, 2) : '{}'
147
- );
148
- const [toolInputJsonErr, setToolInputJsonErr] = useState('');
88
+
89
+ // Step 2 for prompt body (V3). prompt text + agent + skills are
90
+ // persisted in prompts/<body_ref>.yaml; the schedule row only carries
91
+ // body_ref + input.project. In edit mode we GET the yaml to seed these.
92
+ const [promptProject, setPromptProject] = useState(() => {
93
+ if (existing?.body_kind !== 'prompt') return '';
94
+ const p = String(existing.input?.project ?? '');
95
+ // Backend stores 'scratch' for prompts that opted into the default. Map
96
+ // it back to '' so the "— scratch (default) —" placeholder option matches.
97
+ return p === 'scratch' ? '' : p;
98
+ });
99
+ const [promptText, setPromptText] = useState('');
100
+ const [promptAgent, setPromptAgent] = useState<string>('');
101
+ const [promptSkills, setPromptSkills] = useState<string[]>(['ai-orchestration']);
102
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
103
+ const [promptLoadedRef, setPromptLoadedRef] = useState<string>('');
149
104
 
150
105
  const [input, setInput] = useState<Record<string, string>>(
151
106
  existing?.body_kind === 'pipeline' ? inputToStrings(existing.input) : {}
152
107
  );
153
108
 
154
- // Extra skills attached to the dispatched body (pipeline tasks / skill task).
109
+ // Extra skills attached to the dispatched body (pipeline tasks).
155
110
  // Parallel to Job.skills — forwarded as --append-system-prompt; auto-installed
156
- // into the target project on dispatch. Not meaningful for connector_tool.
111
+ // into the target project on dispatch.
157
112
  const [extraSkills, setExtraSkills] = useState<string[]>(Array.isArray(existing?.skills) ? existing!.skills! : []);
158
113
  const [extraSkillsPickerOpen, setExtraSkillsPickerOpen] = useState(false);
159
- // /api/skills returns installedGlobal/installedProjects; we list ALL for the
160
- // picker (auto-install handles missing ones) but body_kind=skill picker uses
161
- // a filtered list (separate state `skills`).
114
+ // Auto-install handles missing ones list ALL skills.
162
115
  const [allSkills, setAllSkills] = useState<SkillItem[]>([]);
163
116
 
164
117
  // Step 3
@@ -209,25 +162,22 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
209
162
  useEffect(() => {
210
163
  (async () => {
211
164
  try {
212
- const [wfR, skR, cnR, csR] = await Promise.all([
165
+ const [wfR, skR, csR, agR] = await Promise.all([
213
166
  fetch('/api/pipelines?type=workflows').then((r) => r.json()),
214
167
  fetch('/api/skills').then((r) => r.json()),
215
- fetch('/api/connectors').then((r) => r.json()).catch(() => ({ connectors: [] })),
216
168
  fetch('/api/chat-proxy/sessions').then((r) => r.json()).catch(() => ({ sessions: [] })),
169
+ fetch('/api/agents?include=all').then((r) => r.json()).catch(() => ({ agents: [] })),
217
170
  ]);
171
+ if (Array.isArray(agR?.agents)) setAgents(agR.agents);
218
172
  if (Array.isArray(csR?.sessions)) setChatSessions(csR.sessions);
219
173
  if (Array.isArray(wfR)) setWorkflows(wfR);
220
174
  else if (wfR?.workflows) setWorkflows(wfR.workflows);
221
175
  if (Array.isArray(skR?.skills)) {
222
- // Body picker only shows installed skills (forces user to install first).
223
- setSkills(skR.skills.filter((s: SkillItem) => s.installedGlobal || (s.installedProjects?.length ?? 0) > 0));
224
176
  // Extra-skills picker lists everything — Forge auto-installs missing
225
177
  // ones into the target project at dispatch time.
226
178
  setAllSkills(skR.skills);
227
179
  }
228
180
  if (skR?.projects) setProjects(skR.projects);
229
- const conns: ConnectorPayload[] = Array.isArray(cnR) ? cnR : (cnR?.connectors || []);
230
- setConnectors(conns.filter((c) => c.installed));
231
181
  } catch (e) {
232
182
  setErr(e instanceof Error ? e.message : String(e));
233
183
  }
@@ -266,13 +216,27 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
266
216
  // eslint-disable-next-line react-hooks/exhaustive-deps
267
217
  }, [bodyKind, bodyRef]);
268
218
 
269
- // Auto-suggest name for skill body
219
+ // Edit mode: load existing prompt yaml so Step 2 form is pre-filled.
220
+ // For new schedules nothing to load; user types the prompt fresh.
270
221
  useEffect(() => {
271
- if (bodyKind === 'skill' && bodyRef && !name) {
272
- setName(`${bodyRef}`);
273
- }
222
+ if (!editing) return;
223
+ if (existing?.body_kind !== 'prompt') return;
224
+ if (promptLoadedRef === existing.body_ref) return;
225
+ (async () => {
226
+ try {
227
+ const r = await fetch(`/api/prompts/${encodeURIComponent(existing.body_ref)}`);
228
+ if (!r.ok) { setErr(`failed to load prompt definition: ${r.status}`); return; }
229
+ const j = await r.json();
230
+ setPromptText(j.prompt ?? '');
231
+ setPromptAgent(j.executor?.agent ?? '');
232
+ setPromptSkills(Array.isArray(j.executor?.skills) ? j.executor.skills : []);
233
+ setPromptLoadedRef(existing.body_ref);
234
+ } catch (e) {
235
+ setErr(e instanceof Error ? e.message : String(e));
236
+ }
237
+ })();
274
238
  // eslint-disable-next-line react-hooks/exhaustive-deps
275
- }, [bodyKind, bodyRef]);
239
+ }, [editing, existing?.body_kind, existing?.body_ref]);
276
240
 
277
241
  // Reset body_ref when body_kind switches
278
242
  function onBodyKindChange(k: BodyKind) {
@@ -283,7 +247,13 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
283
247
  }
284
248
 
285
249
  function canProceed(): boolean {
286
- if (step === 1) return !!bodyRef;
250
+ if (step === 1) {
251
+ // prompt body has no body_ref picker — the schedule name doubles as
252
+ // the prompt name, derived at submit time. Let user proceed past
253
+ // Step 1 just by picking the tab.
254
+ if (bodyKind === 'prompt') return true;
255
+ return !!bodyRef;
256
+ }
287
257
  if (step === 2) {
288
258
  if (bodyKind === 'pipeline') {
289
259
  if (!pipelineSchema) return false;
@@ -292,11 +262,9 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
292
262
  }
293
263
  return true;
294
264
  }
295
- if (bodyKind === 'skill') {
296
- return !!skillProject && !!skillUserPrompt.trim();
297
- }
298
- if (bodyKind === 'connector_tool') {
299
- return !toolInputJsonErr;
265
+ if (bodyKind === 'prompt') {
266
+ // project is optional — empty falls back to the synthetic 'scratch' project
267
+ return !!promptText.trim();
300
268
  }
301
269
  return false;
302
270
  }
@@ -316,17 +284,52 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
316
284
  setSubmitting(true);
317
285
  try {
318
286
  let body_input: Record<string, unknown>;
287
+ let promptNameToUse: string | null = null;
319
288
  if (bodyKind === 'pipeline') {
320
289
  body_input = input;
321
- } else if (bodyKind === 'skill') {
322
- body_input = { project: skillProject, user_prompt: skillUserPrompt };
323
- } else { // connector_tool
324
- try {
325
- body_input = JSON.parse(toolInputJson || '{}');
326
- } catch (e) {
327
- setErr(`Tool input is not valid JSON: ${(e as Error).message}`);
328
- return;
290
+ } else if (bodyKind === 'prompt') {
291
+ // Create/update the prompts/<name>.yaml first; schedule references it by name.
292
+ // In edit mode body_ref is locked and refers to the existing yaml — PATCH it.
293
+ // In create mode derive name from schedule name (slugify) and POST with
294
+ // automatic suffix retry on 409.
295
+ const skillsClean = promptSkills.filter((s) => s && s.trim());
296
+ const executor = { agent: promptAgent || null, skills: skillsClean };
297
+ if (editing && existing?.body_kind === 'prompt') {
298
+ promptNameToUse = existing.body_ref;
299
+ const r = await fetch(`/api/prompts/${encodeURIComponent(existing.body_ref)}`, {
300
+ method: 'PATCH',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify({ prompt: promptText, executor }),
303
+ });
304
+ if (!r.ok) {
305
+ const j = await r.json().catch(() => ({}));
306
+ setErr(j.error || `prompt yaml save failed (${r.status})`);
307
+ return;
308
+ }
309
+ } else {
310
+ const base = slugifyName(name) || `prompt-${Date.now().toString(36).slice(-6)}`;
311
+ let candidate = base;
312
+ for (let i = 2; i <= 50; i++) {
313
+ const r = await fetch('/api/prompts', {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify({ name: candidate, prompt: promptText, executor }),
317
+ });
318
+ if (r.ok) { promptNameToUse = candidate; break; }
319
+ if (r.status === 409) { candidate = `${base}-${i}`; continue; }
320
+ const j = await r.json().catch(() => ({}));
321
+ setErr(j.error || `prompt yaml create failed (${r.status})`);
322
+ return;
323
+ }
324
+ if (!promptNameToUse) {
325
+ setErr(`couldn't pick a free prompt name starting from "${base}"`);
326
+ return;
327
+ }
329
328
  }
329
+ body_input = { project: promptProject };
330
+ } else {
331
+ setErr(`unsupported body_kind: ${bodyKind}`);
332
+ return;
330
333
  }
331
334
  const action_config: Record<string, unknown> = {};
332
335
  if (actionKind === 'chat') {
@@ -344,9 +347,7 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
344
347
  const body: any = {
345
348
  name: name.trim(),
346
349
  input: body_input,
347
- // connector_tool ignores skills (no Claude task spawned); send [] so
348
- // PATCH doesn't preserve a stale value from before kind switch.
349
- skills: bodyKind === 'connector_tool' ? [] : extraSkills,
350
+ skills: extraSkills,
350
351
  action_kind: actionKind,
351
352
  action_config,
352
353
  schedule_kind: trigger,
@@ -357,7 +358,7 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
357
358
  };
358
359
  if (!editing) {
359
360
  body.body_kind = bodyKind;
360
- body.body_ref = bodyRef;
361
+ body.body_ref = bodyKind === 'prompt' ? promptNameToUse! : bodyRef;
361
362
  body.enabled = true;
362
363
  }
363
364
  const url = editing ? `/api/schedules/${encodeURIComponent(existing!.id)}` : '/api/schedules';
@@ -395,35 +396,25 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
395
396
  <Step1
396
397
  bodyKind={bodyKind} onBodyKindChange={onBodyKindChange}
397
398
  bodyRef={bodyRef} onBodyRefChange={setBodyRef}
398
- workflows={workflows} skills={skills} connectors={connectors}
399
- projects={projects}
400
- skillProject={skillProject} onSkillProject={setSkillProject}
399
+ workflows={workflows}
401
400
  />
402
401
  )}
403
402
  {step === 2 && bodyKind === 'pipeline' && pipelineSchema && (
404
403
  <Step2Pipeline schema={pipelineSchema} input={input} onChange={setInput} />
405
404
  )}
406
- {step === 2 && bodyKind === 'skill' && (
407
- <Step2Skill
408
- project={skillProject}
409
- userPrompt={skillUserPrompt} onUserPrompt={setSkillUserPrompt}
410
- skillName={bodyRef}
411
- />
412
- )}
413
- {step === 2 && bodyKind === 'connector_tool' && (
414
- <Step2ConnectorTool
415
- connectors={connectors}
416
- toolRef={bodyRef}
417
- json={toolInputJson}
418
- onJsonChange={(v) => {
419
- setToolInputJson(v);
420
- try { JSON.parse(v || '{}'); setToolInputJsonErr(''); }
421
- catch (e) { setToolInputJsonErr((e as Error).message); }
422
- }}
423
- jsonErr={toolInputJsonErr}
405
+ {step === 2 && bodyKind === 'prompt' && (
406
+ <Step2Prompt
407
+ projects={projects}
408
+ project={promptProject} onProject={setPromptProject}
409
+ promptText={promptText} onPromptText={setPromptText}
410
+ agent={promptAgent} onAgent={setPromptAgent}
411
+ agents={agents}
412
+ skills={promptSkills} onSkills={setPromptSkills}
413
+ allSkills={allSkills}
414
+ editing={editing}
424
415
  />
425
416
  )}
426
- {step === 2 && bodyKind !== 'connector_tool' && (
417
+ {step === 2 && bodyKind === 'pipeline' && (
427
418
  <ExtraSkillsSection
428
419
  skills={extraSkills}
429
420
  onChange={setExtraSkills}
@@ -514,14 +505,11 @@ function Stepper({ step }: { step: 1 | 2 | 3 }) {
514
505
  function Step1({
515
506
  bodyKind, onBodyKindChange,
516
507
  bodyRef, onBodyRefChange,
517
- workflows, skills, connectors,
518
- projects, skillProject, onSkillProject,
508
+ workflows,
519
509
  }: {
520
510
  bodyKind: BodyKind; onBodyKindChange: (k: BodyKind) => void;
521
511
  bodyRef: string; onBodyRefChange: (n: string) => void;
522
- workflows: Workflow[]; skills: SkillItem[]; connectors: ConnectorPayload[];
523
- projects: ProjectInfo[];
524
- skillProject: string; onSkillProject: (n: string) => void;
512
+ workflows: Workflow[];
525
513
  }) {
526
514
  return (
527
515
  <div>
@@ -529,7 +517,7 @@ function Step1({
529
517
 
530
518
  {/* Body kind tabs */}
531
519
  <div className="flex gap-1 mb-3 border-b border-[var(--border)]">
532
- {(['pipeline', 'skill', 'connector_tool'] as BodyKind[]).map((k) => (
520
+ {(['pipeline', 'prompt'] as BodyKind[]).map((k) => (
533
521
  <button
534
522
  key={k}
535
523
  onClick={() => onBodyKindChange(k)}
@@ -539,9 +527,7 @@ function Step1({
539
527
  : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
540
528
  }`}
541
529
  >
542
- {k === 'pipeline' ? '📋 Pipeline'
543
- : k === 'skill' ? '🧩 Skill'
544
- : '🔌 Connector Tool'}
530
+ {k === 'pipeline' ? '📋 Pipeline' : '💬 Prompt'}
545
531
  </button>
546
532
  ))}
547
533
  </div>
@@ -558,108 +544,12 @@ function Step1({
558
544
  ))}
559
545
  </div>
560
546
  )
561
- ) : bodyKind === 'skill' ? (
562
- // Skill body: project first (required), then skill list scoped to that project (or global).
563
- <SkillPicker
564
- skills={skills} projects={projects}
565
- skillProject={skillProject} onSkillProject={onSkillProject}
566
- bodyRef={bodyRef} onBodyRefChange={onBodyRefChange}
567
- />
568
547
  ) : (
569
- // connector_tool flattened plugin.tool list. Source shape is
570
- // { connectors: [{plugin_id, name, installed, entries:[{tools:{name:tool}}] }] }
571
- (() => {
572
- const all = flattenConnectorTools(connectors);
573
- if (all.length === 0) {
574
- return (
575
- <div className="text-[11px] text-[var(--text-secondary)]">
576
- No installed connectors with tools. Install one from Settings → Connectors first.
577
- </div>
578
- );
579
- }
580
- // Group by plugin for nice section headers.
581
- const byPlugin = new Map<string, { name: string; tools: typeof all }>();
582
- for (const x of all) {
583
- if (!byPlugin.has(x.pluginId)) byPlugin.set(x.pluginId, { name: x.pluginName, tools: [] });
584
- byPlugin.get(x.pluginId)!.tools.push(x);
585
- }
586
- return (
587
- <div className="space-y-3">
588
- {Array.from(byPlugin.entries()).map(([pluginId, group]) => (
589
- <div key={pluginId}>
590
- <div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1">{group.name}</div>
591
- <div className="space-y-1.5">
592
- {group.tools.map(({ ref, tool }) => (
593
- <Card key={ref}
594
- title={ref}
595
- desc={(tool.description || '') + (tool.destructive ? ' ⚠ destructive' : '')}
596
- checked={bodyRef === ref}
597
- onPick={() => onBodyRefChange(ref)} />
598
- ))}
599
- </div>
600
- </div>
601
- ))}
602
- </div>
603
- );
604
- })()
605
- )}
606
- </div>
607
- );
608
- }
609
-
610
- function SkillPicker({ skills, projects, skillProject, onSkillProject, bodyRef, onBodyRefChange }: {
611
- skills: SkillItem[]; projects: ProjectInfo[];
612
- skillProject: string; onSkillProject: (n: string) => void;
613
- bodyRef: string; onBodyRefChange: (n: string) => void;
614
- }) {
615
- // Filter skills to those installed globally OR in the chosen project.
616
- // If no project selected yet, show nothing in the list (prompt user to pick project first).
617
- const visibleSkills = skillProject
618
- ? skills.filter((s) => s.installedGlobal || (s.installedProjects || []).includes(skillProject)
619
- || (s.installedProjects || []).some((p) => p.endsWith('/' + skillProject) || p === skillProject))
620
- : [];
621
- return (
622
- <div>
623
- <label className="block mb-3">
624
- <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
625
- <span className="font-mono">project</span>
626
- <span className="text-[var(--red)]">*</span>
627
- </div>
628
- <select
629
- value={skillProject}
630
- onChange={(e) => { onSkillProject(e.target.value); onBodyRefChange(''); }}
631
- className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
632
- >
633
- <option value="">— pick a project —</option>
634
- {projects.map((p) => (
635
- <option key={p.name} value={p.name}>{p.name}</option>
636
- ))}
637
- </select>
638
- <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
639
- Working directory for the Claude task. Skills installed in this project + global skills will appear below.
640
- </div>
641
- </label>
642
-
643
- <div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1">
644
- Skill
645
- {skillProject && visibleSkills.length > 0 && (
646
- <span className="ml-2 normal-case text-[var(--text-secondary)] font-normal">
647
- ({visibleSkills.length} available in <code className="font-mono">{skillProject}</code>)
648
- </span>
649
- )}
650
- </div>
651
- {!skillProject ? (
652
- <div className="text-[11px] text-[var(--text-secondary)] italic">Pick a project first.</div>
653
- ) : visibleSkills.length === 0 ? (
654
- <div className="text-[11px] text-[var(--text-secondary)]">
655
- No skills installed globally or in <code className="font-mono">{skillProject}</code>. Install one from Settings → Skills.
656
- </div>
657
- ) : (
658
- <div className="space-y-1.5">
659
- {visibleSkills.map((s) => (
660
- <Card key={s.name} title={s.name} desc={s.description}
661
- checked={bodyRef === s.name} onPick={() => onBodyRefChange(s.name)} />
662
- ))}
548
+ <div className="text-[11px] text-[var(--text-secondary)] p-3 border border-dashed border-[var(--border)] rounded">
549
+ Prompt mode runs a one-shot Claude task with your prompt text + chosen agent + skills.
550
+ You&apos;ll author the prompt on the next step. The schedule&apos;s name becomes the prompt&apos;s
551
+ filename (<code className="font-mono">prompts/&lt;name&gt;.yaml</code>) so future edits stay
552
+ in one place.
663
553
  </div>
664
554
  )}
665
555
  </div>
@@ -812,335 +702,145 @@ function PipelineInputControl({ field, value, onChange }: {
812
702
  );
813
703
  }
814
704
 
815
- function Step2Skill({ project, userPrompt, onUserPrompt, skillName }: {
816
- project: string;
817
- userPrompt: string; onUserPrompt: (v: string) => void; skillName: string;
818
- }) {
819
- return (
820
- <div>
821
- <div className="text-[11px] text-[var(--text-secondary)] mb-3">
822
- Step 2 / 3 — Skill <code className="font-mono">/{skillName}</code> in
823
- project <code className="font-mono">{project}</code>.
824
- </div>
825
-
826
- <label className="block">
827
- <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
828
- <span className="font-mono">user_prompt</span>
829
- <span className="text-[var(--red)]">*</span>
830
- </div>
831
- <textarea
832
- rows={6}
833
- value={userPrompt}
834
- onChange={(e) => onUserPrompt(e.target.value)}
835
- placeholder="e.g. Fetch all my open Mantis bugs, summarize by category"
836
- className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
837
- />
838
- <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
839
- The user message sent to Claude. The skill's SKILL.md prompts Claude on how to handle it.
840
- </div>
841
- </label>
842
- </div>
843
- );
705
+ // Lower-case, alphanumeric + dash. Used to derive the prompts/<name>.yaml
706
+ // filename from the schedule's display name. The store's name regex is
707
+ // `[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}` so this is a strict subset.
708
+ function slugifyName(s: string): string {
709
+ return s.toLowerCase()
710
+ .replace(/[^a-z0-9]+/g, '-')
711
+ .replace(/^-+|-+$/g, '')
712
+ .slice(0, 50);
844
713
  }
845
714
 
846
- function Step2ConnectorTool({ connectors, toolRef, json, onJsonChange, jsonErr }: {
847
- connectors: ConnectorPayload[]; toolRef: string;
848
- json: string; onJsonChange: (v: string) => void; jsonErr: string;
715
+ function Step2Prompt({
716
+ projects, project, onProject,
717
+ promptText, onPromptText,
718
+ agent, onAgent, agents,
719
+ skills, onSkills, allSkills,
720
+ editing,
721
+ }: {
722
+ projects: ProjectInfo[];
723
+ project: string; onProject: (v: string) => void;
724
+ promptText: string; onPromptText: (v: string) => void;
725
+ agent: string; onAgent: (v: string) => void;
726
+ agents: AgentInfo[];
727
+ skills: string[]; onSkills: (v: string[]) => void;
728
+ allSkills: SkillItem[];
729
+ editing: boolean;
849
730
  }) {
850
- const found = flattenConnectorTools(connectors).find((x) => x.ref === toolRef);
851
- const tool = found?.tool;
852
- const params = tool?.parameters || {};
853
- const paramNames = Object.keys(params);
854
-
855
- // Parse json → object for the form to read. If json is invalid the
856
- // form shows current "field" values from whatever we last parsed
857
- // successfully (the editor is the source of truth either way).
858
- const parsed: Record<string, unknown> = (() => {
859
- try { const v = JSON.parse(json || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; }
860
- catch { return {}; }
861
- })();
862
-
863
- // Toggle: form vs raw json. Default to form if the tool declares
864
- // parameters; fall back to raw JSON for tools without a schema.
865
- const [showRaw, setShowRaw] = useState(paramNames.length === 0);
866
-
867
- // Test result panel — populated by the Test button below the form.
868
- const [testing, setTesting] = useState(false);
869
- const [testResult, setTestResult] = useState<null | {
870
- ok: boolean;
871
- is_error: boolean;
872
- content: string;
873
- duration_ms: number;
874
- error?: string;
875
- }>(null);
876
- const pluginId = toolRef.split('.')[0] || '';
877
- const toolName = toolRef.split('.').slice(1).join('.');
878
- async function runTest() {
879
- setTesting(true);
880
- setTestResult(null);
881
- try {
882
- const r = await fetch('/api/connectors/tool-test', {
883
- method: 'POST',
884
- headers: { 'Content-Type': 'application/json' },
885
- body: JSON.stringify({ plugin_id: pluginId, tool: toolName, input: parsed }),
886
- });
887
- const j = await r.json();
888
- setTestResult(j);
889
- } catch (e) {
890
- setTestResult({ ok: false, is_error: true, content: '', duration_ms: 0, error: (e as Error).message });
891
- } finally { setTesting(false); }
892
- }
893
-
894
- function updateField(name: string, value: unknown) {
895
- const next = { ...parsed };
896
- // Drop empty optional fields so they don't pollute the payload.
897
- if (value === '' || value === null || value === undefined) delete next[name];
898
- else next[name] = value;
899
- onJsonChange(JSON.stringify(next, null, 2));
731
+ function toggleSkill(name: string) {
732
+ onSkills(skills.includes(name) ? skills.filter((s) => s !== name) : [...skills, name]);
900
733
  }
901
-
734
+ // Surface a small curated subset by default; the picker further down
735
+ // lets the user add anything. ai-orchestration is the V3 default so a
736
+ // prompt task can call connectors.
737
+ const featured = ['ai-orchestration', 'temper-memory', 'knowledge-base'];
738
+ const featuredAvailable = featured.filter((n) =>
739
+ skills.includes(n) || allSkills.some((s) => s.name === n)
740
+ );
741
+ const extras = skills.filter((s) => !featured.includes(s));
902
742
  return (
903
743
  <div>
904
744
  <div className="text-[11px] text-[var(--text-secondary)] mb-3">
905
- Step 2 / 3 — Tool: <code className="font-mono">{toolRef}</code>. Dispatched
906
- directly (no LLM); the response becomes the body output.
745
+ Step 2 / 3 — Author the prompt task.{' '}
746
+ {editing && <span>(Editing existing prompt definition.)</span>}
907
747
  </div>
908
748
 
909
- {tool?.description && (
910
- <div className="text-[10px] text-[var(--text-secondary)] italic mb-3 whitespace-pre-wrap">{tool.description}</div>
911
- )}
912
- {tool?.destructive && (
913
- <div className="text-[10px] text-[var(--yellow)] bg-[var(--yellow)]/10 rounded p-2 mb-3">
914
- ⚠ This tool is marked <code className="font-mono">destructive</code>. Schedule will invoke it
915
- every trigger — make sure that's what you want.
916
- </div>
917
- )}
918
-
919
- {/* mode toggle */}
920
- {paramNames.length > 0 && (
921
- <div className="flex gap-2 mb-3 text-[10px]">
922
- <button
923
- onClick={() => setShowRaw(false)}
924
- className={`px-2 py-0.5 rounded border ${!showRaw ? 'border-[var(--text-primary)] bg-[var(--bg-secondary)]' : 'border-[var(--border)] text-[var(--text-secondary)]'}`}
925
- >Form</button>
926
- <button
927
- onClick={() => setShowRaw(true)}
928
- className={`px-2 py-0.5 rounded border ${showRaw ? 'border-[var(--text-primary)] bg-[var(--bg-secondary)]' : 'border-[var(--border)] text-[var(--text-secondary)]'}`}
929
- >Raw JSON</button>
930
- </div>
931
- )}
932
-
933
- {/* form mode */}
934
- {!showRaw && paramNames.length > 0 && (
935
- <div className="space-y-3">
936
- {paramNames.map((name) => {
937
- const p = params[name];
938
- const cur = parsed[name];
939
- return (
940
- <ConnectorToolField
941
- key={name}
942
- name={name}
943
- param={p}
944
- value={cur}
945
- onChange={(v) => updateField(name, v)}
946
- />
947
- );
948
- })}
949
- {jsonErr && (
950
- <div className="text-[10px] text-[var(--red)]">underlying JSON is invalid: {jsonErr}</div>
951
- )}
952
- </div>
953
- )}
954
-
955
- {/* raw json mode (or fallback when tool has no parameters) */}
956
- {(showRaw || paramNames.length === 0) && (
957
- <label className="block">
958
- <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
959
- <span className="font-mono">input (JSON)</span>
960
- </div>
961
- <textarea
962
- rows={8}
963
- value={json}
964
- onChange={(e) => onJsonChange(e.target.value)}
965
- placeholder='{ "bug_id": 1213844 }'
966
- spellCheck={false}
967
- className={`w-full text-[11px] font-mono px-2 py-1 border rounded bg-[var(--bg-secondary)] ${
968
- jsonErr ? 'border-[var(--red)]' : 'border-[var(--border)]'
969
- }`}
970
- />
971
- {jsonErr && (
972
- <div className="text-[10px] text-[var(--red)] mt-0.5">JSON parse error: {jsonErr}</div>
973
- )}
974
- <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
975
- Passed verbatim as the tool's input. Leave as <code className="font-mono">{`{}`}</code> if the tool takes no args.
976
- </div>
977
- </label>
978
- )}
979
-
980
- {/* Test button + result — actually dispatch the tool with the current
981
- input so the user can validate before saving. */}
982
- <div className="mt-3 pt-3 border-t border-[var(--border)]">
983
- <div className="flex items-center gap-2 mb-2">
984
- <button
985
- onClick={() => void runTest()}
986
- disabled={testing || !!jsonErr}
987
- className="text-[11px] px-3 py-1 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)] disabled:opacity-50"
988
- >{testing ? 'Testing…' : '▶ Test'}</button>
989
- <span className="text-[10px] text-[var(--text-secondary)]">
990
- Dispatch the tool once with the current input. Read-only check — doesn't save the schedule.
991
- </span>
992
- </div>
993
- {testResult && (
994
- <div className={`text-[10px] rounded p-2 ${
995
- testResult.ok ? 'bg-[var(--green)]/10' : 'bg-[var(--red)]/10'
996
- }`}>
997
- <div className={testResult.ok ? 'text-[var(--green)]' : 'text-[var(--red)]'}>
998
- {testResult.ok ? '✓ ok' : '✗ failed'} · {testResult.duration_ms}ms
999
- {testResult.error && <> · {testResult.error}</>}
1000
- </div>
1001
- {testResult.content && (
1002
- <pre className="mt-1 font-mono whitespace-pre-wrap break-words max-h-60 overflow-auto text-[var(--text-primary)]">
1003
- {testResult.content}
1004
- </pre>
1005
- )}
1006
- </div>
1007
- )}
1008
- </div>
1009
- </div>
1010
- );
1011
- }
1012
-
1013
- function ConnectorToolField({ name, param, value, onChange }: {
1014
- name: string; param: ConnectorToolParam; value: unknown;
1015
- onChange: (v: unknown) => void;
1016
- }) {
1017
- const label = param.label || name;
1018
- const type = (param.type || 'string').toLowerCase();
1019
- const required = !!param.required;
1020
- const hint = param.description;
1021
-
1022
- const labelEl = (
1023
- <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
1024
- <span className="font-mono">{name}</span>
1025
- {required && <span className="text-[var(--red)]">*</span>}
1026
- {param.label && <span className="text-[10px] text-[var(--text-secondary)]">— {label}</span>}
1027
- </div>
1028
- );
1029
-
1030
- // enum → select
1031
- if (Array.isArray(param.enum) && param.enum.length > 0) {
1032
- const cur = value == null ? '' : String(value);
1033
- return (
1034
- <label className="block">
1035
- {labelEl}
749
+ <label className="block mb-3">
750
+ <div className="text-[11px] mb-0.5"><span className="font-mono">project</span></div>
1036
751
  <select
1037
- value={cur}
1038
- onChange={(e) => onChange(e.target.value || undefined)}
752
+ value={project}
753
+ onChange={(e) => onProject(e.target.value)}
1039
754
  className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1040
755
  >
1041
- <option value="">— pick —</option>
1042
- {param.enum.map((opt) => (
1043
- <option key={String(opt)} value={String(opt)}>{String(opt)}</option>
756
+ <option value="">— scratch (default) —</option>
757
+ {projects.filter((p) => p.name !== 'scratch').map((p) => (
758
+ <option key={p.name} value={p.name}>{p.name}</option>
1044
759
  ))}
1045
760
  </select>
1046
- {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
1047
- </label>
1048
- );
1049
- }
1050
-
1051
- // boolean → checkbox
1052
- if (type === 'boolean') {
1053
- return (
1054
- <label className="block">
1055
- {labelEl}
1056
- <label className="flex items-center gap-2 text-[11px]">
1057
- <input
1058
- type="checkbox"
1059
- checked={!!value}
1060
- onChange={(e) => onChange(e.target.checked || undefined)}
1061
- />
1062
- <span className="text-[11px] text-[var(--text-secondary)]">{value ? 'true' : 'false'}</span>
1063
- </label>
1064
- {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
761
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
762
+ Working directory for the Claude task. Leave on default for prompts that just talk to connectors / APIs and don&apos;t need a real project on disk.
763
+ </div>
1065
764
  </label>
1066
- );
1067
- }
1068
765
 
1069
- // number / integer → number input
1070
- if (type === 'number' || type === 'integer') {
1071
- const cur = value == null ? '' : String(value);
1072
- return (
1073
- <label className="block">
1074
- {labelEl}
1075
- <input
1076
- type="number"
1077
- step={type === 'integer' ? 1 : 'any'}
1078
- value={cur}
1079
- onChange={(e) => {
1080
- const v = e.target.value;
1081
- if (v === '') onChange(undefined);
1082
- else {
1083
- const n = type === 'integer' ? parseInt(v, 10) : parseFloat(v);
1084
- onChange(Number.isFinite(n) ? n : undefined);
1085
- }
1086
- }}
1087
- className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
766
+ <label className="block mb-3">
767
+ <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
768
+ <span className="font-mono">prompt</span>
769
+ <span className="text-[var(--red)]">*</span>
770
+ </div>
771
+ <textarea
772
+ value={promptText}
773
+ onChange={(e) => onPromptText(e.target.value)}
774
+ rows={8}
775
+ placeholder="e.g. 查一下我今天分到的 bug"
776
+ className="w-full text-[11px] font-mono px-2 py-1.5 border border-[var(--border)] rounded bg-[var(--bg-secondary)] resize-y"
1088
777
  />
1089
- {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
778
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
779
+ Sent to the agent verbatim. Keep your own wording — Forge won&apos;t rewrite it.
780
+ </div>
1090
781
  </label>
1091
- );
1092
- }
1093
782
 
1094
- // object / array → JSON textarea
1095
- if (type === 'object' || type === 'array') {
1096
- const cur = value == null ? '' : (typeof value === 'string' ? value : JSON.stringify(value, null, 2));
1097
- return (
1098
- <label className="block">
1099
- {labelEl}
1100
- <textarea
1101
- rows={3}
1102
- value={cur}
1103
- onChange={(e) => {
1104
- const raw = e.target.value;
1105
- if (!raw.trim()) { onChange(undefined); return; }
1106
- try { onChange(JSON.parse(raw)); }
1107
- catch { onChange(raw); /* keep raw text; underlying json becomes invalid */ }
1108
- }}
1109
- spellCheck={false}
1110
- placeholder={type === 'array' ? '[ ]' : '{ }'}
783
+ <label className="block mb-3">
784
+ <div className="text-[11px] mb-0.5"><span className="font-mono">agent</span></div>
785
+ <select
786
+ value={agent}
787
+ onChange={(e) => onAgent(e.target.value)}
1111
788
  className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1112
- />
1113
- {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
789
+ >
790
+ <option value="">— system default —</option>
791
+ {agents.map((a) => (
792
+ <option key={a.id} value={a.id}>
793
+ {a.label || a.id}{a.backendType ? ` (${a.backendType})` : ''}
794
+ </option>
795
+ ))}
796
+ </select>
1114
797
  </label>
1115
- );
1116
- }
1117
798
 
1118
- // string (default) → text input
1119
- const cur = value == null ? '' : String(value);
1120
- const isMultiLine = !!hint && hint.length > 80;
1121
- return (
1122
- <label className="block">
1123
- {labelEl}
1124
- {isMultiLine ? (
1125
- <textarea
1126
- rows={2}
1127
- value={cur}
1128
- onChange={(e) => onChange(e.target.value || undefined)}
1129
- className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1130
- />
1131
- ) : (
1132
- <input
1133
- type="text"
1134
- value={cur}
1135
- onChange={(e) => onChange(e.target.value || undefined)}
1136
- className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1137
- />
1138
- )}
1139
- {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5 whitespace-pre-wrap">{hint}</div>}
1140
- </label>
799
+ <div className="block mb-1">
800
+ <div className="text-[11px] mb-1"><span className="font-mono">skills</span></div>
801
+ <div className="flex flex-wrap gap-1.5">
802
+ {featuredAvailable.map((name) => (
803
+ <label
804
+ key={name}
805
+ className={`text-[10px] font-mono px-2 py-1 border rounded cursor-pointer ${
806
+ skills.includes(name)
807
+ ? 'border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)]'
808
+ : 'border-[var(--border)] hover:bg-[var(--bg-secondary)]/50'
809
+ }`}
810
+ >
811
+ <input
812
+ type="checkbox"
813
+ checked={skills.includes(name)}
814
+ onChange={() => toggleSkill(name)}
815
+ className="mr-1"
816
+ />
817
+ {name}
818
+ </label>
819
+ ))}
820
+ {extras.map((name) => (
821
+ <span
822
+ key={name}
823
+ className="text-[10px] font-mono px-2 py-1 border border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] rounded inline-flex items-center gap-1"
824
+ >
825
+ {name}
826
+ <button
827
+ type="button"
828
+ onClick={() => toggleSkill(name)}
829
+ className="hover:opacity-70"
830
+ aria-label="remove"
831
+ >×</button>
832
+ </span>
833
+ ))}
834
+ </div>
835
+ <div className="text-[10px] text-[var(--text-secondary)] mt-1">
836
+ <code className="font-mono">ai-orchestration</code> lets the agent discover + call your installed connectors.
837
+ </div>
838
+ </div>
839
+ </div>
1141
840
  );
1142
841
  }
1143
842
 
843
+
1144
844
  function Step3({
1145
845
  name, onName,
1146
846
  trigger, onTrigger,