clementine-agent 1.18.109 → 1.18.111
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-store.d.ts +20 -0
- package/dist/agent/skill-store.js +177 -1
- package/dist/cli/dashboard.js +225 -22
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
|
@@ -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 };
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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');
|
|
@@ -24416,9 +24459,17 @@ function operationUsageBadge(usage) {
|
|
|
24416
24459
|
return '<span class="badge badge-blue" title="' + esc(formatTokens(usage.totalInput || 0)) + ' input, ' + esc(formatTokens(usage.totalOutput || 0)) + ' output">' + esc(formatTokens(usage.totalTokens || 0)) + ' tok 7d</span>';
|
|
24417
24460
|
}
|
|
24418
24461
|
|
|
24462
|
+
// PRD §pretty-cron / 1.18.111: pretty primary, raw cron in a hover
|
|
24463
|
+
// tooltip. Power users still get the literal expression on hover; casual
|
|
24464
|
+
// users never have to read "0 8-18 * * 1-5". Falls back to raw inline
|
|
24465
|
+
// (with a help-cursor hint) when describeCron can't summarize.
|
|
24419
24466
|
function operationScheduleHtml(schedule) {
|
|
24420
|
-
var
|
|
24421
|
-
|
|
24467
|
+
var raw = schedule || '';
|
|
24468
|
+
var desc = describeCron(raw);
|
|
24469
|
+
if (desc) {
|
|
24470
|
+
return '<span title="' + esc(raw) + '" style="cursor:help">' + esc(desc) + '</span>';
|
|
24471
|
+
}
|
|
24472
|
+
return '<code title="' + esc(raw) + '" style="color:var(--accent);cursor:help">' + esc(raw) + '</code>';
|
|
24422
24473
|
}
|
|
24423
24474
|
|
|
24424
24475
|
function operationSectionHeader(title, subtitle, badgeClass, badgeText, marginTop) {
|
|
@@ -26877,6 +26928,44 @@ var _skillsState = {
|
|
|
26877
26928
|
query: '', // search input value
|
|
26878
26929
|
};
|
|
26879
26930
|
|
|
26931
|
+
// ── Migration handlers (Phase A.6 / 1.18.110) ────────────────────────
|
|
26932
|
+
// migrateOneSkill: per-skill migration from the detail-pane footer.
|
|
26933
|
+
// migrateAllLegacySkills: bulk migration from the list-pane banner.
|
|
26934
|
+
// Both use POST endpoints in dashboard.ts and refresh the page on success.
|
|
26935
|
+
|
|
26936
|
+
async function migrateOneSkill(name) {
|
|
26937
|
+
if (!confirm('Migrate "' + name + '" to folder form? The original .md file will be renamed to .md.bak (preserved for rollback).')) return;
|
|
26938
|
+
try {
|
|
26939
|
+
var r = await apiFetch('/api/skills/' + encodeURIComponent(name) + '/migrate', { method: 'POST' });
|
|
26940
|
+
var d = await r.json().catch(function() { return {}; });
|
|
26941
|
+
if (!r.ok || d.ok === false) {
|
|
26942
|
+
toast(d.error || ('Migration failed (HTTP ' + r.status + ')'), 'error');
|
|
26943
|
+
return;
|
|
26944
|
+
}
|
|
26945
|
+
toast(d.alreadyMigrated ? 'Already in folder form.' : 'Migrated. Folder created at ' + (d.newSkillPath || ''), 'success');
|
|
26946
|
+
// Reload the skills list so the new layout reflects.
|
|
26947
|
+
if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
|
|
26948
|
+
if (name && typeof showSkillDetail === 'function') showSkillDetail(name);
|
|
26949
|
+
} catch (err) { toast('Migration failed: ' + err, 'error'); }
|
|
26950
|
+
}
|
|
26951
|
+
|
|
26952
|
+
async function migrateAllLegacySkills() {
|
|
26953
|
+
if (!confirm('Migrate ALL legacy skills to folder form? Each .md file becomes <name>/SKILL.md, originals preserved as .bak.')) return;
|
|
26954
|
+
try {
|
|
26955
|
+
var r = await apiFetch('/api/skills/migrate-all', { method: 'POST' });
|
|
26956
|
+
var d = await r.json().catch(function() { return {}; });
|
|
26957
|
+
if (!r.ok || d.ok === false) {
|
|
26958
|
+
toast(d.error || ('Bulk migration failed (HTTP ' + r.status + ')'), 'error');
|
|
26959
|
+
return;
|
|
26960
|
+
}
|
|
26961
|
+
var msg = 'Migrated ' + d.migratedCount + ' skill' + (d.migratedCount === 1 ? '' : 's')
|
|
26962
|
+
+ (d.failedCount > 0 ? ' (' + d.failedCount + ' failed)' : '')
|
|
26963
|
+
+ (d.skippedCount > 0 ? ' (' + d.skippedCount + ' already in folder form)' : '');
|
|
26964
|
+
toast(msg, d.failedCount > 0 ? 'error' : 'success');
|
|
26965
|
+
if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
|
|
26966
|
+
} catch (err) { toast('Bulk migration failed: ' + err, 'error'); }
|
|
26967
|
+
}
|
|
26968
|
+
|
|
26880
26969
|
async function refreshSkillsPage() {
|
|
26881
26970
|
var listEl = document.getElementById('skills-list');
|
|
26882
26971
|
var detailEl = document.getElementById('skills-detail');
|
|
@@ -26961,6 +27050,21 @@ function renderSkillsList() {
|
|
|
26961
27050
|
return;
|
|
26962
27051
|
}
|
|
26963
27052
|
var html = '';
|
|
27053
|
+
// PRD § Skills-First Phase A.6 / 1.18.110: legacy migration banner.
|
|
27054
|
+
// Counts how many flat-form legacy skills exist; shows a one-click
|
|
27055
|
+
// "Migrate all" button when ≥1 found. Banner disappears after they
|
|
27056
|
+
// all become folder form. Per-skill migration also lives in the
|
|
27057
|
+
// detail pane footer for individual control.
|
|
27058
|
+
var legacyCount = _skillsState.data.filter(function(s) {
|
|
27059
|
+
return s.layout === 'flat' && s.schemaVersion === 'legacy';
|
|
27060
|
+
}).length;
|
|
27061
|
+
if (legacyCount > 0 && !_skillsState.query) {
|
|
27062
|
+
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)">'
|
|
27063
|
+
+ '<div style="font-weight:600;color:var(--yellow);margin-bottom:6px">' + legacyCount + ' legacy skill' + (legacyCount === 1 ? '' : 's') + ' to migrate</div>'
|
|
27064
|
+
+ '<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>'
|
|
27065
|
+
+ '<button class="btn-sm btn-primary" onclick="migrateAllLegacySkills()" style="font-size:11px;padding:4px 10px">Migrate all to folder form</button>'
|
|
27066
|
+
+ '</div>';
|
|
27067
|
+
}
|
|
26964
27068
|
for (var i = 0; i < _skillsState.filtered.length; i++) {
|
|
26965
27069
|
var s = _skillsState.filtered[i];
|
|
26966
27070
|
var fm = s.frontmatter || {};
|
|
@@ -27157,7 +27261,11 @@ function renderSkillDetail(s) {
|
|
|
27157
27261
|
if (s.schemaVersion === 'legacy') {
|
|
27158
27262
|
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
27263
|
+ '<strong style="color:var(--yellow)">Legacy schema.</strong> '
|
|
27160
|
-
+ 'This skill uses the pre-redesign frontmatter shape.
|
|
27264
|
+
+ 'This skill uses the pre-redesign frontmatter shape. '
|
|
27265
|
+
+ '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.'
|
|
27266
|
+
+ '<div style="margin-top:10px">'
|
|
27267
|
+
+ '<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>'
|
|
27268
|
+
+ '</div>'
|
|
27161
27269
|
+ '</div>';
|
|
27162
27270
|
} else if (s.schemaVersion === 'anthropic' && s.layout === 'flat') {
|
|
27163
27271
|
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">'
|
|
@@ -29477,44 +29585,139 @@ function updateScheduleHint() {
|
|
|
29477
29585
|
|
|
29478
29586
|
const monthNames = ['','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
29479
29587
|
|
|
29588
|
+
// PRD §pretty-cron / 1.18.111 — describe a cron expression in casual
|
|
29589
|
+
// human-readable form. Output style:
|
|
29590
|
+
// - "Mondays at 8 AM" not "Every Monday at 8:00 AM"
|
|
29591
|
+
// - "5 PM" not "5:00 PM" when minutes=0
|
|
29592
|
+
// - "8 AM–6 PM" (en-dash range) for hour ranges
|
|
29593
|
+
// - "Every minute" / "Every 2 minutes" / "Every 4 hours"
|
|
29594
|
+
// - "Hourly weekdays 8 AM–6 PM" for the business-hours pattern
|
|
29595
|
+
// - Falls back to '' when the expression is too exotic to summarize;
|
|
29596
|
+
// the renderer then shows the raw cron only as a last resort.
|
|
29480
29597
|
function describeCron(expr) {
|
|
29481
|
-
|
|
29598
|
+
if (!expr || typeof expr !== 'string') return '';
|
|
29599
|
+
// @aliases first — common shortcuts.
|
|
29600
|
+
var aliases = {
|
|
29601
|
+
'@yearly': 'Once a year (Jan 1, midnight)',
|
|
29602
|
+
'@annually': 'Once a year (Jan 1, midnight)',
|
|
29603
|
+
'@monthly': 'First of every month, midnight',
|
|
29604
|
+
'@weekly': 'Sundays at midnight',
|
|
29605
|
+
'@daily': 'Every day at midnight',
|
|
29606
|
+
'@hourly': 'Every hour',
|
|
29607
|
+
'@reboot': 'On daemon start',
|
|
29608
|
+
};
|
|
29609
|
+
if (aliases[expr]) return aliases[expr];
|
|
29610
|
+
|
|
29611
|
+
var parts = expr.trim().split(/\\s+/);
|
|
29482
29612
|
if (parts.length !== 5) return '';
|
|
29483
|
-
|
|
29613
|
+
var min = parts[0], hour = parts[1], dom = parts[2], month = parts[3], dow = parts[4];
|
|
29484
29614
|
|
|
29485
|
-
//
|
|
29486
|
-
if (min
|
|
29487
|
-
|
|
29488
|
-
|
|
29615
|
+
// ── Sub-hour cadence ────────────────────────────────────────────────
|
|
29616
|
+
if (min === '*' && hour === '*' && dom === '*' && month === '*' && dow === '*') return 'Every minute';
|
|
29617
|
+
if (min.startsWith('*/')) {
|
|
29618
|
+
var n = parseInt(min.slice(2), 10);
|
|
29619
|
+
if (Number.isFinite(n)) return n === 1 ? 'Every minute' : 'Every ' + n + ' minutes';
|
|
29620
|
+
}
|
|
29621
|
+
// Every N hours (e.g. "0 */2 * * *")
|
|
29622
|
+
if (hour.startsWith('*/') && (min === '0' || min === '*')) {
|
|
29623
|
+
var nh = parseInt(hour.slice(2), 10);
|
|
29624
|
+
if (Number.isFinite(nh)) return nh === 1 ? 'Every hour' : 'Every ' + nh + ' hours';
|
|
29625
|
+
}
|
|
29489
29626
|
|
|
29490
|
-
|
|
29627
|
+
// ── Hour ranges (e.g. "0 8-18 * * 1-5" — hourly during business hours) ──
|
|
29628
|
+
var rangeMatch = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
|
|
29629
|
+
if (rangeMatch && (min === '0' || min === '*')) {
|
|
29630
|
+
var startH = parseInt(rangeMatch[1], 10);
|
|
29631
|
+
var endH = parseInt(rangeMatch[2], 10);
|
|
29632
|
+
if (Number.isFinite(startH) && Number.isFinite(endH)) {
|
|
29633
|
+
var span = formatHour(startH) + '–' + formatHour(endH);
|
|
29634
|
+
if (dow === '1-5') return 'Hourly weekdays ' + span;
|
|
29635
|
+
if (dow === '*' && dom === '*' && month === '*') return 'Hourly ' + span;
|
|
29636
|
+
if (/^[0-6]$/.test(dow)) return 'Hourly ' + plural(dayNames[+dow]) + ' ' + span;
|
|
29637
|
+
}
|
|
29638
|
+
}
|
|
29639
|
+
|
|
29640
|
+
// ── Comma-list of hours at fixed minute (e.g. "0 8,12,16 * * *") ────
|
|
29641
|
+
// Check this BEFORE parseInt — parseInt is lenient and would parse
|
|
29642
|
+
// "8,12,16" as 8, missing the multi-hour case entirely.
|
|
29643
|
+
if (hour.indexOf(',') !== -1 && min !== '*' && !min.startsWith('*/')) {
|
|
29644
|
+
var minN = parseInt(min, 10);
|
|
29645
|
+
if (Number.isFinite(minN)) {
|
|
29646
|
+
return 'Daily at ' + hour.split(',').map(function(h) { return formatTimePretty(+h, minN); }).join(', ');
|
|
29647
|
+
}
|
|
29648
|
+
}
|
|
29649
|
+
|
|
29650
|
+
// ── Single-time-of-day patterns ─────────────────────────────────────
|
|
29651
|
+
var hourNum = parseInt(hour, 10);
|
|
29652
|
+
var minNum = parseInt(min, 10);
|
|
29653
|
+
if (!Number.isFinite(hourNum) || !Number.isFinite(minNum)) return '';
|
|
29654
|
+
// Reject parseInt's lenient mode: a hour like "8,12" would parse to 8.
|
|
29655
|
+
if (String(hourNum) !== hour || String(minNum) !== min) return '';
|
|
29656
|
+
var time = formatTimePretty(hourNum, minNum);
|
|
29491
29657
|
|
|
29492
29658
|
// Specific date: day + month set (e.g. "10 16 1 3 *" = Mar 1 at 4:10 PM)
|
|
29493
29659
|
if (dom !== '*' && month !== '*') {
|
|
29494
|
-
|
|
29660
|
+
var monthStr = monthNames[+month] || ('Month ' + month);
|
|
29495
29661
|
return monthStr + ' ' + dom + ' at ' + time;
|
|
29496
29662
|
}
|
|
29497
29663
|
|
|
29498
29664
|
// Day of month only (e.g. "0 9 15 * *" = 15th of every month)
|
|
29499
29665
|
if (dom !== '*' && month === '*' && dow === '*') {
|
|
29500
|
-
|
|
29501
|
-
|
|
29666
|
+
return ordinal(+dom) + ' of every month at ' + time;
|
|
29667
|
+
}
|
|
29668
|
+
|
|
29669
|
+
// Weekdays (Mon-Fri)
|
|
29670
|
+
if (dow === '1-5') return 'Weekdays at ' + time;
|
|
29671
|
+
|
|
29672
|
+
// Specific weekday → pluralize ("Mondays at 8 AM" not "Every Monday at 8 AM")
|
|
29673
|
+
if (/^[0-6]$/.test(dow)) return plural(dayNames[+dow]) + ' at ' + time;
|
|
29674
|
+
|
|
29675
|
+
// Multiple specific weekdays (e.g. "0 9 * * 1,3,5")
|
|
29676
|
+
if (/^[0-6](,[0-6])+$/.test(dow)) {
|
|
29677
|
+
return dow.split(',').map(function(d) { return shortDay(+d); }).join(', ') + ' at ' + time;
|
|
29502
29678
|
}
|
|
29503
29679
|
|
|
29504
|
-
// Weekdays
|
|
29505
|
-
if (dow === '1-5' && !hour.includes(',')) return 'Weekdays at ' + time;
|
|
29506
29680
|
// Every day
|
|
29507
|
-
if (dow === '*' && dom === '*' && month === '*'
|
|
29508
|
-
// Specific weekday
|
|
29509
|
-
if (/^[0-6]$/.test(dow) && !hour.includes(',')) return 'Every ' + dayNames[+dow] + ' at ' + time;
|
|
29510
|
-
// Multiple weekdays (e.g. "0 9 * * 1,3,5")
|
|
29511
|
-
if (/^[0-6](,[0-6])+$/.test(dow)) return dow.split(',').map(d => dayNames[+d]).join(', ') + ' at ' + time;
|
|
29512
|
-
// Multiple hours
|
|
29513
|
-
if (hour.includes(',')) return 'Daily at ' + hour.split(',').map(h => formatTime(+h, +min)).join(', ');
|
|
29681
|
+
if (dow === '*' && dom === '*' && month === '*') return 'Every day at ' + time;
|
|
29514
29682
|
|
|
29515
29683
|
return '';
|
|
29516
29684
|
}
|
|
29517
29685
|
|
|
29686
|
+
function plural(day) {
|
|
29687
|
+
// "Monday" → "Mondays". Days end in y already so just append 's'.
|
|
29688
|
+
return day + 's';
|
|
29689
|
+
}
|
|
29690
|
+
|
|
29691
|
+
function shortDay(d) {
|
|
29692
|
+
// Compact form for multi-day lists: "Mon, Wed, Fri".
|
|
29693
|
+
return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d] || ('day ' + d);
|
|
29694
|
+
}
|
|
29695
|
+
|
|
29696
|
+
function ordinal(n) {
|
|
29697
|
+
// 1st / 2nd / 3rd / 4th… for the day-of-month phrasing.
|
|
29698
|
+
if (n >= 11 && n <= 13) return n + 'th';
|
|
29699
|
+
var lastDigit = n % 10;
|
|
29700
|
+
if (lastDigit === 1) return n + 'st';
|
|
29701
|
+
if (lastDigit === 2) return n + 'nd';
|
|
29702
|
+
if (lastDigit === 3) return n + 'rd';
|
|
29703
|
+
return n + 'th';
|
|
29704
|
+
}
|
|
29705
|
+
|
|
29706
|
+
function formatHour(h) {
|
|
29707
|
+
// Compact hour-only format used in ranges: "8 AM" / "12 PM" / "6 PM".
|
|
29708
|
+
var ampm = h >= 12 ? 'PM' : 'AM';
|
|
29709
|
+
var hr = h === 0 ? 12 : (h > 12 ? h - 12 : h);
|
|
29710
|
+
return hr + ' ' + ampm;
|
|
29711
|
+
}
|
|
29712
|
+
|
|
29713
|
+
function formatTimePretty(h, m) {
|
|
29714
|
+
// "8 AM" when minutes=0, "8:30 AM" otherwise. Casual, no leading zeros.
|
|
29715
|
+
if (m === 0) return formatHour(h);
|
|
29716
|
+
var ampm = h >= 12 ? 'PM' : 'AM';
|
|
29717
|
+
var hr = h === 0 ? 12 : (h > 12 ? h - 12 : h);
|
|
29718
|
+
return hr + ':' + String(m).padStart(2, '0') + ' ' + ampm;
|
|
29719
|
+
}
|
|
29720
|
+
|
|
29518
29721
|
function setScheduleFromCron(expr) {
|
|
29519
29722
|
// Try to reverse-map a cron expression back to the builder
|
|
29520
29723
|
const parts = expr.split(/\\s+/);
|
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/
|