@synap-core/cli 1.10.0 → 1.11.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.
Files changed (120) hide show
  1. package/dist/commands/capability.js +79 -8
  2. package/dist/commands/capability.js.map +1 -1
  3. package/dist/commands/centrality.js +2 -1
  4. package/dist/commands/centrality.js.map +1 -1
  5. package/dist/commands/context.js +2 -1
  6. package/dist/commands/context.js.map +1 -1
  7. package/dist/commands/data-extra.js +7 -4
  8. package/dist/commands/data-extra.js.map +1 -1
  9. package/dist/commands/data.js +14 -4
  10. package/dist/commands/data.js.map +1 -1
  11. package/dist/commands/diagnose.js +44 -1
  12. package/dist/commands/diagnose.js.map +1 -1
  13. package/dist/commands/digest.js +2 -1
  14. package/dist/commands/digest.js.map +1 -1
  15. package/dist/commands/doc.d.ts +14 -0
  16. package/dist/commands/doc.js +61 -4
  17. package/dist/commands/doc.js.map +1 -1
  18. package/dist/commands/doctor.d.ts +21 -0
  19. package/dist/commands/doctor.js +189 -0
  20. package/dist/commands/doctor.js.map +1 -0
  21. package/dist/commands/events.js +3 -1
  22. package/dist/commands/events.js.map +1 -1
  23. package/dist/commands/graph.js +5 -3
  24. package/dist/commands/graph.js.map +1 -1
  25. package/dist/commands/import.js +40 -9
  26. package/dist/commands/import.js.map +1 -1
  27. package/dist/commands/knowledge.d.ts +6 -0
  28. package/dist/commands/knowledge.js +253 -30
  29. package/dist/commands/knowledge.js.map +1 -1
  30. package/dist/commands/launch.js +2 -2
  31. package/dist/commands/launch.js.map +1 -1
  32. package/dist/commands/lens.js +1 -1
  33. package/dist/commands/lens.js.map +1 -1
  34. package/dist/commands/market-authoring.d.ts +45 -0
  35. package/dist/commands/market-authoring.js +225 -0
  36. package/dist/commands/market-authoring.js.map +1 -0
  37. package/dist/commands/market.d.ts +22 -7
  38. package/dist/commands/market.js +335 -60
  39. package/dist/commands/market.js.map +1 -1
  40. package/dist/commands/mcp.d.ts +24 -2
  41. package/dist/commands/mcp.js +84 -3
  42. package/dist/commands/mcp.js.map +1 -1
  43. package/dist/commands/pod.js +1 -1
  44. package/dist/commands/pod.js.map +1 -1
  45. package/dist/commands/pods.d.ts +14 -0
  46. package/dist/commands/pods.js +156 -2
  47. package/dist/commands/pods.js.map +1 -1
  48. package/dist/commands/proposals-actions.js +3 -0
  49. package/dist/commands/proposals-actions.js.map +1 -1
  50. package/dist/commands/sessions.js +2 -1
  51. package/dist/commands/sessions.js.map +1 -1
  52. package/dist/commands/skills.js +3 -1
  53. package/dist/commands/skills.js.map +1 -1
  54. package/dist/commands/template.d.ts +42 -0
  55. package/dist/commands/template.js +191 -0
  56. package/dist/commands/template.js.map +1 -0
  57. package/dist/commands/upload.d.ts +30 -0
  58. package/dist/commands/upload.js +155 -0
  59. package/dist/commands/upload.js.map +1 -0
  60. package/dist/index.js +118 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/lib/capture-lane.js +5 -3
  63. package/dist/lib/capture-lane.js.map +1 -1
  64. package/dist/lib/capture-structure.d.ts +123 -0
  65. package/dist/lib/capture-structure.js +57 -0
  66. package/dist/lib/capture-structure.js.map +1 -0
  67. package/dist/lib/cp-packages.d.ts +56 -0
  68. package/dist/lib/cp-packages.js +171 -0
  69. package/dist/lib/cp-packages.js.map +1 -1
  70. package/dist/lib/hub-client.d.ts +14 -1
  71. package/dist/lib/hub-client.js +110 -19
  72. package/dist/lib/hub-client.js.map +1 -1
  73. package/dist/lib/id.d.ts +23 -0
  74. package/dist/lib/id.js +35 -0
  75. package/dist/lib/id.js.map +1 -0
  76. package/dist/lib/pod.d.ts +53 -3
  77. package/dist/lib/pod.js +81 -9
  78. package/dist/lib/pod.js.map +1 -1
  79. package/dist/lib/template-file.d.ts +74 -0
  80. package/dist/lib/template-file.js +140 -0
  81. package/dist/lib/template-file.js.map +1 -0
  82. package/node_modules/@synap/hub-rest-client/dist/index.cjs +1261 -0
  83. package/node_modules/@synap/hub-rest-client/dist/index.d.cts +1647 -0
  84. package/node_modules/@synap/hub-rest-client/dist/index.d.ts +1647 -0
  85. package/node_modules/@synap/hub-rest-client/dist/index.js +1231 -0
  86. package/node_modules/@synap/hub-rest-client/package.json +45 -0
  87. package/node_modules/@synap/hub-rest-client/src/client.ts +1631 -0
  88. package/node_modules/@synap/hub-rest-client/src/errors.ts +30 -0
  89. package/node_modules/@synap/hub-rest-client/src/index.ts +153 -0
  90. package/node_modules/@synap/hub-rest-client/src/setup.ts +133 -0
  91. package/node_modules/@synap/hub-rest-client/src/types.ts +1299 -0
  92. package/package.json +3 -2
  93. package/skills/agent-os/SKILL.md +6 -0
  94. package/skills/manifest.json +1 -1
  95. package/skills/onboard/SKILL.md +7 -0
  96. package/skills/synap/SKILL.md +110 -29
  97. package/skills/synap/capture.md +50 -152
  98. package/skills/synap/common-mistakes.md +1 -0
  99. package/skills/synap/content-os.md +83 -0
  100. package/skills/synap/digesting-a-channel.md +25 -0
  101. package/skills/synap/mental-model.md +7 -7
  102. package/skills/synap/multi-entity-capture.md +6 -4
  103. package/skills/synap/worked-examples.md +21 -0
  104. package/skills/synap/writes.md +75 -18
  105. package/skills/synap-app/SKILL.md +319 -0
  106. package/skills/synap-app/_order.txt +5 -0
  107. package/skills/synap-app/_skill.yaml +20 -0
  108. package/skills/synap-app/creating-the-base-app.md +64 -0
  109. package/skills/synap-app/governance.md +42 -0
  110. package/skills/synap-app/install-commands.md +55 -0
  111. package/skills/synap-app/overview.md +50 -0
  112. package/skills/synap-app/using-the-sdk.md +73 -0
  113. package/skills/synap-market/SKILL.md +335 -0
  114. package/skills/synap-market/_order.txt +5 -0
  115. package/skills/synap-market/_skill.yaml +24 -0
  116. package/skills/synap-market/author-and-publish.md +75 -0
  117. package/skills/synap-market/discover-and-fetch.md +59 -0
  118. package/skills/synap-market/governance-and-catalog.md +56 -0
  119. package/skills/synap-market/install.md +55 -0
  120. package/skills/synap-market/overview.md +51 -0
@@ -14,19 +14,18 @@
14
14
  * synap market install <slug> — install
15
15
  * synap market update [slug] [--yes] [--json] — check/apply drift
16
16
  *
17
- * INSTALL SCOPE: the pod's one install door (`/packages/apply`) provisions a
18
- * WORKSPACE from a package definition — it is workspace-centric. So `market
19
- * install` fully installs WORKSPACE-type packages (same door `launch` uses) and,
20
- * for the operational types (capability/skill/…), routes the user to the surface
21
- * that installs them (the browser marketplace, or `synap cap add` for a
22
- * capability already in the pod catalog) rather than silently doing nothing. A
23
- * generic CLI→pod install for standalone non-workspace packages is not wired yet.
17
+ * INSTALL SCOPE: WORKSPACE-type packages install via `/packages/apply` (the
18
+ * same door `launch` uses), which provisions a workspace from the definition.
19
+ * Non-workspace kinds (capability / automation / cell / template) install via
20
+ * the pod's kind-agnostic `market.install` verb on `/capabilities/execute`,
21
+ * which resolves the definition from `cp_catalog_cache`. Types the verb doesn't
22
+ * handle fall back to a route-to-browser message.
24
23
  */
25
24
  import ora from "ora";
26
25
  import chalk from "chalk";
27
- import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, } from "@synap-core/workspace-templates";
26
+ import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, groupByDomain, } from "@synap-core/workspace-templates";
28
27
  import { log } from "../utils/logger.js";
29
- import { resolveHubConfig, hubPost, renderHubError } from "../lib/hub-client.js";
28
+ import { resolveHubConfig, hubPost, renderHubError, HubError } from "../lib/hub-client.js";
30
29
  import { fetchProjects } from "../lib/project.js";
31
30
  import { writeGovernance } from "../lib/capture-lane.js";
32
31
  import { renderNextSteps, FLOW } from "../lib/next-steps.js";
@@ -34,6 +33,7 @@ import { getStoredToken, isTokenLocallyExpired } from "../lib/auth.js";
34
33
  import { fetchPublicBrowseRows, fetchMyRows, fetchAvailableSlugs, fetchPackageDefinition, } from "../lib/cp-packages.js";
35
34
  import { fetchInstalledSlugs, fetchInstalledTemplates } from "../lib/installed.js";
36
35
  import { isSuite } from "../lib/suite.js";
36
+ import { capabilityAdd } from "./capability.js";
37
37
  import { bundledTemplatesVersion } from "../lib/bundle-version.js";
38
38
  import { HubNetworkError } from "../lib/hub-client.js";
39
39
  // Same longer budget launch uses — an apply writes profiles/views/entities.
@@ -116,6 +116,31 @@ export async function buildMarketCatalog(filters) {
116
116
  function typeOf(entry) {
117
117
  return entry.category ?? "workspace";
118
118
  }
119
+ /**
120
+ * Map a CP package `category` (what {@link typeOf} returns) → the pod
121
+ * `market.install` verb's `kind` vocabulary, or `null` when the verb can't
122
+ * install that type. The CP's live PACKAGE_TYPES and the pod's cache `kind`
123
+ * diverge (see cp-catalog-sync.ts): the CP's `workflow` is the pod's
124
+ * `automation`; the CP's `workspace` is the pod's `template` — but a workspace
125
+ * installs through the dedicated `/packages/apply` create path, so it never
126
+ * routes through here. `automation`/`template` are also accepted verbatim so a
127
+ * row already speaking the pod's cache vocabulary still resolves.
128
+ */
129
+ export function marketInstallKind(type) {
130
+ switch (type) {
131
+ case "capability":
132
+ return "capability";
133
+ case "cell":
134
+ return "cell";
135
+ case "workflow":
136
+ case "automation":
137
+ return "automation";
138
+ case "template":
139
+ return "template";
140
+ default:
141
+ return null;
142
+ }
143
+ }
119
144
  /**
120
145
  * Client-side filter. The CP browse route filters REMOTE rows server-side, but
121
146
  * `assembleCatalog` always adds the BUNDLED (all-`workspace`) templates — so a
@@ -135,24 +160,6 @@ function matchesFilters(entry, filters) {
135
160
  }
136
161
  return true;
137
162
  }
138
- /** Status badges — suite (tag) / private / installed / tier / locked. */
139
- function badges(entry, cat) {
140
- const out = [];
141
- if (isSuite(entry))
142
- out.push(chalk.magenta("suite"));
143
- if (entry.isPrivate)
144
- out.push(chalk.yellow("private"));
145
- if (cat.installed.has(entry.slug))
146
- out.push(chalk.green("installed"));
147
- const tier = cat.tierBySlug.get(entry.slug);
148
- if (tier?.requiredTier)
149
- out.push(chalk.cyan(tier.requiredTier));
150
- else if (tier?.pricingModel && tier.pricingModel !== "free")
151
- out.push(chalk.cyan(tier.pricingModel));
152
- if (cat.lockedSlugs.has(entry.slug))
153
- out.push(chalk.red("locked"));
154
- return out;
155
- }
156
163
  function clip(s, n) {
157
164
  return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "…";
158
165
  }
@@ -302,8 +309,12 @@ export async function market(opts) {
302
309
  name: e.name,
303
310
  description: e.description,
304
311
  type: typeOf(e),
312
+ domain: e.domain ?? null,
305
313
  tags: e.tags,
306
314
  suite: isSuite(e),
315
+ overlay: isOverlay(e),
316
+ ridesBase: composeBaseOf(e.slug),
317
+ bundles: bundleCountOf(e.slug),
307
318
  isPrivate: e.isPrivate,
308
319
  installed: cat.installed.has(e.slug),
309
320
  requiredTier: tier?.requiredTier ?? null,
@@ -325,42 +336,153 @@ export async function market(opts) {
325
336
  log.dim("Try 'synap market' with no filter, or 'synap login' to see your private packages.");
326
337
  return;
327
338
  }
328
- // Suites headline; then group by type.
329
- const suites = entries.filter((e) => isSuite(e));
330
- const rest = entries.filter((e) => !isSuite(e));
331
- const render = (entry) => {
332
- const b = badges(entry, cat);
333
- console.log(" " +
334
- chalk.cyan(entry.slug.padEnd(24)) +
335
- chalk.bold(entry.name) +
336
- (b.length ? " " + b.join(" ") : ""));
337
- if (entry.description)
338
- console.log(" " + " ".repeat(24) + chalk.dim(clip(entry.description, 66)));
339
- log.blank();
339
+ renderCatalog(entries, cat);
340
+ }
341
+ /**
342
+ * The kind of composition a workspace template participates in, derived from its
343
+ * bundled dependency edges — NOT hardcoded. A `compose` edge = an OVERLAY that
344
+ * rides a base workspace (installing it bare is meaningless); a `require` edge
345
+ * on a suite = a workspace it BUNDLES. Non-bundled (private/CP-only) rows return
346
+ * `null` base and 0 bundles, which the renderer degrades gracefully.
347
+ */
348
+ function composeBaseOf(slug) {
349
+ for (const dep of bundledEdgesOf(slug)) {
350
+ if (dep.relation === "compose")
351
+ return dep.slug;
352
+ }
353
+ return null;
354
+ }
355
+ function bundleCountOf(slug) {
356
+ return bundledEdgesOf(slug).filter((d) => d.relation === "require").length;
357
+ }
358
+ function isOverlay(entry) {
359
+ return typeOf(entry) === "workspace" && !isSuite(entry) && composeBaseOf(entry.slug) !== null;
360
+ }
361
+ /**
362
+ * Section-aware catalog renderer. Three axes carry distinct signal, so each gets
363
+ * its OWN visual channel instead of one flat colored word (the old list):
364
+ * KIND → the SECTION (Suites / Workspaces-by-domain / Overlays / other types)
365
+ * STATE → a leading ✓ glyph (installed)
366
+ * ACCESS → a dim right-hand tag (private / tier / locked)
367
+ * Name reads first (you scan it); the slug is the dim type-token (you type it).
368
+ */
369
+ function renderCatalog(entries, cat) {
370
+ const width = process.stdout.columns ?? 100;
371
+ const NAME_W = 26;
372
+ const SLUG_W = 20;
373
+ // Resolve a base slug to its display name so an overlay names the WORKSPACE it
374
+ // extends, not its raw slug (e.g. "→ Brand Library", not "→ brand-library").
375
+ const nameBySlug = new Map(entries.map((e) => [e.slug, e.name]));
376
+ const accessTag = (entry) => {
377
+ const parts = [];
378
+ if (entry.isPrivate)
379
+ parts.push(chalk.yellow("private"));
380
+ const tier = cat.tierBySlug.get(entry.slug);
381
+ if (tier?.requiredTier)
382
+ parts.push(chalk.cyan(tier.requiredTier));
383
+ else if (tier?.pricingModel && tier.pricingModel !== "free")
384
+ parts.push(chalk.cyan(tier.pricingModel));
385
+ if (cat.lockedSlugs.has(entry.slug))
386
+ parts.push(chalk.red("locked"));
387
+ return parts.length ? " " + parts.join(" ") : "";
340
388
  };
389
+ // One item = one line: glyph · bold name · default-weight slug (the token you
390
+ // TYPE — never dimmed) · dim detail · dim access.
391
+ const row = (entry, detail) => {
392
+ const glyph = cat.installed.has(entry.slug) ? chalk.green("✓") : " ";
393
+ const name = clip(entry.name, NAME_W).padEnd(NAME_W);
394
+ const slug = clip(entry.slug, SLUG_W).padEnd(SLUG_W);
395
+ const access = accessTag(entry);
396
+ const head = ` ${glyph} ${chalk.bold(name)} ${slug}`;
397
+ // Budget the detail to the remaining terminal width (never wrap/overflow).
398
+ const detailText = detail ?? entry.description ?? "";
399
+ const used = 2 + 1 + 1 + NAME_W + 1 + SLUG_W + 1;
400
+ const room = Math.max(12, width - used - stripLen(access) - 1);
401
+ console.log(head + " " + chalk.dim(clip(detailText, room)) + access);
402
+ };
403
+ // An overlay's identity IS "Name → Base Workspace" — the base it extends must
404
+ // read as part of what it IS, not be buried in the description. So it gets its
405
+ // own row shape: a combined, coloured identity column, then slug, then blurb.
406
+ const IDENT_W = 40;
407
+ const overlayRow = (entry) => {
408
+ const glyph = cat.installed.has(entry.slug) ? chalk.green("✓") : " ";
409
+ const baseName = clip(nameBySlug.get(composeBaseOf(entry.slug) ?? "") ?? composeBaseOf(entry.slug) ?? "", IDENT_W - 22 - 3 // leave room for "name → " inside IDENT_W
410
+ );
411
+ const nameText = clip(entry.name, 22);
412
+ const identityVisible = `${nameText} → ${baseName}`;
413
+ const identity = chalk.bold(nameText) + chalk.dim(" → ") + chalk.cyan(baseName);
414
+ const pad = " ".repeat(Math.max(1, IDENT_W - identityVisible.length));
415
+ const slug = clip(entry.slug, SLUG_W).padEnd(SLUG_W);
416
+ const access = accessTag(entry);
417
+ const used = 2 + 1 + 1 + IDENT_W + 1 + SLUG_W + 1;
418
+ const room = Math.max(10, width - used - stripLen(access) - 1);
419
+ console.log(` ${glyph} ${identity}${pad} ${slug} ` +
420
+ chalk.dim(clip(entry.description ?? "", room)) +
421
+ access);
422
+ };
423
+ // ── Suites — the headline: one install = a whole operating core ────────────
424
+ const suites = entries.filter((e) => isSuite(e));
341
425
  if (suites.length > 0) {
342
- log.heading(" Suites — a whole operating core in one install");
426
+ log.heading(" Suites · a whole operating core in one install");
427
+ log.blank();
428
+ for (const e of suites) {
429
+ const n = bundleCountOf(e.slug);
430
+ row(e, n > 0 ? `bundles ${n} workspace${n === 1 ? "" : "s"}` : e.description ?? "");
431
+ }
343
432
  log.blank();
344
- suites.forEach(render);
345
433
  }
346
- // Group the rest by type, in a stable order.
347
- const order = ["workspace", "capability", "skill", "workflow", "view", "cell"];
348
- const byType = new Map();
349
- for (const e of rest) {
350
- const t = typeOf(e);
351
- (byType.get(t) ?? byType.set(t, []).get(t)).push(e);
434
+ // ── Workspaces — standalone installs, grouped by authored DOMAIN ───────────
435
+ const workspaces = entries.filter((e) => typeOf(e) === "workspace" && !isSuite(e) && !isOverlay(e));
436
+ if (workspaces.length > 0) {
437
+ log.heading(" Workspaces");
438
+ for (const group of groupByDomain(workspaces, (e) => e.domain)) {
439
+ log.blank();
440
+ console.log(" " + chalk.underline(group.label));
441
+ for (const e of group.items)
442
+ row(e);
443
+ }
444
+ log.blank();
352
445
  }
353
- const types = [...byType.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
354
- for (const t of types) {
355
- log.heading(` ${t.charAt(0).toUpperCase() + t.slice(1)}`);
446
+ // ── Add-ons — each rides a workspace you already have (named in-line) ───────
447
+ const overlays = entries.filter((e) => isOverlay(e));
448
+ if (overlays.length > 0) {
449
+ log.heading(" Add-ons · each extends the workspace named after the →");
450
+ log.blank();
451
+ for (const e of overlays)
452
+ overlayRow(e);
356
453
  log.blank();
357
- byType.get(t).forEach(render);
358
454
  }
359
- log.dim(`${entries.length} package${entries.length === 1 ? "" : "s"} available.`);
455
+ // ── Other package types (capability / skill / view / cell) by type ─────────
456
+ const other = entries.filter((e) => typeOf(e) !== "workspace");
457
+ if (other.length > 0) {
458
+ const order = ["capability", "skill", "workflow", "view", "cell"];
459
+ const byType = new Map();
460
+ for (const e of other) {
461
+ const t = typeOf(e);
462
+ (byType.get(t) ?? byType.set(t, []).get(t)).push(e);
463
+ }
464
+ const types = [...byType.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
465
+ for (const t of types) {
466
+ log.heading(` ${t.charAt(0).toUpperCase() + t.slice(1)}`);
467
+ log.blank();
468
+ for (const e of byType.get(t))
469
+ row(e);
470
+ log.blank();
471
+ }
472
+ }
473
+ // Footer: a scannable count line + progressive-disclosure hints.
474
+ const installedN = entries.filter((e) => cat.installed.has(e.slug)).length;
475
+ log.dim(`${installedN} installed · ${entries.length - installedN} available` +
476
+ (suites.length ? ` · ${suites.length} suite${suites.length === 1 ? "" : "s"}` : ""));
360
477
  if (!cat.loggedIn)
361
478
  log.dim("Log in to see your private packages: synap login");
362
479
  renderNextSteps(FLOW.afterMarketList());
363
480
  }
481
+ /** Visible length of a string with chalk color codes stripped — for width math. */
482
+ function stripLen(s) {
483
+ // eslint-disable-next-line no-control-regex
484
+ return s.replace(/\[[0-9;]*m/g, "").length;
485
+ }
364
486
  // ── Optional --project link ──────────────────────────────────────────────────
365
487
  /**
366
488
  * Installs are POD-WIDE. A template provisions WORKSPACES (pod-level) and their
@@ -381,6 +503,67 @@ async function resolveOptionalProject(cfg, explicitId) {
381
503
  }
382
504
  return { projectId: match.id, name: match.name };
383
505
  }
506
+ /** Human render of a preflight report — scannable summary + per-issue reasons. */
507
+ function renderPreflightReport(entry, r) {
508
+ // Hard structural failure — the definition itself won't install.
509
+ if (r.validationErrors.length > 0) {
510
+ log.error(`${entry.name} — ${r.validationErrors.length} structural error${r.validationErrors.length === 1 ? "" : "s"} (won't install):`);
511
+ for (const e of r.validationErrors)
512
+ log.hint(e);
513
+ return;
514
+ }
515
+ const p = r.profiles;
516
+ // Every issue class, not just profile conflicts — the verdict and the printed
517
+ // warnings must agree (`r.ok` excludes advisory deferred/scopeConflicts, so
518
+ // "cleanly" alone could sit above a ⚠ line; count them all here).
519
+ const issueCounts = [];
520
+ if (p.conflicts.length)
521
+ issueCounts.push(`${p.conflicts.length} profile conflict${p.conflicts.length === 1 ? "" : "s"}`);
522
+ if (p.deferred.length)
523
+ issueCounts.push(`${p.deferred.length} duplicate${p.deferred.length === 1 ? "" : "s"}`);
524
+ if (p.scopeConflicts.length)
525
+ issueCounts.push(`${p.scopeConflicts.length} scope note${p.scopeConflicts.length === 1 ? "" : "s"}`);
526
+ if (r.entityLinks.unresolved.length)
527
+ issueCounts.push(`${r.entityLinks.unresolved.length} dropped link${r.entityLinks.unresolved.length === 1 ? "" : "s"}`);
528
+ if (r.views.wouldOrphan.length)
529
+ issueCounts.push(`${r.views.wouldOrphan.length} orphaned view${r.views.wouldOrphan.length === 1 ? "" : "s"}`);
530
+ const blocking = !r.ok; // conflicts / dropped links / orphaned views
531
+ const hasAny = issueCounts.length > 0;
532
+ // Verdict — never says "clean" if any warning will print below.
533
+ if (!hasAny)
534
+ log.success(`Preflight for ${entry.name}: no conflicts — safe to install.`);
535
+ else if (blocking)
536
+ log.warn(`Preflight for ${entry.name}: would install with issues — review before installing.`);
537
+ else
538
+ log.warn(`Preflight for ${entry.name}: installs, with ${issueCounts.length} advisory note${issueCounts.length === 1 ? "" : "s"} — review below.`);
539
+ log.dim(`Profiles: ${p.create.length} to create · ${p.reused.length} reused`);
540
+ if (hasAny)
541
+ log.dim(`Issues: ${issueCounts.join(" · ")}`);
542
+ // Warnings are the PAYLOAD of a dry-run — print them BEFORE the (potentially
543
+ // long) create/reuse list so they never scroll off-screen.
544
+ // The load-bearing signal for an AI author: a declared role/kind that clashes
545
+ // with an existing pod profile is SKIPPED — never created, never mutated.
546
+ for (const c of p.conflicts)
547
+ log.warn(`${c.slug}: declared ${c.declaredKind}, pod has ${c.existingKind} → SKIP (not created)`);
548
+ for (const d of p.deferred)
549
+ log.warn(`${d.slug}: same slug exists but not promotable (${d.reason}) → a duplicate would be created`);
550
+ for (const sc of p.scopeConflicts)
551
+ log.warn(`${sc.slug}: declared scope ${sc.declaredScope}, pod has ${sc.existingScope} → reused as-is (declared scope not applied)`);
552
+ for (const link of r.entityLinks.unresolved)
553
+ log.warn(`link ${link}: an endpoint profile is a conflict → relation dropped`);
554
+ for (const v of r.views.wouldOrphan)
555
+ log.warn(`view "${v}": its scope profile is a conflict → view would have no scope`);
556
+ // One actionable hint for the whole conflict/link/view class — the remedy the
557
+ // per-line messages don't give.
558
+ if (p.conflicts.length || r.entityLinks.unresolved.length || r.views.wouldOrphan.length)
559
+ log.hint("Conflicts are skipped, never merged — rename the template's profile to a new slug, or install as-is to reuse the pod's existing one.");
560
+ for (const slug of p.create)
561
+ log.dim(`+ ${slug} (new profile)`);
562
+ for (const slug of p.reused)
563
+ log.dim(`↺ ${slug} (reuses existing pod profile)`);
564
+ if (!hasAny)
565
+ log.hint("Run without --dry-run to install.");
566
+ }
384
567
  // ── `synap market install <slug>` ───────────────────────────────────────────
385
568
  export async function marketInstall(slug, opts) {
386
569
  const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
@@ -396,14 +579,83 @@ export async function marketInstall(slug, opts) {
396
579
  process.exit(1);
397
580
  }
398
581
  const type = typeOf(entry);
399
- // Non-workspace types have no CLI→pod install door yet (the pod's
400
- // /packages/apply provisions a WORKSPACE). Be honest and route the user.
582
+ // A CAPABILITY installs through the SAME door as `cap add` — one code path, so
583
+ // the two commands can never diverge. That door resolves the template by key
584
+ // (so opt-in / syncByDefault:false caps like unipile-linkedin work — the
585
+ // params-less market.install verb below cannot) AND prompts for any required
586
+ // credential inline (a key), which a server-side verb can never do. `cap add`
587
+ // is now a thin alias of this. `--pod` is honoured (capabilityAdd resolves the
588
+ // active pod itself); `--json` prompts can't render, so a credentialed cap
589
+ // still prompts — run it interactively.
590
+ if (type === "capability") {
591
+ await capabilityAdd(slug, {});
592
+ return;
593
+ }
594
+ // Other non-workspace packages install through the pod's ONE kind-agnostic
595
+ // door — the `market.install` verb on `/capabilities/execute`. (The WORKSPACE
596
+ // path below uses `/packages/apply`, which provisions a workspace, and is
597
+ // left untouched.) The verb resolves the definition from `cp_catalog_cache`
598
+ // by (kind, slug), tier-gates, and — for the CLI's OPERATOR hub key (no
599
+ // agentUserId) — installs directly; an agent call would file a proposal.
401
600
  if (type !== "workspace") {
402
- log.warn(`"${entry.name}" is a ${type} package — not installable from the CLI yet.`);
403
- if (type === "capability") {
404
- log.hint(`Add a capability from the pod catalog: synap cap add "${entry.name}"`);
601
+ const kind = marketInstallKind(type);
602
+ if (!kind) {
603
+ log.warn(`"${entry.name}" is a ${type} package — the CLI can't install this type yet.`);
604
+ log.hint("Install it from the browser marketplace (Marketplace → find it → Install).");
605
+ return;
606
+ }
607
+ let cfg;
608
+ try {
609
+ cfg = await resolveHubConfig(opts);
610
+ }
611
+ catch (e) {
612
+ renderHubError(e);
613
+ process.exit(1);
614
+ }
615
+ const s = opts.json ? null : ora(`Installing ${entry.name}…`).start();
616
+ let res;
617
+ try {
618
+ // Pod-wide install: no workspaceId (a capability/cell/template lands on
619
+ // the pod). `market.install` is a WRITE builtin — an operator run
620
+ // owner-bypasses the gate and runs it in-process.
621
+ res = (await hubPost("/capabilities/execute", { verbId: "market.install", parameters: { slug, kind } }, cfg, applyTimeoutMs));
622
+ }
623
+ catch (e) {
624
+ s?.stop();
625
+ // 404 = this pod predates the seeded `market.install` verb (older deploy).
626
+ if (e instanceof HubError && e.status === 404) {
627
+ log.error(`This pod doesn't expose the market.install verb yet.`);
628
+ log.hint("It needs a newer pod deploy — meanwhile install from the browser marketplace.");
629
+ process.exit(1);
630
+ }
631
+ // The verb throws NOT_FOUND (surfaced as a 500) when the CP kind hasn't
632
+ // synced into `cp_catalog_cache` yet — say so actionably, not opaquely.
633
+ if (e instanceof HubError && /catalog cache/i.test(e.message)) {
634
+ log.error(`"${entry.name}" isn't in the pod's marketplace cache yet.`);
635
+ log.hint("The pod syncs the catalog every ~10 min — retry shortly, or install from the browser marketplace.");
636
+ process.exit(1);
637
+ }
638
+ log.error(`${entry.name} failed`);
639
+ renderHubError(e);
640
+ process.exit(1);
641
+ }
642
+ s?.stop();
643
+ // The execute door wraps the verb's return as { status:"run", result: <MarketInstallOutcome> }.
644
+ // The outcome is { status:"installed", result } (operator) or { status:"proposed", proposalId, reviewUrl }.
645
+ const outcome = (res.result ?? res);
646
+ const status = String(outcome.status ?? "installed");
647
+ if (opts.json) {
648
+ console.log(JSON.stringify({ slug, kind, outcome: status, result: outcome }, null, 2));
649
+ return;
405
650
  }
406
- log.hint("Install it from the browser marketplace (Marketplace → find it → Install).");
651
+ if (status === "proposed") {
652
+ const proposalId = outcome.proposalId ? String(outcome.proposalId) : undefined;
653
+ log.info(`${entry.name} — proposed (under review, not live yet).`);
654
+ if (proposalId)
655
+ log.hint(`Approve: synap proposals approve ${proposalId}`);
656
+ return;
657
+ }
658
+ log.success(`Installed ${entry.name}.`);
407
659
  return;
408
660
  }
409
661
  // Tier gate: don't silently attempt an ungrantable install.
@@ -453,6 +705,29 @@ export async function marketInstall(slug, opts) {
453
705
  const remoteVersion = cat.remoteVersionBySlug.get(slug);
454
706
  if (remoteVersion)
455
707
  pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
708
+ // ── --dry-run: write-free preflight of the CREATE path. ───────────────────
709
+ // Runs the pod's REAL resolver (write-free) so a template author sees
710
+ // profileKind conflicts and other resolution failures BEFORE installing.
711
+ // (Previews the create path only; `--onto` reconcile-preview is not modeled.)
712
+ if (opts.dryRun) {
713
+ const s = opts.json ? null : ora(`Checking ${entry.name}…`).start();
714
+ try {
715
+ const report = (await hubPost("/packages/preflight", pkg, cfg, applyTimeoutMs));
716
+ s?.stop();
717
+ if (opts.json) {
718
+ console.log(JSON.stringify({ slug, ...report }, null, 2));
719
+ return;
720
+ }
721
+ renderPreflightReport(entry, report);
722
+ return;
723
+ }
724
+ catch (e) {
725
+ s?.stop();
726
+ log.error(`${entry.name} preflight failed`);
727
+ renderHubError(e);
728
+ process.exit(1);
729
+ }
730
+ }
456
731
  // Pre-apply state — was this slug already installed with NO version stamp?
457
732
  // Feeds `classifyApplyResult`'s stamp-contradiction check below.
458
733
  const preInstalled = installedTemplates.find((t) => t.slug === slug);
@@ -474,7 +749,7 @@ export async function marketInstall(slug, opts) {
474
749
  }
475
750
  log.info(`${entry.name} — proposed (under review, not live yet).`);
476
751
  if (proposalId)
477
- log.hint(`Approve: synap proposals approve ${proposalId.slice(0, 8)}`);
752
+ log.hint(`Approve: synap proposals approve ${proposalId}`);
478
753
  renderNextSteps(steps);
479
754
  return;
480
755
  }
@@ -616,7 +891,7 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
616
891
  if (!opts.json) {
617
892
  log.info(`${entry.name} — update proposed (under review, not live yet).`);
618
893
  if (proposalId)
619
- log.hint(`Approve: synap proposals approve ${proposalId.slice(0, 8)}`);
894
+ log.hint(`Approve: synap proposals approve ${proposalId}`);
620
895
  }
621
896
  return { slug: entry.slug, outcome: "proposed", status: "proposed", warnings: [] };
622
897
  }