clementine-agent 1.18.123 → 1.18.124

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.
@@ -29,10 +29,10 @@ export declare function extractSkill(assistant: PersonalAssistant, context: {
29
29
  durationMs: number;
30
30
  }): Promise<SkillDocument | null>;
31
31
  /** Move a pending skill to the active skills directory. */
32
- export declare function approvePendingSkill(name: string): {
32
+ export declare function approvePendingSkill(name: string): Promise<{
33
33
  ok: boolean;
34
34
  message: string;
35
- };
35
+ }>;
36
36
  /** Delete a pending skill (reject it). */
37
37
  export declare function rejectPendingSkill(name: string): {
38
38
  ok: boolean;
@@ -121,43 +121,44 @@ function savePendingSkill(skill) {
121
121
  logger.info({ name: skill.name, source: skill.source }, 'Skill queued for approval');
122
122
  }
123
123
  /** Save an approved skill as a formatted markdown file. Agent-scoped if agentSlug set. */
124
- function saveActiveSkill(skill) {
124
+ // 1.18.124 — saveActiveSkill is now a thin wrapper around the shared
125
+ // writeSkill helper. Auto-extracted skills used to land as legacy flat-
126
+ // form (`<name>.md` with top-level triggers/toolsUsed/source) which the
127
+ // Skills page tagged with the orange "LEGACY" badge — confusing on a
128
+ // fresh install. Now they write the same Anthropic-canonical folder
129
+ // form the dashboard + chat paths produce.
130
+ //
131
+ // Also drops the per-overwrite `.md.bak` leak — that was paranoia
132
+ // before the skill catalog had proper provenance. The pending file
133
+ // in PENDING_SKILLS_DIR is the rollback artifact.
134
+ async function saveActiveSkill(skill) {
125
135
  ensureDirs();
126
- const targetDir = skill.agentSlug ? agentSkillsDir(skill.agentSlug) : GLOBAL_SKILLS_DIR;
127
- if (!existsSync(targetDir))
128
- mkdirSync(targetDir, { recursive: true });
129
- // gray-matter's YAML dumper throws on undefined values — omit them
130
- const frontmatter = {
136
+ const { writeSkill } = await import('./skill-store.js');
137
+ // Map SkillDocument's source enum (which still has 'unleashed'/'cron'
138
+ // for back-compat with old pending-skill JSONs on disk) to the
139
+ // writeSkill source enum.
140
+ const writeSource = skill.source === 'unleashed' || skill.source === 'cron' ? 'auto'
141
+ : skill.source === 'chat' ? 'chat'
142
+ : 'manual';
143
+ const result = writeSkill({
144
+ name: skill.name,
131
145
  title: skill.title,
132
146
  description: skill.description,
147
+ // Procedure body only — title/description live in frontmatter
148
+ // under the new schema, no need to repeat them inside the body.
149
+ body: skill.steps,
150
+ tools: skill.toolsUsed,
133
151
  triggers: skill.triggers,
134
- source: skill.source,
135
- toolsUsed: skill.toolsUsed,
136
- useCount: skill.useCount,
137
- createdAt: skill.createdAt,
138
- updatedAt: skill.updatedAt,
139
- };
140
- if (skill.sourceJob)
141
- frontmatter.sourceJob = skill.sourceJob;
142
- if (skill.agentSlug)
143
- frontmatter.agentSlug = skill.agentSlug;
144
- if (skill.lastUsed)
145
- frontmatter.lastUsed = skill.lastUsed;
146
- const content = matter.stringify(`\n# ${skill.title}\n\n${skill.description}\n\n## Procedure\n\n${skill.steps}\n`, frontmatter);
147
- const filePath = path.join(targetDir, `${skill.name}.md`);
148
- // Backup existing before overwrite
149
- if (existsSync(filePath)) {
150
- try {
151
- copyFileSync(filePath, filePath.replace(/\.md$/, '.md.bak'));
152
- }
153
- catch { /* best-effort */ }
154
- }
155
- writeFileSync(filePath, content);
156
- logger.info({ name: skill.name, source: skill.source, agentSlug: skill.agentSlug ?? 'global' }, 'Skill saved');
152
+ agentSlug: skill.agentSlug,
153
+ source: writeSource,
154
+ sourceJob: skill.sourceJob,
155
+ overwrite: true, // approve / merge always replaces in-place
156
+ });
157
+ logger.info({ name: skill.name, source: skill.source, agentSlug: skill.agentSlug ?? 'global', filePath: result.filePath, overwrote: result.overwrote }, 'Skill saved');
157
158
  }
158
159
  // ── Pending Skill Management ────────────────────────────────────────
159
160
  /** Move a pending skill to the active skills directory. */
160
- export function approvePendingSkill(name) {
161
+ export async function approvePendingSkill(name) {
161
162
  ensureDirs();
162
163
  const pendingFile = path.join(PENDING_SKILLS_DIR, `${name}.json`);
163
164
  if (!existsSync(pendingFile)) {
@@ -166,7 +167,7 @@ export function approvePendingSkill(name) {
166
167
  try {
167
168
  const skill = JSON.parse(readFileSync(pendingFile, 'utf-8'));
168
169
  skill.updatedAt = new Date().toISOString();
169
- saveActiveSkill(skill);
170
+ await saveActiveSkill(skill);
170
171
  unlinkSync(pendingFile);
171
172
  logger.info({ name }, 'Pending skill approved and activated');
172
173
  return { ok: true, message: `Skill **${skill.title}** is now active${skill.agentSlug ? ` for ${skill.agentSlug}` : ' (global)'}.` };
@@ -308,7 +309,7 @@ async function mergeSkill(assistant, existing, incoming) {
308
309
  updatedAt: new Date().toISOString(),
309
310
  };
310
311
  // Merges go directly to active (existing skill was already approved)
311
- saveActiveSkill(merged);
312
+ await saveActiveSkill(merged);
312
313
  logger.info({ name: merged.name }, 'Skill merged and updated');
313
314
  return merged;
314
315
  }
@@ -77,5 +77,38 @@ export declare function migrateAllLegacySkills(): {
77
77
  migrated: MigrationResult[];
78
78
  skipped: MigrationResult[];
79
79
  };
80
+ export interface WriteSkillInput {
81
+ /** Slug (lowercase letters/digits/dashes, ≤64 chars, Anthropic regex). */
82
+ name: string;
83
+ /** Human-readable display name. Optional. */
84
+ title?: string;
85
+ /** One-paragraph "what does this do, when should Claude run it" — required by spec. */
86
+ description: string;
87
+ /** Procedure body (Markdown). Required. */
88
+ body: string;
89
+ /** Where the skill came from. Drives lifecycle metadata + dashboard badge. */
90
+ source: 'manual' | 'chat' | 'auto' | 'imported';
91
+ /** Optional tool allowlist — stored under clementine.tools.allow. */
92
+ tools?: string[];
93
+ /** Optional NLP trigger phrases for auto-match — stored under clementine.triggers. */
94
+ triggers?: string[];
95
+ /** Optional agent scope. When set, writes to <agentsDir>/<slug>/skills/
96
+ * instead of the global skills dir. Used by auto-extraction so each
97
+ * hired agent's skills stay isolated by default. */
98
+ agentSlug?: string;
99
+ /** When true, allow overwriting an existing skill (used by update flows). */
100
+ overwrite?: boolean;
101
+ /** Optional source-job tag (auto-extraction provenance). */
102
+ sourceJob?: string;
103
+ }
104
+ export interface WriteSkillResult {
105
+ /** Absolute path to the written SKILL.md. */
106
+ filePath: string;
107
+ /** Slug (matches input name). */
108
+ name: string;
109
+ /** Whether an existing skill was overwritten. */
110
+ overwrote: boolean;
111
+ }
112
+ export declare function writeSkill(input: WriteSkillInput): WriteSkillResult;
80
113
  export {};
81
114
  //# sourceMappingURL=skill-store.d.ts.map
@@ -647,4 +647,70 @@ export function migrateAllLegacySkills() {
647
647
  }
648
648
  return { migrated, skipped };
649
649
  }
650
+ export function writeSkill(input) {
651
+ // Validate name per Anthropic spec — single guard for every caller.
652
+ if (!input.name || !NAME_PATTERN.test(input.name)) {
653
+ throw new Error('writeSkill: name must match ^[a-z0-9][a-z0-9-]{0,63}$');
654
+ }
655
+ if (input.name.length > NAME_MAX_LEN) {
656
+ throw new Error(`writeSkill: name exceeds ${NAME_MAX_LEN} chars`);
657
+ }
658
+ if (RESERVED_NAMES.has(input.name) || /\b(anthropic|claude)\b/i.test(input.name)) {
659
+ throw new Error(`writeSkill: name uses a reserved word`);
660
+ }
661
+ if (!input.description || !input.description.trim()) {
662
+ throw new Error('writeSkill: description is required');
663
+ }
664
+ if (input.description.length > DESCRIPTION_MAX_LEN) {
665
+ throw new Error(`writeSkill: description exceeds ${DESCRIPTION_MAX_LEN} chars`);
666
+ }
667
+ if (!input.body || !input.body.trim()) {
668
+ throw new Error('writeSkill: body is required');
669
+ }
670
+ // Resolve target directory. Agent-scoped writes land under the agent's
671
+ // skills folder so each hired agent's skill set is independent.
672
+ const base = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
673
+ const targetDir = input.agentSlug
674
+ ? path.join(base, 'vault', '00-System', 'agents', input.agentSlug, 'skills')
675
+ : globalSkillsDir();
676
+ if (!existsSync(targetDir))
677
+ mkdirSync(targetDir, { recursive: true });
678
+ const folderPath = path.join(targetDir, input.name);
679
+ const entryPath = path.join(folderPath, 'SKILL.md');
680
+ const existed = existsSync(entryPath);
681
+ if (existed && !input.overwrite) {
682
+ throw new Error(`writeSkill: skill "${input.name}" already exists`);
683
+ }
684
+ mkdirSync(folderPath, { recursive: true });
685
+ // Build the frontmatter. Anthropic-canonical fields (name, description)
686
+ // top-level. Everything else under clementine:. The lifecycle metadata
687
+ // (createdAt / updatedAt / version) keeps the Skills page detail pane
688
+ // accurate without authors having to remember to set it by hand.
689
+ const now = new Date().toISOString();
690
+ const fm = {
691
+ name: input.name,
692
+ description: input.description.trim(),
693
+ };
694
+ if (input.title && input.title.trim())
695
+ fm.title = input.title.trim();
696
+ const ext = {
697
+ source: input.source,
698
+ useCount: 0,
699
+ createdAt: now,
700
+ updatedAt: now,
701
+ version: 1,
702
+ };
703
+ if (input.tools && input.tools.length > 0) {
704
+ ext.tools = { allow: input.tools.map(String).map(s => s.trim()).filter(Boolean) };
705
+ }
706
+ if (input.triggers && input.triggers.length > 0) {
707
+ ext.triggers = input.triggers.map(String).map(s => s.trim()).filter(Boolean);
708
+ }
709
+ if (input.sourceJob)
710
+ ext.sourceJob = input.sourceJob;
711
+ fm.clementine = ext;
712
+ const content = matter.stringify(input.body.endsWith('\n') ? input.body : input.body + '\n', fm);
713
+ writeFileSync(entryPath, content);
714
+ return { filePath: entryPath, name: input.name, overwrote: existed };
715
+ }
650
716
  //# sourceMappingURL=skill-store.js.map
@@ -10293,7 +10293,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
10293
10293
  app.post('/api/skills/pending/:name/approve', async (req, res) => {
10294
10294
  try {
10295
10295
  const { approvePendingSkill } = await import('../agent/skill-extractor.js');
10296
- const result = approvePendingSkill(req.params.name);
10296
+ const result = await approvePendingSkill(req.params.name);
10297
10297
  if (!result.ok) {
10298
10298
  res.status(404).json(result);
10299
10299
  return;
@@ -10318,63 +10318,43 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
10318
10318
  res.status(500).json({ error: String(err) });
10319
10319
  }
10320
10320
  });
10321
- // POST /api/skills — create a new folder-form skill (1.18.115).
10322
- // Anthropic-compatible: top-level `name` + `description` required; the
10323
- // optional `tools` array goes under `clementine.tools.allow` so the
10324
- // frontmatter parses cleanly in vanilla Claude Agent SDK while still
10325
- // letting the cron runtime enforce the allowlist.
10326
- app.post('/api/skills', (req, res) => {
10321
+ // POST /api/skills — create a new folder-form skill via the shared
10322
+ // writeSkill helper (1.18.124). All three skill-creation paths
10323
+ // (dashboard, MCP create_skill, auto-extraction) flow through the
10324
+ // same write-once-validate-once code; this route is now a thin
10325
+ // adapter over the helper.
10326
+ app.post('/api/skills', async (req, res) => {
10327
10327
  try {
10328
10328
  const { name, title, description, body, tools } = req.body ?? {};
10329
- if (!name || typeof name !== 'string') {
10330
- res.status(400).json({ error: 'name is required' });
10331
- return;
10332
- }
10333
- if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
10334
- res.status(400).json({ error: 'name must match ^[a-z0-9][a-z0-9-]{0,63}$ (Anthropic spec)' });
10335
- return;
10336
- }
10337
- if (!description || typeof description !== 'string') {
10338
- res.status(400).json({ error: 'description is required' });
10339
- return;
10340
- }
10341
- if (description.length > 1024) {
10342
- res.status(400).json({ error: 'description must be ≤ 1024 chars (Anthropic spec)' });
10329
+ if (typeof name !== 'string' || typeof description !== 'string' || typeof body !== 'string') {
10330
+ res.status(400).json({ error: 'name, description, and body are required strings' });
10343
10331
  return;
10344
10332
  }
10345
- if (!body || typeof body !== 'string' || !body.trim()) {
10346
- res.status(400).json({ error: 'body is required' });
10347
- return;
10333
+ const { writeSkill } = await import('../agent/skill-store.js');
10334
+ try {
10335
+ const result = writeSkill({
10336
+ name,
10337
+ title: typeof title === 'string' ? title : undefined,
10338
+ description,
10339
+ body,
10340
+ tools: Array.isArray(tools) ? tools.map(String) : undefined,
10341
+ source: 'manual',
10342
+ });
10343
+ res.json({ ok: true, name: result.name, layout: 'folder', filePath: result.filePath });
10348
10344
  }
10349
- const skillsDir = path.join(VAULT_DIR, '00-System', 'skills');
10350
- if (!existsSync(skillsDir))
10351
- mkdirSync(skillsDir, { recursive: true });
10352
- const folderPath = path.join(skillsDir, name);
10353
- const entryPath = path.join(folderPath, 'SKILL.md');
10354
- if (existsSync(entryPath)) {
10355
- res.status(409).json({ error: 'Skill "' + name + '" already exists. Use PUT to update.' });
10356
- return;
10345
+ catch (err) {
10346
+ // writeSkill throws synchronously with a readable message for
10347
+ // every validation failure (name regex, description length,
10348
+ // already-exists, etc.). Surface them as 4xx; only unexpected
10349
+ // I/O errors hit the outer 500 path below.
10350
+ const msg = err instanceof Error ? err.message : String(err);
10351
+ if (msg.includes('already exists')) {
10352
+ res.status(409).json({ error: msg });
10353
+ }
10354
+ else {
10355
+ res.status(400).json({ error: msg });
10356
+ }
10357
10357
  }
10358
- mkdirSync(folderPath, { recursive: true });
10359
- const now = new Date().toISOString();
10360
- const fm = { name, description };
10361
- if (title && typeof title === 'string' && title.trim())
10362
- fm.title = title.trim();
10363
- const allowed = Array.isArray(tools) ? tools.map(String).map(s => s.trim()).filter(Boolean) : [];
10364
- const clementineExt = {
10365
- source: 'manual',
10366
- useCount: 0,
10367
- createdAt: now,
10368
- updatedAt: now,
10369
- version: 1,
10370
- };
10371
- if (allowed.length > 0)
10372
- clementineExt.tools = { allow: allowed };
10373
- fm.clementine = clementineExt;
10374
- const matterMod = require('gray-matter');
10375
- const content = matterMod.stringify(body.endsWith('\n') ? body : body + '\n', fm);
10376
- writeFileSync(entryPath, content);
10377
- res.json({ ok: true, name, layout: 'folder', filePath: entryPath });
10378
10358
  }
10379
10359
  catch (err) {
10380
10360
  res.status(500).json({ error: String(err) });
package/dist/cli/index.js CHANGED
@@ -2832,7 +2832,7 @@ skillsCmd
2832
2832
  try {
2833
2833
  process.env.CLEMENTINE_HOME = BASE_DIR;
2834
2834
  const { approvePendingSkill } = await import('../agent/skill-extractor.js');
2835
- const result = approvePendingSkill(name);
2835
+ const result = await approvePendingSkill(name);
2836
2836
  if (result.ok) {
2837
2837
  console.log(` ${GREEN}✓${RESET} ${result.message}`);
2838
2838
  }
@@ -1068,7 +1068,7 @@ export class Gateway {
1068
1068
  case 'approve': {
1069
1069
  if (!args?.name)
1070
1070
  return 'Missing skill name.';
1071
- const result = approvePendingSkill(args.name);
1071
+ const result = await approvePendingSkill(args.name);
1072
1072
  return result.message;
1073
1073
  }
1074
1074
  case 'reject': {
@@ -17,16 +17,16 @@
17
17
  * description, body presence) is enforced by both the dashboard endpoint
18
18
  * and these tools, so you can't smuggle a bad skill through the chat path.
19
19
  */
20
- import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
21
  import path from 'node:path';
22
22
  import { z } from 'zod';
23
23
  import matter from 'gray-matter';
24
24
  import { VAULT_DIR, textResult, logger } from './shared.js';
25
- // Anthropic spec keep these in sync with skill-store.validateSkill.
25
+ // 1.18.124name regex is the only validator skill-tools still uses
26
+ // directly (for update_skill's pre-flight slug check). All other
27
+ // validations + the file write live in skill-store.writeSkill.
26
28
  const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
27
- const NAME_MAX_LEN = 64;
28
29
  const DESCRIPTION_MAX_LEN = 1024;
29
- const RESERVED_NAMES = new Set(['anthropic', 'claude']);
30
30
  function globalSkillsDir() {
31
31
  return path.join(VAULT_DIR, '00-System', 'skills');
32
32
  }
@@ -50,65 +50,37 @@ export function registerSkillTools(server) {
50
50
  triggers: z.array(z.string()).optional()
51
51
  .describe('Optional natural-language phrases that should auto-match this skill at runtime (e.g. ["morning deal review", "check deals today"]). Stored under clementine.triggers. Pinned skills don\'t need triggers — they fire by name.'),
52
52
  }, async ({ name, title, description, body, tools, triggers }) => {
53
- // Validate per Anthropic spec
54
- if (!NAME_PATTERN.test(name)) {
55
- return textResult(`❌ Name "${name}" doesn't match the spec. Use lowercase letters, digits, and dashes only — must start with a letter or digit, max 64 chars.`);
56
- }
57
- if (name.length > NAME_MAX_LEN) {
58
- return textResult(`❌ Name is too long (${name.length} chars). Max is ${NAME_MAX_LEN}.`);
59
- }
60
- if (RESERVED_NAMES.has(name) || /\b(anthropic|claude)\b/i.test(name)) {
61
- return textResult(`❌ Name "${name}" uses a reserved word ("anthropic" or "claude"). Pick another.`);
62
- }
63
- if (!description || !description.trim()) {
64
- return textResult('❌ Description is required — Claude uses it to decide when to apply this skill.');
65
- }
66
- if (description.length > DESCRIPTION_MAX_LEN) {
67
- return textResult(`❌ Description is too long (${description.length} chars). Max is ${DESCRIPTION_MAX_LEN}.`);
68
- }
69
- if (!body || !body.trim()) {
70
- return textResult('❌ Procedure body is required — that\'s what Claude actually runs.');
71
- }
72
- const skillsDir = globalSkillsDir();
73
- const folderPath = path.join(skillsDir, name);
74
- const entryPath = path.join(folderPath, 'SKILL.md');
75
- if (existsSync(entryPath)) {
76
- return textResult(`❌ Skill "${name}" already exists at ${entryPath}. Use update_skill instead, or pick a different name.`);
77
- }
53
+ // 1.18.124 delegate to the shared writeSkill helper. Validation
54
+ // (name regex, length caps, reserved words, already-exists) is
55
+ // now centralized; the same checks run for the dashboard endpoint
56
+ // and the auto-extraction path.
78
57
  try {
79
- mkdirSync(folderPath, { recursive: true });
80
- const now = new Date().toISOString();
81
- const fm = { name, description };
82
- if (title && title.trim())
83
- fm.title = title.trim();
84
- const clementineExt = {
58
+ const { writeSkill } = await import('../agent/skill-store.js');
59
+ const result = writeSkill({
60
+ name,
61
+ title,
62
+ description,
63
+ body,
64
+ tools,
65
+ triggers,
85
66
  source: 'chat',
86
- useCount: 0,
87
- createdAt: now,
88
- updatedAt: now,
89
- version: 1,
90
- };
91
- if (Array.isArray(tools) && tools.length > 0) {
92
- clementineExt.tools = { allow: tools.map(String).map(s => s.trim()).filter(Boolean) };
93
- }
94
- if (Array.isArray(triggers) && triggers.length > 0) {
95
- clementineExt.triggers = triggers.map(String).map(s => s.trim()).filter(Boolean);
96
- }
97
- fm.clementine = clementineExt;
98
- const content = matter.stringify(body.endsWith('\n') ? body : body + '\n', fm);
99
- writeFileSync(entryPath, content);
100
- logger.info({ name, entryPath, source: 'chat' }, 'Skill created via chat');
67
+ });
68
+ logger.info({ name: result.name, entryPath: result.filePath, source: 'chat' }, 'Skill created via chat');
101
69
  const toolsLine = (tools && tools.length > 0) ? `\nAllowed tools: ${tools.slice(0, 5).join(', ')}${tools.length > 5 ? `, +${tools.length - 5} more` : ''}` : '';
102
70
  const triggersLine = (triggers && triggers.length > 0) ? `\nTriggers: ${triggers.slice(0, 4).join(', ')}${triggers.length > 4 ? `, +${triggers.length - 4} more` : ''}` : '';
103
- return textResult(`✅ Created skill "${name}" at ${entryPath}\n` +
71
+ return textResult(`✅ Created skill "${result.name}" at ${result.filePath}\n` +
104
72
  `Description: ${description.slice(0, 200)}${description.length > 200 ? '…' : ''}` +
105
73
  toolsLine +
106
74
  triggersLine +
107
- `\n\nThe skill is ready to pin to any task — open the cron editor, go to Tools & MCP, click "+ Add skill" and select "${name}". Or invoke it directly in chat: "Run the ${title || name} skill."`);
75
+ `\n\nThe skill is ready to pin to any task — open the cron editor, go to Tools & MCP, click "+ Add skill" and select "${result.name}". Or invoke it directly in chat: "Run the ${title || result.name} skill."`);
108
76
  }
109
77
  catch (err) {
78
+ const msg = err instanceof Error ? err.message : String(err);
79
+ if (msg.includes('already exists')) {
80
+ return textResult(`❌ Skill "${name}" already exists. Use update_skill instead, or pick a different name.`);
81
+ }
110
82
  logger.error({ err, name }, 'create_skill failed');
111
- return textResult(`❌ Failed to write the skill: ${err instanceof Error ? err.message : String(err)}`);
83
+ return textResult(`❌ ${msg}`);
112
84
  }
113
85
  });
114
86
  // ── update_skill ────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.123",
3
+ "version": "1.18.124",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",