clementine-agent 1.18.129 → 1.18.131

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,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
@@ -4550,6 +4550,33 @@ export async function cmdDashboard(opts) {
4550
4550
  // GET /api/skills — full list with usedByTriggers join
4551
4551
  // GET /api/skills/:name — single skill detail (frontmatter + body)
4552
4552
  // Phase A is read-only; Phase B adds POST/PUT for editing.
4553
+ // 1.18.130 — fix: literal-named GET routes under /api/skills/* MUST be
4554
+ // registered BEFORE /api/skills/:name. Previously /api/skills/suppressions
4555
+ // was shadowed and 404'd because the parameterized :name handler caught
4556
+ // it first and looked up a skill literally named "suppressions". The
4557
+ // dashboard logged a 404 in console on every Skills-page load. Same issue
4558
+ // would have affected any future literal-named routes added to /api/skills.
4559
+ app.get('/api/skills/suppressions', async (_req, res) => {
4560
+ try {
4561
+ const { listAllSuppressions } = await import('../agent/skill-suppressions.js');
4562
+ res.json({ ok: true, suppressions: listAllSuppressions() });
4563
+ }
4564
+ catch (err) {
4565
+ res.status(500).json({ ok: false, error: String(err) });
4566
+ }
4567
+ });
4568
+ // /api/skills/pending was shadowed by :name in earlier revs (see comment
4569
+ // at the duplicate GET below — the duplicate is now superseded). Hoisted
4570
+ // up here so 'pending' isn't captured as :name="pending".
4571
+ app.get('/api/skills/pending', async (_req, res) => {
4572
+ try {
4573
+ const { listPendingSkills } = await import('../agent/skill-extractor.js');
4574
+ res.json({ skills: listPendingSkills() });
4575
+ }
4576
+ catch (err) {
4577
+ res.status(500).json({ error: String(err) });
4578
+ }
4579
+ });
4553
4580
  app.get('/api/skills', async (_req, res) => {
4554
4581
  try {
4555
4582
  const { listSkills } = await import('../agent/skill-store.js');
@@ -4587,15 +4614,9 @@ export async function cmdDashboard(opts) {
4587
4614
  // Lets the user manually suppress skills from auto-match retrieval —
4588
4615
  // a complement to the memory store's automatic feedback-driven
4589
4616
  // suppression. Storage is a single JSON file under ~/.clementine/.
4590
- app.get('/api/skills/suppressions', async (_req, res) => {
4591
- try {
4592
- const { listAllSuppressions } = await import('../agent/skill-suppressions.js');
4593
- res.json({ ok: true, suppressions: listAllSuppressions() });
4594
- }
4595
- catch (err) {
4596
- res.status(500).json({ ok: false, error: String(err) });
4597
- }
4598
- });
4617
+ // (GET /api/skills/suppressions registered earlier moved up to win
4618
+ // route precedence over /api/skills/:name. The PUT below stays here
4619
+ // because :name disambiguates it from the GET endpoint above.)
4599
4620
  app.put('/api/skills/suppressions/:name', async (req, res) => {
4600
4621
  try {
4601
4622
  const name = req.params.name;
@@ -4618,6 +4639,168 @@ export async function cmdDashboard(opts) {
4618
4639
  res.status(500).json({ ok: false, error: String(err) });
4619
4640
  }
4620
4641
  });
4642
+ // ── Skill bundled-file CRUD (1.18.130 — Skill Builder Phase 2) ────
4643
+ // The builder treats a skill as its folder. SKILL.md is the entry,
4644
+ // scripts/templates/references are bundled siblings. These endpoints
4645
+ // expose list/read/write/delete so the builder UI can render a real
4646
+ // file tree and persist edits one file at a time.
4647
+ //
4648
+ // Path traversal hardening: every path coming from the URL is run
4649
+ // through resolveBundledFilePath which (a) validates the skill slug,
4650
+ // (b) resolves the candidate against the skill folder, and (c) refuses
4651
+ // anything that escapes the folder via .. or absolute prefixes.
4652
+ function resolveSkillFolder(skillName) {
4653
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(skillName))
4654
+ return null;
4655
+ const folder = path.join(VAULT_DIR, '00-System', 'skills', skillName);
4656
+ return existsSync(path.join(folder, 'SKILL.md')) ? folder : null;
4657
+ }
4658
+ function resolveBundledFilePath(skillFolder, relPath) {
4659
+ // Empty / . / absolute / multiple-leading-slash all rejected.
4660
+ const cleaned = (relPath || '').replace(/^\/+/, '');
4661
+ if (!cleaned || cleaned === '.' || cleaned === '..')
4662
+ return null;
4663
+ if (path.isAbsolute(cleaned))
4664
+ return null;
4665
+ const candidate = path.normalize(path.join(skillFolder, cleaned));
4666
+ // Final resolved path must still be inside the skill folder.
4667
+ const folderResolved = path.resolve(skillFolder);
4668
+ const candidateResolved = path.resolve(candidate);
4669
+ if (candidateResolved !== folderResolved && !candidateResolved.startsWith(folderResolved + path.sep)) {
4670
+ return null;
4671
+ }
4672
+ return candidateResolved;
4673
+ }
4674
+ app.get('/api/skills/:name/files', (req, res) => {
4675
+ try {
4676
+ const folder = resolveSkillFolder(req.params.name);
4677
+ if (!folder)
4678
+ return res.status(404).json({ ok: false, error: 'skill not found' });
4679
+ const out = [];
4680
+ const walk = (dir, prefix) => {
4681
+ const entries = readdirSync(dir);
4682
+ for (const entry of entries) {
4683
+ if (entry.startsWith('.'))
4684
+ continue;
4685
+ if (entry.endsWith('.bak') || entry.endsWith('.bak.md'))
4686
+ continue;
4687
+ const abs = path.join(dir, entry);
4688
+ const rel = prefix ? `${prefix}/${entry}` : entry;
4689
+ let st;
4690
+ try {
4691
+ st = statSync(abs);
4692
+ }
4693
+ catch {
4694
+ continue;
4695
+ }
4696
+ if (st.isDirectory()) {
4697
+ // Only descend one level (scripts/, templates/, references/).
4698
+ if (prefix)
4699
+ continue;
4700
+ out.push({ relPath: rel, sizeBytes: 0, isDir: true });
4701
+ walk(abs, rel);
4702
+ continue;
4703
+ }
4704
+ if (st.isFile()) {
4705
+ out.push({ relPath: rel, sizeBytes: st.size, isDir: false });
4706
+ }
4707
+ }
4708
+ };
4709
+ walk(folder, '');
4710
+ // Sort: SKILL.md first, then directories before files, then alpha.
4711
+ out.sort((a, b) => {
4712
+ if (a.relPath === 'SKILL.md')
4713
+ return -1;
4714
+ if (b.relPath === 'SKILL.md')
4715
+ return 1;
4716
+ const aDir = a.isDir, bDir = b.isDir;
4717
+ if (aDir !== bDir)
4718
+ return aDir ? -1 : 1;
4719
+ return a.relPath.localeCompare(b.relPath);
4720
+ });
4721
+ res.json({ ok: true, folder, files: out });
4722
+ }
4723
+ catch (err) {
4724
+ res.status(500).json({ ok: false, error: String(err) });
4725
+ }
4726
+ });
4727
+ app.get('/api/skills/:name/files/*', (req, res) => {
4728
+ try {
4729
+ const folder = resolveSkillFolder(req.params.name);
4730
+ if (!folder)
4731
+ return res.status(404).json({ ok: false, error: 'skill not found' });
4732
+ const relPath = req.params[0] || '';
4733
+ const abs = resolveBundledFilePath(folder, relPath);
4734
+ if (!abs)
4735
+ return res.status(400).json({ ok: false, error: 'invalid path' });
4736
+ if (!existsSync(abs))
4737
+ return res.status(404).json({ ok: false, error: 'file not found' });
4738
+ const st = statSync(abs);
4739
+ if (st.isDirectory())
4740
+ return res.status(400).json({ ok: false, error: 'path is a directory' });
4741
+ const content = readFileSync(abs, 'utf-8');
4742
+ res.json({ ok: true, relPath, content, sizeBytes: st.size, mtimeMs: st.mtimeMs });
4743
+ }
4744
+ catch (err) {
4745
+ res.status(500).json({ ok: false, error: String(err) });
4746
+ }
4747
+ });
4748
+ app.put('/api/skills/:name/files/*', (req, res) => {
4749
+ try {
4750
+ const folder = resolveSkillFolder(req.params.name);
4751
+ if (!folder)
4752
+ return res.status(404).json({ ok: false, error: 'skill not found' });
4753
+ const relPath = req.params[0] || '';
4754
+ const abs = resolveBundledFilePath(folder, relPath);
4755
+ if (!abs)
4756
+ return res.status(400).json({ ok: false, error: 'invalid path' });
4757
+ const body = (req.body ?? {});
4758
+ if (typeof body.content !== 'string') {
4759
+ return res.status(400).json({ ok: false, error: 'content (string) required' });
4760
+ }
4761
+ // Lazy-create parent dir for new files in subdirs (scripts/foo.py
4762
+ // when scripts/ doesn't exist yet).
4763
+ const parent = path.dirname(abs);
4764
+ if (!existsSync(parent))
4765
+ mkdirSync(parent, { recursive: true });
4766
+ writeFileSync(abs, body.content);
4767
+ const st = statSync(abs);
4768
+ res.json({ ok: true, relPath, sizeBytes: st.size, mtimeMs: st.mtimeMs });
4769
+ }
4770
+ catch (err) {
4771
+ res.status(500).json({ ok: false, error: String(err) });
4772
+ }
4773
+ });
4774
+ app.delete('/api/skills/:name/files/*', (req, res) => {
4775
+ try {
4776
+ const folder = resolveSkillFolder(req.params.name);
4777
+ if (!folder)
4778
+ return res.status(404).json({ ok: false, error: 'skill not found' });
4779
+ const relPath = req.params[0] || '';
4780
+ // SKILL.md cannot be deleted via this endpoint — that's "delete the
4781
+ // whole skill," which has its own flow (POST /api/skills/:name/delete).
4782
+ if (relPath === 'SKILL.md') {
4783
+ return res.status(400).json({ ok: false, error: 'cannot delete SKILL.md — use the skill delete flow' });
4784
+ }
4785
+ const abs = resolveBundledFilePath(folder, relPath);
4786
+ if (!abs)
4787
+ return res.status(400).json({ ok: false, error: 'invalid path' });
4788
+ if (!existsSync(abs))
4789
+ return res.json({ ok: true, alreadyGone: true });
4790
+ const st = statSync(abs);
4791
+ if (st.isDirectory()) {
4792
+ // Remove the directory recursively (only one level deep ever exists).
4793
+ rmSync(abs, { recursive: true, force: true });
4794
+ }
4795
+ else {
4796
+ unlinkSync(abs);
4797
+ }
4798
+ res.json({ ok: true });
4799
+ }
4800
+ catch (err) {
4801
+ res.status(500).json({ ok: false, error: String(err) });
4802
+ }
4803
+ });
4621
4804
  // ── Schedule registry (1.18.129) ───────────────────────────────────
4622
4805
  // Anthropic-pure scheduling: skills stay vanilla, schedules live in
4623
4806
  // ~/.clementine/schedules.json. The cron scheduler reads this in
@@ -10495,17 +10678,9 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
10495
10678
  }
10496
10679
  });
10497
10680
  // ── Skills (Procedural Memory) API ──────────────────────────────────
10498
- // NOTE: /api/skills/pending routes must come before /api/skills/:name so
10499
- // Express doesn't capture "pending" as a :name param.
10500
- app.get('/api/skills/pending', async (_req, res) => {
10501
- try {
10502
- const { listPendingSkills } = await import('../agent/skill-extractor.js');
10503
- res.json({ skills: listPendingSkills() });
10504
- }
10505
- catch (err) {
10506
- res.status(500).json({ error: String(err) });
10507
- }
10508
- });
10681
+ // (GET /api/skills/pending hoisted earlier was shadowed by /api/skills/:name
10682
+ // in this file's previous order. POST routes below are unaffected because
10683
+ // verb differs from the GET :name handler.)
10509
10684
  app.post('/api/skills/pending/:name/approve', async (req, res) => {
10510
10685
  try {
10511
10686
  const { approvePendingSkill } = await import('../agent/skill-extractor.js');
@@ -10539,6 +10714,93 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
10539
10714
  // (dashboard, MCP create_skill, auto-extraction) flow through the
10540
10715
  // same write-once-validate-once code; this route is now a thin
10541
10716
  // adapter over the helper.
10717
+ // 1.18.130 — list available skill templates so the create modal can
10718
+ // render the picker. Templates encode the five common archetypes
10719
+ // (orchestrator, scraper, transformer, notifier, conversational) and
10720
+ // pre-fill body + tools.allow when chosen.
10721
+ app.get('/api/skill-templates', async (_req, res) => {
10722
+ try {
10723
+ const { SKILL_TEMPLATES } = await import('../agent/skill-templates.js');
10724
+ // Ship the picker-relevant fields only (omit the body — the create
10725
+ // flow fetches it via POST /api/skills/from-template). Keeps the
10726
+ // initial picker payload tiny.
10727
+ const items = SKILL_TEMPLATES.map((t) => ({
10728
+ id: t.id,
10729
+ label: t.label,
10730
+ hint: t.hint,
10731
+ emoji: t.emoji,
10732
+ defaultDescription: t.defaultDescription,
10733
+ suggestedTools: t.suggestedTools,
10734
+ bundledFileCount: t.bundledFiles?.length ?? 0,
10735
+ }));
10736
+ res.json({ ok: true, templates: items });
10737
+ }
10738
+ catch (err) {
10739
+ res.status(500).json({ ok: false, error: String(err) });
10740
+ }
10741
+ });
10742
+ // Apply a template to create a new skill. The caller picks a template
10743
+ // id + skill name/title/description; the body comes from the template
10744
+ // (with {{TITLE}} substituted) plus suggestedTools become tools.allow.
10745
+ // Bundled files in the template ship alongside the SKILL.md.
10746
+ app.post('/api/skills/from-template', async (req, res) => {
10747
+ try {
10748
+ const { templateId, name, title, description } = req.body ?? {};
10749
+ if (typeof templateId !== 'string' || typeof name !== 'string') {
10750
+ return res.status(400).json({ error: 'templateId and name required' });
10751
+ }
10752
+ const { getSkillTemplate, renderTemplateBody } = await import('../agent/skill-templates.js');
10753
+ const tmpl = getSkillTemplate(templateId);
10754
+ if (!tmpl)
10755
+ return res.status(404).json({ error: 'unknown template id' });
10756
+ const { writeSkill } = await import('../agent/skill-store.js');
10757
+ const renderedBody = renderTemplateBody(tmpl, typeof title === 'string' && title ? title : name);
10758
+ const finalDescription = (typeof description === 'string' && description.trim())
10759
+ ? description
10760
+ : tmpl.defaultDescription;
10761
+ try {
10762
+ const result = writeSkill({
10763
+ name,
10764
+ title: typeof title === 'string' ? title : undefined,
10765
+ description: finalDescription,
10766
+ body: renderedBody,
10767
+ tools: tmpl.suggestedTools,
10768
+ source: 'manual',
10769
+ });
10770
+ // Drop bundled files alongside SKILL.md by writing each one
10771
+ // through the same file CRUD path the builder uses. Best-effort —
10772
+ // failing on a bundled file shouldn't roll back the skill itself.
10773
+ if (Array.isArray(tmpl.bundledFiles)) {
10774
+ for (const f of tmpl.bundledFiles) {
10775
+ try {
10776
+ const folder = path.dirname(result.filePath);
10777
+ const target = path.join(folder, f.relPath);
10778
+ const parent = path.dirname(target);
10779
+ if (!existsSync(parent))
10780
+ mkdirSync(parent, { recursive: true });
10781
+ writeFileSync(target, f.content);
10782
+ }
10783
+ catch (err) {
10784
+ console.warn('[skill-template] bundled file write failed:', err);
10785
+ }
10786
+ }
10787
+ }
10788
+ res.json({ ok: true, name: result.name, layout: 'folder', filePath: result.filePath, templateApplied: tmpl.id });
10789
+ }
10790
+ catch (err) {
10791
+ const msg = err instanceof Error ? err.message : String(err);
10792
+ if (msg.includes('already exists')) {
10793
+ res.status(409).json({ error: msg });
10794
+ }
10795
+ else {
10796
+ res.status(400).json({ error: msg });
10797
+ }
10798
+ }
10799
+ }
10800
+ catch (err) {
10801
+ res.status(500).json({ error: String(err) });
10802
+ }
10803
+ });
10542
10804
  app.post('/api/skills', async (req, res) => {
10543
10805
  try {
10544
10806
  const { name, title, description, body, tools } = req.body ?? {};
@@ -27640,6 +27902,373 @@ function renderMarkdown(src) {
27640
27902
  function openCreateSkillModal() { _openSkillModal({ mode: 'create' }); }
27641
27903
  function openEditSkillModal(name) { _openSkillModal({ mode: 'edit', name: name }); }
27642
27904
 
27905
+ // ── Skill Builder (1.18.130 — Phase 2) ──────────────────────────────
27906
+ // Three-panel folder editor: file tree (left) | editor (middle) |
27907
+ // available-context sidebar (right). Replaces the cron-modal mental
27908
+ // model with "skill folder is the unit of work; everything you can
27909
+ // reach lives in the right rail." Lazy-mounts on first open.
27910
+ //
27911
+ // State lives on window so the inline onclicks in the dynamically-built
27912
+ // markup can reach it — same pattern as MEMORY.md editor (1.18.127).
27913
+ window._sbState = { skillName: null, currentFile: 'SKILL.md', files: [], dirty: false, saving: false };
27914
+
27915
+ async function openSkillBuilder(skillName) {
27916
+ window._sbState.skillName = skillName;
27917
+ window._sbState.currentFile = 'SKILL.md';
27918
+ window._sbState.dirty = false;
27919
+ var modal = document.getElementById('skill-builder-modal');
27920
+ if (!modal) {
27921
+ modal = document.createElement('div');
27922
+ modal.id = 'skill-builder-modal';
27923
+ modal.style.cssText = 'position:fixed;inset:0;background:var(--bg-primary);z-index:1100;display:none;flex-direction:column';
27924
+ modal.innerHTML =
27925
+ '<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid var(--border);background:var(--bg-secondary);flex-shrink:0">'
27926
+ + '<div style="display:flex;align-items:center;gap:12px">'
27927
+ + '<span style="font-size:16px;font-weight:600;color:var(--text-primary)">⚡ Skill Builder</span>'
27928
+ + '<span id="sb-skill-name" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;color:var(--text-muted);background:var(--bg-tertiary);padding:3px 8px;border-radius:4px"></span>'
27929
+ + '<span id="sb-save-status" style="font-size:11px;color:var(--text-muted)"></span>'
27930
+ + '</div>'
27931
+ + '<div style="display:flex;align-items:center;gap:6px">'
27932
+ + '<button onclick="sbSaveCurrent()" id="sb-save-btn" class="btn-primary" style="font-size:12px;padding:7px 14px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-weight:500;cursor:pointer" disabled>Save (⌘S)</button>'
27933
+ + '<button onclick="closeSkillBuilder()" style="font-size:12px;padding:7px 12px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text-primary);cursor:pointer">Close</button>'
27934
+ + '</div>'
27935
+ + '</div>'
27936
+ + '<div style="flex:1;display:grid;grid-template-columns:240px 1fr 320px;gap:0;overflow:hidden;min-height:0">'
27937
+ // ── Left: file tree ──
27938
+ + '<div style="border-right:1px solid var(--border);background:var(--bg-secondary);display:flex;flex-direction:column;min-height:0">'
27939
+ + '<div style="padding:10px 14px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;display:flex;align-items:center;justify-content:space-between">'
27940
+ + '<span>Files</span>'
27941
+ + '<button onclick="sbAddFile()" title="Add a new file to this skill folder" style="font-size:11px;padding:2px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);cursor:pointer">+ Add</button>'
27942
+ + '</div>'
27943
+ + '<div id="sb-file-tree" style="flex:1;overflow-y:auto;padding:6px 0;font-size:12px"></div>'
27944
+ + '</div>'
27945
+ // ── Middle: editor ──
27946
+ + '<div style="display:flex;flex-direction:column;min-height:0">'
27947
+ + '<div style="padding:8px 14px;border-bottom:1px solid var(--border);background:var(--bg-secondary);font-size:11px;color:var(--text-muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:flex;align-items:center;justify-content:space-between">'
27948
+ + '<span id="sb-file-path"></span>'
27949
+ + '<span id="sb-file-meta" style="color:var(--text-muted)"></span>'
27950
+ + '</div>'
27951
+ + '<textarea id="sb-editor" oninput="sbOnEdit()" spellcheck="false" style="flex:1;border:none;outline:none;padding:14px 18px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.55;background:var(--bg-primary);color:var(--text-primary);resize:none;min-height:0"></textarea>'
27952
+ + '</div>'
27953
+ // ── Right: available-context sidebar ──
27954
+ + '<div style="border-left:1px solid var(--border);background:var(--bg-secondary);display:flex;flex-direction:column;min-height:0">'
27955
+ + '<div style="padding:8px 6px 0;display:flex;gap:2px;border-bottom:1px solid var(--border)">'
27956
+ + '<button onclick="sbSwitchTab(\\x27tools\\x27)" id="sb-tab-tools" class="sb-tab" style="flex:1;font-size:11px;padding:8px 4px;border:none;background:transparent;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;font-weight:500">Tools</button>'
27957
+ + '<button onclick="sbSwitchTab(\\x27skills\\x27)" id="sb-tab-skills" class="sb-tab" style="flex:1;font-size:11px;padding:8px 4px;border:none;background:transparent;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;font-weight:500">Skills</button>'
27958
+ + '</div>'
27959
+ + '<div style="padding:6px 10px;border-bottom:1px solid var(--border)">'
27960
+ + '<input type="text" id="sb-sidebar-search" oninput="sbRenderSidebar()" placeholder="Filter…" style="width:100%;padding:5px 8px;font-size:11px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary)">'
27961
+ + '</div>'
27962
+ + '<div id="sb-sidebar-list" style="flex:1;overflow-y:auto;padding:4px 0"></div>'
27963
+ + '<div style="padding:8px 12px;border-top:1px solid var(--border);font-size:10px;color:var(--text-muted);line-height:1.4">'
27964
+ + 'Click any item to insert a reference at the cursor.'
27965
+ + '</div>'
27966
+ + '</div>'
27967
+ + '</div>';
27968
+ document.body.appendChild(modal);
27969
+ // Cmd-S / Ctrl-S to save (only when builder is open)
27970
+ document.addEventListener('keydown', function(e) {
27971
+ if (modal.style.display === 'none' || modal.style.display === '') return;
27972
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
27973
+ e.preventDefault();
27974
+ sbSaveCurrent();
27975
+ }
27976
+ if (e.key === 'Escape') {
27977
+ if (window._sbState.dirty && !confirm('Unsaved changes will be lost. Close anyway?')) return;
27978
+ closeSkillBuilder();
27979
+ }
27980
+ });
27981
+ }
27982
+ document.getElementById('sb-skill-name').textContent = skillName;
27983
+ modal.style.display = 'flex';
27984
+ // Default sidebar tab + initial loads
27985
+ window._sbActiveTab = 'tools';
27986
+ sbSwitchTab('tools');
27987
+ await sbReloadFileTree();
27988
+ await sbLoadFile('SKILL.md');
27989
+ }
27990
+
27991
+ function closeSkillBuilder() {
27992
+ var m = document.getElementById('skill-builder-modal');
27993
+ if (m) m.style.display = 'none';
27994
+ }
27995
+
27996
+ async function sbReloadFileTree() {
27997
+ var name = window._sbState.skillName;
27998
+ if (!name) return;
27999
+ var tree = document.getElementById('sb-file-tree');
28000
+ if (!tree) return;
28001
+ try {
28002
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(name) + '/files');
28003
+ var d = await r.json();
28004
+ if (!r.ok) {
28005
+ tree.innerHTML = '<div style="padding:14px;color:var(--red);font-size:11px">' + esc(d.error || 'failed') + '</div>';
28006
+ return;
28007
+ }
28008
+ window._sbState.files = d.files || [];
28009
+ var html = '';
28010
+ var lastDir = '';
28011
+ for (var i = 0; i < d.files.length; i++) {
28012
+ var f = d.files[i];
28013
+ var isCurrent = f.relPath === window._sbState.currentFile;
28014
+ var indent = f.relPath.indexOf('/') > -1 ? 18 : 6;
28015
+ var icon = f.isDir ? '📁' : (f.relPath === 'SKILL.md' ? '📜' : (f.relPath.endsWith('.py') || f.relPath.endsWith('.js') || f.relPath.endsWith('.sh') ? '⚙' : '📄'));
28016
+ var size = f.isDir ? '' : '<span style="color:var(--text-muted);font-size:10px;margin-left:auto">' + (f.sizeBytes < 1024 ? f.sizeBytes + 'B' : Math.round(f.sizeBytes / 1024) + 'k') + '</span>';
28017
+ if (f.isDir) {
28018
+ html += '<div style="padding:4px 14px 4px ' + indent + 'px;font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.03em;font-weight:600;display:flex;align-items:center;gap:6px">' + icon + ' ' + esc(f.relPath) + '/</div>';
28019
+ } else {
28020
+ html += '<div onclick="sbLoadFile(\\x27' + jsStr(f.relPath) + '\\x27)" style="padding:5px 14px 5px ' + indent + 'px;cursor:pointer;display:flex;align-items:center;gap:6px;background:' + (isCurrent ? 'var(--bg-tertiary)' : 'transparent') + ';color:' + (isCurrent ? 'var(--accent)' : 'var(--text-primary)') + ';font-weight:' + (isCurrent ? '600' : '400') + '" onmouseover="if(window._sbState.currentFile!==\\x27' + jsStr(f.relPath) + '\\x27)this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="if(window._sbState.currentFile!==\\x27' + jsStr(f.relPath) + '\\x27)this.style.background=\\x27transparent\\x27">'
28021
+ + '<span>' + icon + '</span>'
28022
+ + '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(f.relPath.indexOf('/') > -1 ? f.relPath.split('/').pop() : f.relPath) + '</span>'
28023
+ + size
28024
+ + (f.relPath !== 'SKILL.md' ? '<button onclick="event.stopPropagation();sbDeleteFile(\\x27' + jsStr(f.relPath) + '\\x27)" title="Delete file" style="background:none;border:none;color:var(--text-muted);cursor:pointer;padding:0 4px;font-size:11px">×</button>' : '')
28025
+ + '</div>';
28026
+ }
28027
+ }
28028
+ tree.innerHTML = html || '<div style="padding:14px;color:var(--text-muted);font-size:11px">No files yet.</div>';
28029
+ } catch (err) {
28030
+ tree.innerHTML = '<div style="padding:14px;color:var(--red);font-size:11px">' + esc(String(err)) + '</div>';
28031
+ }
28032
+ }
28033
+
28034
+ async function sbLoadFile(relPath) {
28035
+ if (window._sbState.dirty && !confirm('Unsaved changes will be lost. Switch anyway?')) return;
28036
+ var name = window._sbState.skillName;
28037
+ var ed = document.getElementById('sb-editor');
28038
+ var pathEl = document.getElementById('sb-file-path');
28039
+ var metaEl = document.getElementById('sb-file-meta');
28040
+ if (!ed) return;
28041
+ ed.value = 'Loading…';
28042
+ try {
28043
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(name) + '/files/' + encodeURIComponent(relPath));
28044
+ var d = await r.json();
28045
+ if (!r.ok) { ed.value = ''; toast(d.error || 'Failed to load file', 'error'); return; }
28046
+ ed.value = d.content || '';
28047
+ window._sbState.currentFile = relPath;
28048
+ window._sbState.dirty = false;
28049
+ if (pathEl) pathEl.textContent = relPath;
28050
+ if (metaEl) metaEl.textContent = (d.content || '').split('\\n').length + ' lines · ' + d.sizeBytes + ' bytes';
28051
+ sbUpdateSaveButton();
28052
+ sbReloadFileTree();
28053
+ } catch (err) {
28054
+ ed.value = '';
28055
+ toast('Failed: ' + err, 'error');
28056
+ }
28057
+ }
28058
+
28059
+ function sbOnEdit() {
28060
+ window._sbState.dirty = true;
28061
+ sbUpdateSaveButton();
28062
+ var ed = document.getElementById('sb-editor');
28063
+ var metaEl = document.getElementById('sb-file-meta');
28064
+ if (ed && metaEl) metaEl.textContent = (ed.value || '').split('\\n').length + ' lines · unsaved';
28065
+ }
28066
+
28067
+ function sbUpdateSaveButton() {
28068
+ var btn = document.getElementById('sb-save-btn');
28069
+ var status = document.getElementById('sb-save-status');
28070
+ if (btn) btn.disabled = !window._sbState.dirty || window._sbState.saving;
28071
+ if (status) status.textContent = window._sbState.dirty ? '● unsaved' : (window._sbState.lastSaveAt ? 'Saved · ' + window._sbState.lastSaveAt : '');
28072
+ }
28073
+
28074
+ async function sbSaveCurrent() {
28075
+ if (window._sbState.saving) return;
28076
+ var name = window._sbState.skillName;
28077
+ var relPath = window._sbState.currentFile;
28078
+ var ed = document.getElementById('sb-editor');
28079
+ if (!ed || !name || !relPath) return;
28080
+ window._sbState.saving = true;
28081
+ sbUpdateSaveButton();
28082
+ try {
28083
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(name) + '/files/' + encodeURIComponent(relPath), {
28084
+ method: 'PUT',
28085
+ headers: { 'Content-Type': 'application/json' },
28086
+ body: JSON.stringify({ content: ed.value }),
28087
+ });
28088
+ var d = await r.json();
28089
+ if (!r.ok) { toast(d.error || 'Save failed', 'error'); return; }
28090
+ window._sbState.dirty = false;
28091
+ window._sbState.lastSaveAt = new Date().toLocaleTimeString();
28092
+ toast('Saved ' + relPath, 'success');
28093
+ sbUpdateSaveButton();
28094
+ sbReloadFileTree();
28095
+ } catch (err) {
28096
+ toast('Failed: ' + err, 'error');
28097
+ } finally {
28098
+ window._sbState.saving = false;
28099
+ sbUpdateSaveButton();
28100
+ }
28101
+ }
28102
+
28103
+ async function sbAddFile() {
28104
+ var name = prompt('New file path (e.g. scripts/scrape.py, templates/email.md, references/notes.md):');
28105
+ if (!name || !name.trim()) return;
28106
+ var clean = name.trim().replace(/^\\/+/, '');
28107
+ if (clean === 'SKILL.md') { toast('SKILL.md already exists.', 'warn'); return; }
28108
+ // Seed with sensible default content based on extension
28109
+ var seed = '';
28110
+ if (clean.endsWith('.py')) seed = '#!/usr/bin/env python3\\n"""' + clean + '"""\\n\\n';
28111
+ else if (clean.endsWith('.sh')) seed = '#!/bin/bash\\nset -euo pipefail\\n\\n';
28112
+ else if (clean.endsWith('.md')) seed = '# ' + clean.split('/').pop().replace(/\\.md$/, '') + '\\n\\n';
28113
+ try {
28114
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(window._sbState.skillName) + '/files/' + encodeURIComponent(clean), {
28115
+ method: 'PUT',
28116
+ headers: { 'Content-Type': 'application/json' },
28117
+ body: JSON.stringify({ content: seed }),
28118
+ });
28119
+ var d = await r.json();
28120
+ if (!r.ok) { toast(d.error || 'Failed', 'error'); return; }
28121
+ toast('Created ' + clean, 'success');
28122
+ await sbReloadFileTree();
28123
+ await sbLoadFile(clean);
28124
+ } catch (err) {
28125
+ toast('Failed: ' + err, 'error');
28126
+ }
28127
+ }
28128
+
28129
+ async function sbDeleteFile(relPath) {
28130
+ if (!confirm('Delete ' + relPath + '?')) return;
28131
+ try {
28132
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(window._sbState.skillName) + '/files/' + encodeURIComponent(relPath), { method: 'DELETE' });
28133
+ var d = await r.json();
28134
+ if (!r.ok) { toast(d.error || 'Failed', 'error'); return; }
28135
+ toast('Deleted ' + relPath, 'success');
28136
+ if (window._sbState.currentFile === relPath) await sbLoadFile('SKILL.md');
28137
+ else await sbReloadFileTree();
28138
+ } catch (err) {
28139
+ toast('Failed: ' + err, 'error');
28140
+ }
28141
+ }
28142
+
28143
+ // ── Sidebar tabs (Tools / Skills) ────────────────────────────────────
28144
+ function sbSwitchTab(tab) {
28145
+ window._sbActiveTab = tab;
28146
+ ['tools', 'skills'].forEach(function(t) {
28147
+ var el = document.getElementById('sb-tab-' + t);
28148
+ if (el) {
28149
+ el.style.color = (t === tab) ? 'var(--accent)' : 'var(--text-muted)';
28150
+ el.style.borderBottomColor = (t === tab) ? 'var(--accent)' : 'transparent';
28151
+ }
28152
+ });
28153
+ sbRenderSidebar();
28154
+ }
28155
+
28156
+ async function sbRenderSidebar() {
28157
+ var listEl = document.getElementById('sb-sidebar-list');
28158
+ if (!listEl) return;
28159
+ var q = (document.getElementById('sb-sidebar-search')?.value || '').toLowerCase().trim();
28160
+ if (window._sbActiveTab === 'tools') {
28161
+ await sbRenderToolsTab(listEl, q);
28162
+ } else if (window._sbActiveTab === 'skills') {
28163
+ await sbRenderSkillsTab(listEl, q);
28164
+ }
28165
+ }
28166
+
28167
+ async function sbRenderToolsTab(listEl, q) {
28168
+ // Built-in SDK tools (always present). Click → insert tool name into
28169
+ // the current editor. The skill body referencing a tool by name + a
28170
+ // matching clementine.tools.allow entry is what the runtime honors.
28171
+ var builtins = [
28172
+ { name: 'Read', desc: 'Read a file' },
28173
+ { name: 'Write', desc: 'Write a file' },
28174
+ { name: 'Edit', desc: 'Edit a file in place' },
28175
+ { name: 'Bash', desc: 'Run a shell command' },
28176
+ { name: 'Glob', desc: 'Find files by pattern' },
28177
+ { name: 'Grep', desc: 'Search file contents' },
28178
+ { name: 'WebFetch', desc: 'Fetch a URL' },
28179
+ { name: 'WebSearch', desc: 'Search the web' },
28180
+ { name: 'Agent', desc: 'Spawn a subagent (Task tool)' },
28181
+ ];
28182
+ // MCP catalog (local + Composio merged in 1.18.128)
28183
+ var mcpServers = [];
28184
+ try {
28185
+ if (typeof loadMcpCatalog === 'function') {
28186
+ var cat = await loadMcpCatalog();
28187
+ mcpServers = (cat && cat.servers) || [];
28188
+ }
28189
+ } catch (_) { /* fall through */ }
28190
+ var html = '';
28191
+ // Built-ins section
28192
+ var filteredBuiltins = q ? builtins.filter(function(b) { return (b.name + ' ' + b.desc).toLowerCase().indexOf(q) > -1; }) : builtins;
28193
+ if (filteredBuiltins.length > 0) {
28194
+ html += '<div style="padding:6px 12px;font-size:10px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-tertiary)">Built-in (' + filteredBuiltins.length + ')</div>';
28195
+ for (var i = 0; i < filteredBuiltins.length; i++) {
28196
+ var b = filteredBuiltins[i];
28197
+ html += '<div onclick="sbInsertAtCursor(\\x27' + jsStr(b.name) + '\\x27)" style="padding:7px 12px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border-light)" title="' + esc(b.desc) + '" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27transparent\\x27">'
28198
+ + '<span style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-primary);font-weight:500">' + esc(b.name) + '</span>'
28199
+ + '<span style="color:var(--text-muted);font-size:10px;flex:1;overflow:hidden;text-overflow:ellipsis">' + esc(b.desc) + '</span>'
28200
+ + '</div>';
28201
+ }
28202
+ }
28203
+ // MCP servers section
28204
+ var filteredMcp = q ? mcpServers.filter(function(s) { return (s.name + ' ' + (s.description || '') + ' ' + (s._displayName || '')).toLowerCase().indexOf(q) > -1; }) : mcpServers;
28205
+ if (filteredMcp.length > 0) {
28206
+ html += '<div style="padding:6px 12px;font-size:10px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-tertiary);margin-top:4px">MCP servers (' + filteredMcp.length + ')</div>';
28207
+ for (var j = 0; j < filteredMcp.length; j++) {
28208
+ var m = filteredMcp[j];
28209
+ var displayName = m._displayName || m.name;
28210
+ var sourceTag = m._origin === 'composio' ? '<span style="background:rgba(124,58,237,0.18);color:var(--purple);font-size:8px;padding:0 4px;border-radius:3px;font-weight:600;letter-spacing:0.04em">CMP</span>' : '';
28211
+ // Insert as the mcp__server__ prefix so the user can complete with the tool name.
28212
+ var insertion = 'mcp__' + m.name + '__';
28213
+ html += '<div onclick="sbInsertAtCursor(\\x27' + jsStr(insertion) + '\\x27)" style="padding:7px 12px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--border-light)" title="' + esc(m.description || '') + '" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27transparent\\x27">'
28214
+ + '<span style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-primary);font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(displayName) + '</span>'
28215
+ + sourceTag
28216
+ + '</div>';
28217
+ }
28218
+ }
28219
+ if (!html) html = '<div style="padding:18px;color:var(--text-muted);font-size:11px;text-align:center">No matches.</div>';
28220
+ listEl.innerHTML = html;
28221
+ }
28222
+
28223
+ async function sbRenderSkillsTab(listEl, q) {
28224
+ var skills = [];
28225
+ try {
28226
+ var r = await apiFetch('/api/skills');
28227
+ var d = await r.json();
28228
+ if (r.ok && Array.isArray(d.skills)) skills = d.skills;
28229
+ } catch (_) { /* empty */ }
28230
+ // Exclude self — composing a skill with itself is a footgun
28231
+ skills = skills.filter(function(s) {
28232
+ var fm = s.frontmatter || {};
28233
+ return fm.name !== window._sbState.skillName;
28234
+ });
28235
+ if (q) {
28236
+ skills = skills.filter(function(s) {
28237
+ var fm = s.frontmatter || {};
28238
+ return ((fm.name || '') + ' ' + (fm.title || '') + ' ' + (fm.description || '')).toLowerCase().indexOf(q) > -1;
28239
+ });
28240
+ }
28241
+ if (skills.length === 0) {
28242
+ listEl.innerHTML = '<div style="padding:18px;color:var(--text-muted);font-size:11px;text-align:center">' + (q ? 'No matches.' : 'No other skills available yet.') + '</div>';
28243
+ return;
28244
+ }
28245
+ var html = '';
28246
+ for (var i = 0; i < skills.length; i++) {
28247
+ var s = skills[i];
28248
+ var fm = s.frontmatter || {};
28249
+ var insertion = 'Use the **' + (fm.name || '') + '** skill: ' + (fm.description || fm.title || '');
28250
+ html += '<div onclick="sbInsertAtCursor(\\x27' + jsStr(insertion) + '\\x27)" style="padding:8px 12px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border-light)" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27transparent\\x27">'
28251
+ + '<div style="font-weight:500;color:var(--text-primary)">' + esc(fm.title || fm.name) + '</div>'
28252
+ + '<div style="font-size:10px;color:var(--text-muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;margin-top:2px">' + esc(fm.name) + '</div>'
28253
+ + (fm.description ? '<div style="font-size:11px;color:var(--text-secondary);margin-top:4px;line-height:1.4">' + esc(fm.description.slice(0, 120)) + (fm.description.length > 120 ? '…' : '') + '</div>' : '')
28254
+ + '</div>';
28255
+ }
28256
+ listEl.innerHTML = html;
28257
+ }
28258
+
28259
+ function sbInsertAtCursor(text) {
28260
+ var ed = document.getElementById('sb-editor');
28261
+ if (!ed) return;
28262
+ var start = ed.selectionStart;
28263
+ var end = ed.selectionEnd;
28264
+ var before = ed.value.substring(0, start);
28265
+ var after = ed.value.substring(end);
28266
+ ed.value = before + text + after;
28267
+ ed.selectionStart = ed.selectionEnd = start + text.length;
28268
+ ed.focus();
28269
+ sbOnEdit();
28270
+ }
28271
+
27643
28272
  async function _openSkillModal(opts) {
27644
28273
  opts = opts || {};
27645
28274
  var existing = null;
@@ -27670,6 +28299,12 @@ async function _openSkillModal(opts) {
27670
28299
  + '</div>'
27671
28300
  + '<div style="flex:1;overflow-y:auto;padding:18px 22px">'
27672
28301
  + '<input type="hidden" id="skill-modal-original-name">'
28302
+ + '<input type="hidden" id="skill-modal-template-id">'
28303
+ // 1.18.130 — templates picker. Strip of 5 archetype chips at the top
28304
+ // of the create modal. Click one → fills body + tools.allow defaults
28305
+ // so the user starts from a real procedure shape, not blank.
28306
+ // Only shown in create mode (hidden during edit).
28307
+ + '<div id="skill-modal-templates" style="margin-bottom:14px"></div>'
27673
28308
  + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Name <span style="color:var(--text-muted)">(lowercase, dashes, max 64 chars)</span></label>'
27674
28309
  + '<input id="skill-modal-name" type="text" placeholder="e.g. morning-briefing" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px;font-family:\\x27JetBrains Mono\\x27,monospace">'
27675
28310
  + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Display title <span style="color:var(--text-muted)">(optional, friendlier name)</span></label>'
@@ -27709,6 +28344,87 @@ async function _openSkillModal(opts) {
27709
28344
  modal.style.display = 'flex';
27710
28345
  document.getElementById('skill-modal-name').focus();
27711
28346
  if (typeof updateSkillModalCounters === 'function') updateSkillModalCounters();
28347
+ // 1.18.130 — render templates strip in create mode only.
28348
+ var tplBox = document.getElementById('skill-modal-templates');
28349
+ var tplIdInput = document.getElementById('skill-modal-template-id');
28350
+ if (tplIdInput) tplIdInput.value = '';
28351
+ if (tplBox) {
28352
+ if (opts.mode === 'edit') { tplBox.style.display = 'none'; tplBox.innerHTML = ''; }
28353
+ else { tplBox.style.display = ''; loadSkillTemplatePicker(); }
28354
+ }
28355
+ }
28356
+
28357
+ // 1.18.130 — fetch templates + render the picker strip. Click a chip
28358
+ // to fill body + tools fields with the template's defaults. Templates
28359
+ // don't auto-fill the description because the user might be making a
28360
+ // variant — they pick the template for the procedure, then write their
28361
+ // own description.
28362
+ async function loadSkillTemplatePicker() {
28363
+ var box = document.getElementById('skill-modal-templates');
28364
+ if (!box) return;
28365
+ try {
28366
+ var r = await apiFetch('/api/skill-templates');
28367
+ var d = await r.json();
28368
+ if (!r.ok || !Array.isArray(d.templates)) { box.innerHTML = ''; return; }
28369
+ window._skillTemplates = d.templates;
28370
+ var html = '<div style="display:flex;flex-direction:column;gap:6px">';
28371
+ html += '<div style="font-size:12px;color:var(--text-secondary);font-weight:500">Start from a template <span style="color:var(--text-muted);font-weight:normal">(optional — fills body + suggested tools)</span></div>';
28372
+ html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:6px">';
28373
+ for (var i = 0; i < d.templates.length; i++) {
28374
+ var t = d.templates[i];
28375
+ html += '<button type="button" onclick="applySkillTemplate(\\x27' + jsStr(t.id) + '\\x27)" data-tpl="' + esc(t.id) + '" style="text-align:left;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;display:flex;flex-direction:column;gap:3px;transition:all 0.1s" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27;this.style.borderColor=\\x27var(--accent)\\x27" onmouseout="this.style.background=\\x27var(--bg-secondary)\\x27;this.style.borderColor=\\x27var(--border)\\x27">'
28376
+ + '<span style="font-size:13px;font-weight:600">' + esc(t.emoji) + ' ' + esc(t.label) + '</span>'
28377
+ + '<span style="font-size:10px;color:var(--text-muted);line-height:1.3">' + esc(t.hint) + '</span>'
28378
+ + '</button>';
28379
+ }
28380
+ html += '</div>';
28381
+ html += '<div id="skill-modal-template-applied" style="font-size:11px;color:var(--accent);display:none"></div>';
28382
+ html += '</div>';
28383
+ box.innerHTML = html;
28384
+ } catch (err) {
28385
+ box.innerHTML = '';
28386
+ }
28387
+ }
28388
+
28389
+ // Apply a template to the open create modal — fills body + tools + the
28390
+ // template-id hidden input. Description gets the template's default ONLY
28391
+ // if the field is empty (so users can pre-write a description and still
28392
+ // pick a template without it being clobbered).
28393
+ function applySkillTemplate(templateId) {
28394
+ var t = (window._skillTemplates || []).find(function(x) { return x.id === templateId; });
28395
+ if (!t) return;
28396
+ var tplIdInput = document.getElementById('skill-modal-template-id');
28397
+ if (tplIdInput) tplIdInput.value = templateId;
28398
+ var descEl = document.getElementById('skill-modal-desc');
28399
+ if (descEl && !descEl.value.trim()) descEl.value = t.defaultDescription || '';
28400
+ var toolsEl = document.getElementById('skill-modal-tools');
28401
+ if (toolsEl && !toolsEl.value.trim()) toolsEl.value = (t.suggestedTools || []).join(', ');
28402
+ // Body fills only if empty — the actual rendered body comes from the
28403
+ // backend (with {{TITLE}} interpolation) when the user clicks Save
28404
+ // and we POST to /api/skills/from-template. We show a placeholder
28405
+ // here so the user sees that the body will come from the template.
28406
+ var bodyEl = document.getElementById('skill-modal-body');
28407
+ if (bodyEl && !bodyEl.value.trim()) {
28408
+ bodyEl.value = '# (Template "' + t.label + '" will fill in the body when you save.)\\n\\nFeel free to overwrite this — anything you type here replaces the template body.';
28409
+ }
28410
+ // Highlight the applied template button.
28411
+ var buttons = document.querySelectorAll('#skill-modal-templates button[data-tpl]');
28412
+ for (var i = 0; i < buttons.length; i++) {
28413
+ var btn = buttons[i];
28414
+ if (btn.getAttribute('data-tpl') === templateId) {
28415
+ btn.style.background = 'rgba(255,140,33,0.12)';
28416
+ btn.style.borderColor = 'var(--accent)';
28417
+ } else {
28418
+ btn.style.background = 'var(--bg-secondary)';
28419
+ btn.style.borderColor = 'var(--border)';
28420
+ }
28421
+ }
28422
+ var notice = document.getElementById('skill-modal-template-applied');
28423
+ if (notice) {
28424
+ notice.textContent = '✓ Template "' + t.label + '" applied. Body + tools pre-filled.';
28425
+ notice.style.display = '';
28426
+ }
28427
+ if (typeof updateSkillModalCounters === 'function') updateSkillModalCounters();
27712
28428
  }
27713
28429
 
27714
28430
  // 1.18.127 — live char/line counters under description + body. Color
@@ -27760,17 +28476,42 @@ async function saveSkillFromModal() {
27760
28476
  var tools = toolsRaw ? toolsRaw.split(',').map(function(s){ return s.trim(); }).filter(Boolean) : [];
27761
28477
  var saveBtn = document.getElementById('skill-modal-save');
27762
28478
  if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; }
28479
+ // 1.18.130 — when a template was applied AND the body is the placeholder
28480
+ // we set in applySkillTemplate, route through /api/skills/from-template
28481
+ // so the backend renders the template body with {{TITLE}} substitution
28482
+ // + ships any bundled files. If the user customized the body we honor
28483
+ // their version via the regular endpoint.
28484
+ var templateId = (document.getElementById('skill-modal-template-id')?.value || '').trim();
28485
+ var bodyIsTemplatePlaceholder = !originalName && templateId && body.indexOf('# (Template "') === 0;
27763
28486
  try {
27764
- var endpoint = originalName ? '/api/skills/' + encodeURIComponent(originalName) : '/api/skills';
27765
- var method = originalName ? 'PUT' : 'POST';
27766
- var r = await apiFetch(endpoint, {
27767
- method: method,
27768
- headers: { 'Content-Type': 'application/json' },
27769
- body: JSON.stringify({ name: name, title: title || undefined, description: desc, tools: tools, body: body }),
27770
- });
27771
- if (!r.ok) {
27772
- var d = await r.json().catch(function(){ return {}; });
27773
- return fail(d.error || ('Save failed: HTTP ' + r.status));
28487
+ var d;
28488
+ if (bodyIsTemplatePlaceholder) {
28489
+ var tr = await apiFetch('/api/skills/from-template', {
28490
+ method: 'POST',
28491
+ headers: { 'Content-Type': 'application/json' },
28492
+ body: JSON.stringify({ templateId: templateId, name: name, title: title || undefined, description: desc }),
28493
+ });
28494
+ if (!tr.ok) {
28495
+ d = await tr.json().catch(function(){ return {}; });
28496
+ return fail(d.error || ('Save failed: HTTP ' + tr.status));
28497
+ }
28498
+ // If template included a tools.allow but the user also typed tools,
28499
+ // patch with their explicit list (template's suggestedTools were
28500
+ // pre-filled into the input — overwrite is fine).
28501
+ // The from-template endpoint already wrote suggestedTools; we don't
28502
+ // re-PUT to merge unless the user actually edited the tools field.
28503
+ } else {
28504
+ var endpoint = originalName ? '/api/skills/' + encodeURIComponent(originalName) : '/api/skills';
28505
+ var method = originalName ? 'PUT' : 'POST';
28506
+ var r = await apiFetch(endpoint, {
28507
+ method: method,
28508
+ headers: { 'Content-Type': 'application/json' },
28509
+ body: JSON.stringify({ name: name, title: title || undefined, description: desc, tools: tools, body: body }),
28510
+ });
28511
+ if (!r.ok) {
28512
+ d = await r.json().catch(function(){ return {}; });
28513
+ return fail(d.error || ('Save failed: HTTP ' + r.status));
28514
+ }
27774
28515
  }
27775
28516
  closeSkillModal();
27776
28517
  toast(originalName ? 'Skill updated' : 'Skill created', 'success');
@@ -28522,7 +29263,11 @@ function renderSkillDetail(s) {
28522
29263
  // ── 8. Action footer (1.18.115) — Edit + Delete + Open file. The pane
28523
29264
  // was read-only; users had to leave the dashboard to edit anything.
28524
29265
  html += '<div style="margin-top:24px;display:flex;gap:8px;flex-wrap:wrap">';
28525
- html += '<button class="btn-primary" onclick="openEditSkillModal(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:12px;padding:6px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Edit skill</button>';
29266
+ // 1.18.130 "Open in builder" promoted to primary. The builder is the
29267
+ // bad-ass three-panel folder editor (file tree + editor + Tools/Skills
29268
+ // sidebar). Quick edit modal stays as the lighter alternative.
29269
+ html += '<button class="btn-primary" onclick="openSkillBuilder(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:12px;padding:6px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer" title="Open the full folder editor: file tree + Tools/Skills sidebar + live edit">⚡ Open in builder</button>';
29270
+ html += '<button class="btn-sm" onclick="openEditSkillModal(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:12px;padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer" title="Quick edit just the SKILL.md frontmatter + body">Quick edit</button>';
28526
29271
  html += '<button class="btn-sm" onclick="confirmDeleteSkill(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:12px;padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--red);cursor:pointer">Delete</button>';
28527
29272
  html += '</div>';
28528
29273
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.129",
3
+ "version": "1.18.131",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",