appilot-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/LICENSE +15 -0
  4. package/README.md +74 -16
  5. package/dist/appilot-configurator.mcpb +0 -0
  6. package/dist/cli.d.ts +34 -0
  7. package/dist/cli.js +171 -0
  8. package/dist/client.d.ts +45 -1
  9. package/dist/client.js +74 -1
  10. package/dist/contract/healthContract.js +31 -4
  11. package/dist/index.bundle.js +820 -142
  12. package/dist/index.js +7 -0
  13. package/dist/manifest.d.ts +14 -2
  14. package/dist/manifest.js +31 -9
  15. package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
  16. package/dist/public-marketplace/README.md +23 -0
  17. package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
  18. package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
  19. package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
  20. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
  21. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
  22. package/dist/remote/consent.d.ts +10 -2
  23. package/dist/remote/consent.js +15 -6
  24. package/dist/remote/consentMessages.d.ts +4 -1
  25. package/dist/remote/consentMessages.js +9 -6
  26. package/dist/remote/httpServer.js +2 -2
  27. package/dist/remote/oauth.js +9 -9
  28. package/dist/scaffold.d.ts +68 -6
  29. package/dist/scaffold.js +424 -97
  30. package/dist/server.js +175 -18
  31. package/dist/version.d.ts +1 -1
  32. package/dist/version.js +1 -1
  33. package/examples/app.appilot.json +212 -0
  34. package/mcpb/manifest.json +117 -21
  35. package/package.json +5 -3
  36. package/skills/app-configurator/SKILL.md +61 -19
@@ -21821,7 +21821,7 @@ var SERVER_VERSION;
21821
21821
  var init_version = __esm({
21822
21822
  "src/version.ts"() {
21823
21823
  "use strict";
21824
- SERVER_VERSION = "0.2.1";
21824
+ SERVER_VERSION = "0.3.0";
21825
21825
  }
21826
21826
  });
21827
21827
 
@@ -21859,6 +21859,9 @@ function localized(entity, field) {
21859
21859
  if (Array.isArray(rows) && rows.length) return fromTranslationRows(rows, field);
21860
21860
  return fromBaseAndI18n(entity[field], entity[`${field}_i18n`], entity.source_locale);
21861
21861
  }
21862
+ function credentialAdvice(hasToken) {
21863
+ return hasToken ? "The credential was rejected. Check it in the Backoffice under Service tokens: it may be revoked, expired, or scoped to another app. A token narrowed to one app is refused on every other app in the organization." : 'No credential is set. Put a service token in APPILOT_PAT in the MCP client environment and restart the client. Mint one in the Backoffice under Service tokens: "Inspect only" to read and audit, "Edit configuration" to write, "Set up integrations" to create apps, domains and widget keys.';
21864
+ }
21862
21865
  function safeJson(text) {
21863
21866
  try {
21864
21867
  return JSON.parse(text);
@@ -22007,8 +22010,9 @@ var init_client = __esm({
22007
22010
  const text = await res.text();
22008
22011
  const body = text ? safeJson(text) : void 0;
22009
22012
  if (!res.ok) {
22013
+ const credential = res.status === 401 || res.status === 403 ? ` ${credentialAdvice(!!this.conn.token)}` : "";
22010
22014
  throw new AppilotApiError(
22011
- `${init.method ?? "GET"} ${path} failed: ${describeError(body, res)}`,
22015
+ `${init.method ?? "GET"} ${path} failed: ${describeError(body, res)}${credential}`,
22012
22016
  res.status,
22013
22017
  body
22014
22018
  );
@@ -22112,6 +22116,16 @@ var init_client = __esm({
22112
22116
  listProvisioned() {
22113
22117
  return this.request("/provision/apps");
22114
22118
  }
22119
+ /**
22120
+ * The org's widget keys, prefixes only.
22121
+ *
22122
+ * A caller that cannot see the keys it already minted asks for another every
22123
+ * run, and each one is a live credential.
22124
+ */
22125
+ async listWidgetKeys() {
22126
+ const body = await this.request("/provision/widget-keys");
22127
+ return Array.isArray(body?.widgetKeys) ? body.widgetKeys : [];
22128
+ }
22115
22129
  provisionApp(body) {
22116
22130
  return this.request("/provision/app", { method: "POST", body: JSON.stringify(body) });
22117
22131
  }
@@ -22128,6 +22142,21 @@ var init_client = __esm({
22128
22142
  const url2 = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
22129
22143
  return this.request(`/domain/check?url=${encodeURIComponent(url2)}`);
22130
22144
  }
22145
+ /**
22146
+ * The TXT record a domain needs, and where its verification stands.
22147
+ *
22148
+ * These two live on the apps router rather than under `/provision`, and that
22149
+ * router authenticates an organization SESSION. A service token is refused
22150
+ * there today, which is why `verify_domain` falls back to the provisioning
22151
+ * dry run for the status and says plainly what it could not do.
22152
+ */
22153
+ domainVerification(domainId) {
22154
+ return this.request(`/apps/domains/${domainId}/verification`);
22155
+ }
22156
+ /** Ask Appilot to look for the TXT record now. */
22157
+ triggerDomainVerification(domainId) {
22158
+ return this.request(`/apps/domains/${domainId}/verify`, { method: "POST" });
22159
+ }
22131
22160
  /** Who this credential is: org, app narrowing, scopes. Never a secret. */
22132
22161
  whoami() {
22133
22162
  return this.request("/config/whoami");
@@ -22180,6 +22209,10 @@ var init_client = __esm({
22180
22209
  read("knowledge", () => this.listKnowledge(appId), mapKnowledge),
22181
22210
  expectedLocales?.length ? Promise.resolve(expectedLocales) : this.resolveLocales(appId)
22182
22211
  ]);
22212
+ if (knowledge.length === 0 && !gaps.some((g) => g.entity === "knowledge")) {
22213
+ const refusal = await this.credentialRefusal();
22214
+ if (refusal) gaps.push({ entity: "knowledge", reason: refusal });
22215
+ }
22183
22216
  return {
22184
22217
  expectedLocales: locales,
22185
22218
  views,
@@ -22192,6 +22225,28 @@ var init_client = __esm({
22192
22225
  ...gaps.length ? { gaps } : {}
22193
22226
  };
22194
22227
  }
22228
+ /**
22229
+ * Why an empty read should not be believed, or null when the credential is
22230
+ * fine and the app genuinely has nothing.
22231
+ *
22232
+ * Only a 401 or a 403 counts. An instance too old to answer `whoami` at all
22233
+ * says nothing about the token, and reporting a gap on that would be a
22234
+ * confident false alarm on every on-premise deployment behind cloud.
22235
+ */
22236
+ async credentialRefusal() {
22237
+ if (!this.conn.token) {
22238
+ return `No service token is set, so the knowledge read was anonymous and answered an empty list rather than refusing. ${credentialAdvice(false)}`;
22239
+ }
22240
+ try {
22241
+ await this.whoami();
22242
+ return null;
22243
+ } catch (err) {
22244
+ if (err instanceof AppilotApiError && (err.status === 401 || err.status === 403)) {
22245
+ return `The knowledge read returned nothing and the credential failed its own self-check, so the empty result is not evidence of an empty knowledge base. ${credentialAdvice(true)}`;
22246
+ }
22247
+ return null;
22248
+ }
22249
+ }
22195
22250
  /**
22196
22251
  * Which locales this app's configuration is expected to cover: the union of
22197
22252
  * its domains' configured languages. Falls back to the trilingual baseline
@@ -23024,6 +23079,10 @@ function planMarkers(plan) {
23024
23079
  function localizedValues(text) {
23025
23080
  return Object.values(text).filter((v) => typeof v === "string");
23026
23081
  }
23082
+ function authoredFormValues(plan) {
23083
+ const raw = plan.form_values;
23084
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
23085
+ }
23027
23086
  function lintMarkers(snap) {
23028
23087
  const controlIds = new Set(snap.controls.map((c) => c.semantic_id));
23029
23088
  const formIds = new Set(snap.forms.map((f) => f.semantic_id));
@@ -23041,7 +23100,7 @@ function lintMarkers(snap) {
23041
23100
  recommendation: "Each step needs exactly one executable marker resolving to a known control/form; values never go in the marker."
23042
23101
  });
23043
23102
  }
23044
- for (const [formId, entry] of Object.entries(plan.form_values)) {
23103
+ for (const [formId, entry] of Object.entries(authoredFormValues(plan))) {
23045
23104
  if (!formIds.has(formId)) {
23046
23105
  findings.push({
23047
23106
  severity: "high",
@@ -23051,8 +23110,19 @@ function lintMarkers(snap) {
23051
23110
  });
23052
23111
  continue;
23053
23112
  }
23113
+ const authored = entry;
23114
+ if (!authored || !Array.isArray(authored.fields)) {
23115
+ findings.push({
23116
+ severity: "high",
23117
+ category: "markers",
23118
+ entity: `action_plan:${plan.semantic_id}`,
23119
+ message: `form_values["${formId}"] is not in the authored shape { fields: [{ control_id, value }] }.`,
23120
+ recommendation: "Write an empty fields array when there are no authored defaults."
23121
+ });
23122
+ continue;
23123
+ }
23054
23124
  const fields = fieldsByForm.get(formId) ?? /* @__PURE__ */ new Set();
23055
- for (const f of entry.fields) {
23125
+ for (const f of authored.fields) {
23056
23126
  if (!fields.has(f.control_id)) {
23057
23127
  findings.push({
23058
23128
  severity: "high",
@@ -23075,7 +23145,7 @@ function lintActionability(snap) {
23075
23145
  const markers = planMarkers(plan);
23076
23146
  const hasFormStep = markers.some((m) => m.kind === "form");
23077
23147
  const hasSetValue = markers.some((m) => m.kind === "set_value" || m.kind === "select");
23078
- const hasFormValues = Object.keys(plan.form_values).length > 0;
23148
+ const hasFormValues = Object.keys(authoredFormValues(plan)).length > 0;
23079
23149
  const entersValue = hasFormStep || hasSetValue || hasFormValues;
23080
23150
  const looksLikeCreate = localizedValues({ ...plan.name, ...plan.description }).some((v) => CREATE_INTENT.test(v)) || CREATE_INTENT.test(plan.semantic_id);
23081
23151
  const formFieldIds = new Set(snap.forms.flatMap((f) => f.field_ids));
@@ -23093,7 +23163,7 @@ function lintActionability(snap) {
23093
23163
  if (!form.submit_control_id) continue;
23094
23164
  const clicksSubmit = markers.some((m) => m.kind === "click" && m.id === form.submit_control_id);
23095
23165
  if (!clicksSubmit) continue;
23096
- const fillsForm = markers.some((m) => m.kind === "form" && m.id === form.semantic_id) || (plan.form_values[form.semantic_id]?.fields?.length ?? 0) > 0;
23166
+ const fillsForm = markers.some((m) => m.kind === "form" && m.id === form.semantic_id) || (authoredFormValues(plan)[form.semantic_id]?.fields?.length ?? 0) > 0;
23097
23167
  if (!fillsForm) {
23098
23168
  findings.push({
23099
23169
  severity: "high",
@@ -23441,6 +23511,17 @@ async function planManifest(client, manifest, resolveAppId) {
23441
23511
  }
23442
23512
  return { planToken: manifestDigest(manifest), provisioning, config: config2, notes };
23443
23513
  }
23514
+ function provisioningChanges(preview) {
23515
+ const changes = [];
23516
+ if (preview.app.action !== "reused") changes.push(`the app "${preview.app.name}" (${preview.app.action})`);
23517
+ for (const domain of preview.domains) {
23518
+ if (domain.action !== "reused") changes.push(`the domain ${domain.domain} (${domain.action})`);
23519
+ }
23520
+ if (preview.widgetKey && preview.widgetKey.action !== "reused") {
23521
+ changes.push(`a widget key (${preview.widgetKey.action})`);
23522
+ }
23523
+ return changes;
23524
+ }
23444
23525
  async function applyManifest(client, manifest, options) {
23445
23526
  const expected = manifestDigest(manifest);
23446
23527
  if (options.planToken !== expected) {
@@ -23449,7 +23530,23 @@ async function applyManifest(client, manifest, options) {
23449
23530
  );
23450
23531
  }
23451
23532
  const notes = [];
23452
- const provisioning = await client.provisionApp(provisionRequest(manifest, false));
23533
+ const preview = await client.provisionApp(provisionRequest(manifest, true));
23534
+ const changes = provisioningChanges(preview);
23535
+ if (changes.length > 0 && options.provisionRefusal) {
23536
+ throw new Error(
23537
+ `${options.provisionRefusal}
23538
+ This manifest would change ${changes.join(", ")}, so the apply cannot proceed without it. A manifest whose app, domains and keys already exist applies with config:write alone.`
23539
+ );
23540
+ }
23541
+ let provisioning;
23542
+ if (changes.length === 0) {
23543
+ provisioning = preview;
23544
+ notes.push(
23545
+ "Provisioning wrote nothing: the app, its domains and its keys already matched the manifest, so this apply needed only config:write. The provisioning block below is the preview, which is why it reports dryRun true."
23546
+ );
23547
+ } else {
23548
+ provisioning = await client.provisionApp(provisionRequest(manifest, false));
23549
+ }
23453
23550
  let config2;
23454
23551
  if (manifest.config) {
23455
23552
  const appId = provisioning.app.id;
@@ -23487,6 +23584,32 @@ var init_manifest = __esm({
23487
23584
  });
23488
23585
 
23489
23586
  // src/scaffold.ts
23587
+ function publicKeyIdiom(framework) {
23588
+ switch (framework) {
23589
+ case "next":
23590
+ return {
23591
+ envName: "NEXT_PUBLIC_APPILOT_WIDGET_KEY",
23592
+ expr: "process.env.NEXT_PUBLIC_APPILOT_WIDGET_KEY"
23593
+ };
23594
+ case "sveltekit":
23595
+ return {
23596
+ envName: "PUBLIC_APPILOT_WIDGET_KEY",
23597
+ expr: "publicEnv.PUBLIC_APPILOT_WIDGET_KEY",
23598
+ importLine: "import { env as publicEnv } from '$env/dynamic/public';"
23599
+ };
23600
+ default:
23601
+ return {
23602
+ envName: "VITE_APPILOT_WIDGET_KEY",
23603
+ expr: "import.meta.env.VITE_APPILOT_WIDGET_KEY"
23604
+ };
23605
+ }
23606
+ }
23607
+ function localDevelopmentNotes(idiom) {
23608
+ return [
23609
+ "Local development, first choice: run the app on a hostname that resolves to 127.0.0.1 (myapp.lvh.me:3000, or an /etc/hosts entry such as myapp.local) and register THAT hostname as a domain of the app. The page then resolves its tenant by its own hostname, no widget key is needed at all, DNS verification is not required for this path, and the turn gets the full app context: views, controls, forms, action plans and knowledge.",
23610
+ `Local development, fallback: on bare localhost no domain resolves, so the page needs a wk_test_ key in ${idiom.envName}. The turn gets the organization and the signed-in user and NO app context, because there is no domain to resolve it from. Use this only where the hostname above is impossible.`
23611
+ ];
23612
+ }
23490
23613
  function relayFetchHandler(namespace) {
23491
23614
  return `import { createWidgetTokenHandler } from 'appilot-server';
23492
23615
 
@@ -23508,36 +23631,70 @@ export const POST = createWidgetTokenHandler({
23508
23631
  `;
23509
23632
  }
23510
23633
  function relayNodeHandler(namespace, framework) {
23511
- const mount = framework === "express" ? `app.post('/api/widget/token', relay);` : `fastify.post('/api/widget/token', (request, reply) => relay(request.raw, reply.raw));`;
23512
- return `import { createNodeWidgetTokenHandler } from 'appilot-server/node';
23634
+ const head = `import type { IncomingMessage } from 'node:http';
23635
+ import { createNodeWidgetTokenHandler } from 'appilot-server/node';
23636
+
23637
+ /** What your session middleware puts on the request. Yours will differ. */
23638
+ type RequestWithSession = IncomingMessage & {
23639
+ session?: { user?: { id: string | number; name?: string } };
23640
+ };
23513
23641
 
23514
23642
  // resolveUser is the security boundary. Derive the user from a credential you
23515
23643
  // trust (req.session, req.user). Never from the request body.
23516
23644
  const relay = createNodeWidgetTokenHandler({
23517
- apiUrl: process.env.APPILOT_API_URL,
23518
- widgetKey: process.env.APPILOT_WIDGET_KEY,
23519
- widgetSecret: process.env.APPILOT_WIDGET_SECRET, // server-side only
23645
+ apiUrl: process.env.APPILOT_API_URL!,
23646
+ widgetKey: process.env.APPILOT_WIDGET_KEY!,
23647
+ widgetSecret: process.env.APPILOT_WIDGET_SECRET!, // server-side only
23520
23648
  resolveUser: req => {
23521
- const user = req.session?.user; // your auth, unchanged
23522
- if (!user) return null; // 401 IDENTITY_REQUIRED
23649
+ const user = (req as RequestWithSession).session?.user; // your auth, unchanged
23650
+ if (!user) return null; // 401 IDENTITY_REQUIRED
23523
23651
  return { externalId: \`${namespace}:\${user.id}\`, displayName: user.name };
23524
23652
  },
23525
23653
  });
23654
+ `;
23655
+ if (framework === "express") {
23656
+ return `${head}
23657
+ import type { Express } from 'express';
23658
+
23659
+ export function mountWidgetToken(app: Express): void {
23660
+ app.post('/api/widget/token', relay);
23661
+ }
23662
+ `;
23663
+ }
23664
+ return `${head}
23665
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
23526
23666
 
23527
- ${mount}
23667
+ export function mountWidgetToken(fastify: FastifyInstance): void {
23668
+ fastify.post('/api/widget/token', async (request: FastifyRequest, reply: FastifyReply) => {
23669
+ // The relay answers the raw response itself, so Fastify has to be told it
23670
+ // no longer owns the reply. Without hijack() it reports "Reply was already
23671
+ // sent" on every call.
23672
+ reply.hijack();
23673
+ await relay(request.raw, reply.raw);
23674
+ });
23675
+ }
23528
23676
  `;
23529
23677
  }
23530
23678
  function bootFile(options) {
23531
- const lines = [
23532
- "import { bootAppilotWidget } from 'appilot';",
23679
+ const idiom = publicKeyIdiom(options.framework);
23680
+ const lines = ["import { bootAppilotWidget } from 'appilot';"];
23681
+ if (idiom.importLine) lines.push(idiom.importLine);
23682
+ lines.push(
23683
+ "",
23684
+ "// The widget key is publishable: it names the app to Appilot and is meant to",
23685
+ "// sit in the page. The widget SECRET is a server credential and never appears",
23686
+ `// in this file. Leave ${idiom.envName} unset when the page runs on a hostname`,
23687
+ "// you registered as a domain: the tenant then resolves from the hostname and",
23688
+ "// the turn gets the full app context.",
23689
+ `const widgetKey = ${idiom.expr};`,
23533
23690
  "",
23534
23691
  "// Call once, after your app knows the user is signed in. The widget",
23535
- "// requires an identified user; the relay below mints that identity.",
23692
+ "// requires an identified user; the relay mints that identity.",
23536
23693
  "export function startAppilot() {",
23537
23694
  " bootAppilotWidget({",
23538
- ` widgetScriptUrl: '${options.widgetScriptUrl}',`
23539
- ];
23540
- if (options.widgetKey) lines.push(` widgetKey: '${options.widgetKey}',`);
23695
+ ` widgetScriptUrl: '${options.widgetScriptUrl}',`,
23696
+ " widgetKey,"
23697
+ );
23541
23698
  if (options.apiUrl) lines.push(` appilotApiUrl: '${options.apiUrl}',`);
23542
23699
  lines.push(
23543
23700
  " tokenEndpoint: '/api/widget/token',",
@@ -23546,8 +23703,142 @@ function bootFile(options) {
23546
23703
  );
23547
23704
  return lines.join("\n") + "\n";
23548
23705
  }
23706
+ function rawExchangeFiles(namespace, apiUrl) {
23707
+ return [
23708
+ {
23709
+ path: "appilot/widget-token.sh",
23710
+ language: "bash",
23711
+ contents: `# The whole relay, as one call. Run it from your BACKEND, never a browser:
23712
+ # the widget secret authenticates the exchange and must never leave the server.
23713
+ curl -sS -X POST '${apiUrl}/widget/token' \\
23714
+ -H 'Content-Type: application/json' \\
23715
+ -H "X-Widget-Key: $APPILOT_WIDGET_KEY" \\
23716
+ -H "X-Widget-Secret: $APPILOT_WIDGET_SECRET" \\
23717
+ -d '{"externalId":"${namespace}:4711","displayName":"Ada Lovelace"}'
23718
+
23719
+ # 200 -> {"token":"...","expiresIn":900}. Return that body to the page as-is.
23720
+ `
23721
+ },
23722
+ {
23723
+ path: "appilot/widget_token.py",
23724
+ language: "python",
23725
+ contents: `"""Appilot identity relay, Django view. Flask and FastAPI differ only in the
23726
+ decorator and the response helper.
23727
+
23728
+ The user is resolved from the session the host already trusts. Never from the
23729
+ request body: the body is client-supplied, so reading an id from it would let
23730
+ any caller ask for any user's token.
23731
+ """
23732
+
23733
+ import os
23734
+ import json
23735
+ import urllib.request
23736
+
23737
+ APPILOT_API_URL = os.environ["APPILOT_API_URL"]
23738
+ WIDGET_KEY = os.environ["APPILOT_WIDGET_KEY"]
23739
+ WIDGET_SECRET = os.environ["APPILOT_WIDGET_SECRET"] # server-side only
23740
+ NAMESPACE = "${namespace}"
23741
+
23742
+
23743
+ def widget_token(request):
23744
+ from django.http import JsonResponse
23745
+
23746
+ user = getattr(request, "user", None)
23747
+ if user is None or not user.is_authenticated:
23748
+ return JsonResponse({"code": "IDENTITY_REQUIRED"}, status=401)
23749
+
23750
+ payload = json.dumps(
23751
+ {
23752
+ "externalId": f"{NAMESPACE}:{user.pk}",
23753
+ "displayName": user.get_full_name() or user.get_username(),
23754
+ }
23755
+ ).encode()
23756
+
23757
+ req = urllib.request.Request(
23758
+ f"{APPILOT_API_URL}/widget/token",
23759
+ data=payload,
23760
+ headers={
23761
+ "Content-Type": "application/json",
23762
+ "X-Widget-Key": WIDGET_KEY,
23763
+ "X-Widget-Secret": WIDGET_SECRET,
23764
+ },
23765
+ method="POST",
23766
+ )
23767
+ try:
23768
+ with urllib.request.urlopen(req, timeout=10) as response:
23769
+ body = json.load(response)
23770
+ except Exception:
23771
+ # Never forward the upstream message: it is not yours to surface.
23772
+ return JsonResponse({"code": "WIDGET_TOKEN_EXCHANGE_FAILED"}, status=502)
23773
+
23774
+ return JsonResponse({"token": body["token"], "expiresIn": body["expiresIn"]})
23775
+ `
23776
+ },
23777
+ {
23778
+ path: "appilot/widget_token_controller.rb",
23779
+ language: "ruby",
23780
+ contents: `# Appilot identity relay, Rails controller.
23781
+ #
23782
+ # current_user comes from the session Rails already established. Never from
23783
+ # params: those are client-supplied, and reading an id from them would let any
23784
+ # caller ask for any user's token.
23785
+ require "net/http"
23786
+ require "json"
23787
+
23788
+ class WidgetTokenController < ApplicationController
23789
+ NAMESPACE = "${namespace}".freeze
23790
+
23791
+ def create
23792
+ return render(json: { code: "IDENTITY_REQUIRED" }, status: :unauthorized) unless current_user
23793
+
23794
+ uri = URI("#{ENV.fetch('APPILOT_API_URL')}/widget/token")
23795
+ request = Net::HTTP::Post.new(uri)
23796
+ request["Content-Type"] = "application/json"
23797
+ request["X-Widget-Key"] = ENV.fetch("APPILOT_WIDGET_KEY")
23798
+ request["X-Widget-Secret"] = ENV.fetch("APPILOT_WIDGET_SECRET") # server-side only
23799
+ request.body = {
23800
+ externalId: "#{NAMESPACE}:#{current_user.id}",
23801
+ displayName: current_user.name
23802
+ }.to_json
23803
+
23804
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 10) do |http|
23805
+ http.request(request)
23806
+ end
23807
+
23808
+ unless response.is_a?(Net::HTTPSuccess)
23809
+ # Never forward the upstream message.
23810
+ return render(json: { code: "WIDGET_TOKEN_EXCHANGE_FAILED" }, status: :bad_gateway)
23811
+ end
23812
+
23813
+ body = JSON.parse(response.body)
23814
+ render json: { token: body["token"], expiresIn: body["expiresIn"] }
23815
+ end
23816
+ end
23817
+ `
23818
+ }
23819
+ ];
23820
+ }
23821
+ function rawBootFile(options) {
23822
+ const keyAttr = options.widgetKey ? `
23823
+ data-api-key="${options.widgetKey}"` : "";
23824
+ const apiAttr = options.apiUrl ? `
23825
+ data-api-url="${options.apiUrl}"` : "";
23826
+ return {
23827
+ path: "appilot/widget-snippet.html",
23828
+ language: "html",
23829
+ contents: `<!-- Paste into the layout template your signed-in pages render.
23830
+ The relay above must answer at /api/widget/token on the same origin. -->
23831
+ <script
23832
+ src="${options.widgetScriptUrl}"${keyAttr}${apiAttr}
23833
+ async
23834
+ ></script>
23835
+ `
23836
+ };
23837
+ }
23549
23838
  function scaffoldIntegration(options) {
23550
23839
  const namespace = options.idNamespace ?? "app";
23840
+ const apiUrl = options.apiUrl ?? "https://api.appilot.space";
23841
+ const idiom = publicKeyIdiom(options.framework);
23551
23842
  const files = [];
23552
23843
  switch (options.framework) {
23553
23844
  case "next":
@@ -23584,18 +23875,21 @@ export const action = ({ request }: { request: Request }) => handler(request);
23584
23875
  language: "typescript",
23585
23876
  contents: `import { createWidgetTokenHandler } from 'appilot-server';
23586
23877
  import { env } from '$env/dynamic/private';
23878
+ import type { RequestHandler } from './$types';
23587
23879
 
23588
23880
  const handler = createWidgetTokenHandler({
23589
- apiUrl: env.APPILOT_API_URL,
23590
- widgetKey: env.APPILOT_WIDGET_KEY,
23591
- widgetSecret: env.APPILOT_WIDGET_SECRET,
23881
+ // $env/dynamic/private is Record<string, string | undefined>, so each value is
23882
+ // asserted here rather than handed to the relay as possibly-undefined.
23883
+ apiUrl: env.APPILOT_API_URL!,
23884
+ widgetKey: env.APPILOT_WIDGET_KEY!,
23885
+ widgetSecret: env.APPILOT_WIDGET_SECRET!,
23592
23886
  resolveUser: async request => {
23593
23887
  const user = await getUserFromCookies(request.headers.get('cookie'));
23594
23888
  return user ? { externalId: \`${namespace}:\${user.id}\`, displayName: user.name } : null;
23595
23889
  },
23596
23890
  });
23597
23891
 
23598
- export const POST = ({ request }) => handler(request);
23892
+ export const POST: RequestHandler = ({ request }) => handler(request);
23599
23893
  `
23600
23894
  });
23601
23895
  break;
@@ -23628,38 +23922,95 @@ export const widgetToken = new Hono().post('/api/widget/token', c => handler(c.r
23628
23922
  contents: relayNodeHandler(namespace, options.framework)
23629
23923
  });
23630
23924
  break;
23925
+ case "other":
23926
+ files.push(...rawExchangeFiles(namespace, apiUrl));
23927
+ break;
23928
+ }
23929
+ if (options.framework === "other") {
23930
+ files.push(rawBootFile(options));
23931
+ } else {
23932
+ files.push({ path: "src/appilot/boot.ts", language: "typescript", contents: bootFile(options) });
23933
+ files.push({
23934
+ path: "src/appilot/actions.ts",
23935
+ language: "typescript",
23936
+ contents: CLIENT_ACTION_EXAMPLE
23937
+ });
23938
+ }
23939
+ const serverEnv = [
23940
+ `APPILOT_API_URL=${apiUrl}`,
23941
+ `APPILOT_WIDGET_KEY=${options.widgetKey ?? "wk_test_..."}`,
23942
+ "APPILOT_WIDGET_SECRET=wsk_secret_... # server-side only, never in a client bundle"
23943
+ ];
23944
+ if (options.framework !== "other") {
23945
+ serverEnv.push(
23946
+ `${idiom.envName}=${options.widgetKey ?? "wk_test_..."} # the same publishable key, this time for the page`
23947
+ );
23948
+ }
23949
+ const notes = [
23950
+ "The widget secret must never appear in a client bundle or a public env var. In Next.js that means no NEXT_PUBLIC_ prefix; in Vite, keep it out of VITE_.",
23951
+ "resolveUser is the security boundary: derive the user from a credential you trust, never from the request body.",
23952
+ ...localDevelopmentNotes(idiom),
23953
+ "Call verify_integration once this is wired to confirm the relay answers and the widget actually boots."
23954
+ ];
23955
+ if (options.framework === "other") {
23956
+ notes.unshift(
23957
+ "appilot-server is a Node package, so this host implements the exchange itself. It is one authenticated POST to {apiUrl}/widget/token carrying X-Widget-Key and X-Widget-Secret, and the response body goes back to the page unchanged."
23958
+ );
23631
23959
  }
23632
- files.push({ path: "src/appilot/boot.ts", language: "typescript", contents: bootFile(options) });
23633
- files.push({
23634
- path: "src/appilot/actions.ts",
23635
- language: "typescript",
23636
- contents: CLIENT_ACTION_EXAMPLE
23637
- });
23638
23960
  return {
23639
23961
  framework: options.framework,
23640
23962
  files,
23641
- install: "npm install appilot appilot-server",
23642
- env: [
23643
- `APPILOT_API_URL=${options.apiUrl ?? "https://api.appilot.space"}`,
23644
- `APPILOT_WIDGET_KEY=${options.widgetKey ?? "wk_live_..."}`,
23645
- "APPILOT_WIDGET_SECRET=wsk_secret_... # server-side only, never in a client bundle"
23646
- ].join("\n"),
23647
- notes: [
23648
- "The widget secret must never appear in a client bundle or a public env var. In Next.js that means no NEXT_PUBLIC_ prefix; in Vite, keep it out of VITE_.",
23649
- "resolveUser is the security boundary: derive the user from a credential you trust, never from the request body.",
23650
- "Call verify_integration once this is wired to confirm the relay answers and the widget actually boots."
23651
- ]
23963
+ install: options.framework === "other" ? "No package to install. The relay is one HTTPS call and the widget loads from the script tag above." : "npm install appilot appilot-server",
23964
+ env: serverEnv.join("\n"),
23965
+ notes
23652
23966
  };
23653
23967
  }
23968
+ function toolNameFrom(slug) {
23969
+ return IDENTIFIER_KINDS.tool_name.normalize(slug) || "do_the_thing";
23970
+ }
23971
+ function semanticSlugFrom(slug) {
23972
+ return IDENTIFIER_KINDS.semantic_id.normalize(slug) || "the-thing";
23973
+ }
23974
+ function planSections(shape, capability, ids, viewPath) {
23975
+ if (shape === "read") return null;
23976
+ if (shape === "navigate") {
23977
+ return [
23978
+ {
23979
+ view_path: viewPath,
23980
+ steps: [`Open [${capability}]({{click:${ids.open}}}).`]
23981
+ }
23982
+ ];
23983
+ }
23984
+ return [
23985
+ {
23986
+ view_path: viewPath,
23987
+ steps: [
23988
+ `Open the form with [${capability}]({{click:${ids.open}}}).`,
23989
+ `Fill in [the ${capability.toLowerCase()} form]({{form:${ids.form}}}).`,
23990
+ `Submit it with [Save]({{click:${ids.submit}}}).`
23991
+ ]
23992
+ }
23993
+ ];
23994
+ }
23654
23995
  function scaffoldAgentFirst(options) {
23655
- const { capability, slug, appId } = options;
23656
- const method = options.endpoint?.method?.toUpperCase() ?? "POST";
23996
+ const { capability, appId } = options;
23997
+ const shape = options.shape ?? "create";
23998
+ const viewPath = options.viewPath ?? "/";
23999
+ const toolName = toolNameFrom(options.slug);
24000
+ const slug = semanticSlugFrom(options.slug);
24001
+ const ids = {
24002
+ open: `btn-${slug}`,
24003
+ form: `form-${slug}`,
24004
+ submit: `submit-${slug}`
24005
+ };
24006
+ const method = options.endpoint?.method?.toUpperCase() ?? (shape === "read" ? "GET" : "POST");
23657
24007
  const pathTemplate = options.endpoint?.path ?? `/api/${slug}`;
23658
- const tool = {
24008
+ const trigger = `Use when the user wants to ${capability.toLowerCase()}.`;
24009
+ const tool = options.clientSide ? null : {
23659
24010
  app_id: appId,
23660
- tool_name: slug,
24011
+ tool_name: toolName,
23661
24012
  title: capability,
23662
- description: `Use when the user wants to ${capability.toLowerCase()}.`,
24013
+ description: trigger,
23663
24014
  parameters: {
23664
24015
  type: "object",
23665
24016
  properties: {},
@@ -23674,22 +24025,17 @@ function scaffoldAgentFirst(options) {
23674
24025
  title_i18n: {},
23675
24026
  description_i18n: {}
23676
24027
  };
23677
- const actionPlan = {
24028
+ const sections = planSections(shape, capability, ids, viewPath);
24029
+ const actionPlan = sections && {
23678
24030
  app_id: appId,
23679
- semantic_id: `plan-${slug}`,
24031
+ semantic_id: IDENTIFIER_KINDS.plan_id.normalize(`plan-${slug}`),
23680
24032
  name: capability,
23681
- description: `Use when the user wants to ${capability.toLowerCase()}.`,
23682
- sections: [
23683
- {
23684
- title: capability,
23685
- steps: [
23686
- { text: `Open the form: [${capability}]({{click:btn-${slug}}})` },
23687
- { text: `Fill it in: [the ${slug} form]({{form:form-${slug}}})` },
23688
- { text: `Submit: [Save]({{click:btn-${slug}-submit}})` }
23689
- ]
23690
- }
23691
- ],
23692
- form_values: { [`form-${slug}`]: {} },
24033
+ description: trigger,
24034
+ sections,
24035
+ // Keyed by form id, and each entry carries a `fields` array of
24036
+ // { control_id, value }. The empty array is the authored default: the
24037
+ // user supplies the values at runtime.
24038
+ form_values: shape === "create" ? { [ids.form]: { fields: [] } } : {},
23693
24039
  step_narratives_i18n: {},
23694
24040
  is_active: false
23695
24041
  };
@@ -23711,6 +24057,7 @@ function scaffoldAgentFirst(options) {
23711
24057
  };
23712
24058
  const files = [];
23713
24059
  if (options.clientSide) {
24060
+ const registerName = toolName.replace(/(^|_)([a-z])/g, (_m, _s, c) => c.toUpperCase());
23714
24061
  files.push({
23715
24062
  path: `src/appilot/${slug}.ts`,
23716
24063
  language: "typescript",
@@ -23720,11 +24067,11 @@ function scaffoldAgentFirst(options) {
23720
24067
  //
23721
24068
  // This runs in the page, in the user's own session, so it inherits their
23722
24069
  // permissions and needs no credential of its own. Anything the backend must
23723
- // authorize belongs in the HTTP-proxy tool instead.
23724
- export function register${slug.replace(/(^|[-_])([a-z])/g, (_m, _s, c) => c.toUpperCase())}() {
24070
+ // authorize belongs in an HTTP-proxy tool instead.
24071
+ export function register${registerName}() {
23725
24072
  const handle = registerTool({
23726
- name: '${slug}',
23727
- description: 'Use when the user wants to ${capability.toLowerCase()}.',
24073
+ name: '${toolName}',
24074
+ description: '${trigger}',
23728
24075
  inputSchema: { type: 'object', properties: {}, required: [] },
23729
24076
  // Omit readOnlyHint, or set it false, when this changes something: the
23730
24077
  // agent confirms with the user before calling a mutating action.
@@ -23738,31 +24085,83 @@ export function register${slug.replace(/(^|[-_])([a-z])/g, (_m, _s, c) => c.toUp
23738
24085
  `
23739
24086
  });
23740
24087
  }
24088
+ const order = [];
24089
+ if (sections) {
24090
+ order.push(
24091
+ `1. Create the controls the plan names (${Object.values(ids).slice(0, shape === "create" ? 3 : 1).join(", ")}), with stable locators. inspect_page gives you candidates.`
24092
+ );
24093
+ if (shape === "create") {
24094
+ order.push(`2. Create the form ${ids.form}, naming those controls as entry, submit and required fields.`);
24095
+ }
24096
+ }
24097
+ order.push(
24098
+ options.clientSide ? "Register the client action in the page. It runs in the user's session, so it needs no credential." : "Create the tool. path_template is a path on the host origin, not an absolute URL, and the executor rejects an absolute one. If it calls an authenticated backend, set auth_secret in the same call, because a tool with no stored credential runs in the page and cannot be used as session preflight."
24099
+ );
24100
+ if (sections) {
24101
+ order.push("Create the action plan. Run validate_action_plan on the sections first.");
24102
+ }
24103
+ order.push("Create the knowledge article, and keep the procedure out of it.");
24104
+ order.push("Activate the plan and the article once validate_config is clean.");
24105
+ const notes = [
24106
+ "The test for agent-first is whether a user could complete this capability end to end through the assistant alone. If not, it has a screen and nothing else.",
24107
+ "Both the plan description and the tool description are how the agent finds them. Write the trigger, in the user's words, and localize it.",
24108
+ "is_active is false on the plan and the article on purpose. Turn them on after validate_config passes, not before."
24109
+ ];
24110
+ if (shape === "read") {
24111
+ notes.push(
24112
+ "No action plan is emitted for a read capability. A plan whose only step opens a screen does nothing, and the health contract reports it. The tool answers the question and the knowledge article explains what the answer means."
24113
+ );
24114
+ }
24115
+ if (options.clientSide) {
24116
+ notes.push(
24117
+ "No HTTP-proxy tool is emitted, because the operation runs in the page. Add one only if a second, server-authorized path to the same capability is genuinely needed."
24118
+ );
24119
+ }
24120
+ notes.push(
24121
+ `The plan's steps run on view_path "${viewPath}". Pass viewPath when the screen is somewhere else; the value must match a registered View.`
24122
+ );
24123
+ return { capability, shape, tool, actionPlan, knowledge, files, order, notes };
24124
+ }
24125
+ function integrationSnippet(options) {
24126
+ const idiom = publicKeyIdiom(options.framework ?? "other");
24127
+ const keyAttr = options.widgetKey ? `
24128
+ data-api-key="${options.widgetKey}"` : "";
24129
+ const apiAttr = options.apiUrl ? `
24130
+ data-api-url="${options.apiUrl}"` : "";
24131
+ const bootLines = ["import { bootAppilotWidget } from 'appilot';", "", "bootAppilotWidget({"];
24132
+ bootLines.push(` widgetScriptUrl: '${options.widgetScriptUrl}',`);
24133
+ bootLines.push(` widgetKey: ${idiom.expr},`);
24134
+ if (options.apiUrl) bootLines.push(` appilotApiUrl: '${options.apiUrl}',`);
24135
+ bootLines.push(" tokenEndpoint: '/api/widget/token',", "});");
23741
24136
  return {
23742
- capability,
23743
- tool,
23744
- actionPlan,
23745
- knowledge,
23746
- files,
23747
- order: [
23748
- "1. Create the controls the plan names, with stable locators. inspect_page gives you candidates.",
23749
- "2. Create the form, naming those controls as entry, submit and required fields.",
23750
- "3. Create the tool. path_template is a path on the host origin, not an absolute URL, and the executor rejects an absolute one. If it calls an authenticated backend, set auth_secret in the same call, because a tool with no stored credential runs in the page and cannot be used as session preflight.",
23751
- "4. Create the action plan. Run validate_action_plan first; a plan that only opens an element does nothing.",
23752
- "5. Create the knowledge article, and keep the procedure out of it.",
23753
- "6. Activate the plan and the article once validate_config is clean."
23754
- ],
24137
+ apiUrl: options.apiUrl ?? null,
24138
+ scriptTag: `<script
24139
+ src="${options.widgetScriptUrl}"${keyAttr}${apiAttr}
24140
+ async
24141
+ ></script>`,
24142
+ bootSnippet: bootLines.join("\n"),
24143
+ tokenEndpointHint: "Serve /api/widget/token from your backend with createWidgetTokenHandler from appilot-server. The widget requires an identified user, and the widget secret must never reach a browser.",
24144
+ publicEnvName: idiom.envName,
23755
24145
  notes: [
23756
- "The test for agent-first is whether a user could complete this capability end to end through the assistant alone. If not, it has a screen and nothing else.",
23757
- "Both the plan description and the tool description are how the agent finds them. Write the trigger, in the user's words, and localize it.",
23758
- "is_active is false on the plan and the article on purpose. Turn them on after validate_config passes, not before."
24146
+ "The script tag is the no-build form and carries the key as an attribute. The boot call is the bundled form and reads it from the public variable, which is what survives a key rotation.",
24147
+ ...localDevelopmentNotes(idiom)
23759
24148
  ]
23760
24149
  };
23761
24150
  }
23762
- var CLIENT_ACTION_EXAMPLE;
24151
+ var SCAFFOLD_FRAMEWORKS, CLIENT_ACTION_EXAMPLE;
23763
24152
  var init_scaffold = __esm({
23764
24153
  "src/scaffold.ts"() {
23765
24154
  "use strict";
24155
+ init_validation();
24156
+ SCAFFOLD_FRAMEWORKS = [
24157
+ "next",
24158
+ "express",
24159
+ "fastify",
24160
+ "hono",
24161
+ "remix",
24162
+ "sveltekit",
24163
+ "other"
24164
+ ];
23766
24165
  CLIENT_ACTION_EXAMPLE = `import { registerTool } from 'appilot';
23767
24166
 
23768
24167
  // A client action is an operation your PAGE performs, in the user's own
@@ -24045,7 +24444,7 @@ function createAppilotServer(conn) {
24045
24444
  if (!granted || granted.includes(scope)) return null;
24046
24445
  return errorText(
24047
24446
  new Error(
24048
- `This connection was granted ${granted.length ? granted.join(", ") : "no scopes"}, which does not include ${scope}. Reconnect and approve that scope, using a service token that carries it.`
24447
+ `This connection was granted ${granted.length ? granted.join(", ") : "no scopes"}, which does not include ${scope}. Reconnect and approve that scope, using a service token that carries it. ` + HOW_TO_GRANT[scope]
24049
24448
  )
24050
24449
  );
24051
24450
  }
@@ -24327,24 +24726,25 @@ ${JSON.stringify(bundle, null, 2)}`);
24327
24726
  "create_app",
24328
24727
  {
24329
24728
  title: "Create an app, its domains, and a widget key",
24330
- description: "Provision an Appilot app in one call: the app, the domains it runs on, and optionally a widget key, plus the exact script tag and boot snippet to paste into the host application. Idempotent: re-running converges on the existing app rather than creating a second one. Pass dryRun to preview. The widget key and its secret are returned EXACTLY ONCE, at creation; store the secret in the host backend only. Requires a provision:write service token.",
24729
+ description: "Provision an Appilot app in one call: the app, the domains it runs on, and optionally a widget key, plus the exact script tag and boot snippet to paste into the host application. Idempotent: re-running converges on the existing app rather than creating a second one. Pass dryRun to preview. The widget key and its secret are returned EXACTLY ONCE, at creation; store the secret in the host backend only. Working on your own machine: do NOT pass localhost or 127.0.0.1 as a domain, because they name every developer's machine and are refused. Either run the app on a hostname that resolves to 127.0.0.1 (myapp.lvh.me) and register THAT, which gives the turn full app context and needs no key, or pass isTest true with no domains for a loopback-bound wk_test_ key, which gives the tenant and the user and no app context. Requires a provision:write service token.",
24331
24730
  inputSchema: {
24332
24731
  name: external_exports.string().min(1),
24333
24732
  description: external_exports.string().optional(),
24334
24733
  domains: external_exports.array(external_exports.string()).optional(),
24335
24734
  widgetKeyName: external_exports.string().optional(),
24336
- isTestKey: external_exports.boolean().optional(),
24735
+ isTest: external_exports.boolean().optional(),
24736
+ allowedDomains: external_exports.array(external_exports.string()).optional(),
24337
24737
  dryRun: external_exports.boolean().optional()
24338
24738
  }
24339
24739
  },
24340
- async ({ name, description, domains, widgetKeyName, isTestKey, dryRun }) => {
24740
+ async ({ name, description, domains, widgetKeyName, isTest, allowedDomains, dryRun }) => {
24341
24741
  const refusal = scopeRefusal("provision:write");
24342
24742
  if (refusal) return refusal;
24343
24743
  try {
24344
24744
  const result = await client.provisionApp({
24345
24745
  app: { name, description },
24346
24746
  domains: (domains ?? []).map((domain) => ({ domain })),
24347
- widgetKey: widgetKeyName || isTestKey !== void 0 ? { name: widgetKeyName, isTest: isTestKey } : void 0,
24747
+ widgetKey: widgetKeyName || isTest !== void 0 || allowedDomains ? { name: widgetKeyName, isTest, allowedDomains } : void 0,
24348
24748
  dryRun: dryRun === true
24349
24749
  });
24350
24750
  return text(redactForTransport(result, conn.transport));
@@ -24353,11 +24753,138 @@ ${JSON.stringify(bundle, null, 2)}`);
24353
24753
  }
24354
24754
  }
24355
24755
  );
24756
+ server.registerTool(
24757
+ "list_apps",
24758
+ {
24759
+ title: "List the apps this organization has provisioned",
24760
+ description: `The apps this credential reaches, with each app's registered domains and their verification status. Call it before create_app so "which apps do I have" has an answer, and to find the app id and the domain id every other provisioning tool takes. Carries no secret. Reads the provisioning surface, so it needs a provision:write service token even though it writes nothing.`,
24761
+ inputSchema: {}
24762
+ },
24763
+ async () => {
24764
+ const refusal = scopeRefusal("provision:write");
24765
+ if (refusal) return refusal;
24766
+ try {
24767
+ return text(await client.listProvisioned());
24768
+ } catch (err) {
24769
+ return errorText(err);
24770
+ }
24771
+ }
24772
+ );
24773
+ server.registerTool(
24774
+ "list_widget_keys",
24775
+ {
24776
+ title: "List the widget keys this organization holds",
24777
+ description: "The organization's widget keys by label, prefix, allowed domains and active state. No raw key and no secret: both are shown exactly once, at creation. Call it before create_app so a re-run recognises the key it already minted instead of asking for another, because every extra key is another live credential. Requires a provision:write service token.",
24778
+ inputSchema: {}
24779
+ },
24780
+ async () => {
24781
+ const refusal = scopeRefusal("provision:write");
24782
+ if (refusal) return refusal;
24783
+ try {
24784
+ return text(await client.listWidgetKeys());
24785
+ } catch (err) {
24786
+ return errorText(err);
24787
+ }
24788
+ }
24789
+ );
24790
+ server.registerTool(
24791
+ "verify_domain",
24792
+ {
24793
+ title: "Check or trigger a domain's DNS verification",
24794
+ description: "Where a domain's verification stands, and the exact TXT record it needs. Pass trigger to ask Appilot to look for the record now. A live widget key needs a verified domain, and this is what closes that loop: create_app reports the key as blocked and names the record, and this reports whether the record is visible yet. Requires a provision:write service token.",
24795
+ inputSchema: {
24796
+ domain: external_exports.string().min(1),
24797
+ appId: external_exports.number().int().optional(),
24798
+ trigger: external_exports.boolean().optional()
24799
+ }
24800
+ },
24801
+ async ({ domain, appId, trigger }) => {
24802
+ const refusal = scopeRefusal("provision:write");
24803
+ if (refusal) return refusal;
24804
+ try {
24805
+ const wanted = domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/[/:].*$/, "");
24806
+ const provisioned = await client.listProvisioned();
24807
+ const scoped = appId != null ? provisioned.filter((a) => Number(a.id) === appId) : provisioned;
24808
+ const owner = scoped.find((a) => a.domains.some((d) => String(d.domain).toLowerCase() === wanted));
24809
+ const row = owner?.domains.find((d) => String(d.domain).toLowerCase() === wanted);
24810
+ if (!owner || !row) {
24811
+ return errorText(new Error(
24812
+ `${wanted} is not a registered domain of ${appId != null ? `app ${appId}` : "any app in this organization"}. list_apps shows what is registered, and create_app adds a domain. A loopback name (localhost, 127.0.0.1) is never registrable: run the app on a hostname that resolves to 127.0.0.1 instead.`
24813
+ ));
24814
+ }
24815
+ const result = {
24816
+ app: { id: owner.id, name: owner.name },
24817
+ domain: row.domain,
24818
+ domainId: row.id,
24819
+ verificationStatus: row.verification_status
24820
+ };
24821
+ try {
24822
+ result.record = await client.domainVerification(Number(row.id));
24823
+ } catch {
24824
+ try {
24825
+ const preview = await client.provisionApp({
24826
+ app: { name: owner.name },
24827
+ domains: [{ domain: row.domain }],
24828
+ dryRun: true
24829
+ });
24830
+ const previewed = preview.domains.find((d) => d.domain.toLowerCase() === wanted);
24831
+ if (previewed?.dns_record_name) {
24832
+ result.record = {
24833
+ dns_record_name: previewed.dns_record_name,
24834
+ dns_record_value: previewed.dns_record_value,
24835
+ verification_status: previewed.verification_status
24836
+ };
24837
+ }
24838
+ } catch (err) {
24839
+ result.recordUnavailable = err instanceof Error ? err.message : String(err);
24840
+ }
24841
+ }
24842
+ if (trigger && row.verification_status !== "verified") {
24843
+ try {
24844
+ result.checked = await client.triggerDomainVerification(Number(row.id));
24845
+ } catch (err) {
24846
+ result.triggerRefused = `${err instanceof Error ? err.message : String(err)} Triggering the check authenticates an organization session rather than a service token today, so run it from the Backoffice under Domains once the TXT record is published.`;
24847
+ }
24848
+ }
24849
+ return text(result);
24850
+ } catch (err) {
24851
+ return errorText(err);
24852
+ }
24853
+ }
24854
+ );
24855
+ server.registerTool(
24856
+ "integration_snippet",
24857
+ {
24858
+ title: "Get the script tag and boot call for an app that already exists",
24859
+ description: "The two forms of the widget boot for an app you already provisioned: the no-build script tag, and the bundled bootAppilotWidget call reading the key from the framework's public variable. Use it instead of re-running create_app, which is a provisioning write, when all you lost was the snippet. It composes the answer locally, writes nothing, and needs no scope. Pass the publishable widget key to have it appear in the script tag; the widget SECRET never belongs here.",
24860
+ inputSchema: {
24861
+ appId: external_exports.number().int().optional(),
24862
+ widgetKey: external_exports.string().optional(),
24863
+ widgetScriptUrl: external_exports.string().optional(),
24864
+ framework: external_exports.enum(SCAFFOLD_FRAMEWORKS).optional()
24865
+ }
24866
+ },
24867
+ async ({ appId, widgetKey, widgetScriptUrl, framework }) => {
24868
+ try {
24869
+ return text({
24870
+ appId: appId ?? conn.defaultAppId ?? null,
24871
+ ...integrationSnippet({
24872
+ widgetScriptUrl: widgetScriptUrl ?? DEFAULT_WIDGET_SCRIPT_URL,
24873
+ apiUrl: conn.baseUrl || null,
24874
+ widgetKey: widgetKey ?? null,
24875
+ framework
24876
+ })
24877
+ });
24878
+ } catch (err) {
24879
+ return errorText(err);
24880
+ }
24881
+ }
24882
+ );
24356
24883
  server.registerTool(
24357
24884
  "plan_manifest",
24358
24885
  {
24359
24886
  title: "Preview an app manifest (writes nothing)",
24360
- description: "Diff an appilot.app-manifest against the live instance and return what would change: provisioning actions per app/domain/key, the config-bundle import diff, and the health findings over the resulting state. Writes nothing. Returns a planToken that apply_manifest requires, so an apply always follows a preview of the exact same manifest. Keep the manifest in the repository under version control.",
24887
+ description: "Diff an appilot.app-manifest against the live instance and return what would change: provisioning actions per app/domain/key, the config-bundle import diff, and the health findings over the resulting state. Writes nothing. Returns a planToken that apply_manifest requires, so an apply always follows a preview of the exact same manifest. Keep the manifest in the repository under version control. A complete example ships with this package at examples/app.appilot.json and is also in the docs. Which half is revisioned: the CONFIG half gets a pre_restore revision on every commit, so a wrong import is undone by restoring it; the PROVISIONING half is not revisioned, so a domain or a key it creates is undone by hand.",
24361
24888
  inputSchema: { manifest: external_exports.union([external_exports.record(external_exports.any()), external_exports.string()]) }
24362
24889
  },
24363
24890
  async ({ manifest }) => {
@@ -24379,7 +24906,7 @@ ${JSON.stringify(bundle, null, 2)}`);
24379
24906
  "apply_manifest",
24380
24907
  {
24381
24908
  title: "Apply a previously planned app manifest",
24382
- description: "Provision and configure an app from an appilot.app-manifest. Requires the planToken returned by plan_manifest for the SAME manifest: a mismatch means the manifest changed after it was previewed, and the apply is refused. Pass expectedCurrentHash from the plan so a concurrent config edit is a 409 rather than a silent overwrite. mode=replace makes the config match the bundle exactly, including deletions. Requires provision:write, and config:write when the manifest carries a config bundle.",
24909
+ description: "Provision and configure an app from an appilot.app-manifest. Requires the planToken returned by plan_manifest for the SAME manifest: a mismatch means the manifest changed after it was previewed, and the apply is refused. Pass expectedCurrentHash from the plan so a concurrent config edit is a 409 rather than a silent overwrite. mode=replace makes the config match the bundle exactly, including deletions. Needs config:write when the manifest carries a config bundle, and provision:write only when the provisioning half would actually change something: a manifest whose app, domains and keys already exist applies with config:write alone.",
24383
24910
  inputSchema: {
24384
24911
  manifest: external_exports.union([external_exports.record(external_exports.any()), external_exports.string()]),
24385
24912
  planToken: external_exports.string(),
@@ -24389,15 +24916,19 @@ ${JSON.stringify(bundle, null, 2)}`);
24389
24916
  }
24390
24917
  },
24391
24918
  async ({ manifest, planToken, mode, expectedCurrentHash, allowUnhealthy }) => {
24392
- const refusal = scopeRefusal("provision:write") ?? scopeRefusal("config:write");
24393
- if (refusal) return refusal;
24394
24919
  try {
24395
24920
  const parsed = parseManifest(manifest);
24921
+ if (parsed.config) {
24922
+ const configRefusal = scopeRefusal("config:write");
24923
+ if (configRefusal) return configRefusal;
24924
+ }
24925
+ const provisionRefusal = scopeRefusal("provision:write");
24396
24926
  const result = await applyManifest(client, parsed, {
24397
24927
  planToken,
24398
24928
  mode,
24399
24929
  expectedCurrentHash,
24400
- allowUnhealthy
24930
+ allowUnhealthy,
24931
+ provisionRefusal: provisionRefusal ? provisionRefusal.content[0].text : null
24401
24932
  });
24402
24933
  return text({ ...result, provisioning: redactForTransport(result.provisioning, conn.transport) });
24403
24934
  } catch (err) {
@@ -24409,9 +24940,9 @@ ${JSON.stringify(bundle, null, 2)}`);
24409
24940
  "scaffold_integration",
24410
24941
  {
24411
24942
  title: "Generate the host application integration code",
24412
- description: "Return the source a host application needs: the identity relay for its backend (the one security-critical piece, built on appilot-server), the widget boot call, and a client-action example. Returns file CONTENTS for you to write into the repository; this server never touches the filesystem. Pick the framework that matches the host.",
24943
+ description: "Return the source a host application needs: the identity relay for its backend (the one security-critical piece, built on appilot-server), the widget boot call, and a client-action example. Returns file CONTENTS for you to write into the repository; this server never touches the filesystem. Pick the framework that matches the host, or `other` when the backend is not Node (Django, Rails, PHP), which returns the raw exchange as curl plus a Python and a Ruby handler. The notes carry what local development needs, and the answer differs between a hostname you registered and bare localhost.",
24413
24944
  inputSchema: {
24414
- framework: external_exports.enum(["next", "express", "fastify", "hono", "remix", "sveltekit"]),
24945
+ framework: external_exports.enum(SCAFFOLD_FRAMEWORKS),
24415
24946
  widgetKey: external_exports.string().optional(),
24416
24947
  widgetScriptUrl: external_exports.string().optional(),
24417
24948
  idNamespace: external_exports.string().optional()
@@ -24499,16 +25030,18 @@ ${JSON.stringify(bundle, null, 2)}`);
24499
25030
  "scaffold_agent_first",
24500
25031
  {
24501
25032
  title: "Scaffold one capability so the agent can operate it",
24502
- description: "Return the four artifacts a capability needs to be agent-operable, agreeing with each other: the HTTP-proxy tool, the client action when the operation belongs in the page, the Action Plan that is the procedure, and the knowledge article that carries the meaning and not the steps. Also returns the order to create them in, which matters because a form cannot name controls that do not exist yet. `endpoint.path` is a path on the host origin, not an absolute URL. Use it when building a new agent-first app or making an existing feature reachable through the assistant.",
25033
+ description: "Return the four artifacts a capability needs to be agent-operable, agreeing with each other: the HTTP-proxy tool, the client action when the operation belongs in the page, the Action Plan that is the procedure, and the knowledge article that carries the meaning and not the steps. Also returns the order to create them in, which matters because a form cannot name controls that do not exist yet. `endpoint.path` is a path on the host origin, not an absolute URL. Pass `shape`: `create` is the open, fill and submit plan, `navigate` is one step to a screen, and `read` gets NO plan at all, because a plan whose only step opens a screen does nothing. Pass `clientSide` when the operation belongs in the page, and no HTTP-proxy tool is emitted. Use it when building a new agent-first app or making an existing feature reachable through the assistant.",
24503
25034
  inputSchema: {
24504
25035
  capability: external_exports.string().min(1),
24505
25036
  slug: external_exports.string().min(1),
24506
25037
  appId: external_exports.number().int().optional(),
24507
25038
  endpoint: external_exports.object({ method: external_exports.string(), path: external_exports.string() }).optional(),
24508
- clientSide: external_exports.boolean().optional()
25039
+ clientSide: external_exports.boolean().optional(),
25040
+ shape: external_exports.enum(["create", "navigate", "read"]).optional(),
25041
+ viewPath: external_exports.string().optional()
24509
25042
  }
24510
25043
  },
24511
- async ({ capability, slug, appId, endpoint, clientSide }) => {
25044
+ async ({ capability, slug, appId, endpoint, clientSide, shape, viewPath }) => {
24512
25045
  try {
24513
25046
  return text(
24514
25047
  scaffoldAgentFirst({
@@ -24516,7 +25049,9 @@ ${JSON.stringify(bundle, null, 2)}`);
24516
25049
  slug,
24517
25050
  appId: appId ?? conn.defaultAppId ?? null,
24518
25051
  endpoint: endpoint ?? null,
24519
- clientSide
25052
+ clientSide,
25053
+ shape,
25054
+ viewPath
24520
25055
  })
24521
25056
  );
24522
25057
  } catch (err) {
@@ -24588,7 +25123,7 @@ ${JSON.stringify(bundle, null, 2)}`);
24588
25123
  );
24589
25124
  return server;
24590
25125
  }
24591
- var DEFAULT_WIDGET_SCRIPT_URL, SERVER_INSTRUCTIONS;
25126
+ var DEFAULT_WIDGET_SCRIPT_URL, SERVER_INSTRUCTIONS, HOW_TO_GRANT;
24592
25127
  var init_server3 = __esm({
24593
25128
  "src/server.ts"() {
24594
25129
  "use strict";
@@ -24607,7 +25142,9 @@ var init_server3 = __esm({
24607
25142
  DEFAULT_WIDGET_SCRIPT_URL = "https://cdn.appilot.space/widget/v1/appilot.esm.js";
24608
25143
  SERVER_INSTRUCTIONS = `Audit, extend and fix an Appilot app's content-model configuration, and build a new capability so the agent can operate it.
24609
25144
 
24610
- Work in this order: capabilities (what this instance supports, and the closed vocabularies its entities accept; on-premise trails cloud), read_config, validate_config, then report the findings to the user in plain language, ranked critical to low, each with its concrete fix. Do not paste raw tool output at them.
25145
+ For a new app, work in this order: whoami, then create_app (on bare localhost pass isTest, which mints a wk_test_ key), then scaffold_integration for the host's framework, then verify_integration against the running page, then configure the content model.
25146
+
25147
+ To audit an existing one, work in this order: capabilities (what this instance supports, and the closed vocabularies its entities accept; on-premise trails cloud), read_config, validate_config, then report the findings to the user in plain language, ranked critical to low, each with its concrete fix. Do not paste raw tool output at them.
24611
25148
 
24612
25149
  If read_config returns a gaps array, an entity could not be read. Say so and stop treating that entity as empty: every lint over it silently passed.
24613
25150
 
@@ -24622,6 +25159,12 @@ Three things to get right. A widget secret belongs in the server environment and
24622
25159
  If something is missing or broken in Appilot itself, report_feedback records it. Say plainly what it does: the report is read, and a reply is part of a support plan rather than something promised here. Never put configuration contents, knowledge bodies or secrets in a report, and show the user the exact text first.
24623
25160
 
24624
25161
  Writing needs a config:write service token, reading needs config:read, provisioning needs provision:write, reporting needs feedback:write. A scope refusal means the user should reconnect with a token carrying that scope, not that you should find another route.`;
25162
+ HOW_TO_GRANT = {
25163
+ "config:read": 'config:read is on every Service tokens preset in the Backoffice, "Inspect only" included.',
25164
+ "config:write": 'config:write comes from the Backoffice Service tokens preset "Edit configuration".',
25165
+ "provision:write": 'provision:write is granted by an organization administrator in the Backoffice, under Service tokens with the "Set up integrations" preset, or through the account approval screen. A configurator who is not an administrator cannot mint it and has to ask one.',
25166
+ "feedback:write": "feedback:write is a Backoffice Service tokens option, and it is the only scope that sends anything out of the organization."
25167
+ };
24625
25168
  }
24626
25169
  });
24627
25170
 
@@ -43475,7 +44018,7 @@ var require_view = __commonJS({
43475
44018
  var dirname = path.dirname;
43476
44019
  var basename = path.basename;
43477
44020
  var extname = path.extname;
43478
- var join = path.join;
44021
+ var join2 = path.join;
43479
44022
  var resolve = path.resolve;
43480
44023
  module.exports = View;
43481
44024
  function View(name, options) {
@@ -43537,12 +44080,12 @@ var require_view = __commonJS({
43537
44080
  };
43538
44081
  View.prototype.resolve = function resolve2(dir, file) {
43539
44082
  var ext = this.ext;
43540
- var path2 = join(dir, file);
44083
+ var path2 = join2(dir, file);
43541
44084
  var stat = tryStat(path2);
43542
44085
  if (stat && stat.isFile()) {
43543
44086
  return path2;
43544
44087
  }
43545
- path2 = join(dir, basename(file, ext), "index" + ext);
44088
+ path2 = join2(dir, basename(file, ext), "index" + ext);
43546
44089
  stat = tryStat(path2);
43547
44090
  if (stat && stat.isFile()) {
43548
44091
  return path2;
@@ -47350,7 +47893,7 @@ var require_send = __commonJS({
47350
47893
  var Stream = __require("stream");
47351
47894
  var util2 = __require("util");
47352
47895
  var extname = path.extname;
47353
- var join = path.join;
47896
+ var join2 = path.join;
47354
47897
  var normalize = path.normalize;
47355
47898
  var resolve = path.resolve;
47356
47899
  var sep = path.sep;
@@ -47522,7 +48065,7 @@ var require_send = __commonJS({
47522
48065
  return res;
47523
48066
  }
47524
48067
  parts = path2.split(sep);
47525
- path2 = normalize(join(root, path2));
48068
+ path2 = normalize(join2(root, path2));
47526
48069
  } else {
47527
48070
  if (UP_PATH_REGEXP.test(path2)) {
47528
48071
  debug('malicious path "%s"', path2);
@@ -47655,7 +48198,7 @@ var require_send = __commonJS({
47655
48198
  if (err) return self.onStatError(err);
47656
48199
  return self.error(404);
47657
48200
  }
47658
- var p = join(path2, self._index[i]);
48201
+ var p = join2(path2, self._index[i]);
47659
48202
  debug('stat "%s"', p);
47660
48203
  fs.stat(p, function(err2, stat) {
47661
48204
  if (err2) return next(err2);
@@ -53493,10 +54036,6 @@ function message(key, locale = "en", values = {}) {
53493
54036
  const template = ({ en, es, de }[locale] ?? en)[key];
53494
54037
  return template.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? `{${name}}`);
53495
54038
  }
53496
- function translateError(text, locale) {
53497
- const entry = Object.entries(en).find(([, value]) => value === text);
53498
- return entry ? message(entry[0], locale) : message("unavailableDetail", locale);
53499
- }
53500
54039
  var en, es, de;
53501
54040
  var init_consentMessages = __esm({
53502
54041
  "src/remote/consentMessages.ts"() {
@@ -53556,7 +54095,12 @@ var init_consentMessages = __esm({
53556
54095
  failed: "Connection could not be completed",
53557
54096
  failedHelp: "The approval expired, was already used, or belongs to another browser. Start again from your assistant.",
53558
54097
  unavailableTitle: "Connection unavailable",
53559
- unavailableDetail: "Appilot could not complete the connection. Please try again."
54098
+ unavailableDetail: "Appilot could not complete the connection. Please try again.",
54099
+ // What a client is called when it did not register a name. One constant, so
54100
+ // the same connection is not "your assistant" on one screen and "an MCP
54101
+ // client" on the next.
54102
+ clientFallback: "your assistant",
54103
+ appNumber: "App #{id}"
53560
54104
  };
53561
54105
  es = {
53562
54106
  sessionUnavailable: "La aprobaci\xF3n desde tu cuenta no est\xE1 disponible temporalmente. Intenta conectar otra vez o usa un token de servicio abajo.",
@@ -53613,7 +54157,9 @@ var init_consentMessages = __esm({
53613
54157
  failed: "No se pudo completar la conexi\xF3n",
53614
54158
  failedHelp: "La aprobaci\xF3n venci\xF3, ya se utiliz\xF3 o pertenece a otro navegador. Vuelve a empezar desde tu asistente.",
53615
54159
  unavailableTitle: "Conexi\xF3n no disponible",
53616
- unavailableDetail: "Appilot no pudo completar la conexi\xF3n. Int\xE9ntalo de nuevo."
54160
+ unavailableDetail: "Appilot no pudo completar la conexi\xF3n. Int\xE9ntalo de nuevo.",
54161
+ clientFallback: "tu asistente",
54162
+ appNumber: "Aplicaci\xF3n n.\xBA {id}"
53617
54163
  };
53618
54164
  de = {
53619
54165
  sessionUnavailable: "Die Freigabe \xFCber Ihr Konto ist vor\xFCbergehend nicht verf\xFCgbar. Starten Sie erneut oder verwenden Sie unten ein Service-Token.",
@@ -53670,7 +54216,9 @@ var init_consentMessages = __esm({
53670
54216
  failed: "Verbindung konnte nicht abgeschlossen werden",
53671
54217
  failedHelp: "Die Freigabe ist abgelaufen, wurde bereits verwendet oder geh\xF6rt zu einem anderen Browser. Starten Sie erneut in Ihrem Assistenten.",
53672
54218
  unavailableTitle: "Verbindung nicht verf\xFCgbar",
53673
- unavailableDetail: "Appilot konnte die Verbindung nicht abschlie\xDFen. Versuchen Sie es erneut."
54219
+ unavailableDetail: "Appilot konnte die Verbindung nicht abschlie\xDFen. Versuchen Sie es erneut.",
54220
+ clientFallback: "Ihr Assistent",
54221
+ appNumber: "App Nr. {id}"
53674
54222
  };
53675
54223
  }
53676
54224
  });
@@ -53721,10 +54269,10 @@ function renderConfirmPage(opts) {
53721
54269
  const locale = opts.locale ?? "en";
53722
54270
  const m = (key, values) => escapeHtml(message(key, locale, values));
53723
54271
  const organization = opts.organizationName || (opts.organizationId != null ? `${message("organization", locale)} #${opts.organizationId}` : message("unknown", locale));
53724
- const app = opts.appName || (opts.appId != null ? `App #${opts.appId}` : message("allApps", locale));
54272
+ const app = opts.appName || (opts.appId != null ? message("appNumber", locale, { id: String(opts.appId) }) : message("allApps", locale));
53725
54273
  return page(
53726
54274
  message("review", locale),
53727
- `${steps(2, locale)}<h1>${m("reviewFor", { client: opts.clientName || "AI assistant" })}</h1><p>${m("accepted")}</p>
54275
+ `${steps(2, locale)}<h1>${m("reviewFor", { client: opts.clientName || message("clientFallback", locale) })}</h1><p>${m("accepted")}</p>
53728
54276
  <div class="panel"><dl><div><dt>${m("organization")}</dt><dd>${escapeHtml(organization)}</dd></div><div><dt>${m(opts.appScoped ? "limited" : "appAccess")}</dt><dd>${escapeHtml(app)}</dd></div></dl>${opts.appId != null && !opts.appScoped ? `<small>${m("defaultNote")}</small>` : ""}</div>
53729
54277
  <h2>${m("can")}</h2>${scopeItems(opts.scopes, locale)}
53730
54278
  ${opts.unavailableScopes?.length ? `<div class="panel help"><h2>${m("unavailable")}</h2><p class="small">${m("unavailableHelp", { scopes: opts.unavailableScopes.map((scope) => scopeTitle(scope, locale)).join(", ") })}</p></div>` : ""}
@@ -53735,11 +54283,11 @@ function renderConfirmPage(opts) {
53735
54283
  locale
53736
54284
  );
53737
54285
  }
53738
- function renderErrorPage(title, detail, locale = "en") {
53739
- const translatedTitle = locale === "en" ? title : translateError(title, locale);
54286
+ function renderErrorPage(titleKey, detailKey, locale = "en") {
54287
+ const title = message(titleKey, locale);
53740
54288
  return page(
53741
- translatedTitle,
53742
- `<h1>${escapeHtml(translatedTitle)}</h1><div class="error" role="alert">${escapeHtml(locale === "en" ? detail : translateError(detail, locale))}</div><p>${escapeHtml(message("restart", locale))}</p>`,
54289
+ title,
54290
+ `<h1>${escapeHtml(title)}</h1><div class="error" role="alert">${escapeHtml(message(detailKey, locale))}</div><p>${escapeHtml(message("restart", locale))}</p>`,
53743
54291
  void 0,
53744
54292
  locale
53745
54293
  );
@@ -56384,7 +56932,7 @@ var init_oauth = __esm({
56384
56932
  const sealedRequest = await this.sealAuthRequest({
56385
56933
  language,
56386
56934
  client_id: client.client_id,
56387
- client_name: client.client_name || "your assistant",
56935
+ client_name: client.client_name || message("clientFallback", language),
56388
56936
  redirect_uri: params.redirectUri,
56389
56937
  code_challenge: params.codeChallenge,
56390
56938
  state: params.state,
@@ -56402,7 +56950,7 @@ var init_oauth = __esm({
56402
56950
  },
56403
56951
  sealedRequest,
56404
56952
  client.client_id,
56405
- client.client_name || "AI assistant",
56953
+ client.client_name || message("clientFallback", language),
56406
56954
  requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES],
56407
56955
  language,
56408
56956
  res
@@ -56414,7 +56962,7 @@ var init_oauth = __esm({
56414
56962
  locale: language,
56415
56963
  request: sealedRequest,
56416
56964
  action: this.consentPath,
56417
- clientName: client.client_name || "AI assistant",
56965
+ clientName: client.client_name || message("clientFallback", language),
56418
56966
  baseUrl: this.options.baseUrl,
56419
56967
  backofficeUrl: this.options.backofficeUrl,
56420
56968
  scopes: requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES],
@@ -56429,7 +56977,7 @@ var init_oauth = __esm({
56429
56977
  locale: language,
56430
56978
  request: sealedRequest,
56431
56979
  action: this.consentPath,
56432
- clientName: client.client_name || "an MCP client",
56980
+ clientName: client.client_name || message("clientFallback", language),
56433
56981
  baseUrl: this.options.baseUrl,
56434
56982
  backofficeUrl: this.options.backofficeUrl,
56435
56983
  scopes: requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES]
@@ -56503,22 +57051,14 @@ var init_oauth = __esm({
56503
57051
  } catch {
56504
57052
  return {
56505
57053
  status: 400,
56506
- html: renderErrorPage(
56507
- "This sign-in link expired",
56508
- "Start the connection again from the client that sent you here. A consent link is valid for ten minutes.",
56509
- fallbackLocale
56510
- )
57054
+ html: renderErrorPage("expired", "expiredHelp", fallbackLocale)
56511
57055
  };
56512
57056
  }
56513
57057
  const locale = request.language ?? fallbackLocale;
56514
57058
  if (request.stage !== "request" && request.stage !== "confirm") {
56515
57059
  return {
56516
57060
  status: 400,
56517
- html: renderErrorPage(
56518
- "Invalid request",
56519
- "That confirmation is incomplete. Start again.",
56520
- locale
56521
- )
57061
+ html: renderErrorPage("invalid", "invalidHelp", locale)
56522
57062
  };
56523
57063
  }
56524
57064
  const redirect = new URL(request.redirect_uri);
@@ -56539,7 +57079,7 @@ var init_oauth = __esm({
56539
57079
  locale,
56540
57080
  request: await this.sealAuthRequest({ ...original, scopes }),
56541
57081
  action: this.consentPath,
56542
- clientName: request.client_name || "your assistant",
57082
+ clientName: request.client_name || message("clientFallback", locale),
56543
57083
  baseUrl: this.options.baseUrl,
56544
57084
  backofficeUrl: this.options.backofficeUrl,
56545
57085
  scopes
@@ -56549,11 +57089,7 @@ var init_oauth = __esm({
56549
57089
  if (form.action !== "confirm" || !request.pat || !request.identity) {
56550
57090
  return {
56551
57091
  status: 400,
56552
- html: renderErrorPage(
56553
- "Invalid request",
56554
- "That confirmation is incomplete. Start again.",
56555
- locale
56556
- )
57092
+ html: renderErrorPage("invalid", "invalidHelp", locale)
56557
57093
  };
56558
57094
  }
56559
57095
  return {
@@ -56572,7 +57108,7 @@ var init_oauth = __esm({
56572
57108
  locale,
56573
57109
  request: rawRequest,
56574
57110
  action: this.consentPath,
56575
- clientName: request.client_name || "your assistant",
57111
+ clientName: request.client_name || message("clientFallback", locale),
56576
57112
  baseUrl: this.options.baseUrl,
56577
57113
  backofficeUrl: this.options.backofficeUrl,
56578
57114
  scopes: request.scopes,
@@ -56781,7 +57317,7 @@ function createRemoteApp(config2, options = {}) {
56781
57317
  const code = typeof req.query.code === "string" ? req.query.code : "";
56782
57318
  res.redirect(303, await provider.finishConnection(id, code, req.get("Cookie") ?? "", res));
56783
57319
  } catch {
56784
- res.status(400).type("html").send(renderErrorPage("Connection could not be completed", "The approval expired, was already used, or belongs to another browser. Start again from your assistant.", consentLocale(req.get("Accept-Language"))));
57320
+ res.status(400).type("html").send(renderErrorPage("failed", "failedHelp", consentLocale(req.get("Accept-Language"))));
56785
57321
  }
56786
57322
  });
56787
57323
  app.post("/consent", import_express7.default.urlencoded({ extended: false, limit: "64kb" }), async (req, res) => {
@@ -56794,7 +57330,7 @@ function createRemoteApp(config2, options = {}) {
56794
57330
  res.status(outcome.status).set("Content-Type", "text/html; charset=utf-8").send(outcome.html);
56795
57331
  } catch {
56796
57332
  res.status(500).set("Content-Type", "text/html; charset=utf-8").send(
56797
- renderErrorPage("Connection unavailable", "Appilot could not complete the connection. Please try again.", consentLocale(req.get("Accept-Language")))
57333
+ renderErrorPage("unavailableTitle", "unavailableDetail", consentLocale(req.get("Accept-Language")))
56798
57334
  );
56799
57335
  }
56800
57336
  });
@@ -57049,6 +57585,144 @@ function loadRemoteConfig(env = process.env) {
57049
57585
 
57050
57586
  // src/index.ts
57051
57587
  init_server3();
57588
+
57589
+ // src/cli.ts
57590
+ init_version();
57591
+ import { cpSync, existsSync, readFileSync, mkdirSync } from "node:fs";
57592
+ import { homedir } from "node:os";
57593
+ import { join } from "node:path";
57594
+ import { fileURLToPath } from "node:url";
57595
+ init_client();
57596
+ init_manifest();
57597
+ var HELP = `appilot-mcp ${SERVER_VERSION}
57598
+
57599
+ The Appilot MCP server: read, validate, fix and provision an Appilot app.
57600
+
57601
+ USAGE
57602
+ appilot-mcp Start the MCP server on stdio (what a client launches)
57603
+ appilot-mcp --http Start the remote HTTP service instead
57604
+ appilot-mcp plan <file> Preview an appilot.app-manifest. Writes nothing
57605
+ appilot-mcp apply <file> Plan, then apply the same manifest
57606
+ appilot-mcp install-skill Copy the app-configurator skill to an agent client
57607
+ appilot-mcp --help | --version
57608
+
57609
+ OPTIONS
57610
+ apply --mode merge|replace How the config half is imported. Default merge
57611
+ --allow-unhealthy Import a bundle the health gate would refuse
57612
+ install-skill --claude | --codex | --cursor | --gemini | --dir <path>
57613
+
57614
+ ENVIRONMENT
57615
+ APPILOT_BASE_URL Your instance, e.g. https://api.appilot.space or http://localhost:6001
57616
+ APPILOT_PAT A scoped service token from the Backoffice, under Service tokens
57617
+ APPILOT_APP_ID Optional default app id
57618
+
57619
+ Docs: https://docs.appilot.space/docs/developers/configure-with-ai/overview`;
57620
+ var SKILL_TARGETS = {
57621
+ claude: join(".claude", "skills"),
57622
+ codex: join(".codex", "skills"),
57623
+ cursor: join(".cursor", "skills"),
57624
+ gemini: join(".gemini", "config", "skills")
57625
+ };
57626
+ function bundledSkillDir(from = fileURLToPath(import.meta.url)) {
57627
+ const candidates = [
57628
+ join(from, "..", "..", "skills", "app-configurator"),
57629
+ join(from, "..", "..", "..", "skills", "app-configurator")
57630
+ ];
57631
+ return candidates.find((candidate) => existsSync(join(candidate, "SKILL.md"))) ?? null;
57632
+ }
57633
+ function installSkill(args) {
57634
+ const source = bundledSkillDir();
57635
+ if (!source) {
57636
+ process.stderr.write(
57637
+ "[appilot-mcp] the bundled skill was not found next to this install. Reinstall the package (npm i -g appilot-mcp), or copy skills/app-configurator from the repository by hand.\n"
57638
+ );
57639
+ return 1;
57640
+ }
57641
+ const dirFlag = args.indexOf("--dir");
57642
+ let destParent;
57643
+ if (dirFlag !== -1) {
57644
+ const explicit = args[dirFlag + 1];
57645
+ if (!explicit) {
57646
+ process.stderr.write("[appilot-mcp] --dir needs a path.\n");
57647
+ return 2;
57648
+ }
57649
+ destParent = explicit;
57650
+ } else {
57651
+ const client = Object.keys(SKILL_TARGETS).find((name) => args.includes(`--${name}`));
57652
+ if (!client) {
57653
+ process.stderr.write(
57654
+ `[appilot-mcp] name the client: ${Object.keys(SKILL_TARGETS).map((c) => `--${c}`).join(", ")}, or --dir <path>.
57655
+ `
57656
+ );
57657
+ return 2;
57658
+ }
57659
+ destParent = join(homedir(), SKILL_TARGETS[client]);
57660
+ }
57661
+ const dest = join(destParent, "app-configurator");
57662
+ mkdirSync(destParent, { recursive: true });
57663
+ cpSync(source, dest, { recursive: true });
57664
+ process.stdout.write(`Installed the app-configurator skill to ${dest}
57665
+ Start a new session so the client loads it.
57666
+ `);
57667
+ return 0;
57668
+ }
57669
+ function readManifestFile(path) {
57670
+ if (!path) throw new Error("Name the manifest file: appilot-mcp plan app.appilot.json");
57671
+ if (!existsSync(path)) throw new Error(`No such file: ${path}`);
57672
+ return JSON.parse(readFileSync(path, "utf8"));
57673
+ }
57674
+ async function runPlan(args) {
57675
+ const conn = loadConnection();
57676
+ const client = new AppilotClient(conn);
57677
+ const manifest = parseManifest(readManifestFile(args[0]));
57678
+ const plan = await planManifest(client, manifest, (id) => {
57679
+ const resolved = id ?? conn.defaultAppId;
57680
+ return resolved != null && Number.isFinite(resolved) ? resolved : null;
57681
+ });
57682
+ process.stdout.write(JSON.stringify(plan, null, 2) + "\n");
57683
+ return 0;
57684
+ }
57685
+ async function runApply(args) {
57686
+ const conn = loadConnection();
57687
+ const client = new AppilotClient(conn);
57688
+ const manifest = parseManifest(readManifestFile(args[0]));
57689
+ const modeFlag = args.indexOf("--mode");
57690
+ const mode = modeFlag !== -1 ? args[modeFlag + 1] : void 0;
57691
+ if (mode !== void 0 && mode !== "merge" && mode !== "replace") {
57692
+ throw new Error(`--mode is merge or replace, not ${mode}.`);
57693
+ }
57694
+ const plan = await planManifest(client, manifest, (id) => {
57695
+ const resolved = id ?? conn.defaultAppId;
57696
+ return resolved != null && Number.isFinite(resolved) ? resolved : null;
57697
+ });
57698
+ process.stderr.write(`[appilot-mcp] planned ${plan.planToken.slice(0, 12)}\u2026
57699
+ `);
57700
+ const result = await applyManifest(client, manifest, {
57701
+ planToken: plan.planToken,
57702
+ mode,
57703
+ expectedCurrentHash: plan.config?.expectedCurrentHash,
57704
+ allowUnhealthy: args.includes("--allow-unhealthy")
57705
+ });
57706
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
57707
+ return 0;
57708
+ }
57709
+ async function runCommand(argv) {
57710
+ const [command, ...rest] = argv;
57711
+ if (command === "--help" || command === "-h" || command === "help") {
57712
+ process.stdout.write(HELP + "\n");
57713
+ return 0;
57714
+ }
57715
+ if (command === "--version" || command === "-v" || command === "version") {
57716
+ process.stdout.write(SERVER_VERSION + "\n");
57717
+ return 0;
57718
+ }
57719
+ if (command === "install-skill") return installSkill(rest);
57720
+ if (command === "plan") return runPlan(rest);
57721
+ if (command === "apply") return runApply(rest);
57722
+ return null;
57723
+ }
57724
+
57725
+ // src/index.ts
57052
57726
  async function runStdio() {
57053
57727
  const conn = loadConnection();
57054
57728
  const server = createAppilotServer(conn);
@@ -57061,6 +57735,10 @@ async function runHttp() {
57061
57735
  await startRemote2(loadRemoteConfig());
57062
57736
  }
57063
57737
  async function main() {
57738
+ const handled = await runCommand(process.argv.slice(2));
57739
+ if (handled !== null) {
57740
+ process.exit(handled);
57741
+ }
57064
57742
  if (resolveTransport() === "http") {
57065
57743
  await runHttp();
57066
57744
  return;