@cloudgrid-io/mcp 0.17.0 → 0.18.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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/corpus/capability-map.md +12 -6
  3. package/src/corpus/cloudgrid-yaml.md +7 -5
  4. package/src/corpus/cookbook.md +4 -4
  5. package/src/corpus/install-for-agents.md +1 -2
  6. package/src/corpus/skill-plug.md +69 -6
  7. package/src/corpus/skills-readme.md +9 -9
  8. package/src/corpus/templates/api-docs/README.md +1 -1
  9. package/src/corpus/templates/api-docs/index.md +2 -3
  10. package/src/corpus/templates/appointment-booking/AGENTS.md +1 -1
  11. package/src/corpus/templates/changelog/README.md +1 -1
  12. package/src/corpus/templates/changelog/index.md +2 -3
  13. package/src/corpus/templates/company-website/README.md +2 -3
  14. package/src/corpus/templates/company-website/cloudgrid.yaml +1 -1
  15. package/src/corpus/templates/company-website/index.md +2 -3
  16. package/src/corpus/templates/dashboard/cloudgrid.yaml +1 -1
  17. package/src/corpus/templates/dashboard/index.md +2 -3
  18. package/src/corpus/templates/deck/cloudgrid.yaml +1 -1
  19. package/src/corpus/templates/deck/index.md +2 -3
  20. package/src/corpus/templates/docs-site/README.md +1 -1
  21. package/src/corpus/templates/docs-site/index.md +2 -3
  22. package/src/corpus/templates/landing-page/cloudgrid.yaml +1 -1
  23. package/src/corpus/templates/landing-page/index.md +2 -3
  24. package/src/corpus/templates/membership-site/AGENTS.md +1 -1
  25. package/src/corpus/templates/portfolio/README.md +1 -1
  26. package/src/corpus/templates/portfolio/index.md +2 -3
  27. package/src/corpus/templates/product-launch/README.md +2 -3
  28. package/src/corpus/templates/product-launch/cloudgrid.yaml +1 -1
  29. package/src/corpus/templates/product-launch/index.md +2 -3
  30. package/src/corpus/templates/report/cloudgrid.yaml +1 -1
  31. package/src/corpus/templates/report/index.md +2 -3
  32. package/src/corpus/templates/saas-marketing/README.md +1 -1
  33. package/src/corpus/templates/saas-marketing/index.md +2 -3
  34. package/src/corpus/templates/status-page/README.md +1 -1
  35. package/src/corpus/templates/status-page/index.md +2 -3
  36. package/src/corpus/templates/subscription-management/AGENTS.md +1 -1
  37. package/src/corpus/templates/waitlist/README.md +1 -1
  38. package/src/corpus/templates/waitlist/index.md +2 -3
  39. package/src/corpus/templates/web-app/cloudgrid.yaml +1 -1
  40. package/src/corpus/templates/web-app/index.md +2 -3
  41. package/src/corpus/usage.md +2 -2
  42. package/src/corpus/workflows/api-docs.md +1 -1
  43. package/src/corpus/workflows/changelog.md +1 -1
  44. package/src/corpus/workflows/company-website.md +1 -1
  45. package/src/corpus/workflows/dashboard.md +1 -1
  46. package/src/corpus/workflows/docs-site.md +1 -1
  47. package/src/corpus/workflows/landing-page.md +1 -1
  48. package/src/corpus/workflows/portfolio.md +1 -1
  49. package/src/corpus/workflows/presentation.md +1 -1
  50. package/src/corpus/workflows/product-launch.md +1 -1
  51. package/src/corpus/workflows/report.md +1 -1
  52. package/src/corpus/workflows/saas-marketing.md +1 -1
  53. package/src/corpus/workflows/status-page.md +1 -1
  54. package/src/corpus/workflows/waitlist.md +1 -1
  55. package/src/corpus/workflows/web-app.md +1 -1
  56. package/src/tools.js +225 -630
  57. package/src/web.js +1 -1
  58. package/src/widgets/org-picker.html +3 -3
  59. package/src/corpus/skill-drop.md +0 -140
package/src/tools.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Two editions register from here:
4
4
  // - local (stdio): full toolset, including the CLI-wrapping tools. Identity
5
5
  // comes from ~/.cloudgrid/credentials.
6
- // - web (HTTP, hosted): the light, CLI-free toolset (drop, claim, login).
6
+ // - web (HTTP, hosted): the light, CLI-free toolset (plug, claim, login).
7
7
  // Identity is a per-session token held in memory.
8
8
  //
9
9
  // The difference is injected as a `ctx` object, so the tool logic is written once.
@@ -44,6 +44,14 @@ const ANON_HTML_MAX_BYTES = 2_000_000;
44
44
  const AUTHED_HTML_MAX_BYTES = 25_000_000;
45
45
  const CONSOLE_URL = "https://console.cloudgrid.io/";
46
46
 
47
+ const VISIBILITY_LABELS = {
48
+ private: "Only you",
49
+ org: "Your org",
50
+ authenticated: "Anyone signed in",
51
+ space: "A space",
52
+ link: "Anyone with the link",
53
+ };
54
+
47
55
  // ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
48
56
  // The Apps-SDK UI widgets (openai/outputTemplate → a ui:// html resource) render
49
57
  // as a broken black frame in ChatGPT today, hiding the plain-text result. Gate
@@ -64,14 +72,6 @@ const WIDGET_CSP = {
64
72
  resourceDomains: ["https://*.cloudgrid.io"],
65
73
  };
66
74
 
67
- const VISIBILITY_LABELS = {
68
- private: "Only you",
69
- org: "Your org",
70
- authenticated: "Anyone signed in",
71
- space: "A space",
72
- link: "Anyone with the link",
73
- };
74
-
75
75
  function ok(text) {
76
76
  return { content: [{ type: "text", text }] };
77
77
  }
@@ -94,14 +94,14 @@ Operating rules:
94
94
  6. If a signed-in publish fails with a server error, do not fall back to anonymous publishing (it burns the anonymous quota and downgrades ownership); surface the error, use the CLI fallback if offered, or ask the user.
95
95
  7. When signed in and the user has more than one grid, do not assume a target — the publish tools will ask; relay the choice to the user and pass the chosen grid.
96
96
  8. When a build/deploy fails unexpectedly, offer to report it to the CloudGrid team — only with the user's explicit consent (ask first). Send just the error + the failed request by default (call grid_report), and never send the whole conversation unless the user agrees (include_conversation). Respect privacy.
97
- 9. To modify an existing drop when you don't already have its HTML in context, first call grid_source to fetch the current HTML, apply your change, then call grid_drop/grid_plug with target_entity_id (the drop's entity_id) to update the SAME URL in place. Do not ask the user to paste the HTML back.
98
- 10. Publishing a heavy or local file: in the local edition use the path parameter so it is read from disk (no inline size limit); never base64-encode HTML and never pass a file path (or an @-prefixed path) as html. If a drop looks empty, use grid_source to check what was actually published, then re-plug with the real HTML/path and target_entity_id.
97
+ 9. To modify an existing page when you don't already have its HTML in context, first call grid_source to fetch the current HTML, apply your change, then call grid_plug with target_entity_id (the entity_id) to update the SAME URL in place. Do not ask the user to paste the HTML back.
98
+ 10. Publishing a single HTML page: pass it inline as grid_plug's html parameter (a full self-contained document). For a heavy or image-heavy page in the local edition, pass the path parameter instead so it is read from disk (no inline size limit); never base64-encode HTML and never pass a file path (or an @-prefixed path) as html. If a page looks empty, use grid_source to check what was actually published, then re-plug with the real HTML/path and target_entity_id.
99
99
  11. Persistence check: if the user needs to SAVE data, share state across users/sessions, log in, or store submissions, that's a runtime app-with-data (Mongo-backed), NOT a static page — static templates keep state only in memory and lose it on refresh. Use the app-with-data workflow. This requires the LOCAL edition (Claude Desktop/Code or the CLI); on the hosted edition, tell the user persistence isn't available there and offer a static version.
100
- 12. To choose what to build: match the request against the workflow when: triggers and the capability-map (grid_fetch({kind:"doc", name:"capability-map"})). Pick the template whose needs: matches what the app requires (persistence → database; scheduled → cron; etc.). Static (needs: none) deploys as an inspiration (instant, any edition); anything needing needs: is a runtime (async, local edition).
100
+ 12. To choose what to build: match the request against the workflow when: triggers and the capability-map (grid_fetch({kind:"doc", name:"capability-map"})). Pick the template whose needs: matches what the app requires (persistence → database; scheduled → cron; etc.). Classify the ARTIFACT to pick the deploy: ONE self-contained HTML page (a single file — CSS+JS inline, images/fonts as data: URIs; that is the normal hosted output) → an inspiration — instant, ANY edition, deploy via grid_plug with the inline html param. Only genuinely SEPARATE files/folders (a real assets/ dir, separate .css/.js files, multiple pages, a SPA build) — OR anything needing needs: (data/server/LLM/cron) → a runtime app — grid_plug on a linked folder with a cloudgrid.yaml, local edition only, async build.
101
101
  13. Before writing a cloudgrid.yaml, fetch the reference: grid_fetch({kind:"doc", name:"cloudgrid-yaml"}) — it has the full schema and the needs: vocabulary. Declare infrastructure with needs: (the deployer injects from it): needs: { database: true } → Mongo (DATABASE_MONGODB_URL); needs: { cache: true } → Redis; scheduled work → a service of type: cron (Python or Node). Use needs: OR requires:, never both — declaring both is rejected.
102
102
  14. Databases — CloudGrid supports both, so never tell the user to self-host. Managed: needs: { database: true } provisions Mongo and injects DATABASE_MONGODB_URL. Bring-your-own (they already run Postgres / MySQL / MongoDB / Supabase / Neon / PlanetScale / etc.): needs: { database: { tier: external, secret: MY_DB } } plus grid secrets set MY_DB=<connection-string> — the connection string lives in env SECRETS, never committed. If asked "what databases does CloudGrid support?": all of them — the managed CloudGrid database out of the box, or bring your own via keys — ask which they want.
103
103
 
104
- Deploy is edition-dependent: on the hosted MCP call the drop tool with the HTML; on local MCP / CLI write the file and run the plug tool. An HTML page deploys synchronously, so you get a URL right away.`;
104
+ Deploy is via grid_plug on every edition: for a single HTML page pass it inline as the html param (works on the hosted MCP too); for a multi-file app write the files and pass a folder path (local MCP / CLI). A single HTML page deploys synchronously as an inspiration, so you get a URL right away.`;
105
105
 
106
106
  // The corpus subdirectories that grid_fetch serves, keyed by `kind`. Each
107
107
  // lives in its own subtree of src/corpus/ (populated by scripts/snapshot-corpus.mjs
@@ -710,9 +710,9 @@ async function fetchUserOrgs(token) {
710
710
  }
711
711
  }
712
712
 
713
- // ── Shared grid disambiguation (grid_drop + grid_plug) ──────────────────
714
- // The stateless "which grid?" ask, used identically by both publish verbs so
715
- // they never drift. Given the caller's token and a supplied grid, it decides:
713
+ // ── Shared grid disambiguation (grid_plug) ──────────────────
714
+ // The stateless "which grid?" ask on an authed create. Given the caller's token
715
+ // and a supplied grid, it decides:
716
716
  // - supplied grid matches a membership → { proceed: true, grid }
717
717
  // - >1 grid and none supplied → { picker } (a ready-to-return result)
718
718
  // - exactly one grid → { single: annotatedOrg } — the caller
@@ -771,31 +771,6 @@ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, de
771
771
  return { proceed: true };
772
772
  }
773
773
 
774
- // After an authenticated web drop, upgrade visibility to "link" so the artifact
775
- // is shareable and its preview renders without a sign-in wall. Best-effort — a
776
- // failure here does not fail the drop; the user can always call grid_visibility.
777
- async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
778
- const token = await ctx.getToken();
779
- if (!token || !entityId) return false;
780
- try {
781
- const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
782
- // Send the grid-native header; keep X-CloudGrid-Org in parallel (same value)
783
- // during the org→grid soak. Never send conflicting values → 400 GRID_HEADER_CONFLICT.
784
- if (orgSlug) {
785
- hdrs["X-CloudGrid-Grid"] = orgSlug;
786
- hdrs["X-CloudGrid-Org"] = orgSlug;
787
- }
788
- const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
789
- method: "PATCH",
790
- headers: hdrs,
791
- body: JSON.stringify({ visibility: "link" }),
792
- });
793
- return res.ok;
794
- } catch {
795
- return false;
796
- }
797
- }
798
-
799
774
  // ── Direct-API tools (both editions) ───────────────────────────────────────────
800
775
  function looksLikeFullHtml(s) {
801
776
  const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
@@ -846,6 +821,55 @@ function looksLikePath(s) {
846
821
  return /^(~|\.{0,2}\/|[A-Za-z]:[\\/]|\/)/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
847
822
  }
848
823
 
824
+ // Normalize an inline `html` string — grid_plug's ergonomic single-file publish
825
+ // path — into ONE index.html artifact, reusing the same hardening the old drop
826
+ // verb used (decodeIfBase64Html, the @-path/file-path rejection, the base64
827
+ // guard, and the small-fragment wrap). Returns { path, buffer, type }. Throws on
828
+ // a file-path-looking or non-HTML input so a path/base64 blob is never published
829
+ // as page content. The auth-aware inline size cap is enforced later in runPlug
830
+ // (it depends on the anon-vs-authed wire).
831
+ function htmlToArtifact(html, filename) {
832
+ if (typeof html !== "string" || html.length === 0) {
833
+ throw new Error("`html` must be the complete HTML document as a string.");
834
+ }
835
+ // Strip an invented `@`-prefix shorthand, then reject a bare file path — the
836
+ // inline html path takes HTML CONTENT, not a path (use `path` for a file).
837
+ let candidate = html.startsWith("@") ? html.slice(1) : html;
838
+ if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
839
+ throw new Error(
840
+ `This looks like a file path (\`${candidate.trim()}\`), not HTML. Pass the raw HTML inline as ` +
841
+ "`html`, or pass the file/folder via `path` — do not pass a path as `html`.",
842
+ );
843
+ }
844
+ // Rescue a base64-of-HTML blob passed as `html` (real user repro): decode it
845
+ // rather than publishing a wall of base64 text.
846
+ const { html: resolved } = decodeIfBase64Html(candidate);
847
+ let content = resolved;
848
+ if (!looksLikeFullHtml(content)) {
849
+ const stripped = content.replace(/\s+/g, "");
850
+ const looksBase64 =
851
+ stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
852
+ const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
853
+ const hasTag = /<[a-z][\s\S]*>/i.test(content);
854
+ if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
855
+ // Legit "share this snippet" — wrap a small text/markup fragment into a
856
+ // full document (preserve the old drop's friendly behavior).
857
+ content =
858
+ `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
859
+ `<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
860
+ } else {
861
+ // Large, or base64 that failed to decode to HTML, or a bare file path:
862
+ // refuse instead of silently publishing garbage.
863
+ const kind = looksBase64 ? "base64" : looksLikePath(content) ? "a file path" : "raw data";
864
+ throw new Error(
865
+ `This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
866
+ "or in the local edition pass `path` to the .html file — do NOT base64-encode it.",
867
+ );
868
+ }
869
+ }
870
+ return { path: filename || "index.html", buffer: Buffer.from(content, "utf8"), type: "text/html" };
871
+ }
872
+
849
873
  // Anonymous drops are owned by the platform Guest Org, whose slug is the apex
850
874
  // the public URL hangs off (https://guest.cloudgrid.io/<slug>).
851
875
  const GUEST_ORG_SLUG = "guest";
@@ -908,430 +932,6 @@ export function resolvePlugUrl(data) {
908
932
  return composePlugUrl(data);
909
933
  }
910
934
 
911
- export async function runDrop(
912
- ctx,
913
- { html, path: filePath, filename, anonymous, org, grid, fresh, entity_id, owner_token },
914
- deps = {},
915
- ) {
916
- // `grid` is an accepted alias for `org` (Gilad's org→grid rename); `org` still works.
917
- const targetGrid = org ?? grid;
918
- // The `deps` seam (readFile/exists) exists ONLY so the content-handling
919
- // regression tests can drive the `path` / `@`-path routes offline. Production
920
- // always calls this with no deps.
921
- const { readFile: readFileImpl = readFile, fsExists = existsSync } = deps;
922
-
923
- // Defensive: the web edition schema excludes `path`, but if a model still
924
- // passes one (e.g. from a cached tool description), reject early with a
925
- // clear explanation.
926
- if (ctx.edition === "web" && filePath) {
927
- throw new Error(
928
- "The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
929
- );
930
- }
931
-
932
- // An agent may pass an `@`-prefixed path (invented `@/home/claude/...`
933
- // shorthand) or a bare filesystem path as `html`. Normalise: strip a leading
934
- // `@`, and in the local edition promote a path-looking `html` to the `path`
935
- // route (read from disk) when the file exists. On the hosted server there is
936
- // no disk, so a path-looking `html` is a clear error.
937
- let effectivePath = filePath;
938
- let htmlInput = typeof html === "string" ? html : undefined;
939
- if (!effectivePath && typeof htmlInput === "string") {
940
- let candidate = htmlInput;
941
- if (candidate.startsWith("@")) candidate = candidate.slice(1);
942
- if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
943
- if (ctx.edition === "web") {
944
- throw new Error(
945
- `This looks like a file path (\`${candidate.trim()}\`), but the hosted server cannot read local ` +
946
- `files — pass the raw HTML inline as \`html\`.`,
947
- );
948
- }
949
- const resolvedPath = candidate.trim();
950
- if (fsExists(resolvedPath)) {
951
- effectivePath = resolvedPath;
952
- htmlInput = undefined;
953
- } else {
954
- throw new Error(
955
- `The path \`${resolvedPath}\` does not exist. Pass a valid \`path\` to the .html file, or pass ` +
956
- `the raw HTML inline as \`html\` (do not base64-encode it, and there is no artifact_files parameter).`,
957
- );
958
- }
959
- } else if (candidate !== htmlInput) {
960
- // Leading `@` on genuine inline content — strip it and keep the content.
961
- htmlInput = candidate;
962
- }
963
- }
964
-
965
- let bytes;
966
- let name;
967
- let type;
968
- // Anonymous drops are inline-capped hard; signed-in drops get a larger cap.
969
- // The cap is enforced AFTER auth is resolved (see below), so only inline
970
- // routes flag themselves here — `path` (local, read from disk) is uncapped.
971
- let enforceInlineCap = false;
972
-
973
- if (effectivePath) {
974
- const raw = await readFileImpl(effectivePath);
975
- const asText = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
976
- const { html: decoded, wasBase64 } = decodeIfBase64Html(asText);
977
- const lowerName = String(effectivePath).toLowerCase();
978
- const nameIsHtml = lowerName.endsWith(".html") || lowerName.endsWith(".htm");
979
- if (wasBase64) {
980
- // A base64 `.txt` (or similar) that decoded to real HTML → publish the
981
- // decoded HTML as text/html under an index.html name.
982
- bytes = Buffer.from(decoded, "utf8");
983
- type = "text/html";
984
- name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
985
- } else {
986
- bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
987
- // Content-type sniff (fix #4): serve HTML as text/html so it renders as a
988
- // page instead of downloading as a blob.
989
- if (looksLikeFullHtml(asText) || nameIsHtml) {
990
- type = "text/html";
991
- name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
992
- } else {
993
- type = "application/octet-stream";
994
- name = filename || basename(effectivePath);
995
- }
996
- }
997
- } else if (typeof htmlInput === "string" && htmlInput.length > 0) {
998
- // Rescue a base64-of-HTML blob passed as `html` (real user repro).
999
- const { html: resolved } = decodeIfBase64Html(htmlInput);
1000
- let content = resolved;
1001
- if (!looksLikeFullHtml(content)) {
1002
- const stripped = content.replace(/\s+/g, "");
1003
- const looksBase64 =
1004
- stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
1005
- const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
1006
- const hasTag = /<[a-z][\s\S]*>/i.test(content);
1007
- if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
1008
- // Legit "share this snippet" — wrap a small text/markup fragment into a
1009
- // full document (preserve existing friendly behavior).
1010
- content =
1011
- `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
1012
- `<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
1013
- } else {
1014
- // Large, or base64 that failed to decode to HTML, or a bare file path:
1015
- // refuse instead of silently publishing garbage.
1016
- const kind = looksBase64
1017
- ? "base64"
1018
- : looksLikePath(content)
1019
- ? "a file path"
1020
- : "raw data";
1021
- throw new Error(
1022
- `This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
1023
- `or in the local edition pass \`path\` to the .html file — do NOT base64-encode it. ` +
1024
- `There is no artifact_files parameter.`,
1025
- );
1026
- }
1027
- }
1028
- bytes = Buffer.from(content, "utf8");
1029
- name = filename || "index.html";
1030
- type = "text/html";
1031
- enforceInlineCap = true;
1032
- } else {
1033
- throw new Error("Provide either `html` (inline content) or `path` (a local file).");
1034
- }
1035
-
1036
- // The caller's authed identity (unless they force an anonymous drop).
1037
- let authToken = null;
1038
- let orgSlug = null;
1039
- if (anonymous !== true) {
1040
- authToken = await ctx.getToken();
1041
- if (authToken) {
1042
- orgSlug = targetGrid || (await ctx.getActiveGrid());
1043
- }
1044
- }
1045
-
1046
- // ── Re-plug targeting (unified plug contract / spec v2) ──────
1047
- // A `target_entity_id` on the wire UPDATES THAT SAME ENTITY in place — same
1048
- // entity_id, same slug, same URL, deploy history preserved. Absent → create.
1049
- // The session remembers the last drop, so a re-drop updates it by default;
1050
- // `fresh: true` forces a create; an explicit `entity_id` targets any earlier
1051
- // drop (the durable re-plug handle a stateless caller persisted).
1052
- if (fresh === true && entity_id) {
1053
- throw new Error("`fresh: true` forces a new drop — do not pass `entity_id` with it.");
1054
- }
1055
- const sessionDrop = ctx.state.lastDrop;
1056
- let targetId = null;
1057
- let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
1058
- if (fresh !== true) {
1059
- targetId = entity_id || sessionDrop?.entity_id || null;
1060
- // An anon-minted drop is edited with its OWNER TOKEN (a bearer capability,
1061
- // the anon owner-token contract). Recover it from session state when not passed.
1062
- if (targetId && !ownerToken) {
1063
- if (sessionDrop?.entity_id === targetId && sessionDrop.owner_token) {
1064
- ownerToken = sessionDrop.owner_token;
1065
- } else if (ctx.state.lastAnonClaim?.entity_id === targetId && ctx.state.lastAnonClaim.token) {
1066
- ownerToken = ctx.state.lastAnonClaim.token;
1067
- }
1068
- }
1069
- }
1070
-
1071
- // Pick the edit wire. An anon-owned (Guest-Grid, unclaimed) drop is edited via
1072
- // the owner-token wire EVEN IF the caller is signed in — the entity still lives
1073
- // in the Guest Grid, so an authed edit of it would 404. An authed edit targets
1074
- // an entity the caller owns in their grid.
1075
- let mode = "create"; // "create" | "authed-edit" | "anon-edit"
1076
- if (targetId) {
1077
- if (ownerToken) {
1078
- mode = "anon-edit";
1079
- } else if (authToken) {
1080
- mode = "authed-edit";
1081
- } else if (entity_id) {
1082
- throw new Error(
1083
- "Re-plugging a drop anonymously needs its owner_token (returned when it was created). " +
1084
- "Pass owner_token, sign in if you own it, or pass fresh: true to publish a new drop.",
1085
- );
1086
- } else {
1087
- // Session target, but no way to authorize an edit — fall back to create.
1088
- targetId = null;
1089
- }
1090
- }
1091
- const isAnonymousWire = mode === "anon-edit" || !authToken;
1092
-
1093
- // Auth-aware inline size cap (relocated here — auth is only known now). Anon
1094
- // inline drops stay capped at 2MB; signed-in inline drops get the larger
1095
- // AUTHED cap. `path` (read from disk) is uncapped and does not set the flag.
1096
- if (enforceInlineCap) {
1097
- if (isAnonymousWire) {
1098
- if (bytes.byteLength > ANON_HTML_MAX_BYTES) {
1099
- throw new Error(
1100
- `This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
1101
- `Trim it, or sign in to publish larger.`,
1102
- );
1103
- }
1104
- } else if (bytes.byteLength > AUTHED_HTML_MAX_BYTES) {
1105
- throw new Error(
1106
- `This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
1107
- `${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
1108
- `.html file (read from disk — no inline size limit) instead of pasting it inline.`,
1109
- );
1110
- }
1111
- }
1112
-
1113
- const headers = {};
1114
- if (!isAnonymousWire) {
1115
- headers["Authorization"] = `Bearer ${authToken}`;
1116
- // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1117
- if (orgSlug) {
1118
- headers["X-CloudGrid-Grid"] = orgSlug;
1119
- headers["X-CloudGrid-Org"] = orgSlug;
1120
- }
1121
- }
1122
-
1123
- // Hosted server: attach the trusted-server credential on EVERY anonymous call
1124
- // (creates AND owner-token edits — anon edits consume the same daily anon cap,
1125
- // re-keyed per end user). Falls back gracefully server-side if absent. Only
1126
- // the web edition sets ctx.trustedServer.
1127
- if (isAnonymousWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
1128
- headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
1129
- headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
1130
- }
1131
-
1132
- // Ownership continuity: replay the platform's anon-session cookie across drops in
1133
- // this session, so cookie-class callers can redrop (and claim) what they dropped.
1134
- if (isAnonymousWire && ctx.state.anonCookie) {
1135
- headers["Cookie"] = ctx.state.anonCookie;
1136
- }
1137
-
1138
- const form = new FormData();
1139
- // The artifact part name is unchanged from `/drop/auto` (`artifact`); the plug
1140
- // create path — and the inspiration edit paths — treat every
1141
- // non-`cloudgrid.yaml` part as raw artifact bytes.
1142
- form.append("artifact", new Blob([bytes], { type }), name);
1143
- if (mode !== "create") {
1144
- form.append("target_entity_id", targetId);
1145
- }
1146
- if (mode === "anon-edit") {
1147
- // Anon owner-token contract: ownership is proven by HOLDING the token (a form
1148
- // field, NOT an Authorization header). The response re-mints it.
1149
- form.append("owner_token", ownerToken);
1150
- }
1151
- if (mode === "authed-edit") {
1152
- // The authed update path requires a `cloudgrid.yaml` part on the wire
1153
- // (materializePlugTarball). An inspiration edit ignores its content, so an
1154
- // empty part satisfies the contract without changing the entity's config.
1155
- form.append("cloudgrid.yaml", new Blob([""], { type: "text/plain" }), "cloudgrid.yaml");
1156
- }
1157
- // `/plug` resolves the authed org from the `X-CloudGrid-Org` header (set
1158
- // above), not a form field — so `org_slug` is no longer sent.
1159
-
1160
- let res;
1161
- try {
1162
- res = await fetch(`${API_BASE}/api/v2/plug`, { method: "POST", headers, body: form });
1163
- } catch (err) {
1164
- throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1165
- }
1166
-
1167
- const raw = await res.text();
1168
- let data = null;
1169
- try {
1170
- data = JSON.parse(raw);
1171
- } catch {
1172
- /* handled below */
1173
- }
1174
- if (!res.ok) {
1175
- const code = data?.error?.code;
1176
- const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
1177
- const hint = data?.error?.details?.[0]?.hint;
1178
- const isEdit = mode !== "create";
1179
- // Self-heal rung: a signed-in CREATE that hits the known 400 SCOPE_INVALID
1180
- // platform bug is retried through the bundled CLI — LOCAL edition only,
1181
- // create only, never anonymous. (Mirrors runPlug.)
1182
- if (
1183
- res.status === 400 &&
1184
- code === "SCOPE_INVALID" &&
1185
- ctx.edition === "local" &&
1186
- mode === "create" &&
1187
- !isAnonymousWire &&
1188
- authToken
1189
- ) {
1190
- return plugViaCliFallback(ctx, [{ path: name, buffer: bytes }], deps);
1191
- }
1192
- // An explicit edit NEVER silently creates — surface the reason clearly.
1193
- if (isEdit && res.status === 409) {
1194
- throw new Error(
1195
- `Re-plug rejected (HTTP 409): ${msg} ` +
1196
- "The drop can no longer be edited in place (expired, archived, claimed, or a deploy is running). " +
1197
- "Pass fresh: true to publish a new drop instead.",
1198
- );
1199
- }
1200
- if (mode === "anon-edit" && res.status === 401) {
1201
- throw new Error(
1202
- `Re-plug rejected (HTTP 401): ${msg} ` +
1203
- "The owner_token did not authorize this entity (wrong entity, expired with the drop, or already claimed). " +
1204
- "Pass fresh: true to publish a new drop instead.",
1205
- );
1206
- }
1207
- // Known-code guidance (SCOPE_INVALID on web, 429 anon cap, etc.); unknown
1208
- // codes pass through as the bare error line, unchanged.
1209
- const guidance = errorGuidance({
1210
- status: res.status,
1211
- code,
1212
- edition: ctx.edition,
1213
- isEdit,
1214
- isAnon: isAnonymousWire,
1215
- signedIn: Boolean(authToken),
1216
- });
1217
- const baseLine = `Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`;
1218
- throw new Error(guidance ? `${baseLine} — ${guidance}` : baseLine);
1219
- }
1220
-
1221
- // Persist the platform's anon-session cookie for ownership continuity (so a
1222
- // cookie-class caller can claim — via pickup — what it dropped).
1223
- const setCookies = res.headers.getSetCookie
1224
- ? res.headers.getSetCookie()
1225
- : [res.headers.get("set-cookie")].filter(Boolean);
1226
- const anonCookie = (setCookies || [])
1227
- .map((c) => (c || "").split(";")[0])
1228
- .find((c) => c.startsWith("cg_anon_session="));
1229
- if (anonCookie) ctx.state.anonCookie = anonCookie;
1230
-
1231
- // The server composes the canonical URL; compose client-side only as
1232
- // a fallback when it came back empty.
1233
- const url = resolvePlugUrl(data);
1234
- const isEdit = mode !== "create";
1235
-
1236
- // The anon owner token: the create response mints it; every anon edit
1237
- // RE-MINTS it to the reset TTL (replace the stored one — the freshest token
1238
- // is the one that lives as long as the drop). Same JWT the claim uses.
1239
- let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
1240
- ? data.owner_token
1241
- : null;
1242
- if (!freshOwnerToken && data.claim_url) {
1243
- try {
1244
- freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
1245
- } catch {
1246
- freshOwnerToken = null;
1247
- }
1248
- }
1249
-
1250
- // Remember the drop for session continuity — any caller class. The
1251
- // `{entity_id, owner_token}` pair is the stateless anon re-plug handle.
1252
- if (data.entity_id || url) {
1253
- ctx.state.lastDrop = {
1254
- entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
1255
- url: url ?? ctx.state.lastDrop?.url ?? null,
1256
- owner_token: isAnonymousWire
1257
- ? (freshOwnerToken ?? (mode === "anon-edit" ? ownerToken : null))
1258
- : null,
1259
- };
1260
- }
1261
-
1262
- // Authenticated wire (create or in-place edit): the entity is owned by the caller.
1263
- if (!isAnonymousWire) {
1264
- ctx.state.lastAnonClaim = null;
1265
- const structured = {
1266
- url,
1267
- status: isEdit ? "updated" : "created",
1268
- owned_by: "authenticated",
1269
- ...(data.entity_id ? { entity_id: data.entity_id } : {}),
1270
- };
1271
- const lines = [];
1272
- if (isEdit) {
1273
- lines.push(`Updated in place: ${url}`);
1274
- lines.push("Same link, new content. The expiry timer was reset.");
1275
- } else if (ctx.edition === "web") {
1276
- lines.push(`Your app is live: ${url}`);
1277
- } else {
1278
- lines.push(`Published to your org: ${url}`, "Owned by you.");
1279
- }
1280
- if (ctx.edition === "web" && !isEdit) {
1281
- // Default authed web CREATES to "link" visibility so the URL is shareable
1282
- // and the console thumbnail renders without a sign-in wall. An edit keeps
1283
- // the entity's existing visibility untouched.
1284
- if (data.entity_id) {
1285
- await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
1286
- }
1287
- lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
1288
- const vis = "link";
1289
- lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
1290
- structured.console_url = CONSOLE_URL;
1291
- structured.current_visibility = vis;
1292
- structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
1293
- }
1294
- return { text: lines.join("\n"), structured };
1295
- }
1296
-
1297
- // Anonymous wire — Guest-Grid inspiration (create 201, or owner-token edit).
1298
- // The response carries the reward fields (`claim_url` + `claim_message`), the
1299
- // real `entity_id`, and the OWNER TOKEN — the bearer capability that
1300
- // authorizes BOTH a later anonymous re-plug and the claim (pickup-by-id).
1301
- if (data.claim_url || data.entity_id) {
1302
- ctx.state.lastAnonClaim = {
1303
- token: freshOwnerToken,
1304
- entity_id: data.entity_id ?? ctx.state.lastAnonClaim?.entity_id ?? null,
1305
- url,
1306
- };
1307
- }
1308
- const lines = [];
1309
- if (isEdit) {
1310
- lines.push(`Updated in place: ${url}`);
1311
- lines.push("Same link, new content. The expiry timer (and owner_token) were refreshed.");
1312
- } else {
1313
- lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Live: ${url}`);
1314
- }
1315
- if (data.claim_message) {
1316
- lines.push(data.claim_message);
1317
- } else if (data.claim_url) {
1318
- lines.push("Sign in, then run grid_claim to keep it past the expiry window.");
1319
- }
1320
- if (data.entity_id && freshOwnerToken) {
1321
- lines.push(
1322
- `Re-plug handle (persist to update or claim this drop in a later session): entity_id=${data.entity_id}`,
1323
- );
1324
- }
1325
- return {
1326
- text: lines.join("\n"),
1327
- structured: {
1328
- url,
1329
- status: isEdit ? "updated" : "created",
1330
- ...(data.entity_id ? { entity_id: data.entity_id } : {}),
1331
- ...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
1332
- },
1333
- };
1334
- }
1335
935
 
1336
936
  // ── Consent-gated error reporting (Task 34 / 0.8.1) ──────────────────────────
1337
937
  // Key names that look like they carry a secret. Mirrors the server's
@@ -1873,6 +1473,32 @@ export async function plugViaCliFallback(ctx, artifacts, deps = {}) {
1873
1473
  }
1874
1474
  }
1875
1475
 
1476
+ // After an authenticated WEB inspiration create, upgrade visibility to "link" so
1477
+ // the page is shareable and its preview renders without a sign-in wall — the
1478
+ // whole point of the hosted "share a link with a friend" flow. Best-effort: a
1479
+ // failure here never fails the publish (the user can always call grid_visibility).
1480
+ async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
1481
+ const token = await ctx.getToken();
1482
+ if (!token || !entityId) return false;
1483
+ try {
1484
+ const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
1485
+ // Grid-native header + X-CloudGrid-Org alias (same value) during the soak.
1486
+ // Never send conflicting values → 400 GRID_HEADER_CONFLICT.
1487
+ if (orgSlug) {
1488
+ hdrs["X-CloudGrid-Grid"] = orgSlug;
1489
+ hdrs["X-CloudGrid-Org"] = orgSlug;
1490
+ }
1491
+ const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
1492
+ method: "PATCH",
1493
+ headers: hdrs,
1494
+ body: JSON.stringify({ visibility: "link" }),
1495
+ });
1496
+ return res.ok;
1497
+ } catch {
1498
+ return false;
1499
+ }
1500
+ }
1501
+
1876
1502
  /**
1877
1503
  * The unified create/re-plug verb (spec v2 §3). Two intents on one tool, keyed
1878
1504
  * by `target_entity_id`:
@@ -1889,6 +1515,8 @@ export async function runPlug(ctx, input, deps = {}) {
1889
1515
  const {
1890
1516
  path: srcPath,
1891
1517
  artifact_files,
1518
+ html,
1519
+ filename,
1892
1520
  cloudgrid_yaml,
1893
1521
  target_entity_id,
1894
1522
  grid,
@@ -1897,19 +1525,35 @@ export async function runPlug(ctx, input, deps = {}) {
1897
1525
  owner_token,
1898
1526
  } = input || {};
1899
1527
 
1900
- // ── Source: path XOR artifact_files ────────────────────────────────────────
1901
- if (srcPath && Array.isArray(artifact_files) && artifact_files.length > 0) {
1902
- throw new Error("Pass either `path` or `artifact_files`, not both.");
1528
+ // ── Source: exactly one of html | artifact_files | path ─────────────────────
1529
+ const hasHtml = typeof html === "string" && html.length > 0;
1530
+ const hasArtifacts = Array.isArray(artifact_files) && artifact_files.length > 0;
1531
+ const hasPath = Boolean(srcPath);
1532
+ if ((hasHtml ? 1 : 0) + (hasArtifacts ? 1 : 0) + (hasPath ? 1 : 0) > 1) {
1533
+ throw new Error(
1534
+ "Pass exactly one source: `html` (a single inline HTML document), `artifact_files` " +
1535
+ "(multiple inline files), or `path` (a local file/folder).",
1536
+ );
1903
1537
  }
1904
- if (ctx.edition === "web" && srcPath) {
1538
+ if (ctx.edition === "web" && hasPath) {
1905
1539
  throw new Error(
1906
- "The hosted server cannot read local files — pass the source inline via `artifact_files`.",
1540
+ "The hosted server cannot read local files — pass the source inline via `html` or `artifact_files`.",
1907
1541
  );
1908
1542
  }
1909
1543
  let artifacts;
1910
- if (srcPath) {
1544
+ // Set on the single-file `html` path so the auth-aware inline size cap can be
1545
+ // enforced once the anon-vs-authed wire is known (see below).
1546
+ let inlineHtmlBytes = null;
1547
+ if (hasHtml) {
1548
+ // The ergonomic single-file publish path (the old drop verb): one self-
1549
+ // contained HTML document → one index.html artifact, with the shared
1550
+ // hardening (base64 rescue, @-path/file-path rejection, fragment wrap).
1551
+ const art = htmlToArtifact(html, filename);
1552
+ inlineHtmlBytes = art.buffer.byteLength;
1553
+ artifacts = [art];
1554
+ } else if (hasPath) {
1911
1555
  artifacts = collectPathArtifacts(srcPath);
1912
- } else if (Array.isArray(artifact_files) && artifact_files.length > 0) {
1556
+ } else if (hasArtifacts) {
1913
1557
  let total = 0;
1914
1558
  artifacts = artifact_files.map((f) => {
1915
1559
  if (!f || typeof f.path !== "string" || typeof f.content !== "string") {
@@ -1925,8 +1569,9 @@ export async function runPlug(ctx, input, deps = {}) {
1925
1569
  } else {
1926
1570
  throw new Error(
1927
1571
  ctx.edition === "web"
1928
- ? "Provide the source via `artifact_files`."
1929
- : "Provide the source via `path` (a local file or folder) or `artifact_files`.",
1572
+ ? "Provide the source via `html` (a single inline HTML document) or `artifact_files`."
1573
+ : "Provide the source via `html` (a single inline HTML document), `path` (a local file " +
1574
+ "or folder), or `artifact_files`.",
1930
1575
  );
1931
1576
  }
1932
1577
 
@@ -1970,7 +1615,30 @@ export async function runPlug(ctx, input, deps = {}) {
1970
1615
  );
1971
1616
  }
1972
1617
 
1618
+ // Auth-aware inline size cap for the single-file `html` path (the old drop
1619
+ // cap). Anonymous inline pages stay capped at 2 MB; signed-in inline pages get
1620
+ // the larger AUTHED cap. `path` (read from disk) and `artifact_files` are
1621
+ // bounded by PLUG_MAX_TOTAL_BYTES instead, so they never set inlineHtmlBytes.
1622
+ if (inlineHtmlBytes != null) {
1623
+ if (useAnonWire) {
1624
+ if (inlineHtmlBytes > ANON_HTML_MAX_BYTES) {
1625
+ throw new Error(
1626
+ `This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
1627
+ "Trim it, or sign in to publish larger.",
1628
+ );
1629
+ }
1630
+ } else if (inlineHtmlBytes > AUTHED_HTML_MAX_BYTES) {
1631
+ throw new Error(
1632
+ `This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
1633
+ `${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
1634
+ ".html file (read from disk — no inline size limit) instead of pasting it inline.",
1635
+ );
1636
+ }
1637
+ }
1638
+
1973
1639
  const headers = {};
1640
+ // The authed target grid — also reused for the post-create visibility upgrade.
1641
+ let orgSlug = null;
1974
1642
  if (!useAnonWire && authToken) {
1975
1643
  headers["Authorization"] = `Bearer ${authToken}`;
1976
1644
  // On create, `grid` picks where the entity lands. On re-plug the entity's
@@ -1978,7 +1646,7 @@ export async function runPlug(ctx, input, deps = {}) {
1978
1646
  // the caller's membership from this header and requires it to MATCH the
1979
1647
  // entity's grid, so pass `grid` here too when the target lives outside the
1980
1648
  // active grid.
1981
- const orgSlug = grid || (await ctx.getActiveGrid());
1649
+ orgSlug = grid || (await ctx.getActiveGrid());
1982
1650
  // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1983
1651
  if (orgSlug) {
1984
1652
  headers["X-CloudGrid-Grid"] = orgSlug;
@@ -2012,7 +1680,10 @@ export async function runPlug(ctx, input, deps = {}) {
2012
1680
  // ── Wire assembly ───────────────────────────────────────────────────────────
2013
1681
  const form = new FormData();
2014
1682
  for (const a of artifacts) {
2015
- form.append("artifact", new Blob([a.buffer], { type: "application/octet-stream" }), a.path);
1683
+ // Folder-walk / artifact_files parts ride as octet-stream (server sniffs by
1684
+ // name); the single-file `html` path carries text/html so a bare inline page
1685
+ // renders instead of downloading (the old drop behavior).
1686
+ form.append("artifact", new Blob([a.buffer], { type: a.type || "application/octet-stream" }), a.path);
2016
1687
  }
2017
1688
  if (isEdit) {
2018
1689
  form.append("target_entity_id", target_entity_id);
@@ -2086,7 +1757,7 @@ export async function runPlug(ctx, input, deps = {}) {
2086
1757
  throw new Error(plugErrorMessage(res.status, code, msg, flags));
2087
1758
  }
2088
1759
 
2089
- // Anon-session cookie continuity (mirrors runDrop).
1760
+ // Anon-session cookie continuity.
2090
1761
  const setCookies = res.headers.getSetCookie
2091
1762
  ? res.headers.getSetCookie()
2092
1763
  : [res.headers.get("set-cookie")].filter(Boolean);
@@ -2107,7 +1778,7 @@ export async function runPlug(ctx, input, deps = {}) {
2107
1778
  }
2108
1779
  }
2109
1780
 
2110
- // Session continuity — the same state runDrop keeps.
1781
+ // Session continuity — remember the last plug for re-plug handles.
2111
1782
  if (data.entity_id || url) {
2112
1783
  ctx.state.lastDrop = {
2113
1784
  entity_id: data.entity_id ?? null,
@@ -2146,6 +1817,20 @@ export async function runPlug(ctx, input, deps = {}) {
2146
1817
  // the terminal states get the live wording; anything still building points at
2147
1818
  // the poll_url and grid_status.
2148
1819
  const isBuilding = data.status === "building" || Boolean(data.poll_url);
1820
+
1821
+ // An authed CREATE of an INSPIRATION (the single-file `html` path, or a server-
1822
+ // detected inspiration — NOT a runtime app/agent, NOT a build in flight, NOT an
1823
+ // anon guest drop). This is the case that (on the hosted edition) must be made
1824
+ // link-visible so the shared URL renders without a sign-in wall.
1825
+ const detectedKind = data.detection?.kind;
1826
+ const isInspirationCreate =
1827
+ !isEdit &&
1828
+ !useAnonWire &&
1829
+ !isBuilding &&
1830
+ detectedKind !== "app" &&
1831
+ detectedKind !== "agent" &&
1832
+ (inlineHtmlBytes != null || detectedKind === "inspiration");
1833
+
2149
1834
  const lines = [];
2150
1835
  if (isBuilding) {
2151
1836
  lines.push(
@@ -2158,8 +1843,14 @@ export async function runPlug(ctx, input, deps = {}) {
2158
1843
  ? `Poll ${data.poll_url} or run grid_status until it is ready (trace ${data.trace_id ?? "n/a"}). Do not report it as live until then.`
2159
1844
  : "Run grid_status until it is ready. Do not report it as live until then.",
2160
1845
  );
1846
+ } else if (isEdit) {
1847
+ lines.push(`Updated in place: ${url}`);
1848
+ } else if (isInspirationCreate) {
1849
+ // Authed inspiration create — owned by the caller. Wording mirrors the drop verb.
1850
+ lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Published to your org: ${url}`);
1851
+ if (ctx.edition !== "web") lines.push("Owned by you.");
2161
1852
  } else {
2162
- lines.push(isEdit ? `Updated in place: ${url}` : `Live: ${url}`);
1853
+ lines.push(`Live: ${url}`);
2163
1854
  }
2164
1855
  if (data.entity_id) {
2165
1856
  lines.push(
@@ -2170,6 +1861,22 @@ export async function runPlug(ctx, input, deps = {}) {
2170
1861
  if (isEdit && useAnonWire) {
2171
1862
  lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
2172
1863
  }
1864
+
1865
+ // Hosted "share a link with a friend": a signed-in WEB inspiration create is
1866
+ // upgraded to "link" visibility so its preview renders without a sign-in wall.
1867
+ // Scoped EXACTLY to web + create + inspiration — never runtime apps/agents,
1868
+ // edits, anon drops, or the local edition (which stays owned/org-scoped).
1869
+ if (isInspirationCreate && ctx.edition === "web" && data.entity_id) {
1870
+ await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
1871
+ const vis = "link";
1872
+ structured.console_url = CONSOLE_URL;
1873
+ structured.current_visibility = vis;
1874
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
1875
+ lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
1876
+ lines.push(
1877
+ `Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`,
1878
+ );
1879
+ }
2173
1880
  return { text: lines.join("\n"), structured };
2174
1881
  }
2175
1882
 
@@ -2521,144 +2228,6 @@ export function registerTools(server, ctx) {
2521
2228
 
2522
2229
  // ── Direct-API tools (both editions) ──────────────────────────────────────
2523
2230
 
2524
- // Drop — both editions, but the input schema is edition-aware: the web
2525
- // edition removes `path` (the cloud server cannot read local files) and
2526
- // strengthens `html` so the model always pastes the full document inline.
2527
- const dropReplugParams = {
2528
- fresh: z.boolean().optional().describe("Force a brand-new drop (new URL) even if you already dropped in this session. Default: a re-drop updates the session's drop in place — same URL, new content."),
2529
- entity_id: z.string().optional().describe("Re-plug a SPECIFIC entity by id (the durable handle a previous drop returned) — updates it in place, same URL. Defaults to this session's last drop."),
2530
- owner_token: z.string().optional().describe("The owner token of an anonymously-created drop (returned when it was created; refreshed on every anonymous edit). Needed to re-plug or claim it from a new session."),
2531
- };
2532
- const dropInputSchema = ctx.edition === "web"
2533
- ? {
2534
- html: z.string().optional().describe(
2535
- "The COMPLETE HTML document to publish — paste the full file contents here (not a path). " +
2536
- "For a game, include all HTML/CSS/JS inline so it runs standalone. " +
2537
- "A fragment is wrapped into a full document automatically.",
2538
- ),
2539
- filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
2540
- anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
2541
- grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
2542
- org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
2543
- ...dropReplugParams,
2544
- }
2545
- : {
2546
- html: z.string().optional().describe("Inline HTML to publish (a small fragment is wrapped into a full document). Pass the RAW HTML — do NOT base64-encode it, and do NOT pass an `@`-prefixed path or a file path here. For a large or image-heavy document, use `path` instead."),
2547
- path: z.string().optional().describe("Path to a local .html file to upload instead of inline HTML — read from disk with no inline size limit. Preferred for large or image-heavy documents. There is no `artifact_files` parameter."),
2548
- filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
2549
- anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
2550
- grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
2551
- org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
2552
- ...dropReplugParams,
2553
- };
2554
-
2555
- reg(
2556
- "grid_drop",
2557
- {
2558
- description: "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. Re-drops in the same session UPDATE THE SAME entity in place — same link, new content, expiry reset (pass fresh: true to force a new drop, or entity_id to target a specific earlier drop). If signed in, it publishes into the user's grid as an owned inspiration; if not, it drops anonymously into the Guest Grid, claimable later — the result includes an entity_id + owner_token to persist as the re-plug/claim handle for later sessions. Drops expire per the platform default (7 days) unless claimed/owned; every in-place edit resets the timer. If you want to edit an existing drop but no longer have its HTML, call grid_source first to retrieve it, then re-plug with target_entity_id. For a large or image-heavy document in the local edition, pass `path` to the .html file (read from disk — no inline size limit); do NOT base64-encode the content and do NOT pass an `@`-prefixed path or a file path as `html`. There is no `artifact_files` parameter. Calls POST /api/v2/plug directly.",
2559
- inputSchema: dropInputSchema,
2560
- outputSchema: {
2561
- url: z.string().optional().describe("The public URL of the drop (stable across re-drops of the same entity)."),
2562
- status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
2563
- entity_id: z.string().optional().describe("The entity's durable id — pass back as entity_id to update it in place later."),
2564
- owner_token: z.string().optional().describe("Anonymous drops only: the bearer owner token for later re-plug/claim. Refreshed on every anonymous edit — always persist the newest one."),
2565
- owned_by: z.string().optional().describe("Ownership class, e.g. 'authenticated'."),
2566
- expires_at: z.string().optional().describe("Expiry timestamp, if any."),
2567
- console_url: z.string().optional().describe("URL to manage all apps in the grid."),
2568
- current_visibility: z.string().optional().describe("Current visibility of the drop."),
2569
- visibility_options: z.array(z.object({
2570
- value: z.string().describe("Visibility value to pass to grid_visibility."),
2571
- label: z.string().describe("Human-readable label."),
2572
- })).optional().describe("Available visibility levels."),
2573
- needs_grid: z.boolean().optional().describe("True when the user must choose a grid before dropping."),
2574
- needs_org: z.boolean().optional().describe("Deprecated alias of needs_grid (kept so the org-picker widget keeps working)."),
2575
- grids: z.array(z.object({
2576
- slug: z.string().describe("Grid slug to pass as the grid parameter."),
2577
- name: z.string().describe("Human-readable grid name."),
2578
- role: z.string().describe("User's role in the grid."),
2579
- is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
2580
- })).optional().describe("The user's grids, when a grid choice is needed."),
2581
- orgs: z.array(z.object({
2582
- slug: z.string().describe("Grid slug to pass as the grid parameter."),
2583
- name: z.string().describe("Human-readable grid name."),
2584
- role: z.string().describe("User's role in the grid."),
2585
- is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
2586
- })).optional().describe("Deprecated alias of grids (kept so the org-picker widget keeps working)."),
2587
- needs_sign_in: z.boolean().optional().describe("True when sign-in is needed before dropping."),
2588
- login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
2589
- },
2590
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2591
- ...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
2592
- _meta: {
2593
- ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
2594
- "openai/outputTemplate": LIVE_RESULT_URI,
2595
- },
2596
- } : {}),
2597
- },
2598
- async (input) => {
2599
- try {
2600
- // Web edition: reject `path` early — the hosted server cannot read
2601
- // local files. The schema already omits it, but a model with a
2602
- // cached tool description might still send one.
2603
- if (ctx.edition === "web" && input?.path) {
2604
- return fail(
2605
- "The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
2606
- );
2607
- }
2608
-
2609
- // Web edition: sign-in guidance when unauthenticated.
2610
- if (ctx.edition === "web" && input?.anonymous !== true) {
2611
- const token = await ctx.getToken();
2612
- if (!token) {
2613
- const url = buildLoginUrl(newLoginCode());
2614
- return okResult({
2615
- text: `Sign in to publish to your org.\n${url}`,
2616
- structured: { needs_sign_in: true, login_url: url },
2617
- });
2618
- }
2619
- }
2620
-
2621
- // Stateless grid disambiguation — both editions, when authenticated.
2622
- // No dependency on prior-call state so it works even when the client
2623
- // reconnects on every tool call (ChatGPT Apps SDK behaviour). `grid` is
2624
- // accepted as an alias for `org` (Gilad's rename); `org` still works.
2625
- if (input?.anonymous !== true) {
2626
- const token = await ctx.getToken();
2627
- if (token) {
2628
- const suppliedGrid = input?.grid || input?.org;
2629
- const decision = await resolveGridOrAsk(ctx, {
2630
- token,
2631
- suppliedGrid,
2632
- edition: ctx.edition,
2633
- });
2634
- if (decision.picker) {
2635
- return okResult(decision.picker);
2636
- }
2637
- if (decision.single) {
2638
- if (!decision.single.render_ready) {
2639
- // Single grid but not set up — tell the agent so it can warn
2640
- // the user and offer anonymous drop as fallback. (drop-specific:
2641
- // drop blocks here; plug proceeds with a warning.)
2642
- const annotated = [{ ...decision.single, render_ready: false }];
2643
- return okResult({
2644
- text: `Your only grid "${decision.single.slug}" isn't fully set up yet — pages published there may not load. You can drop anonymously (set anonymous: true) or wait until provisioning completes, then re-run.`,
2645
- structured: { needs_grid: true, needs_org: true, grid_not_ready: true, org_not_ready: true, grids: annotated, orgs: annotated },
2646
- });
2647
- }
2648
- input = { ...(input || {}), org: decision.single.slug };
2649
- } else if (decision.grid) {
2650
- // Supplied grid matched — normalize onto `org` for runDrop.
2651
- input = { ...(input || {}), org: decision.grid };
2652
- }
2653
- }
2654
- }
2655
- return okResult(await runDrop(ctx, input || {}));
2656
- } catch (err) {
2657
- return fail(err.message);
2658
- }
2659
- },
2660
- );
2661
-
2662
2231
  // Claim — both editions.
2663
2232
  reg(
2664
2233
  "grid_claim",
@@ -2689,13 +2258,21 @@ export function registerTools(server, ctx) {
2689
2258
  // CLI-wrapping grid_plug: create and re-plug are one verb, keyed by
2690
2259
  // target_entity_id, and work identically on the hosted transport.
2691
2260
  const plugInputSchema = {
2261
+ html: z.string().optional().describe(
2262
+ "A single self-contained HTML document to publish as an inspiration — the fast single-file path. " +
2263
+ "Pass the COMPLETE raw HTML inline (CSS+JS inline, images/fonts as data: URIs); a small fragment is " +
2264
+ "wrapped into a full document. Do NOT base64-encode it, and do NOT pass an `@`-prefixed path or a " +
2265
+ "file path here. Mutually exclusive with `path` and `artifact_files`. Materialized as one index.html " +
2266
+ "and published instantly on any edition, anonymously (claimable) or into your grid when signed in.",
2267
+ ),
2268
+ filename: z.string().optional().describe("Filename for the single-file `html` path. Defaults to index.html."),
2692
2269
  ...(ctx.edition === "web"
2693
2270
  ? {}
2694
2271
  : {
2695
2272
  path: z.string().optional().describe(
2696
2273
  "Local edition: path to the entity folder (or a single file) to upload. A folder is read " +
2697
2274
  "recursively, honoring .gitignore/.cloudgridignore (plus .git/node_modules always skipped). " +
2698
- "Mutually exclusive with artifact_files.",
2275
+ "Mutually exclusive with `html` and `artifact_files`.",
2699
2276
  ),
2700
2277
  }),
2701
2278
  artifact_files: z.array(z.object({
@@ -2703,8 +2280,10 @@ export function registerTools(server, ctx) {
2703
2280
  content: z.string().describe("File content. Base64 when encoding is base64, otherwise UTF-8 text."),
2704
2281
  encoding: z.enum(["utf8", "base64"]).optional().describe("Content encoding. Default utf8."),
2705
2282
  })).optional().describe(
2706
- "The source inline, one entry per file — for hosted/no-filesystem transports (an HTML deck, a one-file app)." +
2707
- (ctx.edition === "web" ? "" : " Prefer `path` on the local edition."),
2283
+ "The source inline, one entry per file — for hosted/no-filesystem transports (a multi-file app). " +
2284
+ "For a single HTML page prefer `html`." +
2285
+ (ctx.edition === "web" ? "" : " Prefer `path` on the local edition.") +
2286
+ " Mutually exclusive with `html` and `path`.",
2708
2287
  ),
2709
2288
  cloudgrid_yaml: z.string().optional().describe(
2710
2289
  "Inline cloudgrid.yaml (the entity manifest). Optional — server auto-detection applies when omitted. " +
@@ -2738,17 +2317,21 @@ export function registerTools(server, ctx) {
2738
2317
  "grid_plug",
2739
2318
  {
2740
2319
  description:
2741
- "Surface a creation onto the grid — the unified create/re-plug verb (POST /api/v2/plug). " +
2320
+ "Deploy or share a creation on CloudGrid and get a public URL — the ONE create/re-plug verb " +
2321
+ "(POST /api/v2/plug). Use it whenever the user wants to share, publish, send, ship, 'deploy', go " +
2322
+ "live, or get a link to send a friend — for a single HTML page OR a full app. " +
2323
+ "Sources (pass exactly one): `html` — a single self-contained HTML page (the fast path: instant, any " +
2324
+ "edition, anonymous+claimable or into your grid when signed in); " +
2325
+ (ctx.edition === "web"
2326
+ ? "or `artifact_files` — a multi-file app inline. "
2327
+ : "`path` — a local folder/file (a multi-file app); or `artifact_files` — inline files. ") +
2742
2328
  "No target_entity_id → CREATE a new entity (inspiration/app/agent, auto-detected or hinted); " +
2743
2329
  "with target_entity_id → RE-PLUG: update the SAME entity in place — same entity_id, same URL, same " +
2744
2330
  "deploy history, expiry reset. The returned entity_id + url are the durable re-plug handle; persist " +
2745
- "them (plus owner_token for anonymous drops) to update the entity in later sessions. " +
2746
- (ctx.edition === "web"
2747
- ? "Pass the source inline via artifact_files."
2748
- : "Pass the source as a local `path` (folder or file) or inline via artifact_files.") +
2749
- " Note: in-place re-plug currently supports inspirations (HTML/static drops); to rebuild a deployed " +
2750
- "app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder." +
2751
- " If you want to edit an existing drop but no longer have its HTML, call grid_source first to " +
2331
+ "them (plus owner_token for anonymous pages) to update the entity in later sessions. " +
2332
+ "Note: in-place re-plug currently supports inspirations (HTML/static pages); to rebuild a deployed " +
2333
+ "app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder. " +
2334
+ "If you want to edit an existing page but no longer have its HTML, call grid_source first to " +
2752
2335
  "retrieve it, then re-plug with target_entity_id.",
2753
2336
  inputSchema: plugInputSchema,
2754
2337
  outputSchema: {
@@ -2761,12 +2344,24 @@ export function registerTools(server, ctx) {
2761
2344
  claim_url: z.string().optional().describe("Anon create only: sign-in link to claim ownership."),
2762
2345
  claim_message: z.string().optional().describe("Anon create only: the claim nudge to relay."),
2763
2346
  owner_token: z.string().optional().describe("Anonymous drops: the bearer owner token (re-plug + claim). Re-minted on every anonymous edit — persist the newest."),
2347
+ console_url: z.string().optional().describe("Web authed inspiration create: URL to manage all apps in the grid."),
2348
+ current_visibility: z.string().optional().describe("Web authed inspiration create: the visibility set after publish (link)."),
2349
+ visibility_options: z.array(z.object({
2350
+ value: z.string().describe("Visibility value to pass to grid_visibility."),
2351
+ label: z.string().describe("Human-readable label."),
2352
+ })).optional().describe("Web authed inspiration create: available visibility levels."),
2764
2353
  },
2765
2354
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2355
+ ...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
2356
+ _meta: {
2357
+ ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
2358
+ "openai/outputTemplate": LIVE_RESULT_URI,
2359
+ },
2360
+ } : {}),
2766
2361
  },
2767
2362
  async (input) => {
2768
2363
  try {
2769
- // Grid-picker parity with grid_drop: a signed-in user with >1 grid is
2364
+ // Grid-picker: a signed-in user with >1 grid is
2770
2365
  // ASKED which grid to publish to on every CREATE. Only for authed creates
2771
2366
  // (no target_entity_id, not anon). Edits NEVER ask — the grid is fixed by
2772
2367
  // the entity. Anon proceeds as a Guest-Grid drop. Explicit valid grid
@@ -3174,7 +2769,7 @@ export function registerTools(server, ctx) {
3174
2769
  .describe("Short summary of what failed (required). Do not paste the whole conversation here."),
3175
2770
  context: z
3176
2771
  .object({
3177
- tool: z.string().optional().describe("The CloudGrid tool that failed, e.g. grid_drop."),
2772
+ tool: z.string().optional().describe("The CloudGrid tool that failed, e.g. grid_plug."),
3178
2773
  inputs: z.any().optional().describe("The failing inputs (e.g. the HTML/args). Keep it minimal; secrets are scrubbed."),
3179
2774
  grid: z.string().optional().describe("The grid/org slug involved, if any."),
3180
2775
  original_request: z.string().optional().describe("What the user asked for, in one line."),