clementine-agent 1.18.109 → 1.18.110

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.
@@ -57,6 +57,26 @@ export declare function getSkill(name: string, opts?: ListSkillsOptions): Skill
57
57
  * Defends against directory traversal: rejects paths that escape the
58
58
  * skill folder. */
59
59
  export declare function readBundledFile(skill: Skill, relPath: string): string | null;
60
+ export interface MigrationResult {
61
+ ok: boolean;
62
+ /** Path to the new SKILL.md created. Undefined when ok=false. */
63
+ newSkillPath?: string;
64
+ /** Path the original .md was moved to (.bak). Undefined when ok=false. */
65
+ backupPath?: string;
66
+ /** Set when ok=false — what went wrong. */
67
+ error?: string;
68
+ /** Set when the loader found nothing to migrate (already folder-form). */
69
+ alreadyMigrated?: boolean;
70
+ }
71
+ /** Migrate one legacy flat skill to Anthropic-compatible folder form.
72
+ * Idempotent: a folder-form skill is reported as alreadyMigrated. */
73
+ export declare function migrateLegacySkill(name: string): MigrationResult;
74
+ /** Migrate every legacy skill in the global pool. Returns per-skill
75
+ * results so the dashboard can render a summary banner. */
76
+ export declare function migrateAllLegacySkills(): {
77
+ migrated: MigrationResult[];
78
+ skipped: MigrationResult[];
79
+ };
60
80
  /** Diagnostics for the dashboard — expose where the loader looked. */
61
81
  export declare function _skillDirsForDiagnostics(workDir?: string): {
62
82
  global: string;
@@ -21,7 +21,7 @@
21
21
  * runner. Phase C wires runtime invocation. Phase E migrates legacy
22
22
  * crons → folder-form skills.
23
23
  */
24
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
25
25
  import os from 'node:os';
26
26
  import path from 'node:path';
27
27
  import matter from 'gray-matter';
@@ -161,6 +161,17 @@ function coerceClementineExtensions(raw) {
161
161
  out.lastUsed = r.lastUsed;
162
162
  if (typeof r.lastTestPass === 'string')
163
163
  out.lastTestPass = r.lastTestPass;
164
+ // Legacy Clementine concepts preserved through migration.
165
+ if (Array.isArray(r.triggers))
166
+ out.triggers = r.triggers.map(String);
167
+ if (typeof r.source === 'string')
168
+ out.source = r.source;
169
+ if (typeof r.useCount === 'number')
170
+ out.useCount = r.useCount;
171
+ if (typeof r.migratedFrom === 'string')
172
+ out.migratedFrom = r.migratedFrom;
173
+ if (typeof r.migratedAt === 'string')
174
+ out.migratedAt = r.migratedAt;
164
175
  return Object.keys(out).length > 0 ? out : undefined;
165
176
  }
166
177
  /** Coerce raw YAML object → SkillFrontmatter. Filename always wins for
@@ -471,6 +482,171 @@ export function readBundledFile(skill, relPath) {
471
482
  return null;
472
483
  }
473
484
  }
485
+ // ── Legacy → folder-form migration ───────────────────────────────────
486
+ //
487
+ // Converts a flat-form legacy skill (`<name>.md` with title/triggers/
488
+ // toolsUsed/useCount frontmatter) into Anthropic-compatible folder form
489
+ // (`<name>/SKILL.md` with name+description top-level + clementine: namespace
490
+ // for the migration metadata).
491
+ //
492
+ // Preserves everything:
493
+ // - title (legacy display name) → moves to top-level `title` (still readable)
494
+ // - triggers (NLP phrases) → clementine.triggers (preserved for reference)
495
+ // - toolsUsed → clementine.tools.allow (informational → enforced allowlist)
496
+ // - useCount + lastUsed → clementine.useCount + clementine.lastUsed
497
+ // - source → clementine.source
498
+ // - body → unchanged, written to <folder>/SKILL.md
499
+ //
500
+ // The original `<name>.md` is renamed to `<name>.md.bak` (kept on disk for
501
+ // rollback). The bak is filtered out by isLoadableSkillFile so the dashboard
502
+ // won't show it twice.
503
+ import { renameSync } from 'node:fs';
504
+ /** Migrate one legacy flat skill to Anthropic-compatible folder form.
505
+ * Idempotent: a folder-form skill is reported as alreadyMigrated. */
506
+ export function migrateLegacySkill(name) {
507
+ if (!name)
508
+ return { ok: false, error: 'name required' };
509
+ const dir = globalSkillsDir();
510
+ const flat = path.join(dir, name + '.md');
511
+ const folder = path.join(dir, name);
512
+ // Already folder-form → no-op.
513
+ if (existsSync(folder) && existsSync(path.join(folder, 'SKILL.md'))) {
514
+ return { ok: true, alreadyMigrated: true, newSkillPath: path.join(folder, 'SKILL.md') };
515
+ }
516
+ // Source file must exist.
517
+ if (!existsSync(flat))
518
+ return { ok: false, error: `legacy skill file not found: ${flat}` };
519
+ // Folder must NOT exist (collides with the rename target).
520
+ if (existsSync(folder))
521
+ return { ok: false, error: `target folder already exists: ${folder}` };
522
+ // Read + parse the legacy file.
523
+ let raw;
524
+ try {
525
+ raw = readFileSync(flat, 'utf-8');
526
+ }
527
+ catch (err) {
528
+ return { ok: false, error: 'failed to read source: ' + String(err) };
529
+ }
530
+ let parsed;
531
+ try {
532
+ parsed = matter(raw);
533
+ }
534
+ catch (err) {
535
+ return { ok: false, error: 'failed to parse YAML in source: ' + String(err) };
536
+ }
537
+ const data = parsed.data;
538
+ const body = parsed.content || '';
539
+ // Build the new frontmatter:
540
+ // - name (always the filename)
541
+ // - description (preserved from legacy)
542
+ // - title (preserved as display alias)
543
+ // - clementine.* — bucket all the legacy-only fields here
544
+ const newFm = {
545
+ name,
546
+ };
547
+ if (typeof data.description === 'string' && data.description.trim())
548
+ newFm.description = data.description;
549
+ if (typeof data.title === 'string' && data.title.trim() && data.title !== data.description) {
550
+ newFm.title = data.title;
551
+ }
552
+ const clementine = {};
553
+ if (Array.isArray(data.triggers) && data.triggers.length > 0) {
554
+ clementine.triggers = data.triggers.map(String);
555
+ }
556
+ if (Array.isArray(data.toolsUsed) && data.toolsUsed.length > 0) {
557
+ // Convert informational toolsUsed → enforced tools.allow. Authors can
558
+ // tighten by editing in Phase B.
559
+ clementine.tools = { allow: data.toolsUsed.map(String) };
560
+ }
561
+ if (typeof data.source === 'string' && data.source.trim())
562
+ clementine.source = data.source;
563
+ if (typeof data.useCount === 'number')
564
+ clementine.useCount = data.useCount;
565
+ if (typeof data.lastUsed === 'string')
566
+ clementine.lastUsed = data.lastUsed;
567
+ if (typeof data.createdAt === 'string')
568
+ clementine.createdAt = data.createdAt;
569
+ if (typeof data.updatedAt === 'string')
570
+ clementine.updatedAt = data.updatedAt;
571
+ // Stamp the migration provenance so future tooling can see where this
572
+ // came from.
573
+ clementine.migratedFrom = path.basename(flat);
574
+ clementine.migratedAt = new Date().toISOString();
575
+ clementine.version = 1;
576
+ if (Object.keys(clementine).length > 0)
577
+ newFm.clementine = clementine;
578
+ // Serialize: gray-matter handles YAML output. matter.stringify takes
579
+ // (content, data) → returns the full file with frontmatter.
580
+ const newContent = matter.stringify(body.startsWith('\n') ? body : '\n' + body, newFm);
581
+ // Create the folder + write SKILL.md.
582
+ try {
583
+ mkdirSync(folder, { recursive: true });
584
+ writeFileSync(path.join(folder, 'SKILL.md'), newContent);
585
+ }
586
+ catch (err) {
587
+ return { ok: false, error: 'failed to write new SKILL.md: ' + String(err) };
588
+ }
589
+ // Move the original to .bak so the loader stops surfacing it. We use
590
+ // <name>.md.bak which isLoadableSkillFile already filters out.
591
+ const backupPath = flat + '.bak';
592
+ try {
593
+ renameSync(flat, backupPath);
594
+ }
595
+ catch (err) {
596
+ // Roll back the folder we just created so we don't leave the user in
597
+ // a half-migrated state with both shapes for the same name.
598
+ try {
599
+ const fs = require('node:fs');
600
+ fs.unlinkSync(path.join(folder, 'SKILL.md'));
601
+ fs.rmdirSync(folder);
602
+ }
603
+ catch { /* best-effort */ }
604
+ return { ok: false, error: 'failed to rename original to .bak: ' + String(err) };
605
+ }
606
+ return {
607
+ ok: true,
608
+ newSkillPath: path.join(folder, 'SKILL.md'),
609
+ backupPath,
610
+ };
611
+ }
612
+ /** Migrate every legacy skill in the global pool. Returns per-skill
613
+ * results so the dashboard can render a summary banner. */
614
+ export function migrateAllLegacySkills() {
615
+ // Walk the dir directly so we don't trigger a full parse + validation.
616
+ const dir = globalSkillsDir();
617
+ const migrated = [];
618
+ const skipped = [];
619
+ if (!existsSync(dir))
620
+ return { migrated, skipped };
621
+ let entries;
622
+ try {
623
+ entries = readdirSync(dir);
624
+ }
625
+ catch {
626
+ return { migrated, skipped };
627
+ }
628
+ for (const entry of entries) {
629
+ if (!isLoadableSkillFile(entry))
630
+ continue;
631
+ const name = entry.replace(/\.md$/, '');
632
+ // Quickly check it's actually legacy — avoid migrating an Anthropic-
633
+ // form flat file that the user explicitly authored without a folder.
634
+ const filePath = path.join(dir, entry);
635
+ let isLegacy = false;
636
+ try {
637
+ const raw = readFileSync(filePath, 'utf-8');
638
+ const data = matter(raw).data;
639
+ isLegacy = ['title', 'triggers', 'toolsUsed', 'useCount', 'source'].some((k) => k in data);
640
+ }
641
+ catch { /* leave isLegacy=false */ }
642
+ if (!isLegacy) {
643
+ skipped.push({ ok: true, alreadyMigrated: true, newSkillPath: filePath });
644
+ continue;
645
+ }
646
+ migrated.push(migrateLegacySkill(name));
647
+ }
648
+ return { migrated, skipped };
649
+ }
474
650
  /** Diagnostics for the dashboard — expose where the loader looked. */
475
651
  export function _skillDirsForDiagnostics(workDir) {
476
652
  return { global: globalSkillsDir(), project: projectSkillsDir(workDir) ?? null };
@@ -4647,6 +4647,49 @@ export async function cmdDashboard(opts) {
4647
4647
  res.status(500).json({ ok: false, error: String(err) });
4648
4648
  }
4649
4649
  });
4650
+ // ── Skill migration (legacy .md → folder/SKILL.md) ─────────────────
4651
+ // Two endpoints: per-skill and bulk. Both wrap migrateLegacySkill /
4652
+ // migrateAllLegacySkills from skill-store.ts. The original .md is
4653
+ // renamed to .md.bak so the migration is rollback-able by hand.
4654
+ app.post('/api/skills/:name/migrate', async (req, res) => {
4655
+ try {
4656
+ const name = req.params.name;
4657
+ if (!name) {
4658
+ res.status(400).json({ ok: false, error: 'name required' });
4659
+ return;
4660
+ }
4661
+ const { migrateLegacySkill } = await import('../agent/skill-store.js');
4662
+ const result = migrateLegacySkill(name);
4663
+ if (!result.ok) {
4664
+ res.status(409).json(result);
4665
+ return;
4666
+ }
4667
+ res.json(result);
4668
+ }
4669
+ catch (err) {
4670
+ res.status(500).json({ ok: false, error: String(err) });
4671
+ }
4672
+ });
4673
+ app.post('/api/skills/migrate-all', async (_req, res) => {
4674
+ try {
4675
+ const { migrateAllLegacySkills } = await import('../agent/skill-store.js');
4676
+ const result = migrateAllLegacySkills();
4677
+ const failed = result.migrated.filter((m) => !m.ok);
4678
+ const succeeded = result.migrated.filter((m) => m.ok && !m.alreadyMigrated);
4679
+ res.json({
4680
+ ok: true,
4681
+ totalChecked: result.migrated.length + result.skipped.length,
4682
+ migratedCount: succeeded.length,
4683
+ failedCount: failed.length,
4684
+ skippedCount: result.skipped.length,
4685
+ migrated: result.migrated,
4686
+ skipped: result.skipped,
4687
+ });
4688
+ }
4689
+ catch (err) {
4690
+ res.status(500).json({ ok: false, error: String(err) });
4691
+ }
4692
+ });
4650
4693
  app.get('/api/cron/drafts', async (_req, res) => {
4651
4694
  try {
4652
4695
  const { listDraftNames } = await import('../agent/draft-store.js');
@@ -26877,6 +26920,44 @@ var _skillsState = {
26877
26920
  query: '', // search input value
26878
26921
  };
26879
26922
 
26923
+ // ── Migration handlers (Phase A.6 / 1.18.110) ────────────────────────
26924
+ // migrateOneSkill: per-skill migration from the detail-pane footer.
26925
+ // migrateAllLegacySkills: bulk migration from the list-pane banner.
26926
+ // Both use POST endpoints in dashboard.ts and refresh the page on success.
26927
+
26928
+ async function migrateOneSkill(name) {
26929
+ if (!confirm('Migrate "' + name + '" to folder form? The original .md file will be renamed to .md.bak (preserved for rollback).')) return;
26930
+ try {
26931
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(name) + '/migrate', { method: 'POST' });
26932
+ var d = await r.json().catch(function() { return {}; });
26933
+ if (!r.ok || d.ok === false) {
26934
+ toast(d.error || ('Migration failed (HTTP ' + r.status + ')'), 'error');
26935
+ return;
26936
+ }
26937
+ toast(d.alreadyMigrated ? 'Already in folder form.' : 'Migrated. Folder created at ' + (d.newSkillPath || ''), 'success');
26938
+ // Reload the skills list so the new layout reflects.
26939
+ if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
26940
+ if (name && typeof showSkillDetail === 'function') showSkillDetail(name);
26941
+ } catch (err) { toast('Migration failed: ' + err, 'error'); }
26942
+ }
26943
+
26944
+ async function migrateAllLegacySkills() {
26945
+ if (!confirm('Migrate ALL legacy skills to folder form? Each .md file becomes <name>/SKILL.md, originals preserved as .bak.')) return;
26946
+ try {
26947
+ var r = await apiFetch('/api/skills/migrate-all', { method: 'POST' });
26948
+ var d = await r.json().catch(function() { return {}; });
26949
+ if (!r.ok || d.ok === false) {
26950
+ toast(d.error || ('Bulk migration failed (HTTP ' + r.status + ')'), 'error');
26951
+ return;
26952
+ }
26953
+ var msg = 'Migrated ' + d.migratedCount + ' skill' + (d.migratedCount === 1 ? '' : 's')
26954
+ + (d.failedCount > 0 ? ' (' + d.failedCount + ' failed)' : '')
26955
+ + (d.skippedCount > 0 ? ' (' + d.skippedCount + ' already in folder form)' : '');
26956
+ toast(msg, d.failedCount > 0 ? 'error' : 'success');
26957
+ if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
26958
+ } catch (err) { toast('Bulk migration failed: ' + err, 'error'); }
26959
+ }
26960
+
26880
26961
  async function refreshSkillsPage() {
26881
26962
  var listEl = document.getElementById('skills-list');
26882
26963
  var detailEl = document.getElementById('skills-detail');
@@ -26961,6 +27042,21 @@ function renderSkillsList() {
26961
27042
  return;
26962
27043
  }
26963
27044
  var html = '';
27045
+ // PRD § Skills-First Phase A.6 / 1.18.110: legacy migration banner.
27046
+ // Counts how many flat-form legacy skills exist; shows a one-click
27047
+ // "Migrate all" button when ≥1 found. Banner disappears after they
27048
+ // all become folder form. Per-skill migration also lives in the
27049
+ // detail pane footer for individual control.
27050
+ var legacyCount = _skillsState.data.filter(function(s) {
27051
+ return s.layout === 'flat' && s.schemaVersion === 'legacy';
27052
+ }).length;
27053
+ if (legacyCount > 0 && !_skillsState.query) {
27054
+ html += '<div style="padding:12px 14px;background:rgba(245,158,11,0.10);border-bottom:1px solid var(--yellow);font-size:11px;line-height:1.5;color:var(--text-secondary)">'
27055
+ + '<div style="font-weight:600;color:var(--yellow);margin-bottom:6px">' + legacyCount + ' legacy skill' + (legacyCount === 1 ? '' : 's') + ' to migrate</div>'
27056
+ + '<div style="margin-bottom:8px">Convert flat <code>.md</code> files to folder form (<code>name/SKILL.md</code>) so you can bundle templates + scripts + reference docs.</div>'
27057
+ + '<button class="btn-sm btn-primary" onclick="migrateAllLegacySkills()" style="font-size:11px;padding:4px 10px">Migrate all to folder form</button>'
27058
+ + '</div>';
27059
+ }
26964
27060
  for (var i = 0; i < _skillsState.filtered.length; i++) {
26965
27061
  var s = _skillsState.filtered[i];
26966
27062
  var fm = s.frontmatter || {};
@@ -27157,7 +27253,11 @@ function renderSkillDetail(s) {
27157
27253
  if (s.schemaVersion === 'legacy') {
27158
27254
  html += '<div style="margin-top:24px;padding:12px 14px;background:rgba(245,158,11,0.08);border:1px solid var(--yellow);border-radius:6px;font-size:12px;color:var(--text-secondary);line-height:1.5">'
27159
27255
  + '<strong style="color:var(--yellow)">Legacy schema.</strong> '
27160
- + 'This skill uses the pre-redesign frontmatter shape. Phase B will offer a one-click migration to the Anthropic-compatible format (name + description top-level; cron-tailored fields under <code>clementine:</code>).'
27256
+ + 'This skill uses the pre-redesign frontmatter shape. '
27257
+ + 'Click <strong>Migrate to folder form</strong> below to convert it to <code>' + esc(fm.name) + '/SKILL.md</code> with the cron-tailored fields (triggers, toolsUsed, etc.) moved under a <code>clementine:</code> namespace. The original is preserved as a <code>.bak</code> file for rollback.'
27258
+ + '<div style="margin-top:10px">'
27259
+ + '<button class="btn-sm btn-primary" onclick="migrateOneSkill(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:11px;padding:4px 12px">Migrate to folder form →</button>'
27260
+ + '</div>'
27161
27261
  + '</div>';
27162
27262
  } else if (s.schemaVersion === 'anthropic' && s.layout === 'flat') {
27163
27263
  html += '<div style="margin-top:24px;padding:12px 14px;background:rgba(59,130,246,0.06);border:1px solid var(--blue);border-radius:6px;font-size:12px;color:var(--text-secondary);line-height:1.5">'
package/dist/types.d.ts CHANGED
@@ -383,6 +383,20 @@ export interface ClementineSkillExtensions {
383
383
  lastUsed?: string;
384
384
  /** Last successful "Test this skill" run (Phase B). */
385
385
  lastTestPass?: string;
386
+ /** Legacy: NLP-style trigger phrases the pre-redesign chat router used
387
+ * to match incoming messages against this skill. Preserved for the
388
+ * migration UI; not enforced. */
389
+ triggers?: string[];
390
+ /** Legacy: 'manual' / 'auto' / 'imported' — provenance label on the
391
+ * pre-redesign skills. */
392
+ source?: string;
393
+ /** Legacy: incrementing counter of runs that invoked the skill. */
394
+ useCount?: number;
395
+ /** Filename the original legacy skill was migrated from. Helps the
396
+ * migration UI show what came from where. */
397
+ migratedFrom?: string;
398
+ /** ISO timestamp of when the migration ran. */
399
+ migratedAt?: string;
386
400
  }
387
401
  /** Parsed frontmatter. Anthropic-canonical fields are top-level; our
388
402
  * extensions live under `clementine`. Legacy fields (title/triggers/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.109",
3
+ "version": "1.18.110",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",