appilot-mcp 0.1.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 (52) 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 +102 -24
  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 +122 -3
  9. package/dist/client.js +306 -31
  10. package/dist/config.d.ts +14 -0
  11. package/dist/config.js +19 -0
  12. package/dist/contract/bundleSnapshot.js +8 -1
  13. package/dist/contract/healthContract.d.ts +1 -1
  14. package/dist/contract/healthContract.js +100 -10
  15. package/dist/contract/types.d.ts +37 -1
  16. package/dist/index.bundle.js +4487 -16520
  17. package/dist/index.js +7 -0
  18. package/dist/inspect.d.ts +88 -0
  19. package/dist/inspect.js +384 -0
  20. package/dist/manifest.d.ts +14 -2
  21. package/dist/manifest.js +31 -9
  22. package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
  23. package/dist/public-marketplace/README.md +23 -0
  24. package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
  25. package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
  26. package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
  27. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
  28. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
  29. package/dist/redaction.d.ts +18 -3
  30. package/dist/redaction.js +27 -3
  31. package/dist/remote/consent.d.ts +30 -20
  32. package/dist/remote/consent.js +114 -82
  33. package/dist/remote/consentMessages.d.ts +65 -0
  34. package/dist/remote/consentMessages.js +199 -0
  35. package/dist/remote/handoff.d.ts +10 -0
  36. package/dist/remote/handoff.js +44 -0
  37. package/dist/remote/httpServer.js +28 -5
  38. package/dist/remote/oauth.d.ts +39 -6
  39. package/dist/remote/oauth.js +281 -36
  40. package/dist/scaffold.d.ts +110 -1
  41. package/dist/scaffold.js +474 -39
  42. package/dist/server.js +425 -38
  43. package/dist/soak.js +21 -1
  44. package/dist/templates.d.ts +62 -0
  45. package/dist/templates.js +255 -0
  46. package/dist/verify.js +18 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/examples/app.appilot.json +212 -0
  50. package/mcpb/manifest.json +117 -15
  51. package/package.json +5 -3
  52. package/skills/app-configurator/SKILL.md +136 -25
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@
20
20
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
21
  import { loadConnection, loadRemoteConfig, resolveTransport, RemoteConfigError } from './config.js';
22
22
  import { createAppilotServer } from './server.js';
23
+ import { runCommand } from './cli.js';
23
24
  async function runStdio() {
24
25
  const conn = loadConnection();
25
26
  const server = createAppilotServer(conn);
@@ -32,6 +33,12 @@ async function runHttp() {
32
33
  await startRemote(loadRemoteConfig());
33
34
  }
34
35
  async function main() {
36
+ // A subcommand answers and exits. Without this, `appilot-mcp --help` started
37
+ // the stdio server and waited on stdin, which reads as a hang.
38
+ const handled = await runCommand(process.argv.slice(2));
39
+ if (handled !== null) {
40
+ process.exit(handled);
41
+ }
35
42
  if (resolveTransport() === 'http') {
36
43
  await runHttp();
37
44
  return;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Read a page and report what an author needs to register it.
3
+ *
4
+ * This is the half of the tool surface that was missing for the developer who
5
+ * says "look at this screen and configure it". Everything else could read the
6
+ * configuration and validate it; nothing could see the thing being configured,
7
+ * so a control's locator had to come from a human picking it in the extension.
8
+ *
9
+ * Two paths, because the transports differ in what they can run.
10
+ *
11
+ * url Loads the real page in Playwright. Playwright is an optional peer, so
12
+ * this path degrades rather than crashing, exactly as `soak.ts` and
13
+ * `verify.ts` do.
14
+ * html Scans pasted markup with no browser. Flat and structural only: it
15
+ * reads the attributes on interactive tags in document order and groups
16
+ * fields by the form they appear inside. It cannot compute an accessible
17
+ * name, and it does not know what is visible. It is what keeps the
18
+ * hosted endpoint useful, and it says which of the two produced a
19
+ * result so nobody mistakes one for the other.
20
+ *
21
+ * A locator candidate is ranked by whether it survives the next render, which
22
+ * is the only property that matters: an auto-generated id passes every static
23
+ * check and is gone on redeploy. The `type` on each candidate is an Appilot
24
+ * `locator_type`, not a CSS selector kind, so the output can be pasted into a
25
+ * control body unchanged.
26
+ */
27
+ export type LocatorType = 'id' | 'class_text' | 'aria' | 'xpath' | 'semantic';
28
+ export interface LocatorCandidate {
29
+ locator: string;
30
+ type: LocatorType;
31
+ /** Higher is more likely to still resolve after the next deploy. */
32
+ stability: 'stable' | 'reasonable' | 'fragile';
33
+ why: string;
34
+ }
35
+ export interface InspectedElement {
36
+ tag: string;
37
+ role: string | null;
38
+ text: string | null;
39
+ kind: 'action' | 'field' | 'region';
40
+ /** Best first. */
41
+ candidates: LocatorCandidate[];
42
+ /** Semantic id of an already-configured control whose locator matches. */
43
+ configuredAs?: string;
44
+ }
45
+ export interface InspectedForm {
46
+ /** The form's own candidates, for the entry control. */
47
+ candidates: LocatorCandidate[];
48
+ fields: InspectedElement[];
49
+ submit: InspectedElement | null;
50
+ }
51
+ export interface InspectResult {
52
+ source: 'browser' | 'html';
53
+ available: boolean;
54
+ url?: string;
55
+ /** The path a View would carry, when a URL was given. */
56
+ viewPath?: string;
57
+ elements: InspectedElement[];
58
+ forms: InspectedForm[];
59
+ /** What is already registered for this view, so nothing is authored twice. */
60
+ alreadyConfigured?: {
61
+ controls: string[];
62
+ forms: string[];
63
+ zones: string[];
64
+ };
65
+ note?: string;
66
+ }
67
+ /**
68
+ * Rank the ways to address one element.
69
+ *
70
+ * Order is the contract: whoever consumes this takes the first candidate unless
71
+ * they have a reason not to, so the first candidate must be the one that ages
72
+ * best, never the shortest.
73
+ */
74
+ export declare function rankCandidates(attrs: Record<string, string>, tag: string, text: string | null): LocatorCandidate[];
75
+ /**
76
+ * Scan pasted markup for interactive elements.
77
+ *
78
+ * Deliberately flat. It walks tag openings in document order and tracks only one
79
+ * nesting fact, whether it is currently inside a form, because that is the one
80
+ * relationship the content model needs and the one a regex can get right.
81
+ */
82
+ export declare function inspectHtml(html: string): InspectResult;
83
+ export declare function inspectPage(opts: {
84
+ url?: string;
85
+ html?: string;
86
+ storageStatePath?: string;
87
+ timeoutMs?: number;
88
+ }): Promise<InspectResult>;
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Read a page and report what an author needs to register it.
3
+ *
4
+ * This is the half of the tool surface that was missing for the developer who
5
+ * says "look at this screen and configure it". Everything else could read the
6
+ * configuration and validate it; nothing could see the thing being configured,
7
+ * so a control's locator had to come from a human picking it in the extension.
8
+ *
9
+ * Two paths, because the transports differ in what they can run.
10
+ *
11
+ * url Loads the real page in Playwright. Playwright is an optional peer, so
12
+ * this path degrades rather than crashing, exactly as `soak.ts` and
13
+ * `verify.ts` do.
14
+ * html Scans pasted markup with no browser. Flat and structural only: it
15
+ * reads the attributes on interactive tags in document order and groups
16
+ * fields by the form they appear inside. It cannot compute an accessible
17
+ * name, and it does not know what is visible. It is what keeps the
18
+ * hosted endpoint useful, and it says which of the two produced a
19
+ * result so nobody mistakes one for the other.
20
+ *
21
+ * A locator candidate is ranked by whether it survives the next render, which
22
+ * is the only property that matters: an auto-generated id passes every static
23
+ * check and is gone on redeploy. The `type` on each candidate is an Appilot
24
+ * `locator_type`, not a CSS selector kind, so the output can be pasted into a
25
+ * control body unchanged.
26
+ */
27
+ /**
28
+ * Ids a framework generated. Registering one of these is the single most common
29
+ * way a configuration passes every check and then does nothing on the real page.
30
+ */
31
+ const GENERATED_ID = [
32
+ /^(nc|mui|radix|headlessui|chakra|mantine|ember|ext-gen|yui)[-_:]/i,
33
+ /^:r[0-9a-z]+:$/i,
34
+ /^[a-f0-9]{8,}$/i,
35
+ /\d{4,}$/,
36
+ /^react-aria\d+$/i,
37
+ ];
38
+ function idLooksGenerated(id) {
39
+ return GENERATED_ID.some(re => re.test(id));
40
+ }
41
+ const TEST_ATTRS = ['data-testid', 'data-test-id', 'data-test', 'data-qa', 'data-cy', 'data-automation-id'];
42
+ const ACTION_TAGS = new Set(['button', 'a', 'summary']);
43
+ const FIELD_TAGS = new Set(['input', 'select', 'textarea']);
44
+ const REGION_TAGS = new Set(['nav', 'main', 'aside', 'header', 'footer', 'section', 'form']);
45
+ /**
46
+ * Rank the ways to address one element.
47
+ *
48
+ * Order is the contract: whoever consumes this takes the first candidate unless
49
+ * they have a reason not to, so the first candidate must be the one that ages
50
+ * best, never the shortest.
51
+ */
52
+ export function rankCandidates(attrs, tag, text) {
53
+ const out = [];
54
+ const q = (v) => v.replace(/"/g, '\\"');
55
+ for (const attr of TEST_ATTRS) {
56
+ const value = attrs[attr];
57
+ if (value) {
58
+ out.push({
59
+ locator: `[${attr}="${q(value)}"]`,
60
+ type: 'class_text',
61
+ stability: 'stable',
62
+ why: 'A test attribute exists to be addressed and is not rewritten by a redesign.',
63
+ });
64
+ }
65
+ }
66
+ if (attrs['aria-label']) {
67
+ out.push({
68
+ locator: attrs['aria-label'],
69
+ type: 'aria',
70
+ stability: 'stable',
71
+ why: 'An accessible name is user-visible and changes only when the interface changes.',
72
+ });
73
+ }
74
+ const id = attrs.id;
75
+ if (id) {
76
+ if (idLooksGenerated(id)) {
77
+ out.push({
78
+ locator: `#${id}`,
79
+ type: 'id',
80
+ stability: 'fragile',
81
+ why: 'This id looks generated by a framework. It will differ on the next render. Do not register it.',
82
+ });
83
+ }
84
+ else {
85
+ out.push({
86
+ locator: `#${id}`,
87
+ type: 'id',
88
+ stability: 'stable',
89
+ why: 'An authored id is addressed directly and survives styling changes.',
90
+ });
91
+ }
92
+ }
93
+ if (attrs.name && (FIELD_TAGS.has(tag) || tag === 'form')) {
94
+ out.push({
95
+ locator: `[name="${q(attrs.name)}"]`,
96
+ type: 'class_text',
97
+ stability: 'stable',
98
+ why: 'A field name is part of the form contract, so it outlives the markup around it.',
99
+ });
100
+ }
101
+ if (attrs.placeholder) {
102
+ out.push({
103
+ locator: `[placeholder="${q(attrs.placeholder)}"]`,
104
+ type: 'class_text',
105
+ stability: 'reasonable',
106
+ why: 'A placeholder is user-visible copy, so it moves with translation and rewording.',
107
+ });
108
+ }
109
+ if (attrs.role && text) {
110
+ out.push({
111
+ locator: `${attrs.role}:${text}`,
112
+ type: 'aria',
113
+ stability: 'reasonable',
114
+ why: 'Role plus accessible name, which is how a person finds the element.',
115
+ });
116
+ }
117
+ if (text && text.length <= 60) {
118
+ out.push({
119
+ locator: text,
120
+ type: 'semantic',
121
+ stability: 'reasonable',
122
+ why: 'Visible text. Correct until the copy is reworded or translated.',
123
+ });
124
+ }
125
+ if (out.length === 0) {
126
+ out.push({
127
+ locator: tag,
128
+ type: 'class_text',
129
+ stability: 'fragile',
130
+ why: 'Nothing addressable on this element. Ask the app team for a data attribute before registering it.',
131
+ });
132
+ }
133
+ const rank = { stable: 0, reasonable: 1, fragile: 2 };
134
+ return out.sort((a, b) => rank[a.stability] - rank[b.stability]);
135
+ }
136
+ function classify(tag, role) {
137
+ if (FIELD_TAGS.has(tag))
138
+ return 'field';
139
+ if (ACTION_TAGS.has(tag) || role === 'button' || role === 'link' || role === 'menuitem')
140
+ return 'action';
141
+ return 'region';
142
+ }
143
+ /* -------------------------------------------------------------------------- */
144
+ /* Pasted markup */
145
+ /* -------------------------------------------------------------------------- */
146
+ const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>`]+))?)*)\s*\/?>/g;
147
+ const ATTR_RE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>`]+)))?/g;
148
+ function parseAttrs(raw) {
149
+ const attrs = {};
150
+ ATTR_RE.lastIndex = 0;
151
+ let m;
152
+ while ((m = ATTR_RE.exec(raw)) !== null) {
153
+ const name = m[1].toLowerCase();
154
+ attrs[name] = m[2] ?? m[3] ?? m[4] ?? '';
155
+ }
156
+ return attrs;
157
+ }
158
+ function stripTags(html) {
159
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
160
+ }
161
+ /**
162
+ * Scan pasted markup for interactive elements.
163
+ *
164
+ * Deliberately flat. It walks tag openings in document order and tracks only one
165
+ * nesting fact, whether it is currently inside a form, because that is the one
166
+ * relationship the content model needs and the one a regex can get right.
167
+ */
168
+ export function inspectHtml(html) {
169
+ const elements = [];
170
+ const forms = [];
171
+ let current = null;
172
+ TAG_RE.lastIndex = 0;
173
+ let match;
174
+ while ((match = TAG_RE.exec(html)) !== null) {
175
+ const raw = match[0];
176
+ const tag = match[1].toLowerCase();
177
+ const closing = raw.startsWith('</');
178
+ if (tag === 'form') {
179
+ if (closing) {
180
+ if (current)
181
+ forms.push(current);
182
+ current = null;
183
+ }
184
+ else {
185
+ const attrs = parseAttrs(match[2] ?? '');
186
+ current = { candidates: rankCandidates(attrs, 'form', null), fields: [], submit: null };
187
+ }
188
+ continue;
189
+ }
190
+ if (closing)
191
+ continue;
192
+ if (!ACTION_TAGS.has(tag) && !FIELD_TAGS.has(tag) && !REGION_TAGS.has(tag))
193
+ continue;
194
+ const attrs = parseAttrs(match[2] ?? '');
195
+ if (attrs.type === 'hidden')
196
+ continue;
197
+ // Text runs to the matching close tag when there is one on the same
198
+ // nesting level. Good enough for a button label, which is what it is for.
199
+ let text = null;
200
+ if (!FIELD_TAGS.has(tag)) {
201
+ const close = html.indexOf(`</${tag}`, match.index + raw.length);
202
+ if (close > -1 && close - match.index < 2000) {
203
+ const inner = stripTags(html.slice(match.index + raw.length, close));
204
+ text = inner ? inner.slice(0, 120) : null;
205
+ }
206
+ }
207
+ if (!text && attrs.value && FIELD_TAGS.has(tag))
208
+ text = attrs.value.slice(0, 120);
209
+ const role = attrs.role ?? null;
210
+ const element = {
211
+ tag,
212
+ role,
213
+ text,
214
+ kind: classify(tag, role),
215
+ candidates: rankCandidates(attrs, tag, text),
216
+ };
217
+ const isSubmit = tag === 'button'
218
+ ? attrs.type !== 'button' && attrs.type !== 'reset'
219
+ : tag === 'input' && attrs.type === 'submit';
220
+ if (current) {
221
+ if (isSubmit && !current.submit)
222
+ current.submit = element;
223
+ else if (element.kind === 'field')
224
+ current.fields.push(element);
225
+ else
226
+ elements.push(element);
227
+ }
228
+ else {
229
+ elements.push(element);
230
+ }
231
+ }
232
+ if (current)
233
+ forms.push(current);
234
+ return {
235
+ source: 'html',
236
+ available: true,
237
+ elements,
238
+ forms,
239
+ note: 'Scanned pasted markup with no browser. Visibility, computed roles and accessible names are unknown here, and nothing confirms these selectors resolve on the running page. Run soak_selectors, or inspect by URL from a local server, before trusting a locator.',
240
+ };
241
+ }
242
+ /* -------------------------------------------------------------------------- */
243
+ /* Live page */
244
+ /* -------------------------------------------------------------------------- */
245
+ /* eslint-disable @typescript-eslint/no-explicit-any */
246
+ async function loadPlaywright() {
247
+ try {
248
+ return await import('playwright');
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
254
+ /**
255
+ * What runs inside the page. Returns raw attribute bags, so the ranking stays
256
+ * here in one place and is unit-testable without a browser.
257
+ */
258
+ const EXTRACT = `() => {
259
+ const out = [];
260
+ const sel = 'button, a[href], input, select, textarea, summary, [role="button"], [role="link"], [role="menuitem"], nav, main, aside, header, footer, form';
261
+ for (const el of document.querySelectorAll(sel)) {
262
+ const rect = el.getBoundingClientRect();
263
+ const style = getComputedStyle(el);
264
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
265
+ if (rect.width === 0 && rect.height === 0 && el.tagName !== 'FORM') continue;
266
+ const attrs = {};
267
+ for (const a of el.attributes) attrs[a.name.toLowerCase()] = a.value;
268
+ const label = (el.getAttribute('aria-label') || el.textContent || '').replace(/\\s+/g, ' ').trim();
269
+ out.push({
270
+ tag: el.tagName.toLowerCase(),
271
+ attrs,
272
+ text: label ? label.slice(0, 120) : null,
273
+ formIndex: el.form ? Array.prototype.indexOf.call(document.forms, el.form) : -1,
274
+ isSubmit: (el.tagName === 'BUTTON' && el.type !== 'button' && el.type !== 'reset') || (el.tagName === 'INPUT' && el.type === 'submit'),
275
+ });
276
+ }
277
+ return out;
278
+ }`;
279
+ export async function inspectPage(opts) {
280
+ if (opts.html)
281
+ return inspectHtml(opts.html);
282
+ if (!opts.url) {
283
+ return {
284
+ source: 'html',
285
+ available: false,
286
+ elements: [],
287
+ forms: [],
288
+ note: 'Pass either a url to load, or html to scan.',
289
+ };
290
+ }
291
+ const playwright = await loadPlaywright();
292
+ if (!playwright) {
293
+ return {
294
+ source: 'browser',
295
+ available: false,
296
+ elements: [],
297
+ forms: [],
298
+ note: 'No browser is available here, so this page cannot be loaded. Two ways forward, in order of preference: ask the person to open the page, copy the outerHTML of the region they care about, and call this tool again with `html` instead of `url`; or run the Appilot MCP server locally over stdio, where Playwright can be installed (pnpm add -D playwright && npx playwright install chromium).',
299
+ };
300
+ }
301
+ let browser;
302
+ try {
303
+ browser = await playwright.chromium.launch({ headless: true });
304
+ }
305
+ catch (err) {
306
+ return {
307
+ source: 'browser',
308
+ available: false,
309
+ elements: [],
310
+ forms: [],
311
+ note: `A browser could not be launched: ${err instanceof Error ? err.message : String(err)}. Run npx playwright install chromium, or pass html instead of url.`,
312
+ };
313
+ }
314
+ try {
315
+ const context = await browser.newContext(opts.storageStatePath ? { storageState: opts.storageStatePath } : undefined);
316
+ const page = await context.newPage();
317
+ await page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout: opts.timeoutMs ?? 20000 });
318
+ const raw = (await page.evaluate(EXTRACT));
319
+ const elements = [];
320
+ const formMap = new Map();
321
+ for (const item of raw) {
322
+ const role = item.attrs.role ?? null;
323
+ const element = {
324
+ tag: item.tag,
325
+ role,
326
+ text: item.text,
327
+ kind: classify(item.tag, role),
328
+ candidates: rankCandidates(item.attrs, item.tag, item.text),
329
+ };
330
+ if (item.tag === 'form') {
331
+ const index = Array.from(formMap.keys()).length;
332
+ formMap.set(index, { candidates: element.candidates, fields: [], submit: null });
333
+ continue;
334
+ }
335
+ const form = item.formIndex >= 0 ? formMap.get(item.formIndex) : undefined;
336
+ if (form) {
337
+ if (item.isSubmit && !form.submit)
338
+ form.submit = element;
339
+ else if (element.kind === 'field')
340
+ form.fields.push(element);
341
+ else
342
+ elements.push(element);
343
+ }
344
+ else {
345
+ elements.push(element);
346
+ }
347
+ }
348
+ let viewPath;
349
+ try {
350
+ viewPath = new URL(opts.url).pathname;
351
+ }
352
+ catch {
353
+ viewPath = undefined;
354
+ }
355
+ return {
356
+ source: 'browser',
357
+ available: true,
358
+ url: opts.url,
359
+ viewPath,
360
+ elements,
361
+ forms: Array.from(formMap.values()),
362
+ };
363
+ }
364
+ catch (err) {
365
+ // A navigation failure is a finding, not a crash. The same rule the soak
366
+ // and the integration verifier follow: report it and let the caller act.
367
+ return {
368
+ source: 'browser',
369
+ available: false,
370
+ url: opts.url,
371
+ elements: [],
372
+ forms: [],
373
+ note: `The page could not be inspected: ${err instanceof Error ? err.message : String(err)}`,
374
+ };
375
+ }
376
+ finally {
377
+ try {
378
+ await browser.close();
379
+ }
380
+ catch {
381
+ /* closing a browser that already died is not a failure worth reporting */
382
+ }
383
+ }
384
+ }
@@ -81,12 +81,24 @@ export declare function planManifest(client: AppilotClient, manifest: AppManifes
81
81
  * the token is unobtainable without the plan call and changes with the
82
82
  * manifest.
83
83
  */
84
- export declare function applyManifest(client: AppilotClient, manifest: AppManifest, options: {
84
+ export interface ApplyManifestOptions {
85
85
  planToken: string;
86
86
  mode?: 'merge' | 'replace';
87
87
  expectedCurrentHash?: string;
88
88
  allowUnhealthy?: boolean;
89
- }): Promise<{
89
+ /**
90
+ * The refusal to raise when the caller may not provision, or null/undefined
91
+ * when it may.
92
+ *
93
+ * Set by the tool layer from the connection's grant. The apply then proceeds
94
+ * whenever the provisioning half changes nothing, which is the ordinary case
95
+ * in CI: the app, its domains and its keys were created once by a person and
96
+ * every run after that only syncs configuration. Demanding provision:write
97
+ * for that run contradicted the reason the two scopes are separate.
98
+ */
99
+ provisionRefusal?: string | null;
100
+ }
101
+ export declare function applyManifest(client: AppilotClient, manifest: AppManifest, options: ApplyManifestOptions): Promise<{
90
102
  provisioning: ProvisionAppResponse;
91
103
  config?: unknown;
92
104
  notes: string[];
package/dist/manifest.js CHANGED
@@ -106,21 +106,43 @@ export async function planManifest(client, manifest, resolveAppId) {
106
106
  }
107
107
  return { planToken: manifestDigest(manifest), provisioning, config, notes };
108
108
  }
109
- /**
110
- * Apply a previously planned manifest.
111
- *
112
- * `planToken` must match the manifest being applied. This is the structural
113
- * version of "always dry-run first": the guidance cannot be skipped, because
114
- * the token is unobtainable without the plan call and changes with the
115
- * manifest.
116
- */
109
+ /** What the provisioning half of this manifest would change, in plain words. */
110
+ function provisioningChanges(preview) {
111
+ const changes = [];
112
+ if (preview.app.action !== 'reused')
113
+ changes.push(`the app "${preview.app.name}" (${preview.app.action})`);
114
+ for (const domain of preview.domains) {
115
+ if (domain.action !== 'reused')
116
+ changes.push(`the domain ${domain.domain} (${domain.action})`);
117
+ }
118
+ if (preview.widgetKey && preview.widgetKey.action !== 'reused') {
119
+ changes.push(`a widget key (${preview.widgetKey.action})`);
120
+ }
121
+ return changes;
122
+ }
117
123
  export async function applyManifest(client, manifest, options) {
118
124
  const expected = manifestDigest(manifest);
119
125
  if (options.planToken !== expected) {
120
126
  throw new Error('planToken does not match this manifest. Run plan_manifest on the exact manifest you intend to apply, then pass the planToken it returns. A mismatch means the manifest changed after it was previewed.');
121
127
  }
122
128
  const notes = [];
123
- const provisioning = await client.provisionApp(provisionRequest(manifest, false));
129
+ // Ask what provisioning would do before deciding whether it may. The dry run
130
+ // is the same call with `dryRun: true`, so this costs one request and turns
131
+ // "this manifest needs provision:write" from a property of the tool into a
132
+ // property of the manifest in front of it.
133
+ const preview = await client.provisionApp(provisionRequest(manifest, true));
134
+ const changes = provisioningChanges(preview);
135
+ if (changes.length > 0 && options.provisionRefusal) {
136
+ throw new Error(`${options.provisionRefusal}\nThis 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.`);
137
+ }
138
+ let provisioning;
139
+ if (changes.length === 0) {
140
+ provisioning = preview;
141
+ notes.push('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.');
142
+ }
143
+ else {
144
+ provisioning = await client.provisionApp(provisionRequest(manifest, false));
145
+ }
124
146
  let config;
125
147
  if (manifest.config) {
126
148
  const appId = provisioning.app.id;
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "appilot",
3
+ "owner": {
4
+ "name": "Appilot",
5
+ "url": "https://appilot.space"
6
+ },
7
+ "metadata": {
8
+ "description": "Appilot agent tooling: skills and MCP servers that configure and operate Appilot apps.",
9
+ "version": "0.3.0"
10
+ },
11
+ "plugins": [
12
+ {
13
+ "name": "app-configurator",
14
+ "source": "./plugins/app-configurator",
15
+ "description": "Set up, configure, audit, repair, back up, restore and verify an Appilot app: provision the app, its domains and its widget key, scaffold the host integration, and keep the content model correct against the config health contract. Bundles the app-configurator skill and the Appilot MCP server (cloud or on-premise).",
16
+ "version": "0.3.0",
17
+ "strict": false
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,23 @@
1
+ # Appilot plugins
2
+
3
+ The Claude Code marketplace for the Appilot **app-configurator** plugin: the
4
+ `app-configurator` skill plus the Appilot MCP server, which reads, validates,
5
+ fixes and provisions an Appilot app.
6
+
7
+ ```
8
+ /plugin marketplace add appilot/appilot-plugins
9
+ /plugin install app-configurator@appilot
10
+ ```
11
+
12
+ The plugin asks for your instance URL and a scoped service token from the
13
+ Backoffice, under Service tokens.
14
+
15
+ Other ways in, for clients that are not Claude Code: `npx -y appilot-mcp` plus
16
+ `appilot-mcp install-skill --codex` (or `--cursor`, `--gemini`), the hosted
17
+ endpoint at https://mcp.appilot.space/mcp for ChatGPT and claude.ai, and the
18
+ Claude Desktop bundle at https://downloads.appilot.space/claude-desktop.
19
+
20
+ Docs: https://docs.appilot.space/docs/developers/configure-with-ai/overview
21
+
22
+ Built from appilot-mcp 0.3.0. Do not edit here; this tree is generated by
23
+ `pnpm -C packages/tools/appilot-mcp build:marketplace` in the Appilot monorepo.
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "app-configurator",
3
+ "version": "0.3.0",
4
+ "description": "Set up, configure, audit, repair, back up, restore, and verify an Appilot app: provision the app, its domains and widget key, scaffold the host integration, and keep the content model correct. Uses the Appilot MCP server and the app-configurator skill.",
5
+ "author": {
6
+ "name": "Appilot",
7
+ "url": "https://appilot.space"
8
+ },
9
+ "homepage": "https://docs.appilot.space/docs/developers/configure-with-ai/overview",
10
+ "skills": "./skills/",
11
+ "mcpServers": {
12
+ "appilot": {
13
+ "command": "node",
14
+ "args": [
15
+ "${CLAUDE_PLUGIN_ROOT}/dist/index.bundle.js"
16
+ ],
17
+ "env": {
18
+ "APPILOT_BASE_URL": "${user_config.base_url}",
19
+ "APPILOT_PAT": "${user_config.pat}",
20
+ "APPILOT_APP_ID": "${user_config.app_id}"
21
+ }
22
+ }
23
+ },
24
+ "userConfig": {
25
+ "base_url": {
26
+ "type": "string",
27
+ "title": "Appilot backend URL",
28
+ "description": "Cloud or on-premise Appilot backend URL.",
29
+ "required": true
30
+ },
31
+ "pat": {
32
+ "type": "string",
33
+ "title": "Appilot service token",
34
+ "description": "Scoped appilot_pat service token. config:read for audits, config:write for changes, provision:write to create apps, domains, and widget keys.",
35
+ "sensitive": true
36
+ },
37
+ "app_id": {
38
+ "type": "string",
39
+ "title": "Default Appilot app ID",
40
+ "description": "Optional default app ID used when a tool call does not provide one."
41
+ }
42
+ }
43
+ }