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.
- package/dist/agent/skill-templates.d.ts +55 -0
- package/dist/agent/skill-templates.js +220 -0
- package/dist/cli/dashboard.js +776 -31
- package/package.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill templates — 1.18.130 (Phase 2 / Skill Builder).
|
|
3
|
+
*
|
|
4
|
+
* Each template scaffolds a SKILL.md frontmatter + body skeleton plus
|
|
5
|
+
* (optionally) starter bundled files. Templates encode the five common
|
|
6
|
+
* skill archetypes the user pulled out of the Anthropic skills video:
|
|
7
|
+
*
|
|
8
|
+
* - **Orchestrator** — the meta-skill that routes work to other skills
|
|
9
|
+
* - **Scraper / Poller** — read external data, surface what's new
|
|
10
|
+
* - **Transformer** — mutate input → produce output (no side effects)
|
|
11
|
+
* - **Notifier** — send a message to Discord/Slack/email when X happens
|
|
12
|
+
* - **Conversational** — interactive multi-turn agent for one workflow
|
|
13
|
+
*
|
|
14
|
+
* The Builder asks the user to pick one when creating a new skill;
|
|
15
|
+
* the chosen template defines the starting body + suggested tools.allow.
|
|
16
|
+
* Authors edit from there. Templates aren't a runtime concept — they're
|
|
17
|
+
* just initial content the writeSkill() helper persists like any other
|
|
18
|
+
* skill.
|
|
19
|
+
*/
|
|
20
|
+
export interface SkillTemplate {
|
|
21
|
+
/** Stable id used by the picker. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** Display name shown in the picker. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** One-line "use when" hint for the picker subtitle. */
|
|
26
|
+
hint: string;
|
|
27
|
+
/** Emoji shown next to the label — gives the picker visual texture. */
|
|
28
|
+
emoji: string;
|
|
29
|
+
/** Initial frontmatter description filled into the create modal.
|
|
30
|
+
* User can override before save; serves as a writing prompt. */
|
|
31
|
+
defaultDescription: string;
|
|
32
|
+
/** Initial Markdown body. Should follow the Anthropic procedure
|
|
33
|
+
* shape (numbered steps, clear inputs/outputs section) so authors
|
|
34
|
+
* start from a good pattern instead of a blank page. */
|
|
35
|
+
body: string;
|
|
36
|
+
/** Suggested clementine.tools.allow allowlist. The Builder pre-fills
|
|
37
|
+
* the tools chip list so authors see "this archetype usually needs
|
|
38
|
+
* Read + Bash + memory_write" right away. */
|
|
39
|
+
suggestedTools: string[];
|
|
40
|
+
/** Optional bundled files to drop alongside SKILL.md. Each entry is
|
|
41
|
+
* written via the same writeSkill folder as the entry-point file.
|
|
42
|
+
* Example: an Orchestrator template ships a templates/output.md
|
|
43
|
+
* scaffold; a Scraper ships a scripts/fetch.py stub. */
|
|
44
|
+
bundledFiles?: Array<{
|
|
45
|
+
relPath: string;
|
|
46
|
+
content: string;
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
export declare const SKILL_TEMPLATES: SkillTemplate[];
|
|
50
|
+
/** Lookup by id. */
|
|
51
|
+
export declare function getSkillTemplate(id: string): SkillTemplate | null;
|
|
52
|
+
/** Apply a template to a skill name — substitutes \`{{TITLE}}\` placeholders
|
|
53
|
+
* in the body with the user's display title. */
|
|
54
|
+
export declare function renderTemplateBody(template: SkillTemplate, displayTitle: string): string;
|
|
55
|
+
//# sourceMappingURL=skill-templates.d.ts.map
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill templates — 1.18.130 (Phase 2 / Skill Builder).
|
|
3
|
+
*
|
|
4
|
+
* Each template scaffolds a SKILL.md frontmatter + body skeleton plus
|
|
5
|
+
* (optionally) starter bundled files. Templates encode the five common
|
|
6
|
+
* skill archetypes the user pulled out of the Anthropic skills video:
|
|
7
|
+
*
|
|
8
|
+
* - **Orchestrator** — the meta-skill that routes work to other skills
|
|
9
|
+
* - **Scraper / Poller** — read external data, surface what's new
|
|
10
|
+
* - **Transformer** — mutate input → produce output (no side effects)
|
|
11
|
+
* - **Notifier** — send a message to Discord/Slack/email when X happens
|
|
12
|
+
* - **Conversational** — interactive multi-turn agent for one workflow
|
|
13
|
+
*
|
|
14
|
+
* The Builder asks the user to pick one when creating a new skill;
|
|
15
|
+
* the chosen template defines the starting body + suggested tools.allow.
|
|
16
|
+
* Authors edit from there. Templates aren't a runtime concept — they're
|
|
17
|
+
* just initial content the writeSkill() helper persists like any other
|
|
18
|
+
* skill.
|
|
19
|
+
*/
|
|
20
|
+
export const SKILL_TEMPLATES = [
|
|
21
|
+
{
|
|
22
|
+
id: 'orchestrator',
|
|
23
|
+
label: 'Orchestrator',
|
|
24
|
+
hint: 'Routes work to other skills based on conditions. The "meta-skill" pattern from the Anthropic agent skills video.',
|
|
25
|
+
emoji: '🎯',
|
|
26
|
+
defaultDescription: 'Route incoming work through a sequence of decisions. For each item, pick the right downstream skill based on the conditions in the body.',
|
|
27
|
+
suggestedTools: ['Agent', 'Read'],
|
|
28
|
+
body: `# {{TITLE}}
|
|
29
|
+
|
|
30
|
+
This skill is an **orchestrator** — it doesn't do the work itself, it routes work to other skills based on conditions.
|
|
31
|
+
|
|
32
|
+
## Procedure
|
|
33
|
+
|
|
34
|
+
1. **Gather inputs.** State what you need to know to make routing decisions (e.g., query a data source, read state, check a flag).
|
|
35
|
+
|
|
36
|
+
2. **For each item, decide the path:**
|
|
37
|
+
- If <condition A> → invoke the **<skill-name-a>** skill with \`{key: value}\`
|
|
38
|
+
- If <condition B> → invoke the **<skill-name-b>** skill with \`{key: value}\`
|
|
39
|
+
- Otherwise → log to the daily note as "no match"
|
|
40
|
+
|
|
41
|
+
3. **Summarize.** Send a short Discord summary: how many items, which skills fired, any errors.
|
|
42
|
+
|
|
43
|
+
## Sub-skills referenced
|
|
44
|
+
|
|
45
|
+
- \`<skill-name-a>\` — describe what it does
|
|
46
|
+
- \`<skill-name-b>\` — describe what it does
|
|
47
|
+
|
|
48
|
+
## Tips for writing orchestrators
|
|
49
|
+
|
|
50
|
+
- Keep the routing logic in this body. Sub-skills should be pure functions that don't know they're being called from here.
|
|
51
|
+
- Pin every sub-skill to the same scheduled task so their bodies are loaded into context together.
|
|
52
|
+
- The Agent tool is what dispatches sub-skills — keep it in your tools.allow.
|
|
53
|
+
`,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'scraper',
|
|
57
|
+
label: 'Scraper / Poller',
|
|
58
|
+
hint: 'Read an external source, surface what is new since the last run.',
|
|
59
|
+
emoji: '🔍',
|
|
60
|
+
defaultDescription: 'Poll an external source on a schedule and surface what is new since the last run. Tracks state via the cron progress mechanism so it does not re-process old items.',
|
|
61
|
+
suggestedTools: ['Read', 'WebFetch', 'cron_progress_read', 'cron_progress_write'],
|
|
62
|
+
body: `# {{TITLE}}
|
|
63
|
+
|
|
64
|
+
Read an external data source on a schedule, surface only what's changed since the last run.
|
|
65
|
+
|
|
66
|
+
## Procedure
|
|
67
|
+
|
|
68
|
+
1. **Read state.** \`cron_progress_read\` to get \`{processed_ids: [...]}\` (default \`[]\`).
|
|
69
|
+
|
|
70
|
+
2. **Query the source.** Describe exactly what you fetch (URL, MCP tool call, file path).
|
|
71
|
+
|
|
72
|
+
3. **Filter to new items.** Compare each item's id against \`processed_ids\`. Skip ones already seen.
|
|
73
|
+
|
|
74
|
+
4. **For each new item:**
|
|
75
|
+
- <do the thing>
|
|
76
|
+
|
|
77
|
+
5. **Persist state.** \`cron_progress_write({processed_ids: [...]})\` with the union of old + new ids.
|
|
78
|
+
|
|
79
|
+
6. **Output.** If nothing was new, output \`__NOTHING__\`. Otherwise summarize what was processed.
|
|
80
|
+
|
|
81
|
+
## Inputs
|
|
82
|
+
|
|
83
|
+
- (declare any \`clementine.inputs\` parameters here so the agent reads them as inputs)
|
|
84
|
+
`,
|
|
85
|
+
bundledFiles: [
|
|
86
|
+
{
|
|
87
|
+
relPath: 'references/state-shape.md',
|
|
88
|
+
content: `# State shape\\n\\nThis skill reads/writes its state via \`cron_progress_*\`. Document the shape here so future-you remembers what each key means.\\n\\n\\\`\\\`\\\`json\\n{\\n "processed_ids": ["id1", "id2"],\\n "last_run_summary": "..."\\n}\\n\\\`\\\`\\\`\\n`,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'transformer',
|
|
94
|
+
label: 'Transformer',
|
|
95
|
+
hint: 'Take input → produce output. Pure function, no side effects.',
|
|
96
|
+
emoji: '⚙',
|
|
97
|
+
defaultDescription: 'Pure transformation: receives input, produces output. No external side effects. Useful as a sub-skill called by an orchestrator.',
|
|
98
|
+
suggestedTools: ['Read', 'Write'],
|
|
99
|
+
body: `# {{TITLE}}
|
|
100
|
+
|
|
101
|
+
Transform input into output. No side effects — safe to call from any context.
|
|
102
|
+
|
|
103
|
+
## Inputs
|
|
104
|
+
|
|
105
|
+
Declare in \`clementine.inputs\`:
|
|
106
|
+
|
|
107
|
+
\\\`\\\`\\\`yaml
|
|
108
|
+
clementine:
|
|
109
|
+
inputs:
|
|
110
|
+
source_text:
|
|
111
|
+
type: string
|
|
112
|
+
description: The raw text to transform
|
|
113
|
+
required: true
|
|
114
|
+
style:
|
|
115
|
+
type: string
|
|
116
|
+
enum: [casual, formal, urgent]
|
|
117
|
+
default: casual
|
|
118
|
+
\\\`\\\`\\\`
|
|
119
|
+
|
|
120
|
+
## Procedure
|
|
121
|
+
|
|
122
|
+
1. **Read inputs.** All inputs are interpolated as \`{{ input_name }}\`.
|
|
123
|
+
|
|
124
|
+
2. **Transform.** Describe the transformation step by step.
|
|
125
|
+
|
|
126
|
+
3. **Output.** Return the transformed text only — no commentary, no narration.
|
|
127
|
+
|
|
128
|
+
## Output shape
|
|
129
|
+
|
|
130
|
+
\\\`\\\`\\\`
|
|
131
|
+
<one-line summary>
|
|
132
|
+
|
|
133
|
+
<transformed body>
|
|
134
|
+
\\\`\\\`\\\`
|
|
135
|
+
`,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'notifier',
|
|
139
|
+
label: 'Notifier',
|
|
140
|
+
hint: 'Send a message somewhere when a condition fires.',
|
|
141
|
+
emoji: '📣',
|
|
142
|
+
defaultDescription: 'Send a message to Discord/Slack/email/SMS when a condition is met. Composes well with a Scraper that detects "something changed."',
|
|
143
|
+
suggestedTools: ['Read', 'discord_channel_send', 'slack_post_message'],
|
|
144
|
+
body: `# {{TITLE}}
|
|
145
|
+
|
|
146
|
+
Notify a destination when a condition fires.
|
|
147
|
+
|
|
148
|
+
## Procedure
|
|
149
|
+
|
|
150
|
+
1. **Check the condition.** Describe what triggers the notification (e.g., new audit row, daily digest ready, alert threshold crossed).
|
|
151
|
+
|
|
152
|
+
2. **Build the message.** Keep it short. Include:
|
|
153
|
+
- What happened
|
|
154
|
+
- Why it matters (one line)
|
|
155
|
+
- Action the recipient should take, if any
|
|
156
|
+
|
|
157
|
+
3. **Send via the right channel:**
|
|
158
|
+
- Use \`discord_channel_send\` for the team / yourself
|
|
159
|
+
- Use \`slack_post_message\` if the recipient prefers Slack
|
|
160
|
+
- Use \`mcp__gmail__send\` for external recipients
|
|
161
|
+
|
|
162
|
+
4. **Confirm delivery.** Output a one-liner like "Sent to #ops-alerts at 09:14."
|
|
163
|
+
|
|
164
|
+
## Inputs
|
|
165
|
+
|
|
166
|
+
\\\`\\\`\\\`yaml
|
|
167
|
+
clementine:
|
|
168
|
+
inputs:
|
|
169
|
+
destination:
|
|
170
|
+
type: string
|
|
171
|
+
enum: [discord-ops, slack-team, email-owner]
|
|
172
|
+
default: discord-ops
|
|
173
|
+
severity:
|
|
174
|
+
type: string
|
|
175
|
+
enum: [info, warning, urgent]
|
|
176
|
+
default: info
|
|
177
|
+
\\\`\\\`\\\`
|
|
178
|
+
`,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'conversational',
|
|
182
|
+
label: 'Conversational',
|
|
183
|
+
hint: 'Multi-turn interactive agent for one specific workflow.',
|
|
184
|
+
emoji: '💬',
|
|
185
|
+
defaultDescription: 'Multi-turn conversational agent for a specific workflow (e.g., onboarding a new client, debugging a system). Maintains a focused context across the conversation.',
|
|
186
|
+
suggestedTools: ['Read', 'memory_read', 'memory_write', 'note_create'],
|
|
187
|
+
body: `# {{TITLE}}
|
|
188
|
+
|
|
189
|
+
Run a focused multi-turn conversation. The user comes to you with a specific goal; you guide them step by step until it's done.
|
|
190
|
+
|
|
191
|
+
## Procedure
|
|
192
|
+
|
|
193
|
+
1. **Open with intent confirmation.** "Sounds like you want to do X. Is that right?" Don't start tools until they confirm.
|
|
194
|
+
|
|
195
|
+
2. **Gather what you need.** Ask one question at a time, not a wall.
|
|
196
|
+
|
|
197
|
+
3. **Use tools sparingly.** Each tool call should be obvious and explainable in the next turn.
|
|
198
|
+
|
|
199
|
+
4. **Save what you learn.** Use \`memory_write\` for facts that should persist beyond this conversation.
|
|
200
|
+
|
|
201
|
+
5. **Close cleanly.** When done, summarize what was accomplished + any next steps for the user.
|
|
202
|
+
|
|
203
|
+
## Conversation principles
|
|
204
|
+
|
|
205
|
+
- Match the user's tone (casual / formal / urgent — read their first message)
|
|
206
|
+
- Never start a multi-step process without checking in first
|
|
207
|
+
- If the user changes direction mid-conversation, acknowledge + pivot — don't pretend they asked for what you started
|
|
208
|
+
`,
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
/** Lookup by id. */
|
|
212
|
+
export function getSkillTemplate(id) {
|
|
213
|
+
return SKILL_TEMPLATES.find((t) => t.id === id) ?? null;
|
|
214
|
+
}
|
|
215
|
+
/** Apply a template to a skill name — substitutes \`{{TITLE}}\` placeholders
|
|
216
|
+
* in the body with the user's display title. */
|
|
217
|
+
export function renderTemplateBody(template, displayTitle) {
|
|
218
|
+
return template.body.replace(/\{\{TITLE\}\}/g, displayTitle);
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=skill-templates.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
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
|
-
//
|
|
10499
|
-
//
|
|
10500
|
-
|
|
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
|
|
27765
|
-
|
|
27766
|
-
|
|
27767
|
-
|
|
27768
|
-
|
|
27769
|
-
|
|
27770
|
-
|
|
27771
|
-
|
|
27772
|
-
|
|
27773
|
-
|
|
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
|
-
|
|
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
|
|