@worca/ui 0.41.0 → 0.42.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,137 @@ 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`);
288
316
  runWorcaTemplates(projectRoot, [
289
317
  'export',
290
318
  '--to',
291
319
  bundlePath,
292
320
  '--templates',
293
321
  id,
322
+ '--mode',
323
+ normalizedMode,
294
324
  ]);
295
325
  if (existsSync(bundlePath)) {
296
- return JSON.parse(readFileSync(bundlePath, 'utf8'));
326
+ const buf = readFileSync(bundlePath);
327
+ // ZIP local-file-header magic: 'PK\x03\x04'.
328
+ const isZip =
329
+ buf.length >= 4 &&
330
+ buf[0] === 0x50 &&
331
+ buf[1] === 0x4b &&
332
+ buf[2] === 0x03 &&
333
+ buf[3] === 0x04;
334
+ if (isZip) {
335
+ return { json: false, filename: `${id}-bundle.zip`, data: buf };
336
+ }
337
+ return { json: true, data: JSON.parse(buf.toString('utf8')) };
297
338
  }
298
- return { templates: [{ id }] };
339
+ return { json: true, data: { templates: [{ id }] } };
299
340
  } finally {
300
341
  cleanupTemp(dir);
301
342
  }
302
343
  }
303
344
 
345
+ /**
346
+ * Export a single template to a (secret) GitHub gist via the CLI and return the
347
+ * gist URL. Delegates to `worca templates export --to gist`, which shells out to
348
+ * `gh gist create` and prints the URL on stdout. Throws (via runWorcaTemplates)
349
+ * with the CLI stderr as the message when gh is unavailable or gist creation
350
+ * fails — the caller maps that to a JSON error. Gist export only supports
351
+ * overlay-free templates (the CLI rejects overlays); the UI hides the button
352
+ * for templates with overlays, matching that constraint.
353
+ */
354
+ function exportGist(projectRoot, id) {
355
+ const stdout = runWorcaTemplates(
356
+ projectRoot,
357
+ ['export', '--to', 'gist', '--templates', id],
358
+ { timeout: 30000 },
359
+ );
360
+ // The CLI prints the gist URL (from `gh gist create`) as its final stdout line.
361
+ const match = stdout.match(/https?:\/\/\S+/);
362
+ return match ? match[0] : null;
363
+ }
364
+
365
+ function handleZipImport(req, res) {
366
+ const dstTier = req.query.dst_tier;
367
+ if (!isValidTier(dstTier)) {
368
+ return res.status(400).json({
369
+ ok: false,
370
+ error: `dst_tier must be one of: ${TIERS.join(', ')}`,
371
+ });
372
+ }
373
+ if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
374
+
375
+ const zipBuffer = req.body;
376
+ const tmpPath = join(
377
+ tmpdir(),
378
+ `worca-import-${Math.random().toString(36).slice(2)}.zip`,
379
+ );
380
+ try {
381
+ writeFileSync(tmpPath, zipBuffer);
382
+ const stdout = runWorcaTemplates(
383
+ req.project.projectRoot,
384
+ ['import', '--from', tmpPath, '--scope', dstTier, '--non-interactive'],
385
+ { timeout: 60000 },
386
+ );
387
+ // Best-effort summary: the CLI may print imported template ids to stdout
388
+ const imported = [];
389
+ for (const line of (stdout || '').split('\n')) {
390
+ const m = line.match(/imported[:\s]+([a-z0-9_-]+)/i);
391
+ if (m) imported.push({ id: m[1], tier: dstTier });
392
+ }
393
+ res.json({ ok: true, count: imported.length || 1, imported });
394
+ } catch (err) {
395
+ res
396
+ .status(statusForCliCode(err.cliCode))
397
+ .json({ ok: false, error: err.message, code: err.cliCode });
398
+ } finally {
399
+ try {
400
+ rmSync(tmpPath, { force: true });
401
+ } catch {
402
+ /* ignore cleanup errors */
403
+ }
404
+ }
405
+ }
406
+
407
+ function handleJsonImport(req, res) {
408
+ const { bundle, dst_tier: dstTier } = req.body || {};
409
+ if (!bundle || typeof bundle !== 'object') {
410
+ return res
411
+ .status(400)
412
+ .json({ ok: false, error: 'bundle must be a JSON object' });
413
+ }
414
+ if (!isValidTier(dstTier)) {
415
+ return res.status(400).json({
416
+ ok: false,
417
+ error: `dst_tier must be one of: ${TIERS.join(', ')}`,
418
+ });
419
+ }
420
+ if (rejectBuiltinWrite(res, dstTier, 'import to')) return;
421
+ try {
422
+ const result = importBundle(req.project.projectRoot, bundle, dstTier);
423
+ res.json({ ok: true, ...result });
424
+ } catch (err) {
425
+ res
426
+ .status(statusForCliCode(err.cliCode))
427
+ .json({ ok: false, error: err.message, code: err.cliCode });
428
+ }
429
+ }
430
+
304
431
  function importBundle(projectRoot, bundle, tier) {
305
432
  if (!bundle || !Array.isArray(bundle.templates)) {
306
433
  throw new Error('Bundle must contain a "templates" array');
@@ -434,31 +561,21 @@ export function createTemplatesRoutes() {
434
561
  * doesn't match the literal "import" path segment as a tier
435
562
  * parameter (which would 400 with "tier must be one of …").
436
563
  *
437
- * Body: { bundle: {templates: [...]}, dst_tier }
564
+ * Accepts two content types:
565
+ * application/zip — raw zip bytes; dst_tier as query param
566
+ * application/json — { bundle: {templates: [...]}, dst_tier }
438
567
  */
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
- });
568
+ router.post(
569
+ '/templates/import',
570
+ expressRaw({ type: 'application/zip', limit: '1mb' }),
571
+ (req, res) => {
572
+ const ctype = req.headers['content-type'] || '';
573
+ if (ctype.startsWith('application/zip')) {
574
+ return handleZipImport(req, res);
575
+ }
576
+ return handleJsonImport(req, res);
577
+ },
578
+ );
462
579
 
463
580
  /**
464
581
  * POST /api/projects/:projectId/templates/validate
@@ -611,9 +728,11 @@ export function createTemplatesRoutes() {
611
728
  * POST /api/projects/:projectId/templates/:tier/:id/rename
612
729
  * Body: { dst_tier, dst_id }
613
730
  *
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.
731
+ * Delegates to `worca templates rename`a single CLI call that runs
732
+ * duplicate pointer-rewrite delete atomically in one process.
733
+ * partial_rename (500) is surfaced when the CLI reports that duplicate
734
+ * succeeded but delete failed (exit code 3, stderr contains
735
+ * "partial_rename").
617
736
  */
618
737
  router.post('/templates/:tier/:id/rename', (req, res) => {
619
738
  const { tier: srcTier, id: srcId } = req.params;
@@ -646,25 +765,33 @@ export function createTemplatesRoutes() {
646
765
  }
647
766
 
648
767
  try {
649
- duplicateTemplateViaCli(projectRoot, srcId, dstId, dstTier);
768
+ runWorcaTemplates(projectRoot, [
769
+ 'rename',
770
+ '--src-id',
771
+ srcId,
772
+ '--src-scope',
773
+ srcTier,
774
+ '--dst-id',
775
+ dstId,
776
+ '--dst-scope',
777
+ dstTier,
778
+ ]);
650
779
  } catch (err) {
780
+ if (err.cliCode === 'partial_rename') {
781
+ return res.status(500).json({
782
+ ok: false,
783
+ code: 'partial_rename',
784
+ error: `Renamed to "${dstId}" (${dstTier}) but failed to remove the source "${srcId}" (${srcTier}): ${err.message}`,
785
+ src_tier: srcTier,
786
+ src_id: srcId,
787
+ dst_tier: dstTier,
788
+ dst_id: dstId,
789
+ });
790
+ }
651
791
  return res
652
792
  .status(statusForCliCode(err.cliCode))
653
793
  .json({ ok: false, error: err.message, code: err.cliCode });
654
794
  }
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
795
  res.json({
669
796
  ok: true,
670
797
  src_tier: srcTier,
@@ -676,13 +803,27 @@ export function createTemplatesRoutes() {
676
803
 
677
804
  /**
678
805
  * GET /api/projects/:projectId/templates/:tier/:id/bundle
806
+ *
807
+ * Optional ?mode=standalone|delta (default standalone). Standalone emits a
808
+ * self-contained bundle (config materialised + prompts resolved); delta emits
809
+ * the sparse overlay. Output format (zip vs JSON) is auto-detected.
679
810
  */
680
811
  router.get('/templates/:tier/:id/bundle', (req, res) => {
681
812
  const { tier, id } = req.params;
682
813
  if (rejectInvalidTierId(res, tier, id)) return;
814
+ const mode = req.query.mode === 'delta' ? 'delta' : 'standalone';
683
815
  try {
684
- const bundle = exportBundle(req.project.projectRoot, id);
685
- res.json({ ok: true, bundle });
816
+ const result = exportBundle(req.project.projectRoot, id, mode);
817
+ if (!result.json) {
818
+ res.setHeader('Content-Type', 'application/zip');
819
+ res.setHeader(
820
+ 'Content-Disposition',
821
+ `attachment; filename="${result.filename}"`,
822
+ );
823
+ res.send(result.data);
824
+ } else {
825
+ res.json({ ok: true, bundle: result.data });
826
+ }
686
827
  } catch (err) {
687
828
  res
688
829
  .status(statusForCliCode(err.cliCode))
@@ -690,6 +831,103 @@ export function createTemplatesRoutes() {
690
831
  }
691
832
  });
692
833
 
834
+ /**
835
+ * POST /api/projects/:projectId/templates/:tier/:id/bundle?format=gist
836
+ *
837
+ * Create a (secret) GitHub gist from the template bundle and return its URL.
838
+ * Gist creation is a mutation (it shells out to `gh gist create`), so it is a
839
+ * POST distinct from the GET bundle download above. Only `?format=gist` is
840
+ * accepted here; zip/JSON downloads use the GET route. The client copies the
841
+ * returned `gist_url` to the clipboard so the template can be shared and
842
+ * imported elsewhere via `worca templates import --from <url>`.
843
+ */
844
+ router.post('/templates/:tier/:id/bundle', (req, res) => {
845
+ const { tier, id } = req.params;
846
+ if (rejectInvalidTierId(res, tier, id)) return;
847
+ if (req.query.format !== 'gist') {
848
+ return res.status(400).json({
849
+ ok: false,
850
+ error: 'unsupported bundle POST format — use ?format=gist',
851
+ });
852
+ }
853
+ try {
854
+ const gistUrl = exportGist(req.project.projectRoot, id);
855
+ if (!gistUrl) {
856
+ return res.status(502).json({
857
+ ok: false,
858
+ error: 'gist created but no URL was returned by gh',
859
+ });
860
+ }
861
+ res.json({ ok: true, gist_url: gistUrl });
862
+ } catch (err) {
863
+ // gh-missing / gist-create failures arrive here with the CLI stderr as the
864
+ // message (it mentions "gh"/"gist", which the client maps to a friendly
865
+ // "GitHub CLI is not available" toast).
866
+ res
867
+ .status(statusForCliCode(err.cliCode))
868
+ .json({ ok: false, error: err.message, code: err.cliCode });
869
+ }
870
+ });
871
+
872
+ /**
873
+ * GET /api/projects/:projectId/templates/:tier/:id/overlays
874
+ *
875
+ * Returns every overlay .md file from <tmpl>/agents/, keyed by filename.
876
+ */
877
+ router.get('/templates/:tier/:id/overlays', (req, res) => {
878
+ const { tier, id } = req.params;
879
+ if (rejectInvalidTierId(res, tier, id)) return;
880
+ const { projectRoot } = req.project;
881
+ const tierDir = dirForTier(projectRoot, tier);
882
+ const tmplDir = join(tierDir, id);
883
+ if (!existsSync(join(tmplDir, 'template.json'))) {
884
+ return res.status(404).json({
885
+ ok: false,
886
+ error: `Template "${id}" not found in ${tier} scope`,
887
+ });
888
+ }
889
+ try {
890
+ const agentsDir = join(tmplDir, 'agents');
891
+ const overlays = {};
892
+ if (existsSync(agentsDir)) {
893
+ for (const f of readdirSync(agentsDir)) {
894
+ if (!_OVERLAY_NAME_RE.test(f)) continue;
895
+ try {
896
+ overlays[f] = readFileSync(join(agentsDir, f), 'utf8');
897
+ } catch {
898
+ /* skip unreadable files */
899
+ }
900
+ }
901
+ }
902
+ res.json({ ok: true, overlays });
903
+ } catch (err) {
904
+ res.status(500).json({ ok: false, error: err.message });
905
+ }
906
+ });
907
+
908
+ /**
909
+ * GET /api/projects/:projectId/templates/:tier/:id/prompts
910
+ *
911
+ * Effective per-stage prompt model for the editor's "Prompts" tab: each agent
912
+ * `*.md` and user-prompt `*.block.md` resolved against the built-in core
913
+ * prompts, classified as 'builtin' (fallback), 'pipeline' (replace), or
914
+ * 'extends' (append/overwrite merge). Unlike /overlays this is never empty —
915
+ * a template with no overlays still shows every built-in prompt. Tolerant of a
916
+ * missing template dir (returns core-only) so the tab works for new drafts.
917
+ */
918
+ router.get('/templates/:tier/:id/prompts', (req, res) => {
919
+ const { tier, id } = req.params;
920
+ if (rejectInvalidTierId(res, tier, id)) return;
921
+ const { projectRoot } = req.project;
922
+ const coreDir = join(projectRoot, '.claude', 'worca', 'agents', 'core');
923
+ const overlayDir = join(dirForTier(projectRoot, tier), id, 'agents');
924
+ try {
925
+ res.json({ ok: true, prompts: buildPromptsModel(coreDir, overlayDir) });
926
+ } catch (err) {
927
+ res.status(500).json({ ok: false, error: err.message });
928
+ }
929
+ });
930
+
693
931
  /**
694
932
  * PUT /api/projects/:projectId/default-template
695
933
  *