@wirl/mcp 0.2.1 → 0.2.2

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 (3) hide show
  1. package/README.md +5 -3
  2. package/bin.js +592 -104
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,13 +4,15 @@ The wirl MCP server, run locally. It lets a coding agent deploy what it just bui
4
4
 
5
5
  ## Prefer the hosted server
6
6
 
7
- If your agent supports HTTP MCP, you do not need this package. Connect to the hosted endpoint and it opens the browser once to sign in:
7
+ If your agent supports HTTP MCP, you do not need this package. Connect to the hosted endpoint:
8
8
 
9
9
  ```bash
10
10
  claude mcp add --transport http wirl https://app.wirl.dev/mcp
11
11
  ```
12
12
 
13
- The token it receives is deploy-scoped by default: it can deploy, roll back, pull and list, and cannot share apps, change who can open them, or touch connections, members or tokens. One checkbox on the consent page grants full access.
13
+ `claude mcp add` only saves the server's configuration. Then sign in: run `/mcp` inside Claude Code and follow the steps in the browser, or run `claude mcp login wirl` in a terminal.
14
+
15
+ The token the hosted endpoint gives the agent is deploy-scoped by default: it can deploy, roll back, pull and list, and cannot share apps, change who can open them, or touch connections, members or tokens. One checkbox on the consent page grants full access.
14
16
 
15
17
  ## Running it locally
16
18
 
@@ -28,7 +30,7 @@ Cursor and anything else that speaks MCP take the same command in their own conf
28
30
 
29
31
  If your npm registry does not carry wirl, or to run exactly the build app.wirl.dev serves, name its address instead of the package: `claude mcp add wirl -- npx -y https://app.wirl.dev/cli/wirl-mcp.tgz`.
30
32
 
31
- The first time, the agent calls `wirl_login`, shows you a link and a code, and carries on once you approve in the browser. A local server acts with the CLI's credentials, which are full-scope; revoke them on the Connect an agent page at app.wirl.dev if an agent should lose access.
33
+ The local server signs in the first time a wirl tool needs it: the agent calls `wirl_login`, which returns a link and a code, and the agent shows both to the person. The person approves the code in the browser, and the agent's next wirl tool call works. A machine already signed in with `npx wirl login` skips this. A local server acts with the CLI's credentials, which are full-scope; revoke them on the Connect an agent page at app.wirl.dev if an agent should lose access.
32
34
 
33
35
  ## Tools
34
36
 
package/bin.js CHANGED
@@ -15,10 +15,14 @@ var __export = (target, all) => {
15
15
  };
16
16
 
17
17
  // packages/core/src/types.ts
18
- var AppKind;
18
+ var AppRole, AppKind;
19
19
  var init_types = __esm({
20
20
  "packages/core/src/types.ts"() {
21
21
  "use strict";
22
+ AppRole = {
23
+ VIEWER: "viewer",
24
+ EDITOR: "editor"
25
+ };
22
26
  AppKind = {
23
27
  STATIC: "static",
24
28
  SERVICE: "service",
@@ -189,9 +193,33 @@ function validateSlug(input) {
189
193
  function isWellFormedSlug(input) {
190
194
  return typeof input === "string" && input.length >= SLUG_MIN && input.length <= SLUG_MAX && SLUG_RE.test(input) && !input.includes(HOST_SEPARATOR);
191
195
  }
192
- function slugify(input) {
193
- const base = input.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, SLUG_MAX).replace(/-$/, "");
194
- return isValidSlug(base) ? base : null;
196
+ function slugBase(input) {
197
+ return input.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, SLUG_MAX).replace(/-$/, "");
198
+ }
199
+ function previewSlugFor(raw) {
200
+ const base = slugBase(raw);
201
+ switch (validateSlug(base).problem) {
202
+ case void 0:
203
+ return { ok: true, slug: base };
204
+ case "reserved":
205
+ return {
206
+ ok: false,
207
+ problem: "reserved",
208
+ message: `The preview name "${base}" is reserved. The reserved preview names are: ${[...RESERVED_SLUGS].sort().join(", ")}. Pick another name.`
209
+ };
210
+ case "too_short":
211
+ return {
212
+ ok: false,
213
+ problem: "too_short",
214
+ message: `That name is too short: it makes a preview name of one character, and a preview name needs at least ${String(SLUG_MIN)}.`
215
+ };
216
+ default:
217
+ return {
218
+ ok: false,
219
+ problem: "no_characters",
220
+ message: "That name has no usable characters: a preview name is made of the letters a to z and the digits 0 to 9 in what you give it, and none are left."
221
+ };
222
+ }
195
223
  }
196
224
  function appLabel(orgSlug, appSlug) {
197
225
  return `${orgSlug}${HOST_SEPARATOR}${appSlug}`;
@@ -1794,6 +1822,9 @@ var init_migrations = __esm({
1794
1822
  leaseMs: 6e5,
1795
1823
  orgApplies: 2,
1796
1824
  // per org: the limit one tenant's applies meet
1825
+ // Per app, its live database and its previews' together, inside the org's
1826
+ // two, so one app's deployers can never take both (OIM-927).
1827
+ appApplies: 1,
1797
1828
  // Per control-plane process. The lease holds no pooled connection, so this
1798
1829
  // bounds the process's work, not its pool; at `orgApplies` two each,
1799
1830
  // filling it takes eight orgs (T5b-6).
@@ -2695,7 +2726,7 @@ var init_runtime = __esm({
2695
2726
  });
2696
2727
 
2697
2728
  // packages/core/src/broker-stream.ts
2698
- var BROKER_LIMITS, STREAMED_MEDIA_TYPES;
2729
+ var BROKER_LIMITS, STREAMED_MEDIA_TYPES, MIB;
2699
2730
  var init_broker_stream = __esm({
2700
2731
  "packages/core/src/broker-stream.ts"() {
2701
2732
  "use strict";
@@ -2712,15 +2743,132 @@ var init_broker_stream = __esm({
2712
2743
  streamIdleMs: 4 * 6e4,
2713
2744
  /** Bytes in any one answer, streamed or read whole. */
2714
2745
  answerBytes: 25 * 1024 * 1024,
2746
+ /**
2747
+ * Bytes in the body of any one call an app makes. The body is read whole
2748
+ * before the call is sent, and before its grant is looked up, so a body
2749
+ * past this never opens a key and never reaches a vendor (OIM-931).
2750
+ * 100 MiB: Cloudflare publishes 100 MB as the request body its Free and
2751
+ * Pro plans take, and a deployed app's call is first held whole by the
2752
+ * egress Worker, in an isolate Cloudflare gives 128 MB of memory. Nothing
2753
+ * else bounds a caller that holds a broker token and reaches the control
2754
+ * plane itself: Railway sets no size on a body, only five minutes to send it.
2755
+ */
2756
+ requestBytes: 100 * 1024 * 1024,
2757
+ /**
2758
+ * Bytes of call bodies one app's calls hold at once, its previews' with
2759
+ * them (OIM-951): counted as each chunk arrives, from a call's first byte
2760
+ * until the broker has answered it (broker-bodies.ts). At least
2761
+ * `requestBytes`, so one call at the cap always fits an app that holds
2762
+ * nothing else; a quarter of `heldBodyBytes`, so four apps of four owners
2763
+ * at their share fill the whole (one owner's apps cannot: `ownerHeldBodyBytes`).
2764
+ */
2765
+ appHeldBodyBytes: 128 * 1024 * 1024,
2766
+ /**
2767
+ * Bytes of call bodies the calls of every app one owner has hold at once,
2768
+ * their previews' with them (OIM-967): the person `apps.owner_user_id`
2769
+ * names, whichever organisations their apps are in. Apps and
2770
+ * organisations cost nothing to create (no cap on either), so a share per
2771
+ * app let one person with four apps fill the whole, and one per
2772
+ * organisation would let them do it with four organisations; a person
2773
+ * costs a sign-in. Two apps at their share, so one app that runs
2774
+ * away still leaves its owner's other apps room, and half of
2775
+ * `heldBodyBytes`, so one owner can never take more than half, and a
2776
+ * second sign-in is what it takes to fill it. At least `requestBytes`, so
2777
+ * one call at the cap always fits an owner whose apps hold nothing else.
2778
+ */
2779
+ ownerHeldBodyBytes: 256 * 1024 * 1024,
2780
+ /**
2781
+ * Bytes of call bodies every app's calls hold at once, in this process.
2782
+ * A call's reader starts at 16 KiB and doubles its buffer as the body
2783
+ * arrives, so the buffers behind this hold at most twice it, 1 GiB, and
2784
+ * 16 KiB a call beside (the ruling in OIM-951).
2785
+ */
2786
+ heldBodyBytes: 512 * 1024 * 1024,
2787
+ /** The `Retry-After` of a call refused for either. */
2788
+ heldBodyRetryAfterSeconds: 10,
2789
+ /**
2790
+ * How long a call's body may send nothing before its read is stopped:
2791
+ * an upload's (OIM-935). A deployed app's body comes from the egress
2792
+ * Worker, which holds it whole before it sends it, and `wirl dev` reads it
2793
+ * whole first, so no ordinary sender ever pauses (fix round 1, R1-F2).
2794
+ */
2795
+ heldBodyStallMs: 3e4,
2796
+ /**
2797
+ * How long a call's body may take to arrive whole: 100 MiB in 120 seconds
2798
+ * is about 7 Mbit/s, which a body between Cloudflare and the control plane
2799
+ * passes many times over, and well under Node's 300 seconds a request,
2800
+ * after which the body would be cut anyway (fix round 1, R1-F2).
2801
+ */
2802
+ heldBodyArrivalMs: 12e4,
2803
+ /**
2804
+ * Bytes of answers read whole that one app's calls hold at once, its
2805
+ * previews' with them (OIM-966): counted as each chunk arrives from the
2806
+ * vendor, from an answer's first byte until wirl's server has taken the
2807
+ * last of it, or the app has gone (broker-bodies.ts). A vendor's event
2808
+ * stream is not counted: it is passed on as the app reads it, and holds
2809
+ * about 285 to 441 KiB at a time, measured end to end through the control
2810
+ * plane's server (`passOn` in broker.ts; OIM-966 fix round 1, R1-F3,
2811
+ * R2-F2). More than two `answerBytes`, so two answers at the cap fit an
2812
+ * app that holds nothing else; a quarter of `heldAnswerBytes`, so four apps
2813
+ * of four owners at their share fill the whole (one owner's apps cannot:
2814
+ * `ownerHeldAnswerBytes`).
2815
+ */
2816
+ appHeldAnswerBytes: 64 * 1024 * 1024,
2817
+ /**
2818
+ * Bytes of answers read whole that the calls of every app one owner has
2819
+ * hold at once, their previews' with them (OIM-967), for the reasons
2820
+ * `ownerHeldBodyBytes` gives: two apps at their share, half of
2821
+ * `heldAnswerBytes`, and more than two `answerBytes` and than the room a
2822
+ * call needs, so an owner whose apps hold nothing else can always send.
2823
+ */
2824
+ ownerHeldAnswerBytes: 128 * 1024 * 1024,
2825
+ /**
2826
+ * Bytes of answers read whole that every app's calls hold at once, in this
2827
+ * process. An answer's reader doubles its buffer as it fills, as a body's
2828
+ * does, and never past `answerBytes`, so while answers arrive the buffers
2829
+ * behind this hold at most twice it, 512 MiB, and 16 KiB an answer beside;
2830
+ * an answer read whole is copied to exactly its size before it is handed
2831
+ * on, so from then its count is its memory (the ruling in OIM-966, and its
2832
+ * fix round 1, R1-F2).
2833
+ */
2834
+ heldAnswerBytes: 256 * 1024 * 1024,
2835
+ /**
2836
+ * The room an app's share of answers, and the whole, must each have left
2837
+ * for a call to be sent at all: one answer at the cap. With less, the call
2838
+ * is refused with a 503 before it leaves wirl, rather than run at its
2839
+ * vendor and its answer cut (OIM-966 fix round 1, R1-F1).
2840
+ */
2841
+ heldAnswerRoomBytes: 25 * 1024 * 1024,
2842
+ /** The `Retry-After` of a call refused for that room, before it was sent. */
2843
+ heldAnswerRetryAfterSeconds: 10,
2844
+ /**
2845
+ * How long an answer read whole may wait for wirl's server to take the
2846
+ * last of it once it is handed back: past this it is cut, and its count
2847
+ * goes back, so no app holds its share, or the whole, by not reading.
2848
+ * Under Cloudflare's measured 125 seconds with nothing moving, which ends
2849
+ * a deployed app's unread answer anyway (OIM-966 fix round 1, R1-F1).
2850
+ */
2851
+ heldAnswerHandOutMs: 12e4,
2715
2852
  /**
2716
2853
  * Streamed answers one app may have open at once. Each holds a vendor
2717
2854
  * socket and an inbound connection on one control-plane instance for up
2718
2855
  * to fourteen minutes, so one app cannot be allowed an unbounded number.
2719
2856
  * Counted in memory, per instance, like the control plane's other limits.
2720
2857
  */
2721
- openStreamsPerApp: 20
2858
+ openStreamsPerApp: 20,
2859
+ /**
2860
+ * How long an event stream wirl cut waits for its closing event to leave
2861
+ * the server before it ends the stream with its error anyway (OIM-837 fix
2862
+ * round 1). The event is on its way when the server's queue of what it has
2863
+ * to write has emptied; a reader that is slow but reading empties a full
2864
+ * kernel buffer, a few hundred kilobytes, well inside this. Past it the
2865
+ * reader is not reading, and the stream ends as it always did; the write
2866
+ * stall that holds a connection longer than this is OIM-968's.
2867
+ */
2868
+ cutEventSentMs: 5e3
2722
2869
  };
2723
2870
  STREAMED_MEDIA_TYPES = Object.freeze(["text/event-stream", "application/x-ndjson"]);
2871
+ MIB = 1024 * 1024;
2724
2872
  }
2725
2873
  });
2726
2874
 
@@ -2789,7 +2937,7 @@ arrives: pass \`response.body\` straight on. There are no WebSockets. A front en
2789
2937
  answers everything else; add \`"spa": true\` for a client-side router. Nothing outside that folder is served.
2790
2938
 
2791
2939
  Preview before you deploy: \`wirl preview\` gives a folder its own address and database (at first only what
2792
- \`migrations/\` makes). \`wirl fetch <path> --preview <name>\` or \`wirl_preview_fetch\` shows what it answers.
2940
+ \`migrations/\` makes). \`wirl fetch <path> --preview <name>\` or \`wirl_preview_fetch\` shows what it answers. Every request a preview's module gets names the preview in \`x-wirl-preview\`, and wirl marks its pages as previews.
2793
2941
 
2794
2942
  npm packages work: install them in the project and the deploy bundles them in. Nothing is installed on the
2795
2943
  server: native code will not run. Node built-ins work (\`node:crypto\`, \`node:path\`, ...; \`Buffer\` and
@@ -2855,7 +3003,7 @@ var init_version = __esm({
2855
3003
  "packages/core/src/version.ts"() {
2856
3004
  "use strict";
2857
3005
  WORKSPACE_VERSION = "0.0.0";
2858
- BUILT_VERSION = true ? "0.2.1" : null;
3006
+ BUILT_VERSION = true ? "0.2.2" : null;
2859
3007
  }
2860
3008
  });
2861
3009
 
@@ -2883,6 +3031,33 @@ var init_install = __esm({
2883
3031
  }
2884
3032
  });
2885
3033
 
3034
+ // packages/core/src/share-role.ts
3035
+ function readShareRole(value) {
3036
+ if (value === void 0 || value === null) return { ok: true, role: void 0 };
3037
+ if (value === AppRole.VIEWER || value === AppRole.EDITOR) return { ok: true, role: value };
3038
+ return { ok: false, message: SHARE_ROLE_REFUSAL };
3039
+ }
3040
+ var SHARE_ROLE_REFUSAL;
3041
+ var init_share_role = __esm({
3042
+ "packages/core/src/share-role.ts"() {
3043
+ "use strict";
3044
+ init_types();
3045
+ SHARE_ROLE_REFUSAL = "role must be viewer or editor.";
3046
+ }
3047
+ });
3048
+
3049
+ // packages/core/src/connect.ts
3050
+ var CLAUDE_CODE_SIGN_IN, CLAUDE_CODE_LOCAL_SIGN_IN, CLI_LOGIN_DOES;
3051
+ var init_connect = __esm({
3052
+ "packages/core/src/connect.ts"() {
3053
+ "use strict";
3054
+ init_install();
3055
+ CLAUDE_CODE_SIGN_IN = "`claude mcp add` only saves the server's configuration. Then sign in: run `/mcp` inside Claude Code and follow the steps in the browser, or run `claude mcp login wirl` in a terminal.";
3056
+ CLAUDE_CODE_LOCAL_SIGN_IN = `The local server signs in the first time a wirl tool needs it: the agent calls \`wirl_login\`, which returns a link and a code, and the agent shows both to the person. The person approves the code in the browser, and the agent's next wirl tool call works. A machine already signed in with \`${WIRL_BIN} login\` skips this.`;
3057
+ CLI_LOGIN_DOES = "prints a link and a code to approve in the browser";
3058
+ }
3059
+ });
3060
+
2886
3061
  // packages/core/src/porting.ts
2887
3062
  function dependencyNames(packageJson) {
2888
3063
  if (packageJson === void 0) return [];
@@ -3103,6 +3278,66 @@ var init_porting = __esm({
3103
3278
  }
3104
3279
  });
3105
3280
 
3281
+ // packages/core/src/throttle-report.ts
3282
+ var THROTTLE_REPORT, EDGE_REFUSALS_NOTE;
3283
+ var init_throttle_report = __esm({
3284
+ "packages/core/src/throttle-report.ts"() {
3285
+ "use strict";
3286
+ init_public_path();
3287
+ THROTTLE_REPORT = {
3288
+ /**
3289
+ * One report per app a minute at most from each gate isolate, and what
3290
+ * one report counts spans a minute at most.
3291
+ */
3292
+ windowMs: 6e4,
3293
+ /**
3294
+ * How long the refusal that opens a window waits for more before the
3295
+ * report goes, so a burst is one report with its count rather than a
3296
+ * report of its first refusal.
3297
+ */
3298
+ gatherMs: 15e3,
3299
+ /**
3300
+ * The longest a refused request is asked to stay open, after its 429 has
3301
+ * gone, for the report to be sent. Cloudflare keeps a request's
3302
+ * `waitUntil` work for 30 seconds after the response, shared by all of
3303
+ * it, and the send itself has `sendTimeoutMs` of what is left.
3304
+ */
3305
+ maxWaitMs: 2e4,
3306
+ sendTimeoutMs: 5e3,
3307
+ /**
3308
+ * Reports one request carries for windows no refusal could wait for
3309
+ * (`flushDue`). A request that was never refused must not queue its own
3310
+ * reads behind posts that are not its own: Cloudflare runs six of a
3311
+ * request's connections at once and queues the rest.
3312
+ */
3313
+ flushPerRequest: 2,
3314
+ /** Apps one gate isolate counts for at once; a refusal for another app past this is not counted. */
3315
+ maxApps: 1e3,
3316
+ /** Lines in one report: each of an app's public paths, by each ceiling. */
3317
+ maxEntries: 2 * PUBLIC_PATH_LIMITS.perApp,
3318
+ /** A path is cut to this many characters, as `public.refused`'s is. */
3319
+ maxPathChars: 256,
3320
+ /**
3321
+ * The most of a report's body the control plane reads. A full report is
3322
+ * under 8 KiB: a path a request matched is ASCII, as a URL's path is.
3323
+ */
3324
+ bodyBytes: 16384,
3325
+ /**
3326
+ * Reports the control plane reads for one app a minute, from every isolate
3327
+ * and location together. Of those, the first writes the app's one
3328
+ * `public.throttled` row for that minute and the rest write nothing.
3329
+ */
3330
+ perAppPerMinute: 10,
3331
+ /** The org's Activity page summarises this many apps' latest `public.throttled` lines, a line an app, from the last `logsPageDays` days. */
3332
+ activityLines: 5,
3333
+ /** The app's Logs page shows its latest this many `public.throttled` lines, from the last `logsPageDays` days. */
3334
+ logsPageLines: 20,
3335
+ logsPageDays: 7
3336
+ };
3337
+ EDGE_REFUSALS_NOTE = "Requests wirl refuses before your app is called, such as a public path's 429 over its ceiling, are not invocations and never show here; anyone who can deploy the app sees those 429s counted as public.throttled on its Logs page in the dashboard.";
3338
+ }
3339
+ });
3340
+
3106
3341
  // packages/core/src/supplied-bundle.ts
3107
3342
  var SUPPLIED_BUNDLE_PATH, MAX_SUPPLIED_BUNDLE_BYTES;
3108
3343
  var init_supplied_bundle = __esm({
@@ -3127,6 +3362,9 @@ var init_manifest_transport = __esm({
3127
3362
  });
3128
3363
 
3129
3364
  // packages/core/src/preview.ts
3365
+ function versionNumbersSentence(listing) {
3366
+ return `A preview takes a version number from the same count as a deploy, and ${listing} never lists a preview, so a deploy's number can skip: if the newest version listed is v1 and this deploy is v4, v2 and v3 were previews.`;
3367
+ }
3130
3368
  var PREVIEW_TTL_DAYS, MAX_PREVIEWS_PER_APP;
3131
3369
  var init_preview = __esm({
3132
3370
  "packages/core/src/preview.ts"() {
@@ -3166,7 +3404,10 @@ var init_src = __esm({
3166
3404
  init_contract();
3167
3405
  init_version();
3168
3406
  init_install();
3407
+ init_share_role();
3408
+ init_connect();
3169
3409
  init_porting();
3410
+ init_throttle_report();
3170
3411
  init_supplied_bundle();
3171
3412
  init_manifest_transport();
3172
3413
  init_preview();
@@ -4812,7 +5053,7 @@ init_src();
4812
5053
  import { access, link, lstat as lstat2, mkdir as mkdir3, open, readFile as readFile3, readdir, rename as rename2, rm as rm3, stat as stat3, unlink, writeFile as writeFile2 } from "node:fs/promises";
4813
5054
  import { homedir } from "node:os";
4814
5055
  import { basename as basename2, dirname as dirname3, isAbsolute, join as join5, relative, resolve as resolve4, sep } from "node:path";
4815
- import { create as tarCreate, extract as tarExtract } from "tar";
5056
+ import { create as tarCreate, extract as tarExtract, Parser as TarParser } from "tar";
4816
5057
  import { gzipSync } from "node:zlib";
4817
5058
 
4818
5059
  // packages/core/src/node/preview-name.ts
@@ -4820,10 +5061,9 @@ init_slug();
4820
5061
  import { createHash } from "node:crypto";
4821
5062
  var HASH_SUFFIX_LENGTH = 5;
4822
5063
  function previewNameFor(raw, orgSlug, appSlug) {
4823
- const slug = slugify(raw);
4824
- if (!slug) {
4825
- return { ok: false, message: "That name has no usable characters, or is reserved." };
4826
- }
5064
+ const made = previewSlugFor(raw);
5065
+ if (!made.ok) return { ok: false, message: made.message };
5066
+ const slug = made.slug;
4827
5067
  const budget = MAX_HOST_LABEL - (appLabel(orgSlug, appSlug).length + HOST_SEPARATOR.length);
4828
5068
  if (budget < 2) {
4829
5069
  return {
@@ -4943,6 +5183,64 @@ function shellPath(value) {
4943
5183
  return shellQuote(value);
4944
5184
  }
4945
5185
 
5186
+ // packages/cli/src/skill.ts
5187
+ init_src();
5188
+ var SKILL_INTRO = `A new app is visible to everyone in the org when the org came from a
5189
+ company email domain; in any other org, including a personal one or one
5190
+ made with \`wirl orgs create\`, it starts private. Deploys are one
5191
+ command; sharing is by email; a third-party API is declared, never pasted.`;
5192
+ var SKILL_DOCS_LINE = "Before you build, fetch https://docs.wirl.dev/llms-full.txt: every page of the docs as one Markdown file, so there is no need to read the site page by page.";
5193
+ var SKILL_RULES = `1. **Never ask for or paste an API key.** Declare the connection in \`wirl.json\` and deploy. Needs no key at all (a public API)? Declare it with "kind": "none" and its hosts. If the key is missing, the deploy prints a link to the app's keys page; show the person that link. Locally, keys live in \`.wirl/keys.json\`, which git ignores. To try a vendor call locally without the key, map its connection to a server of your own in \`.wirl/mocks.json\`, like \`{ "anthropic": "http://127.0.0.1:4010" }\`: \`wirl dev\` then sends the calls to that connection's hosts there instead (\`wirl dev --help\` has the rest).
5194
+ 2. **Call third-party APIs by their real URL** (\`fetch("https://api.hubspot.com/...")\`). wirl attaches the key on the way out, deployed and under \`wirl dev\` alike.
5195
+ 3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
5196
+ 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
5197
+ 5. **A file that can lose data waits for a person.** A deploy that would apply a destructive file (one wirl.json's \`"destructive"\` list must name) to the live database stops and names the page where a person approves each one. Show the person those pages, and deploy again once they say they approved. If \`${WIRL_BIN} deploy\` asks you to type a file's name, stop it and show the page instead, or deploy with \`--json\`, which never asks. Never type the name yourself or send the confirmation: that is the person's, and no flag skips it.`;
5198
+ var SKILL_MANIFEST_EXAMPLE = `\`\`\`json
5199
+ { "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
5200
+ \`\`\``;
5201
+ var SKILL_FILES_PARAGRAPH = "No blob storage: files go in `env.DB`, at most 2,000,000 bytes a value or row (the limit Cloudflare publishes for D1), so store a larger file in chunks, one row each, a few MB to a batch. `wirl dev` refuses a single bound value over 2,000,000 bytes, but a row's total, a value grown in SQL, or a batch bigger than one call to D1 carries can still pass locally and fail deployed.";
5202
+ var SKILL_STATIC_PARAGRAPH = 'A static folder maps a path to one file exactly: a path ending in `/` serves the `index.html` in that folder, `/about` never finds `about.html`, and a path that names no file goes to the module (with `"spa": true`, a page navigation gets `index.html` instead; in an app with no module, the top `index.html`, or a 404 for a missing file with an extension). Next.js runs only as a static export: `output: "export"` and `trailingSlash: true` in `next.config` make `next build` write `out/`, which goes under `"static"`; `next start` cannot run, and API routes move into the module.';
5203
+ function composeSkill(parts) {
5204
+ if (parts.description.trim() === "" || /[\r\n]/.test(parts.description)) {
5205
+ throw new Error("a skill description is one non-empty line");
5206
+ }
5207
+ return `---
5208
+ name: wirl
5209
+ description: ${parts.description}
5210
+ ---
5211
+
5212
+ # wirl
5213
+
5214
+ ${SKILL_INTRO}
5215
+
5216
+ ${SKILL_DOCS_LINE}
5217
+
5218
+ ## Commands
5219
+
5220
+ ${parts.commands.trimEnd()}
5221
+
5222
+ ## Rules
5223
+
5224
+ ${SKILL_RULES}
5225
+
5226
+ ## wirl.json
5227
+
5228
+ ${SKILL_MANIFEST_EXAMPLE}
5229
+
5230
+ ## What runs on wirl
5231
+
5232
+ ${BUILD_CONTRACT}
5233
+
5234
+ ${SKILL_FILES_PARAGRAPH}
5235
+
5236
+ ${SKILL_STATIC_PARAGRAPH}
5237
+
5238
+ ## Flows
5239
+
5240
+ ${FLOWS_CONTRACT}
5241
+ `;
5242
+ }
5243
+
4946
5244
  // packages/cli/src/core.ts
4947
5245
  function credentialsPath(env = process.env) {
4948
5246
  return env["WIRL_CREDENTIALS_PATH"] || join5(homedir(), ".config", "wirl", "credentials.json");
@@ -5233,23 +5531,9 @@ var SKILL_RUN = NPM_PUBLISHED ? `Run the CLI as \`${WIRL_BIN} <command>\`, which
5233
5531
  ` : "";
5234
5532
  var SKILL_FALLBACK = hasFallback(SKILL_INSTALL) ? `- ${fallbackWhen(namedControlPlane(SKILL_ORIGIN))} install the CLI from the control plane instead with \`npm install -g ${SKILL_INSTALL.tarball.cli}\`, run plain \`wirl\` where this file says \`${WIRL_BIN}\`, and name \`${SKILL_INSTALL.tarball.mcp}\` in place of \`${SKILL_INSTALL.mcp}\`.
5235
5533
  ` : "";
5236
- var SKILL_MARKDOWN = `---
5237
- name: wirl
5238
- description: Deploy and share the internal app in this project with wirl (${WIRL_BIN}). Use when the user wants a tool, dashboard or prototype put somewhere colleagues can open it, shared with a named person, run locally the way it will run deployed, or connected to a third-party API; or wants work that outlives one request, such as waiting for a person to approve something or trying again tomorrow.
5239
- ---
5240
-
5241
- # wirl
5242
-
5243
- A new app is visible to everyone in the org when the org came from a
5244
- company email domain; in any other org, including a personal one or one
5245
- made with \`wirl orgs create\`, it starts private. Deploys are one
5246
- command; sharing is by email; a third-party API is declared, never pasted.
5247
-
5248
- Before you build, fetch https://docs.wirl.dev/llms-full.txt: every page of the docs as one Markdown file, so there is no need to read the site page by page.
5249
-
5250
- ## Commands
5251
-
5252
- ${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --transport http wirl https://app.wirl.dev/mcp\` opens the browser once to sign in, and needs no install. No HTTP MCP support? \`claude mcp add wirl -- npx -y ${SKILL_INSTALL.mcp}\` does the same, running locally. Neither available: \`${WIRL_BIN} login\` prints a link and a code; the person approves in the browser. Do this once, when any command says you are not signed in.
5534
+ var SKILL_MARKDOWN = composeSkill({
5535
+ description: `Deploy and share the internal app in this project with wirl (${WIRL_BIN}). Use when the user wants a tool, dashboard or prototype put somewhere colleagues can open it, shared with a named person, run locally the way it will run deployed, or connected to a third-party API; or wants work that outlives one request, such as waiting for a person to approve something or trying again tomorrow.`,
5536
+ commands: `${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --transport http wirl https://app.wirl.dev/mcp\` needs no install. ${CLAUDE_CODE_SIGN_IN} No HTTP MCP support? \`claude mcp add wirl -- npx -y ${SKILL_INSTALL.mcp}\` runs the same tools locally. ${CLAUDE_CODE_LOCAL_SIGN_IN} Neither available: \`${WIRL_BIN} login\` prints a link and a code; the person approves in the browser. Do this once, when any command says you are not signed in.
5253
5537
  - \`${WIRL_BIN} deploy\` \u2014 deploys this directory. Prints the URL and who can open it.
5254
5538
  - \`${WIRL_BIN} share someone@company.com\` \u2014 that person can open it. Add \`--role editor\` so they can deploy too.
5255
5539
  - \`${WIRL_BIN} dev\` \u2014 runs the app here as it will run deployed, signed in as a stub user.
@@ -5258,33 +5542,8 @@ ${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --trans
5258
5542
  - \`${WIRL_BIN} migrations\` \u2014 what this app's database has applied, read only.
5259
5543
  - \`${WIRL_BIN} migrations adopt\` \u2014 once, for an app whose tables were made in code: writes \`migrations/0001_init.sql\` from the live database's shape.
5260
5544
  - \`${WIRL_BIN} --help\` \u2014 everything else: orgs, members, connections, move, transfer, rollback, flows and runs.
5261
- ${SKILL_FALLBACK}
5262
- ## Rules
5263
-
5264
- 1. **Never ask for or paste an API key.** Declare the connection in \`wirl.json\` and deploy. Needs no key at all (a public API)? Declare it with "kind": "none" and its hosts. If the key is missing, the deploy prints a link to the app's keys page; show the person that link. Locally, keys live in \`.wirl/keys.json\`, which git ignores. To try a vendor call locally without the key, map its connection to a server of your own in \`.wirl/mocks.json\`, like \`{ "anthropic": "http://127.0.0.1:4010" }\`: \`wirl dev\` then sends the calls to that connection's hosts there instead (\`wirl dev --help\` has the rest).
5265
- 2. **Call third-party APIs by their real URL** (\`fetch("https://api.hubspot.com/...")\`). wirl attaches the key on the way out, deployed and under \`wirl dev\` alike.
5266
- 3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
5267
- 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
5268
- 5. **A file that can lose data waits for a person.** A deploy that would apply a destructive file (one wirl.json's \`"destructive"\` list must name) to the live database stops and names the page where a person approves each one. Show the person those pages, and deploy again once they say they approved. If \`${WIRL_BIN} deploy\` asks you to type a file's name, stop it and show the page instead, or deploy with \`--json\`, which never asks. Never type the name yourself or send the confirmation: that is the person's, and no flag skips it.
5269
-
5270
- ## wirl.json
5271
-
5272
- \`\`\`json
5273
- { "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
5274
- \`\`\`
5275
-
5276
- ## What runs on wirl
5277
-
5278
- ${BUILD_CONTRACT}
5279
-
5280
- No blob storage: files go in \`env.DB\`, at most 2,000,000 bytes a value or row (the limit Cloudflare publishes for D1), so store a larger file in chunks, one row each, a few MB to a batch. \`wirl dev\` refuses a single bound value over 2,000,000 bytes, but a row's total, a value grown in SQL, or a batch bigger than one call to D1 carries can still pass locally and fail deployed.
5281
-
5282
- A static folder maps a path to one file exactly: a path ending in \`/\` serves the \`index.html\` in that folder, \`/about\` never finds \`about.html\`, and a path that names no file goes to the module (with \`"spa": true\`, a page navigation gets \`index.html\` instead; in an app with no module, the top \`index.html\`, or a 404 for a missing file with an extension). Next.js runs only as a static export: \`output: "export"\` and \`trailingSlash: true\` in \`next.config\` make \`next build\` write \`out/\`, which goes under \`"static"\`; \`next start\` cannot run, and API routes move into the module.
5283
-
5284
- ## Flows
5285
-
5286
- ${FLOWS_CONTRACT}
5287
- `;
5545
+ ${SKILL_FALLBACK}`
5546
+ });
5288
5547
  async function pullNextStep(dir) {
5289
5548
  return `cd ${shellPath(dir)} && ${await pulledDevCommand(dir)}`;
5290
5549
  }
@@ -5595,23 +5854,51 @@ async function callApiBytes(creds, path, options = {}) {
5595
5854
  }
5596
5855
  const declared = Number(response.headers.get("content-length") ?? 0);
5597
5856
  if (declared > MAX_PULL_BYTES) throw new CliError(`That is ${Math.round(declared / 1048576)} MB, more than wirl will pull.`);
5598
- const bytes = new Uint8Array(await response.arrayBuffer());
5857
+ let bytes;
5858
+ try {
5859
+ bytes = new Uint8Array(await response.arrayBuffer());
5860
+ } catch {
5861
+ throw new CliError(PULL_CUT, void 0, { code: "pull_cut" });
5862
+ }
5599
5863
  if (bytes.byteLength > MAX_PULL_BYTES) throw new CliError("The download was larger than wirl will pull.");
5600
5864
  return bytes;
5601
5865
  }
5866
+ var PULL_CUT = "The pull was cut before it finished, so nothing was written. Run it again.";
5867
+ var pullShort = (dir) => `The pull's archive is cut short or damaged, so nothing was written to ${dir}. Run it again.`;
5602
5868
  var MAX_PULL_BYTES = 256 * 1048576;
5603
5869
  var pullApp = (creds, org, app, options = {}) => callApiBytes(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/pull`, options.fetchImpl ? { fetchImpl: options.fetchImpl } : {});
5870
+ function isWholeArchive(archive) {
5871
+ return new Promise((resolve6, reject) => {
5872
+ let sound = true;
5873
+ let ended2 = false;
5874
+ const parser = new TarParser({ onReadEntry: (entry) => void entry.resume() });
5875
+ parser.on("warn", (code) => {
5876
+ if (code === "TAR_BAD_ARCHIVE" || code === "TAR_ENTRY_INVALID") sound = false;
5877
+ });
5878
+ parser.on("eof", () => {
5879
+ ended2 = true;
5880
+ });
5881
+ parser.on("error", (error) => typeof error.code === "string" && error.code.startsWith("Z_") ? resolve6(false) : reject(error));
5882
+ parser.on("end", () => resolve6(sound && ended2));
5883
+ parser.end(archive);
5884
+ });
5885
+ }
5604
5886
  async function unpackPull(bytes, dir, options = {}) {
5605
- await mkdir3(dir, { recursive: true });
5606
- const existing = await readdir(dir);
5887
+ const existing = await readdir(dir).catch((error) => {
5888
+ if (error.code === "ENOENT") return [];
5889
+ throw error;
5890
+ });
5607
5891
  if (existing.length > 0 && !options.force) {
5608
5892
  throw new CliError(`${dir} is not empty.`, "Pull into a new directory, or pass --force to overwrite.");
5609
5893
  }
5894
+ const archive = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
5895
+ if (!await isWholeArchive(archive)) throw new CliError(pullShort(dir), void 0, { code: "pull_cut" });
5896
+ await mkdir3(dir, { recursive: true });
5610
5897
  await new Promise((resolve6, reject) => {
5611
5898
  const extractor = tarExtract({ cwd: dir });
5612
5899
  extractor.on("error", reject);
5613
5900
  extractor.on("finish", resolve6);
5614
- extractor.end(Buffer.from(bytes));
5901
+ extractor.end(archive);
5615
5902
  });
5616
5903
  const written = [];
5617
5904
  await collect(dir, dir, written);
@@ -5969,10 +6256,13 @@ function describeVisibility(level, org) {
5969
6256
  }
5970
6257
  }
5971
6258
  var readVisibility = (creds, org, app) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}`).then((r) => r.app.visibility ?? "unknown");
5972
- async function changeVisibility(creds, options) {
5973
- if (!VISIBILITY_LEVELS.includes(options.level)) {
5974
- throw new CliError(`Visibility is private, org or public, not "${String(options.level)}".`);
6259
+ function refuseVisibilityLevel(level) {
6260
+ if (!VISIBILITY_LEVELS.includes(level)) {
6261
+ throw new CliError(`Visibility is private, org or public, not "${String(level)}".`);
5975
6262
  }
6263
+ }
6264
+ async function changeVisibility(creds, options) {
6265
+ refuseVisibilityLevel(options.level);
5976
6266
  if (options.level === "public") {
5977
6267
  throw new CliError(
5978
6268
  "Public means anyone on the internet with the link can open it, without signing in, so a person decides that with their own click.",
@@ -5987,11 +6277,59 @@ async function changeVisibility(creds, options) {
5987
6277
  return result2.visibility;
5988
6278
  }
5989
6279
  var unmailedNote = (result2) => result2.mailed === false && result2.message ? ` ${result2.message}` : "";
6280
+ function refuseShareRole(value) {
6281
+ const read = readShareRole(value);
6282
+ if (read.ok) return read.role;
6283
+ const shown2 = typeof value === "string" ? terminalSafe(value.slice(0, 40)) : typeof value;
6284
+ throw new CliError(`Role is viewer or editor, not "${shown2}".`);
6285
+ }
5990
6286
  var shareApp = (creds, org, app, email, role) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
5991
6287
  method: "POST",
5992
- body: JSON.stringify({ email, role }),
6288
+ // No role is no `role`: the control plane then keeps the one a person has.
6289
+ body: JSON.stringify(role === void 0 ? { email } : { email, role }),
5993
6290
  contentType: "application/json"
5994
6291
  });
6292
+ function shareSentence(result2, email, asked, voice) {
6293
+ const roleOf = (value) => value === "viewer" || value === "editor" ? value : void 0;
6294
+ const outcome = ["added", "kept", "unchanged", "changed"].includes(String(result2.outcome)) ? result2.outcome : void 0;
6295
+ const role = roleOf(result2.role);
6296
+ const old = outcome === void 0 || role === void 0;
6297
+ const now = role ?? asked ?? "viewer";
6298
+ const was = roleOf(result2.previousRole);
6299
+ const article = (name) => name === "editor" ? "an editor" : "a viewer";
6300
+ const mail = unmailedNote(result2);
6301
+ const signIn = voice === "cli" ? "the moment they first sign in" : "when they first sign in";
6302
+ if (result2.pending) {
6303
+ const wait = `They will have access ${signIn}.`;
6304
+ if (!old && outcome === "kept") return `${email} was already invited as ${now}, and no role was given, so it stays ${now}. ${wait}${mail}`;
6305
+ const lead2 = `Invited ${email} as ${now}. ${wait}`;
6306
+ if (old || outcome === "added") return `${lead2}${mail}`;
6307
+ if (outcome === "unchanged") return `${lead2} The invitation already said that.${mail}`;
6308
+ if (was === void 0) return `${lead2} The invitation's role was changed.${mail}`;
6309
+ return `${lead2} The invitation said ${was} before.${mail}`;
6310
+ }
6311
+ if (!old && outcome === "kept") {
6312
+ const change = voice === "cli" ? "Pass --role viewer or --role editor" : 'Pass role "viewer" or "editor"';
6313
+ return `${email} already had access as ${now}, and no role was given, so it stays ${now}. ${change} to change it.`;
6314
+ }
6315
+ const lead = voice === "cli" ? `Shared with ${email} as ${now}.` : `${email} can now open the app as ${now}.`;
6316
+ if (old || outcome === "added") return lead;
6317
+ if (outcome === "unchanged") return `${lead} They already had that role.`;
6318
+ if (was === void 0) return `${lead} Their role was changed.`;
6319
+ return `${lead} They were ${article(was)} before.`;
6320
+ }
6321
+ var unshareApp = (creds, org, app, email) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
6322
+ method: "DELETE",
6323
+ body: JSON.stringify({ email }),
6324
+ contentType: "application/json"
6325
+ });
6326
+ function unshareSentence(result2, email) {
6327
+ const share = result2.shareRevoked ?? result2.revoked;
6328
+ const invitation = result2.invitationWithdrawn ?? false;
6329
+ const found = share ? invitation ? `Revoked ${email}'s share and withdrew the invitation still waiting for them.` : `Revoked ${email}'s share.` : invitation ? `Withdrew ${email}'s invitation.` : `${email} held no share on the app and had no invitation to it, so there was nothing to take back.`;
6330
+ const wasDeleted = result2.appDeleted === true && (share || invitation) ? " The app was deleted when this was asked, so a restore will not bring it back." : "";
6331
+ return `${found}${wasDeleted} That takes back only what was shared by name: the app's level (wirl_visibility) and the rights of its owner and the organisation's owners and admins are unchanged.`;
6332
+ }
5995
6333
  var listDeployments = (creds, org, app) => callApi(
5996
6334
  creds,
5997
6335
  `/api/v1/apps/${seg(org)}/${seg(app)}/deployments`
@@ -6119,7 +6457,7 @@ var fetchLogs = (creds, org, app, filters) => callApi(
6119
6457
  );
6120
6458
  var LINE_INDENT = " ";
6121
6459
  function terminalSafe(text) {
6122
- return escapeInvisible(text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
6460
+ return escapeInvisible(text.replace(/\r\n/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
6123
6461
  }
6124
6462
  function utcSecond(at2) {
6125
6463
  const ms = Date.parse(at2);
@@ -6155,7 +6493,8 @@ function formatLogPage(page, since) {
6155
6493
  // queryable. Without this an empty answer right after a request
6156
6494
  // reads as "nothing happened".
6157
6495
  " A request made in the last minute may not have arrived yet: ask again shortly."
6158
- )}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
6496
+ )}
6497
+ ${EDGE_REFUSALS_NOTE}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
6159
6498
  return page.cursor === null ? body : `${body}
6160
6499
 
6161
6500
  More before this: pass cursor=${page.cursor}.`;
@@ -6555,20 +6894,22 @@ async function previewFetch(creds, org, app, name, path, options) {
6555
6894
  const headers = callerHeaders(options.headers);
6556
6895
  headers.set(VISIT_HEADER, mint.token);
6557
6896
  const networkFetch = options.networkFetchImpl ?? fetch;
6558
- const sentAt = Date.now();
6897
+ const clock = options.now ?? (() => performance.now());
6898
+ const sentAt = clock();
6899
+ const elapsed = () => Math.round(clock() - sentAt);
6559
6900
  const response = await networkFetch(target.toString(), {
6560
6901
  method: options.method ?? (options.body !== void 0 ? "POST" : "GET"),
6561
6902
  headers,
6562
6903
  redirect: "manual",
6563
6904
  ...options.body === void 0 ? {} : { body: options.body }
6564
6905
  });
6565
- const headersMs = Date.now() - sentAt;
6906
+ const headersMs = elapsed();
6566
6907
  const contentType = response.headers.get("content-type") ?? "";
6567
6908
  const location = response.headers.get("location");
6568
6909
  const text = isTextBody(contentType);
6569
6910
  const streamWaitMs = Math.min(options.streamWaitMs ?? FETCH_STREAM_WAIT_MS, FETCH_WAIT_MAX_SECONDS * 1e3);
6570
6911
  const keep = options.keepBodyBytes === void 0 ? null : Math.min(options.keepBodyBytes, FETCH_FILE_MAX_BYTES);
6571
- const read = await readBounded(response, streamWaitMs, sentAt, keep ?? FETCH_READ_CEILING_BYTES);
6912
+ const read = await readBounded(response, streamWaitMs, elapsed, keep ?? FETCH_READ_CEILING_BYTES);
6572
6913
  const bytes = read.bytes;
6573
6914
  const truncated = text && (read.cut || bytes.byteLength > FETCH_BODY_CAP_BYTES);
6574
6915
  const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
@@ -6644,7 +6985,7 @@ var FETCH_STREAM_WAIT_MS = 1e4;
6644
6985
  var FETCH_WAIT_MAX_SECONDS = 300;
6645
6986
  var FETCH_READ_CEILING_BYTES = 8 * FETCH_BODY_CAP_BYTES;
6646
6987
  var FETCH_FILE_MAX_BYTES = 1e8;
6647
- async function readBounded(response, waitMs, sentAt, ceilingBytes) {
6988
+ async function readBounded(response, waitMs, elapsed, ceilingBytes) {
6648
6989
  if (response.body === null) return { bytes: new Uint8Array(0), chunks: [], stillStreaming: false, cut: false };
6649
6990
  const reader = response.body.getReader();
6650
6991
  const chunks = [];
@@ -6663,7 +7004,7 @@ async function readBounded(response, waitMs, sentAt, ceilingBytes) {
6663
7004
  break;
6664
7005
  }
6665
7006
  if (next.done) break;
6666
- chunks.push({ atMs: Date.now() - sentAt, bytes: next.value });
7007
+ chunks.push({ atMs: elapsed(), bytes: next.value });
6667
7008
  total += next.value.byteLength;
6668
7009
  if (total > ceilingBytes) {
6669
7010
  cut = true;
@@ -6708,9 +7049,9 @@ function formatFetchResult(result2, saved) {
6708
7049
  0,
6709
7050
  `answered after ${seconds(result2.headersMs)} s; ${said} line${said === 1 ? "" : "s"} in ${pieces} piece${pieces === 1 ? "" : "s"} over ${seconds(last)} s (each line shown at the time it was complete)`
6710
7051
  );
7052
+ const unfinished = lastLineUnfinished(result2);
6711
7053
  timed.forEach((line, i) => {
6712
- const unfinished = i === timed.length - 1 && !result2.timed.lastComplete && (!result2.bodyComplete || result2.truncated);
6713
- lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished ? " (the line had not ended)" : ""}`);
7054
+ lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished && i === timed.length - 1 ? " (the line had not ended)" : ""}`);
6714
7055
  });
6715
7056
  } else {
6716
7057
  lines.push(result2.bodyText);
@@ -6723,6 +7064,44 @@ function formatFetchResult(result2, saved) {
6723
7064
  }
6724
7065
  return lines.join("\n");
6725
7066
  }
7067
+ function lastLineUnfinished(result2) {
7068
+ return result2.timed !== null && !result2.timed.lastComplete && (!result2.bodyComplete || result2.truncated);
7069
+ }
7070
+ var STREAMED_DATA_MAX_LINES = 2e3;
7071
+ var STREAMED_DATA_MAX_TEXT_BYTES = 16 * 1024;
7072
+ function streamedFetchData(result2) {
7073
+ if (result2.bodyText === null || result2.timed === null) return null;
7074
+ const listed2 = listedLines(result2.timed.lines);
7075
+ return {
7076
+ status: result2.status,
7077
+ statusText: result2.statusText,
7078
+ contentType: result2.contentType,
7079
+ location: result2.location,
7080
+ refused: result2.refused,
7081
+ answeredAfterMs: result2.headersMs,
7082
+ streamWaitMs: result2.streamWaitMs,
7083
+ stillStreaming: result2.stillStreaming,
7084
+ bodyComplete: result2.bodyComplete,
7085
+ truncated: result2.truncated,
7086
+ bodyBytes: result2.bodyBytes,
7087
+ pieces: result2.timed.pieces,
7088
+ lastLineEnded: !lastLineUnfinished(result2),
7089
+ lines: listed2,
7090
+ linesLeftOut: result2.timed.lines.length - listed2.length
7091
+ };
7092
+ }
7093
+ function listedLines(all) {
7094
+ const listed2 = [];
7095
+ let bytes = 0;
7096
+ for (const line of all) {
7097
+ if (listed2.length >= STREAMED_DATA_MAX_LINES) break;
7098
+ const size = Buffer.byteLength(line.text);
7099
+ if (listed2.length > 0 && bytes + size > STREAMED_DATA_MAX_TEXT_BYTES) break;
7100
+ bytes += size;
7101
+ listed2.push({ atMs: line.atMs, text: line.text });
7102
+ }
7103
+ return listed2;
7104
+ }
6726
7105
  function seconds(ms) {
6727
7106
  return (ms / 1e3).toFixed(2);
6728
7107
  }
@@ -7073,6 +7452,8 @@ function formatMigrationsListing(listing, app) {
7073
7452
  init_src();
7074
7453
  import { hostname } from "node:os";
7075
7454
  import { basename as basename3, resolve as resolve5 } from "node:path";
7455
+ var reads = (title) => ({ title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
7456
+ var readsTheirText = (title) => ({ ...reads(title), openWorldHint: true });
7076
7457
  var APPS_DOMAIN = "wirl.run";
7077
7458
  var pendingLogin = null;
7078
7459
  function loginInstructions(login) {
@@ -7155,6 +7536,7 @@ var SHARED = [
7155
7536
  {
7156
7537
  name: "wirl_list_connections",
7157
7538
  description: `List the connections in the organisation: named credentials an app may be granted, such as a HubSpot or Stripe key, with the hosts each allows, who made each (by name and email) and whether it holds its key, is waiting for one, or needs none. Use this to learn the exact name to put under "connections" in wirl.json before deploying an app that needs one; a name already listed here is that connection, not a new one, and other apps may be granted it too. The app never receives the key: it calls the vendor's own real URL (fetch("https://api.hubspot.com/...")) and wirl attaches the key on the way out. Declaring one at deploy grants it at once only when you made it or are an org admin, signed in with full access, it was never revoked from that app, and no request for it is already waiting; otherwise, unless the app already holds it, it opens or keeps a request that its maker or an org admin approves. There is no tool to approve one.`,
7539
+ annotations: reads("List connections"),
7158
7540
  inputSchema: {
7159
7541
  type: "object",
7160
7542
  properties: {
@@ -7177,27 +7559,55 @@ var SHARED = [
7177
7559
  },
7178
7560
  {
7179
7561
  name: "wirl_share",
7180
- description: `Give a named colleague access to a deployed app, by email address. Use this when the user says someone should be able to see what they built. Access belongs to that person and can be taken back. Viewers are free \u2014 sharing never costs anything, so there is no reason to hesitate. Role "viewer" can open it; "editor" can also deploy to it. A connection with deploy-only access cannot share; if the server says so, tell the person to open the app's Access page in the dashboard instead.`,
7562
+ description: `Give a named colleague access to a deployed app, by email address. Use this when the user says someone should be able to see what they built. Access belongs to that person and can be taken back. Viewers are free \u2014 sharing never costs anything, so there is no reason to hesitate. Role "viewer" can open it; "editor" can also deploy to it. Sharing again with no role keeps the role a person has: pass "role" only to give or change one, and the answer says what the role is and, when it changed one, what it was. A connection with deploy-only access cannot share; if the server says so, tell the person to open the app's Access page in the dashboard instead.`,
7563
+ annotations: { title: "Share an app with a person", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
7181
7564
  inputSchema: {
7182
7565
  type: "object",
7183
7566
  properties: {
7184
7567
  app: { type: "string", description: 'The app slug, e.g. "quarterly-dashboard".' },
7185
7568
  email: { type: "string", description: "Work email address of the person." },
7186
- role: { type: "string", enum: ["viewer", "editor"], description: "Defaults to viewer." },
7569
+ role: {
7570
+ type: "string",
7571
+ enum: ["viewer", "editor"],
7572
+ description: "Leave it out unless the person should be given or moved to a role: a new person is a viewer, and someone who already has access, or is already invited, keeps the role they have. Naming one sets it, lowering an editor to viewer included."
7573
+ },
7187
7574
  org: { type: "string", description: "Organisation slug. Optional when the person belongs to only one; otherwise the tool says which ones exist and asks." }
7188
7575
  },
7189
7576
  required: ["app", "email"]
7190
7577
  },
7191
7578
  async handler(args, ctx) {
7579
+ const role = refuseShareRole(args["role"]);
7192
7580
  const org = await defaultOrg(ctx.creds, str(args, "org"));
7193
- const role = str(args, "role") === "editor" ? "editor" : "viewer";
7194
- const result2 = await shareApp(ctx.creds, org, str(args, "app"), str(args, "email"), role);
7195
- return result2.pending ? `Invited ${str(args, "email")} as ${role}. They will have access when they first sign in.${unmailedNote(result2)}` : `${str(args, "email")} can now open the app as ${role}.`;
7581
+ const email = str(args, "email");
7582
+ const result2 = await shareApp(ctx.creds, org, str(args, "app"), email, role);
7583
+ return shareSentence(result2, email, role, "mcp");
7584
+ }
7585
+ },
7586
+ {
7587
+ name: "wirl_unshare",
7588
+ description: `Take back a named person's access to a deployed app, by the email address it was shared with. Use this when the user says someone should no longer see what they built. It takes the whole share away, one person at a time; to change a role instead, use wirl_share with "role". It also withdraws an invitation that is still waiting for an address, and works while the app is deleted and can still be restored. It says what it found; finding nothing is not an error. Needs a sign-in with full access, like wirl_share.`,
7589
+ annotations: { title: "Take back a share", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7590
+ inputSchema: {
7591
+ type: "object",
7592
+ properties: {
7593
+ app: { type: "string", description: 'The app slug, e.g. "quarterly-dashboard".' },
7594
+ email: { type: "string", description: "The email address the app was shared with." },
7595
+ org: { type: "string", description: "Organisation slug. Optional when the person belongs to only one; otherwise the tool says which ones exist and asks." }
7596
+ },
7597
+ required: ["app", "email"]
7598
+ },
7599
+ async handler(args, ctx) {
7600
+ refuseUnshareArgs(args);
7601
+ const org = await defaultOrg(ctx.creds, str(args, "org"));
7602
+ const email = str(args, "email");
7603
+ const result2 = await unshareApp(ctx.creds, org, str(args, "app"), email);
7604
+ return unshareSentence(result2, email);
7196
7605
  }
7197
7606
  },
7198
7607
  {
7199
7608
  name: "wirl_visibility",
7200
7609
  description: `See or change who can open an app. With no level it only reports the current one. "private": only its owner, the organisation's owners and admins, and the people it is shared with by email. "org": everyone signed in at the organisation. Use this when the person says who the app is for ("only me", "just my team and whoever I share it with", "the whole company"). It cannot make an app public, and nothing an agent can call can: anyone on the internet being able to open it is a decision a person takes with their own click, on the app's Access page in the dashboard, so if they ask for that, give them that link. To give one named person access use wirl_share instead. A connection with deploy-only access cannot change this either; if the server says so, send the person to the same Access page.`,
7610
+ annotations: { title: "See or set who can open an app", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7201
7611
  inputSchema: {
7202
7612
  type: "object",
7203
7613
  properties: {
@@ -7221,6 +7631,7 @@ var SHARED = [
7221
7631
  {
7222
7632
  name: "wirl_list_apps",
7223
7633
  description: "List the apps the user can open, with their URLs and who can see them. Use this to find an app before deploying to it or sharing it, or when the user asks what they have already deployed.",
7634
+ annotations: reads("List apps"),
7224
7635
  inputSchema: { type: "object", properties: { org: { type: "string", description: "Organisation slug. Optional when the person belongs to only one; otherwise the tool says which ones exist and asks." } } },
7225
7636
  async handler(args, ctx) {
7226
7637
  const org = await defaultOrg(ctx.creds, str(args, "org"));
@@ -7232,6 +7643,7 @@ var SHARED = [
7232
7643
  {
7233
7644
  name: "wirl_list_versions",
7234
7645
  description: "List past deployments of an app, newest first, with their version numbers and ids. Use this before rolling back, to find which version to return to. A preview takes a version number from the same sequence and is never listed here, so the numbers can skip: a first deploy after one preview is v2.",
7646
+ annotations: reads("List app versions"),
7235
7647
  inputSchema: {
7236
7648
  type: "object",
7237
7649
  properties: {
@@ -7250,6 +7662,7 @@ var SHARED = [
7250
7662
  {
7251
7663
  name: "wirl_rollback",
7252
7664
  description: "Put an earlier deployment back to being the live one. Use this when a deploy broke something and the user wants the previous version back. Instant \u2014 nothing is rebuilt. Get the deployment id from wirl_list_versions first.",
7665
+ annotations: { title: "Roll an app back", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7253
7666
  inputSchema: {
7254
7667
  type: "object",
7255
7668
  properties: {
@@ -7271,6 +7684,7 @@ var SHARED = [
7271
7684
  {
7272
7685
  name: "wirl_migrations",
7273
7686
  description: "List what one of an app's databases has applied, read only: its own record of migrations (_wirl_migrations), read now, and wirl's ledger of what it sent there, one line a file, saying where the two differ. Use this before changing anything when a deploy was refused over a migration, or said it could not tell whether a file applied. It writes nothing, and needs the access a deploy needs.",
7687
+ annotations: reads("Show applied migrations"),
7274
7688
  inputSchema: {
7275
7689
  type: "object",
7276
7690
  properties: {
@@ -7291,6 +7705,7 @@ var SHARED = [
7291
7705
  {
7292
7706
  name: "wirl_list_schedules",
7293
7707
  description: "List an app's schedules (from its wirl.json) with the last run's outcome and the next run time. Use this to see whether a scheduled job is healthy.",
7708
+ annotations: reads("List schedules"),
7294
7709
  inputSchema: {
7295
7710
  type: "object",
7296
7711
  properties: {
@@ -7309,6 +7724,7 @@ var SHARED = [
7309
7724
  {
7310
7725
  name: "wirl_run",
7311
7726
  description: "Run one of an app's schedules right now and wait for the outcome (up to the schedule's timeout). Use this after deploying a schedule to check it works, instead of waiting for the clock. With `preview`, runs one of a preview's own schedules instead: it must be declared in the previewed wirl.json (a preview writes no schedule rows, so nothing of its fires on a clock), and the outcome comes back at once, with no queue to wait on.",
7727
+ annotations: { title: "Run a schedule now", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7312
7728
  inputSchema: {
7313
7729
  type: "object",
7314
7730
  properties: {
@@ -7342,6 +7758,7 @@ var SHARED = [
7342
7758
  {
7343
7759
  name: "wirl_list_flows",
7344
7760
  description: `List the flows an app declares under "flows" in its wirl.json, each with its step timeout, its retries, how many of its runs are unfinished and its last run. Use this to find a flow's exact name before wirl_start_flow, or to see whether runs are piling up. With preview, lists what that preview's own build declares instead of the live app's.`,
7761
+ annotations: reads("List flows"),
7345
7762
  inputSchema: {
7346
7763
  type: "object",
7347
7764
  properties: {
@@ -7362,6 +7779,7 @@ var SHARED = [
7362
7779
  {
7363
7780
  name: "wirl_start_flow",
7364
7781
  description: "Start a run of one of an app's flows. Use this to try a flow you have built: put it up with wirl_preview, start it here with preview and fast (fast, for a preview's run only, makes each sleep, until and retry delay the flow asks for 5 seconds, each wait's timeout 60 seconds, and each retry of a failing step 10 seconds, 90 after a try that ran out of time; wirl_retry_run still refuses a failed run until 4 minutes after it failed), follow it with wirl_show_run, answer each wait it reaches with wirl_send_event, and read what each step printed with wirl_logs with the run's id, and with preview for a preview's run. A key names the run in the app's own terms: starting the flow again with the same key finds the run it started instead of starting another, until 30 days after that run ends, so give each new try a new key. The input is at most 64 KiB: keep rows in env.DB and pass ids. Where flows are not on for the organisation yet, the answer says so and nothing starts.",
7782
+ annotations: { title: "Start a flow run", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7365
7783
  inputSchema: {
7366
7784
  type: "object",
7367
7785
  properties: {
@@ -7407,6 +7825,7 @@ var SHARED = [
7407
7825
  {
7408
7826
  name: "wirl_list_runs",
7409
7827
  description: "List an app's flow runs, newest first, each with its status, the step it is at and when it started. Use this to find a run to look at with wirl_show_run, or to see which runs failed or are waiting. Narrow it with flow and status. A preview's runs are listed only with preview, and the live app's only without it.",
7828
+ annotations: reads("List flow runs"),
7410
7829
  inputSchema: {
7411
7830
  type: "object",
7412
7831
  properties: {
@@ -7431,6 +7850,7 @@ var SHARED = [
7431
7850
  {
7432
7851
  name: "wirl_show_run",
7433
7852
  description: `Show one flow run: its status, who started it, what it waits for or sleeps until or, while wirl is still creating it, when wirl tries again, the step it goes on with next and since or from when (a step reports only when it ends, so one that is running says it has not reported yet), its output or error, its page on the dashboard, its newest ${SHOWN_RUN_ROWS} attempts with the version of the app that answered each and each attempt's deadline, and the earlier attempts that went wrong, and its newest ${SHOWN_RUN_ROWS} events with who sent each and whether it reached the run (the run's page lists every one). Use this after wirl_start_flow to follow a run step by step, and to learn which event a waiting run asks for before wirl_send_event. A preview's run is found only with preview, and a live run only without it.`,
7853
+ annotations: readsTheirText("Show a flow run"),
7434
7854
  inputSchema: {
7435
7855
  type: "object",
7436
7856
  properties: {
@@ -7451,6 +7871,7 @@ var SHARED = [
7451
7871
  {
7452
7872
  name: "wirl_send_event",
7453
7873
  description: "Answer what a preview's run waits for: send it the event its wait asks for, with an optional JSON object as the event's body. Use this to walk a flow through its waits while trying it on a preview, once wirl_show_run says which event the run waits for. The event is sent as you, so its from.email is your own address, and an app that takes a decision only from people it lists takes yours only if you are listed; to try that path, list yourself in the build you preview, and never loosen the check. It is for a preview's runs only: a live run is answered by a person, on the app's own page or the run's page on the dashboard, so this refuses a live run before sending anything and says where the person goes. Only a wait open for that event takes it, and only the first answer to a wait counts.",
7874
+ annotations: { title: "Answer a wait on a preview run", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7454
7875
  inputSchema: {
7455
7876
  type: "object",
7456
7877
  properties: {
@@ -7492,6 +7913,7 @@ var SHARED = [
7492
7913
  {
7493
7914
  name: "wirl_cancel_run",
7494
7915
  description: "Cancel a flow run: a step already running is stopped where it is. Use this when a run is stuck, was started by mistake, or is doing the wrong thing. A run that had already finished is left as it is, and the answer says so. A preview's run is found only with preview, and a live run only without it.",
7916
+ annotations: { title: "Cancel a flow run", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
7495
7917
  inputSchema: {
7496
7918
  type: "object",
7497
7919
  properties: {
@@ -7512,6 +7934,7 @@ var SHARED = [
7512
7934
  {
7513
7935
  name: "wirl_retry_run",
7514
7936
  description: "Run a failed flow run again, from the step it failed at, or from an earlier step with from. Use this when what made the run fail has passed, such as a vendor that was down, or once a fix is deployed live, rather than starting a new run. A run can be retried from four minutes after it failed until three days after, while Cloudflare still keeps it; outside that the answer says why. A preview's failed run cannot be retried once the preview has been deployed again or removed, or has expired: start a new run. A preview's run is found only with preview, and a live run only without it. Where flows are not on for the organisation yet, the answer says so.",
7937
+ annotations: { title: "Retry a failed flow run", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7515
7938
  inputSchema: {
7516
7939
  type: "object",
7517
7940
  properties: {
@@ -7541,6 +7964,7 @@ var SHARED = [
7541
7964
  {
7542
7965
  name: "wirl_logs",
7543
7966
  description: "What a deployed app printed, grouped per request (page load, webhook, schedule run, flow step), newest first, with the status and any exception. Use this first when a deploy misbehaves, a schedule or webhook failed, or someone reports an error, before changing code. Defaults to the last hour. A line usually appears within a minute of the request, not at once; if the run you expect is missing, wait and read again. With `preview`, reads that preview's own script instead of the live app's history. Logs are what the app printed, as far as Cloudflare kept them: Workers Logs can miss a request's lines, all of them at times. A run's record is wirl_show_run and the run's page, not its logs: never count a run's steps from them.",
7967
+ annotations: readsTheirText("Read app logs"),
7544
7968
  inputSchema: {
7545
7969
  type: "object",
7546
7970
  properties: {
@@ -7593,6 +8017,7 @@ var SHARED = [
7593
8017
  {
7594
8018
  name: "wirl_egress",
7595
8019
  description: "What a deployed app called out through wirl, call by call, newest first: when, the method and host, the connection, the status, and allowed or the code wirl refused it with; a flow step's call also names its run, step number n and attempt, and the path it called, never its query string. No header or body is recorded. Use this to check which calls a page, a schedule or a flow step actually made to a vendor, or why one was refused, before changing code: two calls of a retried step differ by attempt. Defaults to the last hour, at most seven days back. A flow step's call refused because its deadline had passed is not recorded. Only the app's own calls, for someone who can deploy it, as its logs are.",
8020
+ annotations: readsTheirText("Read app outgoing calls"),
7596
8021
  inputSchema: {
7597
8022
  type: "object",
7598
8023
  properties: {
@@ -7624,6 +8049,7 @@ var SHARED = [
7624
8049
  {
7625
8050
  name: "wirl_list_previews",
7626
8051
  description: `List an app's previews: folders put up at their own address, never live, each with its own database (its tables from migrations/, and at first no rows but the ones those files insert). Use this to find a preview's exact name before calling wirl_preview_fetch or wirl_delete_preview, or to see when one expires. An app holds at most ${MAX_PREVIEWS_PER_APP} previews at once, each for up to ${PREVIEW_TTL_DAYS} days unless redeployed.`,
8052
+ annotations: reads("List previews"),
7627
8053
  inputSchema: {
7628
8054
  type: "object",
7629
8055
  properties: {
@@ -7641,7 +8067,8 @@ var SHARED = [
7641
8067
  },
7642
8068
  {
7643
8069
  name: "wirl_preview_fetch",
7644
- description: `Make one request to a preview and show what it answers: the status, the content type, and the body as text \u2014 the page source for HTML, so you can see what the app actually rendered and fix it. Use this after wirl_preview instead of asking the person to open the link and describe what they see. A binary body reports its type and size, not its bytes. A text body over 100 KB is cut, and the answer says so. A stream (server-sent events, newline-delimited JSON, or any text still arriving after 10 seconds) is shown with the time each line arrived, as far as it got, and says whether it was still going: that is how to check a stream arrives piece by piece. A preview's own database starts with the tables in migrations/ and no rows but the ones those files insert, so a page that expects rows may look blank until something writes to it, and its schedules do not run on a clock. A 401, or a redirect back to sign in, means the visit was refused or the preview has expired or been deleted, not that the app itself answered that way. The headers argument cannot set host, cookie, content-length, or any x-wirl-* header (including x-wirl-visit): those are wirl's own, and are dropped rather than sent. This tool cannot send a file or save an answer as bytes: body is sent as text, exactly as given (an @ in it is just a character), and a binary answer reports only its type and size. From a shell, wirl fetch --data @file sends a file and --output <file> saves the answer, each up to 100,000,000 bytes.`,
8070
+ description: `Make one request to a preview and show what it answers: the status, the content type, and the body as text \u2014 the page source for HTML, so you can see what the app actually rendered and fix it. Use this after wirl_preview instead of asking the person to open the link and describe what they see. A binary body reports its type and size, not its bytes. A text body over 100 KB is cut, and the answer says so. A stream (server-sent events, newline-delimited JSON, or any text still arriving after 10 seconds) is shown with the time each line arrived, as far as it got, and says whether it was still going: that is how to check a stream arrives piece by piece. A stream also comes back as data, in the result's structuredContent: the same lines as { atMs, text }, atMs being whole milliseconds from sending the request to the read that completed the line (lines that arrived together share one), the first ${STREAMED_DATA_MAX_LINES.toLocaleString("en-US")} lines or ${String(STREAMED_DATA_MAX_TEXT_BYTES / 1024)} KB of their text, whichever comes first (always at least the first line), with linesLeftOut counting any more (the text shows them all), and the status, the content type and the flags stillStreaming, bodyComplete, truncated and lastLineEnded; any other answer is the text alone. A preview's own database starts with the tables in migrations/ and no rows but the ones those files insert, so a page that expects rows may look blank until something writes to it, and its schedules do not run on a clock. A 401, or a redirect back to sign in, means the visit was refused or the preview has expired or been deleted, not that the app itself answered that way. The headers argument cannot set host, cookie, content-length, or any x-wirl-* header (including x-wirl-visit): those are wirl's own, and are dropped rather than sent. This tool cannot send a file or save an answer as bytes: body is sent as text, exactly as given (an @ in it is just a character), and a binary answer reports only its type and size. From a shell, wirl fetch --data @file sends a file and --output <file> saves the answer, each up to 100,000,000 bytes.`,
8071
+ annotations: { title: "Request a page from a preview", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
7645
8072
  inputSchema: {
7646
8073
  type: "object",
7647
8074
  properties: {
@@ -7659,24 +8086,15 @@ var SHARED = [
7659
8086
  },
7660
8087
  required: ["app", "name", "path"]
7661
8088
  },
8089
+ answer: previewFetchAnswer,
7662
8090
  async handler(args, ctx) {
7663
- const org = await defaultOrg(ctx.creds, str(args, "org"));
7664
- const headersArg = args["headers"];
7665
- const headers = headersArg !== null && typeof headersArg === "object" && !Array.isArray(headersArg) ? Object.fromEntries(Object.entries(headersArg).map(([k, v]) => [k, String(v)])) : void 0;
7666
- const appsDomain = ctx.appsDomain ?? (await whoami(ctx.creds)).appsDomain;
7667
- const result2 = await previewFetch(ctx.creds, org, str(args, "app"), str(args, "name"), str(args, "path", "/"), {
7668
- appsDomain,
7669
- ...ctx.networkFetch ? { networkFetchImpl: ctx.networkFetch } : {},
7670
- ...str(args, "method") ? { method: str(args, "method") } : {},
7671
- ...typeof args["body"] === "string" ? { body: args["body"] } : {},
7672
- ...headers ? { headers } : {}
7673
- });
7674
- return formatFetchResult(result2);
8091
+ return (await previewFetchAnswer(args, ctx)).text;
7675
8092
  }
7676
8093
  },
7677
8094
  {
7678
8095
  name: "wirl_delete_preview",
7679
8096
  description: `Remove a preview: its script, its files, its database and its address all go away. Use this to free a slot when an app already has ${MAX_PREVIEWS_PER_APP} previews (the most it can hold at once) and a new one is needed, or when a preview is no longer wanted before its own expiry. Idempotent: deleting one already gone still succeeds.`,
8097
+ annotations: { title: "Delete a preview", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7680
8098
  inputSchema: {
7681
8099
  type: "object",
7682
8100
  properties: {
@@ -7699,6 +8117,7 @@ var SHARED = [
7699
8117
  {
7700
8118
  name: "wirl_delete_app",
7701
8119
  description: `Delete an app. Use this only when the person has asked for this app to be deleted; to try something, use wirl_preview. It is a soft delete: the app's address and its previews stop answering at once, its schedules stop and its unfinished runs are cancelled, and its data is kept for ${String(RETENTION.deletedAppGraceDays)} days. Until then its owner, while a member of the organisation, or an organisation owner or admin can bring it back with wirl apps restore <app> --org <org>, or from the Recently deleted list on the organisation's tools page in the dashboard; an app that was public comes back only from that list. Its name stays taken for good. Needs a full-scope sign-in: a deploy-scoped token is refused, with where to do it instead.`,
8120
+ annotations: { title: "Delete an app", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7702
8121
  inputSchema: {
7703
8122
  type: "object",
7704
8123
  properties: {
@@ -7719,6 +8138,7 @@ var SHARED = [
7719
8138
  {
7720
8139
  name: "wirl_whoami",
7721
8140
  description: "Show which account this session is signed in as and which organisations it belongs to. Use this to check the connection works, or to find an organisation slug.",
8141
+ annotations: reads("Show who is signed in"),
7722
8142
  inputSchema: { type: "object", properties: {} },
7723
8143
  async handler(_args, ctx) {
7724
8144
  const me = await whoami(ctx.creds);
@@ -7726,9 +8146,26 @@ var SHARED = [
7726
8146
  }
7727
8147
  }
7728
8148
  ];
8149
+ async function previewFetchAnswer(args, ctx) {
8150
+ const org = await defaultOrg(ctx.creds, str(args, "org"));
8151
+ const headersArg = args["headers"];
8152
+ const headers = headersArg !== null && typeof headersArg === "object" && !Array.isArray(headersArg) ? Object.fromEntries(Object.entries(headersArg).map(([k, v]) => [k, String(v)])) : void 0;
8153
+ const appsDomain = ctx.appsDomain ?? (await whoami(ctx.creds)).appsDomain;
8154
+ const result2 = await previewFetch(ctx.creds, org, str(args, "app"), str(args, "name"), str(args, "path", "/"), {
8155
+ appsDomain,
8156
+ ...ctx.networkFetch ? { networkFetchImpl: ctx.networkFetch } : {},
8157
+ ...ctx.now ? { now: ctx.now } : {},
8158
+ ...str(args, "method") ? { method: str(args, "method") } : {},
8159
+ ...typeof args["body"] === "string" ? { body: args["body"] } : {},
8160
+ ...headers ? { headers } : {}
8161
+ });
8162
+ const data = streamedFetchData(result2);
8163
+ return { text: formatFetchResult(result2), ...data === null ? {} : { structured: { ...data } } };
8164
+ }
7729
8165
  var LOGIN = {
7730
8166
  name: "wirl_login",
7731
8167
  description: "Sign this machine in to wirl so the other wirl tools work. Use this the first time, or whenever another wirl tool says it is not signed in. It returns a link and a short code: show both to the person, ask them to open the link and approve, then retry whatever you were doing. Nothing to paste back \u2014 the token is saved for you once they approve.",
8168
+ annotations: { title: "Sign in to wirl", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7732
8169
  inputSchema: { type: "object", properties: {} },
7733
8170
  anonymous: true,
7734
8171
  async handler() {
@@ -7746,7 +8183,8 @@ var LOGIN = {
7746
8183
  var EXISTING_CONNECTION_SENTENCE = "A declared name the org already has is that connection, not a new one: the answer names who made it, whether it holds a key and the other apps you can open that use it. Declaring a kind it does not have is refused, unless a deploy, never a preview, may change it: it has no key, you made it or are an org admin, no other app is granted it, and neither kind is webhook_secret. ";
7747
8184
  var LOCAL_DEPLOY = {
7748
8185
  name: "wirl_deploy",
7749
- description: `Deploy a directory of files to a URL that colleagues can open. Use this when the user has built something \u2014 a dashboard, a report, an internal tool \u2014 and wants it somewhere real instead of on their laptop. A new app is visible to everyone in the org when the org came from a company email domain; in any other org, including a personal one or one made with wirl orgs create, it starts private. Either way, share it by email to add someone outside that. Returns the URL, and who can open the app as the edge has it when the deploy finishes (a visibility change made while it ran included). Creates the app on first deploy. A wirl.json beside or inside the directory declares a "server" module for a backend and any "connections" the app needs; the answer says which declared connections are usable now, which are waiting on an admin and which need a key, and names the flows the version declares. New files in migrations/ are applied to the app's database before the version goes live; one that fails fails the deploy, and the version already live, if any, keeps serving. A destructive file (one wirl.json's "destructive" list must name) waits for a person before it applies to the live database: the refusal gives the address of the page where they approve it, and nothing this tool takes skips that. ` + EXISTING_CONNECTION_SENTENCE + "Refuses, without uploading anything, if the directory contains what looks like a live API key or password.",
8186
+ description: `Deploy a directory of files to a URL that colleagues can open. Use this when the user has built something \u2014 a dashboard, a report, an internal tool \u2014 and wants it somewhere real instead of on their laptop. A new app is visible to everyone in the org when the org came from a company email domain; in any other org, including a personal one or one made with wirl orgs create, it starts private. Either way, share it by email to add someone outside that. Returns the URL, and who can open the app as the edge has it when the deploy finishes (a visibility change made while it ran included). Creates the app on first deploy. A wirl.json beside or inside the directory declares a "server" module for a backend and any "connections" the app needs; the answer says which declared connections are usable now, which are waiting on an admin and which need a key, and names the flows the version declares. New files in migrations/ are applied to the app's database before the version goes live; one that fails fails the deploy, and the version already live, if any, keeps serving. A destructive file (one wirl.json's "destructive" list must name) waits for a person before it applies to the live database: the refusal gives the address of the page where they approve it, and nothing this tool takes skips that. ` + EXISTING_CONNECTION_SENTENCE + "Refuses, without uploading anything, if the directory contains what looks like a live API key or password. " + versionNumbersSentence("wirl_list_versions"),
8187
+ annotations: { title: "Deploy an app", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
7750
8188
  inputSchema: {
7751
8189
  type: "object",
7752
8190
  properties: {
@@ -7815,6 +8253,7 @@ function connectionLines(connections) {
7815
8253
  var LOCAL_PULL = {
7816
8254
  name: "wirl_pull",
7817
8255
  description: "Fetch the source of a deployed app into a local directory, to change it and deploy again. Use this when the user wants to work on an app a colleague built and shared with them, or to recover the source of one of their own. Anyone who can open the app can pull it. Refuses a directory that already has files in it unless told to overwrite.",
8256
+ annotations: { title: "Pull an app source", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7818
8257
  inputSchema: {
7819
8258
  type: "object",
7820
8259
  properties: {
@@ -7843,6 +8282,7 @@ var PREVIEW_LOGS_SENTENCE = "Deploying a preview again under its name replaces i
7843
8282
  var LOCAL_PREVIEW = {
7844
8283
  name: "wirl_preview",
7845
8284
  description: "Put a directory of files up as a preview: a version of an app that is never live, at its own address, with its own database (every file in migrations/ applied, and no rows but the ones those files insert, the first time that name is used), open to the same people who can already open the app. Use this to preview before deploying \u2014 before wirl_deploy, whenever a change has not been checked yet \u2014 then look at what it actually rendered with wirl_preview_fetch instead of asking the person to open a link and describe what they see. It uses the app's real connections (as wirl_deploy's does, the answer says which declared connections are usable now, which are waiting on an admin and which need a key, and names the flows the build declares), but its own database starts with no rows but the ones the files in migrations/ insert, and its schedules do not run on a clock, so a page that expects rows may look blank until something writes to it. " + EXISTING_CONNECTION_SENTENCE + `An app holds at most ${MAX_PREVIEWS_PER_APP} previews at once; a sixth is refused until one is deleted with wirl_delete_preview. Each one expires after ${PREVIEW_TTL_DAYS} days if not redeployed. ` + PREVIEW_LOGS_SENTENCE + "Refuses, without uploading anything, if the directory contains what looks like a live API key or password. Works for a new app too: the app is made if it is not there yet, and its own address answers 404 until wirl_deploy.",
8285
+ annotations: { title: "Preview an app", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
7846
8286
  inputSchema: {
7847
8287
  type: "object",
7848
8288
  properties: {
@@ -7896,6 +8336,30 @@ function refuseUnlessNames(names) {
7896
8336
  throw new CliError(`${JSON.stringify(value)} is not ${what} name. ${validateSlug(value).message ?? ""}`.trimEnd());
7897
8337
  }
7898
8338
  }
8339
+ var UNSHARE_TAKES = `It takes a person's whole share away, one person at a time: to change a role, use wirl_share with "role". Nothing was changed.`;
8340
+ function refuseUnshareArgs(args) {
8341
+ const allowed = ["app", "email", "org"];
8342
+ const extra = Object.keys(args).filter((key) => !allowed.includes(key));
8343
+ if (extra.length > 0) {
8344
+ const listed2 = extra.map((key) => JSON.stringify(key));
8345
+ const said = listed2.length === 1 ? listed2[0] : `${listed2.slice(0, -1).join(", ")} or ${listed2[listed2.length - 1]}`;
8346
+ throw new CliError(`wirl_unshare takes "app", "email" and "org", not ${said}. ${UNSHARE_TAKES}`);
8347
+ }
8348
+ const app = args["app"];
8349
+ if (app === void 0 || app === "") throw new CliError('Which app? Pass its slug as "app".');
8350
+ if (typeof app !== "string") throw new CliError(`"app" is an app's slug, as text.`);
8351
+ if (!isWellFormedSlug(app)) throw new CliError(`${JSON.stringify(app)} is not an app's name, so nothing was changed.`);
8352
+ const email = args["email"];
8353
+ if (email === void 0 || typeof email === "string" && email.trim() === "") {
8354
+ throw new CliError(`Whose access? Pass the person's email address as "email".`);
8355
+ }
8356
+ if (typeof email !== "string") throw new CliError('"email" is one address, as text: run this once for each person.');
8357
+ const org = args["org"];
8358
+ if (org !== void 0 && typeof org !== "string") throw new CliError(`"org" is an organisation's name, as text.`);
8359
+ if (typeof org === "string" && org !== "" && !isWellFormedSlug(org)) {
8360
+ throw new CliError(`${JSON.stringify(org)} is not an organisation's name, so nothing was changed.`);
8361
+ }
8362
+ }
7899
8363
  function npmFallbackLines() {
7900
8364
  const origin = apiOriginFromEnv();
7901
8365
  const install = installFor(origin);
@@ -7903,7 +8367,8 @@ function npmFallbackLines() {
7903
8367
  }
7904
8368
  var REMOTE_DEPLOY = {
7905
8369
  name: "wirl_deploy",
7906
- description: "Deploy a directory of files to a URL that colleagues can open. This server runs at app.wirl.dev and cannot read files on the machine you are on, so it returns the exact commands to run in a terminal (and makes the app first, when you name one); the upload itself happens from the machine holding the files, never through this chat. A new app is visible to everyone in the org when the org came from a company email domain; in any other org, including a personal one or one made with wirl orgs create, it starts private. Returns the URL it will get.",
8370
+ description: "Deploy a directory of files to a URL that colleagues can open. This server runs at app.wirl.dev and cannot read files on the machine you are on, so it returns the exact commands to run in a terminal (and makes the app first, when you name one); the upload itself happens from the machine holding the files, never through this chat. A new app is visible to everyone in the org when the org came from a company email domain; in any other org, including a personal one or one made with wirl orgs create, it starts private. Returns the URL it will get. " + versionNumbersSentence("wirl_list_versions"),
8371
+ annotations: { title: "Deploy an app", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
7907
8372
  inputSchema: {
7908
8373
  type: "object",
7909
8374
  properties: {
@@ -7923,7 +8388,7 @@ var REMOTE_DEPLOY = {
7923
8388
  app ? `App ${app} exists in ${org}. Upload it from the machine that holds the files:` : "Upload it from the machine that holds the files:",
7924
8389
  "",
7925
8390
  ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
7926
- ` ${WIRL_BIN} login # once per machine; opens the browser`,
8391
+ ` ${WIRL_BIN} login # once per machine; ${CLI_LOGIN_DOES}`,
7927
8392
  ` ${WIRL_BIN} deploy ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}`,
7928
8393
  ...npmFallbackLines(),
7929
8394
  "",
@@ -7936,6 +8401,7 @@ var REMOTE_DEPLOY = {
7936
8401
  var REMOTE_PULL = {
7937
8402
  name: "wirl_pull",
7938
8403
  description: "Fetch the source of a deployed app to work on it. This server cannot write to the machine you are on, so it returns the command to run in a terminal.",
8404
+ annotations: reads("Pull an app source"),
7939
8405
  inputSchema: {
7940
8406
  type: "object",
7941
8407
  properties: {
@@ -7961,6 +8427,7 @@ var REMOTE_PULL = {
7961
8427
  var REMOTE_PREVIEW = {
7962
8428
  name: "wirl_preview",
7963
8429
  description: "Put a directory of files up as a preview: a version of an app that is never live, at its own address, open to the same people who can already open the app. This server runs at app.wirl.dev and cannot read files on the machine you are on, so it returns the exact command to run in a terminal; the upload itself happens from the machine holding the files, never through this chat. Prefer suggesting this over wirl_deploy for a change that has not been checked yet \u2014 preview before deploying, then look at it with wirl_preview_fetch. Works for a new app too: the command makes the app if it is not there yet. " + PREVIEW_LOGS_SENTENCE.trimEnd(),
8430
+ annotations: reads("Preview an app"),
7964
8431
  inputSchema: {
7965
8432
  type: "object",
7966
8433
  properties: {
@@ -7977,10 +8444,9 @@ var REMOTE_PREVIEW = {
7977
8444
  async handler(args, ctx) {
7978
8445
  refuseUnlessNames({ org: str(args, "org"), app: str(args, "app") });
7979
8446
  const given = str(args, "name");
7980
- const slug = given === "" ? "" : slugify(given);
7981
- if (slug === null || slug !== "" && !isValidSlug(slug)) {
7982
- throw new CliError(`${JSON.stringify(given)} makes no preview name: as a slug it is empty, one character, or a reserved word.`);
7983
- }
8447
+ const made = given === "" ? null : previewSlugFor(given);
8448
+ if (made !== null && !made.ok) throw new CliError(made.message);
8449
+ const slug = made === null ? "" : made.slug;
7984
8450
  const org = await defaultOrg(ctx.creds, str(args, "org"));
7985
8451
  const directory = str(args, "directory", ".");
7986
8452
  const app = str(args, "app");
@@ -7991,7 +8457,7 @@ var REMOTE_PREVIEW = {
7991
8457
  "Upload it from the machine that holds the files:",
7992
8458
  "",
7993
8459
  ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
7994
- ` ${WIRL_BIN} login # once per machine; opens the browser`,
8460
+ ` ${WIRL_BIN} login # once per machine; ${CLI_LOGIN_DOES}`,
7995
8461
  ` ${WIRL_BIN} preview ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}${name ? ` --name ${shellQuote(name)}` : ""}`,
7996
8462
  ...npmFallbackLines(),
7997
8463
  "",
@@ -8012,6 +8478,7 @@ async function manifestFolder(directory) {
8012
8478
  var LOCAL_MIGRATIONS_ADOPT = {
8013
8479
  name: "wirl_migrations_adopt",
8014
8480
  description: `${MIGRATIONS_ADOPT_WHAT} The project's .wirl/local.sqlite is recorded too when it has the same shape and no record of other files, so wirl dev keeps its rows. Pointed at build output, it writes beside the wirl.json next to it, and the build must copy migrations/ to the top of what it deploys. Adopting again replaces the record of an earlier adopt, and is refused once a later file has applied.`,
8481
+ annotations: { title: "Adopt app tables as migrations", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
8015
8482
  inputSchema: {
8016
8483
  type: "object",
8017
8484
  properties: {
@@ -8035,6 +8502,7 @@ var LOCAL_MIGRATIONS_ADOPT = {
8035
8502
  var REMOTE_MIGRATIONS_ADOPT = {
8036
8503
  name: "wirl_migrations_adopt",
8037
8504
  description: `${MIGRATIONS_ADOPT_WHAT} This server runs at app.wirl.dev and cannot write files on the machine you are on, so it returns the command to run in a terminal there.`,
8505
+ annotations: reads("Adopt app tables as migrations"),
8038
8506
  inputSchema: {
8039
8507
  type: "object",
8040
8508
  properties: {
@@ -8053,7 +8521,7 @@ var REMOTE_MIGRATIONS_ADOPT = {
8053
8521
  "Run it on the machine that holds the project, in a terminal:",
8054
8522
  "",
8055
8523
  ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
8056
- ` ${WIRL_BIN} login # once per machine; opens the browser`,
8524
+ ` ${WIRL_BIN} login # once per machine; ${CLI_LOGIN_DOES}`,
8057
8525
  ` ${WIRL_BIN} migrations adopt ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}`,
8058
8526
  ...npmFallbackLines(),
8059
8527
  "",
@@ -8090,6 +8558,14 @@ function negotiate(requested) {
8090
8558
  function result(id, value) {
8091
8559
  return JSON.stringify({ jsonrpc: "2.0", id, result: value });
8092
8560
  }
8561
+ function escapeInvisibleDeep(value) {
8562
+ if (typeof value === "string") return escapeInvisible(value);
8563
+ if (Array.isArray(value)) return value.map(escapeInvisibleDeep);
8564
+ if (value !== null && typeof value === "object") {
8565
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, escapeInvisibleDeep(inner)]));
8566
+ }
8567
+ return value;
8568
+ }
8093
8569
  function failure(id, code, message) {
8094
8570
  return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
8095
8571
  }
@@ -8115,7 +8591,10 @@ async function handleRpc(request, ctx) {
8115
8591
  tools: tools.map((t) => ({
8116
8592
  name: t.name,
8117
8593
  description: t.description,
8118
- inputSchema: t.inputSchema
8594
+ inputSchema: t.inputSchema,
8595
+ // After the three fields every client has always read: a client
8596
+ // that knows no annotations ignores it (OIM-844).
8597
+ annotations: t.annotations
8119
8598
  }))
8120
8599
  });
8121
8600
  case "tools/call": {
@@ -8125,8 +8604,17 @@ async function handleRpc(request, ctx) {
8125
8604
  if (!tool) return failure(id, -32602, `Unknown tool: ${name}`);
8126
8605
  const args = params["arguments"] ?? {};
8127
8606
  try {
8128
- const text = tool.anonymous ? await tool.handler(args) : await tool.handler(args, ctx ?? { creds: await credentialsOrPending(), mode: "local" });
8129
- return result(id, { content: [{ type: "text", text: escapeInvisible(text) }] });
8607
+ let answer;
8608
+ if (tool.anonymous) {
8609
+ answer = { text: await tool.handler(args) };
8610
+ } else {
8611
+ const context = ctx ?? { creds: await credentialsOrPending(), mode: "local" };
8612
+ answer = tool.answer ? await tool.answer(args, context) : { text: await tool.handler(args, context) };
8613
+ }
8614
+ return result(id, {
8615
+ content: [{ type: "text", text: escapeInvisible(answer.text) }],
8616
+ ...answer.structured === void 0 ? {} : { structuredContent: escapeInvisibleDeep(answer.structured) }
8617
+ });
8130
8618
  } catch (error) {
8131
8619
  const message = error instanceof CliError ? [error.message, error.hint].filter(Boolean).join("\n") : error instanceof Error ? error.message : String(error);
8132
8620
  return result(id, { content: [{ type: "text", text: escapeInvisible(message) }], isError: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirl/mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "MCP server for wirl: let your coding agent deploy what it just built, privately.",
5
5
  "keywords": [
6
6
  "mcp",