@offlocal/mcp 0.1.0 → 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.
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
- import { createDeployment, markUploadComplete, uploadBundle, } from "../client.js";
2
+ import { createDeployment, markUploadComplete, rollback, uploadBundle, } from "../client.js";
3
3
  import { detect } from "../detect.js";
4
+ import { ICON, ui } from "../ui.js";
4
5
  import { bundleDirectory } from "../zip.js";
5
6
  export const DeployInputShape = {
6
7
  sourceDir: z
@@ -25,6 +26,9 @@ export async function deployHandler(input) {
25
26
  const detected = await detect(input.sourceDir);
26
27
  const appName = input.appName ?? detected.appName;
27
28
  const port = input.port ?? detected.port;
29
+ if (detected.strategy === "unknown") {
30
+ return needsDockerfile(input.sourceDir, port);
31
+ }
28
32
  const bundle = await bundleDirectory(input.sourceDir);
29
33
  const created = await createDeployment({
30
34
  appName,
@@ -36,17 +40,25 @@ export async function deployHandler(input) {
36
40
  });
37
41
  await uploadBundle(created.upload.url, bundle.buffer);
38
42
  await markUploadComplete(created.deploymentId);
43
+ const buildSource = detected.strategy === "dockerfile"
44
+ ? "your Dockerfile"
45
+ : `auto-generated Dockerfile (${detected.framework})`;
46
+ const slug = created.slug ?? appName;
47
+ const expectedUrl = created.expectedUrl ?? `https://${slug}.offlocal.ai`;
39
48
  const summary = {
40
49
  deploymentId: created.deploymentId,
50
+ projectId: created.projectId,
41
51
  appName,
52
+ slug,
42
53
  framework: detected.framework,
54
+ buildSource,
43
55
  port,
44
56
  bundle: {
45
57
  fileCount: bundle.fileCount,
46
58
  bytes: bundle.byteSize,
47
59
  },
48
60
  status: "uploaded",
49
- expectedUrl: `https://${appName}.offlocal.ai`,
61
+ expectedUrl,
50
62
  pollAfterSeconds: 45,
51
63
  expectedTotalSeconds: 180,
52
64
  };
@@ -54,26 +66,78 @@ export async function deployHandler(input) {
54
66
  content: [
55
67
  {
56
68
  type: "text",
57
- text: `Deployment created and source uploaded.\n\n` +
58
- ` deploymentId: ${summary.deploymentId}\n` +
59
- ` appName: ${summary.appName}\n` +
60
- ` framework: ${summary.framework}\n` +
61
- ` port: ${summary.port}\n` +
62
- ` bundle: ${summary.bundle.fileCount} files, ${summary.bundle.bytes} bytes\n` +
63
- ` expectedUrl: ${summary.expectedUrl}\n\n` +
64
- `WAIT this is a normal, slow operation.\n` +
65
- `Typical end-to-end time: 2-4 minutes (CodeBuild cold start + ECS rollout).\n` +
66
- `Do NOT poll offlocal_status faster than once every 30 seconds. ` +
67
- `Repeated polling does not speed anything up; the only thing that helps is time.\n\n` +
68
- `Recommended sequence:\n` +
69
+ text: `${ICON.rocket} **Deploying \`${summary.appName}\`** source uploaded, build started.\n\n` +
70
+ `| | |\n|---|---|\n` +
71
+ `| ${ICON.web} URL | ${summary.expectedUrl} |\n` +
72
+ `| ${ICON.spark} Framework | ${summary.framework} (${summary.buildSource}) |\n` +
73
+ `| Port | ${summary.port} |\n` +
74
+ `| Bundle | ${summary.bundle.fileCount} files · ${summary.bundle.bytes} bytes |\n\n` +
75
+ `———\n` +
76
+ `${ICON.arrow} **Next (you, the agent):** tell the user it's building (2–4 min) and you'll ` +
77
+ `report back when it's live. Then:\n` +
69
78
  ` 1. Sleep 45 seconds.\n` +
70
- ` 2. Call offlocal_status({ deploymentId: "${summary.deploymentId}" }).\n` +
71
- ` 3. The response will include "pollAfterSeconds" sleep that long, then call status again.\n` +
72
- ` 4. Continue until status is 'live' or 'failed'.\n\n` +
73
- `Tell the user the deploy is in progress and you'll report back when it's live.`,
79
+ ` 2. \`offlocal_status({ deploymentId: "${summary.deploymentId}" })\`.\n` +
80
+ ` 3. Sleep the \`pollAfterSeconds\` it returns, then call status again — exactly once per interval.\n` +
81
+ ` 4. Repeat until \`live\` or \`failed\`. Polling faster does NOT speed up the build.\n` +
82
+ `Keep the **projectId** from the live status for any database step.`,
83
+ },
84
+ ],
85
+ structuredContent: summary,
86
+ };
87
+ }
88
+ export const RollbackInputShape = {
89
+ projectId: z
90
+ .string()
91
+ .min(1)
92
+ .describe("The Offlocal projectId to roll back (from offlocal_deploy / offlocal_status)."),
93
+ toDeploymentId: z
94
+ .string()
95
+ .optional()
96
+ .describe("Optional: a specific previous deploymentId to roll back to. Omit to roll back to the most recent prior build."),
97
+ };
98
+ export async function rollbackHandler(input) {
99
+ const res = await rollback(input.projectId, input.toDeploymentId);
100
+ if (res.error || !res.deployment) {
101
+ const hint = res.error === "no_rollback_target" || res.error === "deployment_not_found"
102
+ ? " There's no earlier build with an image to roll back to (this project may only have one successful deploy)."
103
+ : "";
104
+ return ui({
105
+ show: `${ICON.fail} Rollback failed: ${res.message ?? res.error}.${hint}`,
106
+ structured: res,
107
+ });
108
+ }
109
+ const d = res.deployment;
110
+ return ui({
111
+ show: `${ICON.spark} **Rolling back** to the build from \`${res.rolledBackTo}\` — re-deploying that image (skips the build, ~1 min).`,
112
+ next: `Poll \`offlocal_status({ deploymentId: "${d.id}" })\` until it's \`live\`. It reuses the previous image, so there's no rebuild.`,
113
+ structured: { deployment: d, rolledBackTo: res.rolledBackTo ?? null },
114
+ });
115
+ }
116
+ function needsDockerfile(sourceDir, port) {
117
+ const summary = {
118
+ status: "needs_dockerfile",
119
+ sourceDir,
120
+ suggestedPort: port,
121
+ };
122
+ return {
123
+ content: [
124
+ {
125
+ type: "text",
126
+ text: `I couldn't confidently detect this project's stack, so I didn't upload anything yet.\n\n` +
127
+ `To deploy it, add a Dockerfile to the project root, then run offlocal_deploy again.\n\n` +
128
+ `Write a production Dockerfile at:\n` +
129
+ ` ${sourceDir}/Dockerfile\n\n` +
130
+ `Requirements for it to work on Offlocal:\n` +
131
+ ` - It must build the app and end with a CMD or ENTRYPOINT that starts the server.\n` +
132
+ ` - The server must listen on the port in the PORT environment variable\n` +
133
+ ` (default to ${port} if PORT is unset), bound to 0.0.0.0 - not localhost.\n` +
134
+ ` - EXPOSE that same port.\n` +
135
+ ` - Use a multi-stage build for compiled languages to keep the image small.\n\n` +
136
+ `Base the Dockerfile on the actual files in the project (language, package\n` +
137
+ `manager, build and start commands). Once it's saved, call offlocal_deploy again\n` +
138
+ `with the same sourceDir - Offlocal will use your Dockerfile as-is.`,
74
139
  },
75
140
  ],
76
141
  structuredContent: summary,
77
142
  };
78
143
  }
79
- //# sourceMappingURL=deploy.js.map
@@ -35,4 +35,3 @@ export async function logsHandler(input) {
35
35
  structuredContent: { logs },
36
36
  };
37
37
  }
38
- //# sourceMappingURL=logs.js.map
@@ -11,7 +11,7 @@ export async function pingHandler(_input) {
11
11
  text: "Not connected to Offlocal.\n\n" +
12
12
  `Reason: ${result.reason}\n\n` +
13
13
  "Common fixes:\n" +
14
- " - If you're calling from WSL, make sure OFFLOCAL_API_URL points at the Windows host (http://host.docker.internal:4000) not localhost.\n" +
14
+ " - If you're calling from WSL, make sure OFFLOCAL_API_URL points at the Windows host (http://host.docker.internal:4000) - not localhost.\n" +
15
15
  " - Make sure `pnpm dev:api` is running on the Windows side.\n" +
16
16
  " - Double-check OFFLOCAL_AGENT_KEY matches a key created in the dashboard.",
17
17
  },
@@ -30,6 +30,4 @@ export async function pingHandler(_input) {
30
30
  structuredContent: { connected: true, email },
31
31
  };
32
32
  }
33
- // Silence unused-import warnings if z isn't used after init.
34
33
  void z;
35
- //# sourceMappingURL=ping.js.map
@@ -0,0 +1,338 @@
1
+ import { z } from "zod";
2
+ import { apiUrlEnvVar, detectComponents, detectRequirements, } from "../detect.js";
3
+ import { getPreferences, listConnections, resolveRequirement, setPreference, } from "../client.js";
4
+ import { ICON, progressBar, step, ui } from "../ui.js";
5
+ const OFFLOCAL_NATIVE = {
6
+ hosting: { label: "Offlocal.ai (managed)", cost: "instant · *.offlocal.ai" },
7
+ postgres: { label: "Offlocal Managed Postgres", cost: "included in your plan" },
8
+ };
9
+ const CAP_LABEL = {
10
+ hosting: "Web app",
11
+ postgres: "Postgres database",
12
+ mysql: "MySQL database",
13
+ redis: "Redis",
14
+ };
15
+ const ROLE_LABEL = {
16
+ web: "Web app",
17
+ api: "API / server",
18
+ app: "App",
19
+ };
20
+ const PROVIDER_COST = {
21
+ supabase: "free tier available",
22
+ planetscale: "no free tier (~$39/mo)",
23
+ digitalocean: "~$15/mo",
24
+ railway: "usage-based (paid plan)",
25
+ vercel: "coming soon",
26
+ };
27
+ export const PlanInputShape = {
28
+ sourceDir: z
29
+ .string()
30
+ .min(1)
31
+ .describe("Absolute path to the project root to deploy."),
32
+ };
33
+ export async function planHandler(input) {
34
+ const [components, connections, preferences] = await Promise.all([
35
+ detectComponents(input.sourceDir),
36
+ listConnections().catch(() => []),
37
+ getPreferences().catch(() => ({})),
38
+ ]);
39
+ const connected = new Set(connections.filter((c) => c.status === "connected").map((c) => c.provider));
40
+ const multi = components.length > 1;
41
+ const reqMap = new Map();
42
+ for (const c of components) {
43
+ const reqs = await detectRequirements(c.absPath).catch(() => []);
44
+ for (const r of reqs)
45
+ if (!reqMap.has(r.capability))
46
+ reqMap.set(r.capability, r);
47
+ }
48
+ const requirements = [...reqMap.values()];
49
+ const hostingOpts = (await resolveRequirement("hosting").catch(() => ({ options: [] }))).options;
50
+ const items = [];
51
+ for (const c of components) {
52
+ const prefCapability = multi ? `hosting:${c.name}` : "hosting";
53
+ const label = multi
54
+ ? `${ROLE_LABEL[c.role] ?? "App"} · \`${c.relPath || c.name}\` (${c.framework})`
55
+ : `${CAP_LABEL.hosting} · ${c.framework}`;
56
+ items.push(buildItem({
57
+ prefCapability,
58
+ capability: "hosting",
59
+ kind: "hosting",
60
+ label,
61
+ component: c,
62
+ preferences,
63
+ options: hostingOpts,
64
+ connected,
65
+ }));
66
+ }
67
+ for (const req of requirements) {
68
+ const opts = (await resolveRequirement(req.capability).catch(() => ({ options: [] }))).options;
69
+ items.push(buildItem({
70
+ prefCapability: req.capability,
71
+ capability: req.capability,
72
+ kind: "resource",
73
+ label: CAP_LABEL[req.capability] ?? req.capability,
74
+ preferences,
75
+ options: opts,
76
+ connected,
77
+ reason: req.reason,
78
+ }));
79
+ }
80
+ const dbTarget = components.find((c) => c.role === "api") ?? components[0];
81
+ const apiComp = components.find((c) => c.role === "api");
82
+ const links = apiComp
83
+ ? components
84
+ .filter((c) => c.role === "web")
85
+ .map((w) => {
86
+ const env = apiUrlEnvVar(w.framework);
87
+ return {
88
+ web: w.name,
89
+ webPath: w.absPath,
90
+ api: apiComp.name,
91
+ envVar: env.key,
92
+ buildTime: env.buildTime,
93
+ };
94
+ })
95
+ : [];
96
+ const planLines = items.map((it, i) => {
97
+ const emoji = it.isOfflocal ? ICON.offlocal : it.recommended ? ICON.connected : ICON.open;
98
+ const connect = it.needsConnect ? ` ${ICON.warn} first-time connect (~30s)` : "";
99
+ const saved = it.fromPreference ? " _(your saved choice)_" : "";
100
+ const note = it.note ? ` ${ICON.soon} _${it.note}_` : "";
101
+ return ` ${i + 1}. ${emoji} ${it.label} → **${it.recommendedLabel}**${saved}${connect} · _${it.cost}_${note}`;
102
+ });
103
+ const wiringBlock = links.length
104
+ ? `\n**Wiring** _(automatic)_\n` +
105
+ links
106
+ .map((l) => ` ${ICON.plug} \`${l.web}\` reaches \`${l.api}\` via \`${l.envVar}\` (auto-set to ${l.api}'s live URL)`)
107
+ .join("\n") +
108
+ `\n`
109
+ : "";
110
+ const checklist = buildChecklist(items, links, dbTarget);
111
+ const checklistLines = checklist.map((c) => step("pending", c.text));
112
+ const detectedLine = multi
113
+ ? `monorepo · ${components.length} apps (${components.map((c) => c.name).join(", ")})` +
114
+ (requirements.length ? ` + ${requirements.map((r) => r.capability).join(", ")}` : "")
115
+ : `${components[0]?.framework ?? "app"} app` +
116
+ (requirements.length
117
+ ? ` + ${requirements.map((r) => r.reason).join(", ")}`
118
+ : "");
119
+ const hasDatastore = requirements.some((r) => r.capability === "postgres" || r.capability === "mysql");
120
+ const hasServerComponent = components.some((c) => c.role !== "web");
121
+ const noteEphemeral = !hasDatastore && hasServerComponent;
122
+ const show = `${ICON.search} **Offlocal.ai — deployment plan**\n\n` +
123
+ `**Detected:** ${detectedLine}\n\n` +
124
+ `**Plan**\n${planLines.join("\n")}\n` +
125
+ wiringBlock +
126
+ `\n**Setup checklist** ${progressBar(0, checklist.length)}\n${checklistLines.join("\n")}\n\n` +
127
+ `Reply **"go"** to ship it ${ICON.rocket} — or switch any line ` +
128
+ `(e.g. _"use Supabase for the database"_, _"host the API on Railway"_).`;
129
+ const persistenceCheck = noteEphemeral
130
+ ? `\n\nNote: this host's storage is ephemeral. Mention data persistence only if the user asks about keeping data between deploys — a database covers it.`
131
+ : "";
132
+ const agentNote = `When the user switches a line, call \`offlocal_set_preference\` ` +
133
+ `(capability \`${multi ? "hosting:<component> or postgres/…" : "hosting/postgres/…"}\`) then re-run \`offlocal_plan\`.` +
134
+ persistenceCheck +
135
+ `\n\n**Execution order once they confirm — walk the checklist top to bottom, ` +
136
+ `re-showing it with ${ICON.done} on finished steps so they see live progress:**\n` +
137
+ executionSteps(items, links, dbTarget).join("\n");
138
+ return ui({
139
+ show,
140
+ next: `Show the plan above to the user and wait for "go" (or an edit). Do NOT deploy or provision anything yet.`,
141
+ agentNote,
142
+ structured: {
143
+ multiComponent: multi,
144
+ components,
145
+ requirements,
146
+ items,
147
+ links,
148
+ checklist,
149
+ dbTargetComponent: dbTarget?.name ?? null,
150
+ },
151
+ });
152
+ }
153
+ function buildChecklist(items, links, dbTarget) {
154
+ const out = [];
155
+ let n = 0;
156
+ const hosting = items.filter((it) => it.kind === "hosting");
157
+ const resources = items.filter((it) => it.kind === "resource");
158
+ for (const it of hosting) {
159
+ n += 1;
160
+ out.push({
161
+ n,
162
+ kind: "hosting",
163
+ text: `Deploy ${labelShort(it)} → ${it.recommendedLabel}`,
164
+ });
165
+ }
166
+ for (const it of resources) {
167
+ n += 1;
168
+ const connect = it.needsConnect ? ` (connect ${it.recommendedLabel} first)` : "";
169
+ out.push({
170
+ n,
171
+ kind: "resource",
172
+ text: `Provision ${it.label} → ${it.recommendedLabel}${connect}`,
173
+ });
174
+ }
175
+ for (const l of links) {
176
+ n += 1;
177
+ out.push({
178
+ n,
179
+ kind: "link",
180
+ text: `Wire \`${l.web}\` → \`${l.api}\` URL (set ${l.envVar})`,
181
+ });
182
+ }
183
+ const redeploy = new Set();
184
+ if (resources.length && hosting.length)
185
+ redeploy.add(dbTarget?.name);
186
+ for (const l of links)
187
+ redeploy.add(l.web);
188
+ if (redeploy.size) {
189
+ n += 1;
190
+ out.push({
191
+ n,
192
+ kind: "redeploy",
193
+ text: `Redeploy ${[...redeploy].map(humanComp).join(" & ")} to apply injected env`,
194
+ });
195
+ }
196
+ return out;
197
+ }
198
+ function projName(name) {
199
+ if (!name || name === "root" || name === "app")
200
+ return "the app";
201
+ return name;
202
+ }
203
+ function executionSteps(items, links, dbTarget) {
204
+ const lines = [];
205
+ const hosting = items.filter((it) => it.kind === "hosting");
206
+ const resources = items.filter((it) => it.kind === "resource");
207
+ for (const it of hosting) {
208
+ if (it.isOfflocal) {
209
+ lines.push(`• ${ICON.rocket} Deploy ${labelShort(it)}: \`offlocal_deploy({ sourceDir: "${it.componentPath || "<root>"}" })\` → poll \`offlocal_status\` until live → note the **projectId** and **live URL**.`);
210
+ }
211
+ else if (it.recommended) {
212
+ lines.push(`• ${ICON.web} Host ${labelShort(it)} on ${it.recommendedLabel}: ${it.cost === "coming soon" ? `${it.recommendedLabel} support is coming soon.` : `if not connected, \`offlocal_connect\` → share link → poll \`offlocal_list_connections\`. (External hosting is provider-driven.)`}`);
213
+ }
214
+ }
215
+ for (const it of resources) {
216
+ if (it.isOfflocal) {
217
+ lines.push(`• ${ICON.db} ${it.label} (Offlocal Managed Postgres): \`offlocal_create_managed_database({ projectId: <${projName(dbTarget?.name)} projectId> })\`. DATABASE_URL is wired in automatically.`);
218
+ }
219
+ else {
220
+ lines.push(`• ${ICON.db} ${it.label} on ${it.recommendedLabel}: ` +
221
+ (it.needsConnect
222
+ ? `\`offlocal_connect\` → share link → poll \`offlocal_list_connections\` until connected, then `
223
+ : "") +
224
+ `\`offlocal_provision({ provider: "${it.recommended}", type: "database" })\` for a PREVIEW + cost → confirm with the user → call again with \`confirm: true, projectId: <${projName(dbTarget?.name)} projectId>\`. If it returns status **"provisioning"**, poll \`offlocal_provision_status({ actionId })\` until ready.`);
225
+ }
226
+ }
227
+ for (const l of links) {
228
+ lines.push(`• ${ICON.plug} Wire \`${l.web}\` → \`${l.api}\`: once \`${l.api}\` is live, take its URL from \`offlocal_status\` and ` +
229
+ `\`offlocal_set_env({ projectId: <${l.web} projectId>, vars: { "${l.envVar}": "<${l.api} live URL>" } })\`.` +
230
+ (l.buildTime ? ` _(build-time var — only applies on a fresh deploy)_` : ""));
231
+ }
232
+ const redeploy = new Set();
233
+ if (resources.length && hosting.length)
234
+ redeploy.add(dbTarget?.name);
235
+ for (const l of links)
236
+ redeploy.add(l.web);
237
+ if (redeploy.size) {
238
+ lines.push(`• ${ICON.spark} **Redeploy** ${[...redeploy].map(humanComp).join(" & ")} (\`offlocal_deploy\` on each sourceDir) so the injected env (DATABASE_URL / API URL) takes effect.`);
239
+ }
240
+ return lines;
241
+ }
242
+ function humanComp(name) {
243
+ if (!name || name === "root" || name === "app")
244
+ return "the app";
245
+ return `\`${name}\``;
246
+ }
247
+ function labelShort(it) {
248
+ return humanComp(it.componentName);
249
+ }
250
+ function buildItem(args) {
251
+ const { prefCapability, capability, kind, label, preferences, options, connected, component, reason } = args;
252
+ const native = OFFLOCAL_NATIVE[capability];
253
+ const offlocalAvailable = Boolean(native);
254
+ const pref = preferences[prefCapability];
255
+ const prefOption = options.find((o) => o.provider === pref);
256
+ const prefComingSoon = Boolean(prefOption && prefOption.status === "coming_soon");
257
+ const prefValid = Boolean(pref &&
258
+ !prefComingSoon &&
259
+ (pref === "offlocal" ? offlocalAvailable : Boolean(prefOption)));
260
+ const recommended = prefValid && pref
261
+ ? pref
262
+ : offlocalAvailable
263
+ ? "offlocal"
264
+ : (options.find((o) => o.connected)?.provider ??
265
+ options.find((o) => o.connectable)?.provider ??
266
+ null);
267
+ const isOfflocal = recommended === "offlocal";
268
+ const recommendedLabel = !recommended
269
+ ? "— no platform available —"
270
+ : isOfflocal
271
+ ? native.label
272
+ : (options.find((o) => o.provider === recommended)?.label ?? recommended);
273
+ const cost = isOfflocal
274
+ ? native.cost
275
+ : recommended
276
+ ? (PROVIDER_COST[recommended] ?? "provider pricing")
277
+ : "";
278
+ const needsConnect = Boolean(recommended) && !isOfflocal && !connected.has(recommended);
279
+ const note = prefComingSoon
280
+ ? `${prefOption?.label ?? pref} support is coming soon`
281
+ : undefined;
282
+ return {
283
+ prefCapability,
284
+ capability,
285
+ kind,
286
+ label,
287
+ componentName: component?.name,
288
+ componentPath: component?.absPath,
289
+ recommended,
290
+ recommendedLabel,
291
+ fromPreference: prefValid,
292
+ needsConnect,
293
+ isOfflocal,
294
+ cost,
295
+ reason,
296
+ note,
297
+ options: [
298
+ ...(offlocalAvailable
299
+ ? [{ platform: "offlocal", label: native.label, status: "available" }]
300
+ : []),
301
+ ...options.map((o) => ({ platform: o.provider, label: o.label, status: o.status })),
302
+ ],
303
+ };
304
+ }
305
+ export const SetPreferenceInputShape = {
306
+ capability: z
307
+ .string()
308
+ .min(1)
309
+ .max(40)
310
+ .describe('The capability to remember a platform for: "hosting", "postgres", "mysql", "redis", or per-component hosting like "hosting:web" / "hosting:api" (names come from offlocal_plan).'),
311
+ platform: z
312
+ .string()
313
+ .min(1)
314
+ .describe('The platform: "offlocal", "supabase", "planetscale", "digitalocean", "railway", or "vercel".'),
315
+ };
316
+ export async function setPreferenceHandler(input) {
317
+ const platform = input.platform.toLowerCase();
318
+ let comingSoon = false;
319
+ if (platform !== "offlocal") {
320
+ const conns = await listConnections().catch(() => []);
321
+ const meta = conns.find((c) => c.provider === platform);
322
+ comingSoon = meta?.availability === "coming_soon";
323
+ }
324
+ await setPreference(input.capability, platform);
325
+ if (comingSoon) {
326
+ return ui({
327
+ show: `${ICON.soon} **${platform}** support is coming soon. I've saved it for **${input.capability}** ` +
328
+ `and will switch to it automatically once it's available.`,
329
+ next: `Re-run \`offlocal_plan({ sourceDir })\` to see the updated plan.`,
330
+ structured: { capability: input.capability, platform, comingSoon: true },
331
+ });
332
+ }
333
+ return ui({
334
+ show: `${ICON.done} Saved — **${input.capability} → ${platform}**. I'll default to this from now on.`,
335
+ next: `Re-run \`offlocal_plan({ sourceDir })\` to show the user the updated plan.`,
336
+ structured: { capability: input.capability, platform },
337
+ });
338
+ }
@@ -0,0 +1,25 @@
1
+ import { getPlanInfo } from "../client.js";
2
+ import { ICON, ui } from "../ui.js";
3
+ export const PlansInputShape = {};
4
+ export async function plansHandler() {
5
+ const info = await getPlanInfo();
6
+ const tierLines = info.plans.map((p) => {
7
+ const isCurrent = p.plan === info.plan;
8
+ const head = isCurrent
9
+ ? `${ICON.connected} **${p.label} — ${p.priceLabel}** _(your plan)_`
10
+ : `${ICON.open} **${p.label} — ${p.priceLabel}**`;
11
+ const projects = `${p.projects} project${p.projects === 1 ? "" : "s"}`;
12
+ return `${head}\n ${projects} · ${p.features.slice(0, 4).join(" · ")}`;
13
+ });
14
+ const upcoming = info.upcoming.length
15
+ ? `\n\n${ICON.soon} **On the roadmap:** ${info.upcoming.join(" · ")}`
16
+ : "";
17
+ return ui({
18
+ show: `${ICON.spark} **Offlocal.ai plans**\n\n` +
19
+ `You're on **${info.planLabel}** — using ${info.usage.projects}/${info.limits.projects} projects, ${info.limits.managedDbs} managed database${info.limits.managedDbs === 1 ? "" : "s"} included.\n\n` +
20
+ tierLines.join("\n\n") +
21
+ upcoming,
22
+ next: `Use this to answer the user's plan and pricing questions. If they need more capacity (projects, databases, custom domains), share ${info.billingUrl} where they can change plans.`,
23
+ structured: { ...info },
24
+ });
25
+ }