@worca/ui 0.41.0 → 0.43.0

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.
@@ -39,9 +39,10 @@ import {
39
39
  } from 'node:fs';
40
40
  import { tmpdir } from 'node:os';
41
41
  import { dirname, join } from 'node:path';
42
- import { Router } from 'express';
42
+ import { raw as expressRaw, Router } from 'express';
43
43
  import { atomicWriteSync } from './atomic-write.js';
44
44
  import { templatesDir } from './paths.js';
45
+ import { buildPromptsModel } from './template-prompts.js';
45
46
 
46
47
  /**
47
48
  * Match template IDs: lowercase alphanumeric, hyphens, and underscores,
@@ -52,15 +53,26 @@ import { templatesDir } from './paths.js';
52
53
  const TEMPLATE_RE = /^[a-z0-9_-]{1,64}$/;
53
54
  const TIERS = ['project', 'user', 'builtin'];
54
55
  const MUTABLE_TIERS = ['project', 'user'];
56
+ const _OVERLAY_NAME_RE = /^[a-z0-9._-]{1,64}\.(md|block\.md)$/;
55
57
  export { TEMPLATE_RE, TIERS };
56
58
 
57
59
  function isValidTier(tier) {
58
60
  return TIERS.includes(tier);
59
61
  }
60
- function isMutableTier(tier) {
62
+ function _isMutableTier(tier) {
61
63
  return MUTABLE_TIERS.includes(tier);
62
64
  }
63
65
 
66
+ function hasOverlays(tmplDir) {
67
+ const agentsDir = join(tmplDir, 'agents');
68
+ if (!existsSync(agentsDir)) return false;
69
+ try {
70
+ return readdirSync(agentsDir).some((f) => _OVERLAY_NAME_RE.test(f));
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
64
76
  function readTemplateJson(dirPath) {
65
77
  const manifestPath = join(dirPath, 'template.json');
66
78
  if (!existsSync(manifestPath)) return null;
@@ -112,6 +124,7 @@ function listTemplatesFlat(projectRoot) {
112
124
  const manifest = readTemplateJson(join(dir, entry.name));
113
125
  if (!manifest) continue;
114
126
  const id = manifest.id || entry.name;
127
+ const tmplDir = join(dir, entry.name);
115
128
  out.push({
116
129
  tier,
117
130
  id,
@@ -122,6 +135,7 @@ function listTemplatesFlat(projectRoot) {
122
135
  tags: manifest.tags || [],
123
136
  created_at: manifest.created_at,
124
137
  builtin: manifest.builtin === true || tier === 'builtin',
138
+ has_overlays: hasOverlays(tmplDir),
125
139
  });
126
140
  }
127
141
  }
@@ -190,6 +204,8 @@ function runWorcaTemplates(projectRoot, args, opts = {}) {
190
204
  combined.includes('invalid')
191
205
  ) {
192
206
  code = 'validation_error';
207
+ } else if (combined.includes('partial_rename')) {
208
+ code = 'partial_rename';
193
209
  }
194
210
  const e = new Error(stderr.trim() || err.message || 'worca CLI failed');
195
211
  e.cliCode = code;
@@ -281,26 +297,153 @@ function duplicateTemplateViaCli(projectRoot, srcId, dstId, dstTier) {
281
297
  ]);
282
298
  }
283
299
 
284
- function exportBundle(projectRoot, id) {
300
+ /**
301
+ * Export a template bundle. Returns `{ json, data }` where `json` is
302
+ * true for JSON bundles (data is parsed object) and false for zip bundles
303
+ * (data is raw Buffer with filename in `filename`).
304
+ *
305
+ * `mode` is 'standalone' (default — self-contained config + resolved prompts)
306
+ * or 'delta' (sparse overlay). Standalone materialises a prompt set even when
307
+ * the template has no on-disk overlays, so the CLI may emit a zip in cases the
308
+ * old on-disk `hasOverlays` check would have missed. We therefore sniff the
309
+ * produced file's magic bytes instead of predicting the format.
310
+ */
311
+ function exportBundle(projectRoot, id, mode = 'standalone') {
312
+ const normalizedMode = mode === 'delta' ? 'delta' : 'standalone';
285
313
  const dir = mkdtempSync(join(tmpdir(), 'worca-bundle-'));
286
- const bundlePath = join(dir, `${id}.json`);
287
314
  try {
315
+ const bundlePath = join(dir, `${id}-bundle.out`);
316
+ // --include-models / --include-pricing: bundle the project's worca.models
317
+ // and worca.pricing entries that the template references (e.g. a custom
318
+ // "glm-ds" alias). The CLI already filters to *referenced* aliases via
319
+ // collect_referenced_model_aliases, so built-ins the importer already has
320
+ // (opus/sonnet/haiku) aren't shipped redundantly. Without these flags a
321
+ // bundle that names a custom alias is broken on import.
288
322
  runWorcaTemplates(projectRoot, [
289
323
  'export',
290
324
  '--to',
291
325
  bundlePath,
292
326
  '--templates',
293
327
  id,
328
+ '--mode',
329
+ normalizedMode,
330
+ '--include-models',
331
+ '--include-pricing',
294
332
  ]);
295
333
  if (existsSync(bundlePath)) {
296
- return JSON.parse(readFileSync(bundlePath, 'utf8'));
334
+ const buf = readFileSync(bundlePath);
335
+ // ZIP local-file-header magic: 'PK\x03\x04'.
336
+ const isZip =
337
+ buf.length >= 4 &&
338
+ buf[0] === 0x50 &&
339
+ buf[1] === 0x4b &&
340
+ buf[2] === 0x03 &&
341
+ buf[3] === 0x04;
342
+ if (isZip) {
343
+ return { json: false, filename: `${id}-bundle.zip`, data: buf };
344
+ }
345
+ return { json: true, data: JSON.parse(buf.toString('utf8')) };
297
346
  }
298
- return { templates: [{ id }] };
347
+ return { json: true, data: { templates: [{ id }] } };
299
348
  } finally {
300
349
  cleanupTemp(dir);
301
350
  }
302
351
  }
303
352
 
353
+ /**
354
+ * Export a single template to a (secret) GitHub gist via the CLI and return the
355
+ * gist URL. Delegates to `worca templates export --to gist`, which shells out to
356
+ * `gh gist create` and prints the URL on stdout. Throws (via runWorcaTemplates)
357
+ * with the CLI stderr as the message when gh is unavailable or gist creation
358
+ * fails — the caller maps that to a JSON error. Gist export only supports
359
+ * overlay-free templates (the CLI rejects overlays); the UI hides the button
360
+ * for templates with overlays, matching that constraint.
361
+ */
362
+ function exportGist(projectRoot, id) {
363
+ const stdout = runWorcaTemplates(
364
+ projectRoot,
365
+ [
366
+ 'export',
367
+ '--to',
368
+ 'gist',
369
+ '--templates',
370
+ id,
371
+ '--include-models',
372
+ '--include-pricing',
373
+ ],
374
+ { timeout: 30000 },
375
+ );
376
+ // The CLI prints the gist URL (from `gh gist create`) as its final stdout line.
377
+ const match = stdout.match(/https?:\/\/\S+/);
378
+ return match ? match[0] : null;
379
+ }
380
+
381
+ function handleZipImport(req, res) {
382
+ const dstTier = req.query.dst_tier;
383
+ if (!isValidTier(dstTier)) {
384
+ return res.status(400).json({
385
+ ok: false,
386
+ error: `dst_tier must be one of: ${TIERS.join(', ')}`,
387
+ });
388
+ }
389
+ if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
390
+
391
+ const zipBuffer = req.body;
392
+ const tmpPath = join(
393
+ tmpdir(),
394
+ `worca-import-${Math.random().toString(36).slice(2)}.zip`,
395
+ );
396
+ try {
397
+ writeFileSync(tmpPath, zipBuffer);
398
+ const stdout = runWorcaTemplates(
399
+ req.project.projectRoot,
400
+ ['import', '--from', tmpPath, '--scope', dstTier, '--non-interactive'],
401
+ { timeout: 60000 },
402
+ );
403
+ // Best-effort summary: the CLI may print imported template ids to stdout
404
+ const imported = [];
405
+ for (const line of (stdout || '').split('\n')) {
406
+ const m = line.match(/imported[:\s]+([a-z0-9_-]+)/i);
407
+ if (m) imported.push({ id: m[1], tier: dstTier });
408
+ }
409
+ res.json({ ok: true, count: imported.length || 1, imported });
410
+ } catch (err) {
411
+ res
412
+ .status(statusForCliCode(err.cliCode))
413
+ .json({ ok: false, error: err.message, code: err.cliCode });
414
+ } finally {
415
+ try {
416
+ rmSync(tmpPath, { force: true });
417
+ } catch {
418
+ /* ignore cleanup errors */
419
+ }
420
+ }
421
+ }
422
+
423
+ function handleJsonImport(req, res) {
424
+ const { bundle, dst_tier: dstTier } = req.body || {};
425
+ if (!bundle || typeof bundle !== 'object') {
426
+ return res
427
+ .status(400)
428
+ .json({ ok: false, error: 'bundle must be a JSON object' });
429
+ }
430
+ if (!isValidTier(dstTier)) {
431
+ return res.status(400).json({
432
+ ok: false,
433
+ error: `dst_tier must be one of: ${TIERS.join(', ')}`,
434
+ });
435
+ }
436
+ if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
437
+ try {
438
+ const result = importBundle(req.project.projectRoot, bundle, dstTier);
439
+ res.json({ ok: true, ...result });
440
+ } catch (err) {
441
+ res
442
+ .status(statusForCliCode(err.cliCode))
443
+ .json({ ok: false, error: err.message, code: err.cliCode });
444
+ }
445
+ }
446
+
304
447
  function importBundle(projectRoot, bundle, tier) {
305
448
  if (!bundle || !Array.isArray(bundle.templates)) {
306
449
  throw new Error('Bundle must contain a "templates" array');
@@ -434,31 +577,21 @@ export function createTemplatesRoutes() {
434
577
  * doesn't match the literal "import" path segment as a tier
435
578
  * parameter (which would 400 with "tier must be one of …").
436
579
  *
437
- * Body: { bundle: {templates: [...]}, dst_tier }
580
+ * Accepts two content types:
581
+ * application/zip — raw zip bytes; dst_tier as query param
582
+ * application/json — { bundle: {templates: [...]}, dst_tier }
438
583
  */
439
- router.post('/templates/import', (req, res) => {
440
- const { bundle, dst_tier: dstTier } = req.body || {};
441
- if (!bundle || typeof bundle !== 'object') {
442
- return res
443
- .status(400)
444
- .json({ ok: false, error: 'bundle must be a JSON object' });
445
- }
446
- if (!isValidTier(dstTier)) {
447
- return res.status(400).json({
448
- ok: false,
449
- error: `dst_tier must be one of: ${TIERS.join(', ')}`,
450
- });
451
- }
452
- if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
453
- try {
454
- const result = importBundle(req.project.projectRoot, bundle, dstTier);
455
- res.json({ ok: true, ...result });
456
- } catch (err) {
457
- res
458
- .status(statusForCliCode(err.cliCode))
459
- .json({ ok: false, error: err.message, code: err.cliCode });
460
- }
461
- });
584
+ router.post(
585
+ '/templates/import',
586
+ expressRaw({ type: 'application/zip', limit: '1mb' }),
587
+ (req, res) => {
588
+ const ctype = req.headers['content-type'] || '';
589
+ if (ctype.startsWith('application/zip')) {
590
+ return handleZipImport(req, res);
591
+ }
592
+ return handleJsonImport(req, res);
593
+ },
594
+ );
462
595
 
463
596
  /**
464
597
  * POST /api/projects/:projectId/templates/validate
@@ -611,9 +744,11 @@ export function createTemplatesRoutes() {
611
744
  * POST /api/projects/:projectId/templates/:tier/:id/rename
612
745
  * Body: { dst_tier, dst_id }
613
746
  *
614
- * Same best-effort composition as beforeduplicate then delete.
615
- * partial_rename (500) when the second leg fails after the first
616
- * lands on disk.
747
+ * Delegates to `worca templates rename`a single CLI call that runs
748
+ * duplicate pointer-rewrite delete atomically in one process.
749
+ * partial_rename (500) is surfaced when the CLI reports that duplicate
750
+ * succeeded but delete failed (exit code 3, stderr contains
751
+ * "partial_rename").
617
752
  */
618
753
  router.post('/templates/:tier/:id/rename', (req, res) => {
619
754
  const { tier: srcTier, id: srcId } = req.params;
@@ -646,25 +781,33 @@ export function createTemplatesRoutes() {
646
781
  }
647
782
 
648
783
  try {
649
- duplicateTemplateViaCli(projectRoot, srcId, dstId, dstTier);
784
+ runWorcaTemplates(projectRoot, [
785
+ 'rename',
786
+ '--src-id',
787
+ srcId,
788
+ '--src-scope',
789
+ srcTier,
790
+ '--dst-id',
791
+ dstId,
792
+ '--dst-scope',
793
+ dstTier,
794
+ ]);
650
795
  } catch (err) {
796
+ if (err.cliCode === 'partial_rename') {
797
+ return res.status(500).json({
798
+ ok: false,
799
+ code: 'partial_rename',
800
+ error: `Renamed to "${dstId}" (${dstTier}) but failed to remove the source "${srcId}" (${srcTier}): ${err.message}`,
801
+ src_tier: srcTier,
802
+ src_id: srcId,
803
+ dst_tier: dstTier,
804
+ dst_id: dstId,
805
+ });
806
+ }
651
807
  return res
652
808
  .status(statusForCliCode(err.cliCode))
653
809
  .json({ ok: false, error: err.message, code: err.cliCode });
654
810
  }
655
- try {
656
- deleteTemplateViaCli(projectRoot, srcTier, srcId);
657
- } catch (err) {
658
- return res.status(500).json({
659
- ok: false,
660
- code: 'partial_rename',
661
- error: `Renamed to "${dstId}" (${dstTier}) but failed to remove the source "${srcId}" (${srcTier}): ${err.message}`,
662
- src_tier: srcTier,
663
- src_id: srcId,
664
- dst_tier: dstTier,
665
- dst_id: dstId,
666
- });
667
- }
668
811
  res.json({
669
812
  ok: true,
670
813
  src_tier: srcTier,
@@ -676,20 +819,131 @@ export function createTemplatesRoutes() {
676
819
 
677
820
  /**
678
821
  * GET /api/projects/:projectId/templates/:tier/:id/bundle
822
+ *
823
+ * Optional ?mode=standalone|delta (default standalone). Standalone emits a
824
+ * self-contained bundle (config materialised + prompts resolved); delta emits
825
+ * the sparse overlay. Output format (zip vs JSON) is auto-detected.
679
826
  */
680
827
  router.get('/templates/:tier/:id/bundle', (req, res) => {
681
828
  const { tier, id } = req.params;
682
829
  if (rejectInvalidTierId(res, tier, id)) return;
830
+ const mode = req.query.mode === 'delta' ? 'delta' : 'standalone';
831
+ try {
832
+ const result = exportBundle(req.project.projectRoot, id, mode);
833
+ if (!result.json) {
834
+ res.setHeader('Content-Type', 'application/zip');
835
+ res.setHeader(
836
+ 'Content-Disposition',
837
+ `attachment; filename="${result.filename}"`,
838
+ );
839
+ res.send(result.data);
840
+ } else {
841
+ res.json({ ok: true, bundle: result.data });
842
+ }
843
+ } catch (err) {
844
+ res
845
+ .status(statusForCliCode(err.cliCode))
846
+ .json({ ok: false, error: err.message, code: err.cliCode });
847
+ }
848
+ });
849
+
850
+ /**
851
+ * POST /api/projects/:projectId/templates/:tier/:id/bundle?format=gist
852
+ *
853
+ * Create a (secret) GitHub gist from the template bundle and return its URL.
854
+ * Gist creation is a mutation (it shells out to `gh gist create`), so it is a
855
+ * POST distinct from the GET bundle download above. Only `?format=gist` is
856
+ * accepted here; zip/JSON downloads use the GET route. The client copies the
857
+ * returned `gist_url` to the clipboard so the template can be shared and
858
+ * imported elsewhere via `worca templates import --from <url>`.
859
+ */
860
+ router.post('/templates/:tier/:id/bundle', (req, res) => {
861
+ const { tier, id } = req.params;
862
+ if (rejectInvalidTierId(res, tier, id)) return;
863
+ if (req.query.format !== 'gist') {
864
+ return res.status(400).json({
865
+ ok: false,
866
+ error: 'unsupported bundle POST format — use ?format=gist',
867
+ });
868
+ }
683
869
  try {
684
- const bundle = exportBundle(req.project.projectRoot, id);
685
- res.json({ ok: true, bundle });
870
+ const gistUrl = exportGist(req.project.projectRoot, id);
871
+ if (!gistUrl) {
872
+ return res.status(502).json({
873
+ ok: false,
874
+ error: 'gist created but no URL was returned by gh',
875
+ });
876
+ }
877
+ res.json({ ok: true, gist_url: gistUrl });
686
878
  } catch (err) {
879
+ // gh-missing / gist-create failures arrive here with the CLI stderr as the
880
+ // message (it mentions "gh"/"gist", which the client maps to a friendly
881
+ // "GitHub CLI is not available" toast).
687
882
  res
688
883
  .status(statusForCliCode(err.cliCode))
689
884
  .json({ ok: false, error: err.message, code: err.cliCode });
690
885
  }
691
886
  });
692
887
 
888
+ /**
889
+ * GET /api/projects/:projectId/templates/:tier/:id/overlays
890
+ *
891
+ * Returns every overlay .md file from <tmpl>/agents/, keyed by filename.
892
+ */
893
+ router.get('/templates/:tier/:id/overlays', (req, res) => {
894
+ const { tier, id } = req.params;
895
+ if (rejectInvalidTierId(res, tier, id)) return;
896
+ const { projectRoot } = req.project;
897
+ const tierDir = dirForTier(projectRoot, tier);
898
+ const tmplDir = join(tierDir, id);
899
+ if (!existsSync(join(tmplDir, 'template.json'))) {
900
+ return res.status(404).json({
901
+ ok: false,
902
+ error: `Template "${id}" not found in ${tier} scope`,
903
+ });
904
+ }
905
+ try {
906
+ const agentsDir = join(tmplDir, 'agents');
907
+ const overlays = {};
908
+ if (existsSync(agentsDir)) {
909
+ for (const f of readdirSync(agentsDir)) {
910
+ if (!_OVERLAY_NAME_RE.test(f)) continue;
911
+ try {
912
+ overlays[f] = readFileSync(join(agentsDir, f), 'utf8');
913
+ } catch {
914
+ /* skip unreadable files */
915
+ }
916
+ }
917
+ }
918
+ res.json({ ok: true, overlays });
919
+ } catch (err) {
920
+ res.status(500).json({ ok: false, error: err.message });
921
+ }
922
+ });
923
+
924
+ /**
925
+ * GET /api/projects/:projectId/templates/:tier/:id/prompts
926
+ *
927
+ * Effective per-stage prompt model for the editor's "Prompts" tab: each agent
928
+ * `*.md` and user-prompt `*.block.md` resolved against the built-in core
929
+ * prompts, classified as 'builtin' (fallback), 'pipeline' (replace), or
930
+ * 'extends' (append/overwrite merge). Unlike /overlays this is never empty —
931
+ * a template with no overlays still shows every built-in prompt. Tolerant of a
932
+ * missing template dir (returns core-only) so the tab works for new drafts.
933
+ */
934
+ router.get('/templates/:tier/:id/prompts', (req, res) => {
935
+ const { tier, id } = req.params;
936
+ if (rejectInvalidTierId(res, tier, id)) return;
937
+ const { projectRoot } = req.project;
938
+ const coreDir = join(projectRoot, '.claude', 'worca', 'agents', 'core');
939
+ const overlayDir = join(dirForTier(projectRoot, tier), id, 'agents');
940
+ try {
941
+ res.json({ ok: true, prompts: buildPromptsModel(coreDir, overlayDir) });
942
+ } catch (err) {
943
+ res.status(500).json({ ok: false, error: err.message });
944
+ }
945
+ });
946
+
693
947
  /**
694
948
  * PUT /api/projects/:projectId/default-template
695
949
  *