@nullplatform/mcp 0.1.6 → 0.1.7

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 (46) hide show
  1. package/dist/i18n.js +206 -22
  2. package/dist/np/client.js +3 -0
  3. package/dist/np/journey.js +236 -24
  4. package/dist/tool-names.js +7 -0
  5. package/dist/tools/action-flow.js +94 -0
  6. package/dist/tools/approvals.js +2 -2
  7. package/dist/tools/builds.js +23 -6
  8. package/dist/tools/create-link.js +163 -0
  9. package/dist/tools/create-service.js +149 -0
  10. package/dist/tools/delete-flow.js +30 -0
  11. package/dist/tools/delete-link.js +95 -0
  12. package/dist/tools/delete-param.js +76 -0
  13. package/dist/tools/delete-service.js +108 -0
  14. package/dist/tools/deployments.js +2 -2
  15. package/dist/tools/index.js +14 -0
  16. package/dist/tools/logs.js +2 -2
  17. package/dist/tools/metrics.js +4 -1
  18. package/dist/tools/overview.js +2 -2
  19. package/dist/tools/params.js +78 -17
  20. package/dist/tools/playbook.js +2 -1
  21. package/dist/tools/releases.js +2 -2
  22. package/dist/tools/services.js +2 -2
  23. package/dist/tools/set-params.js +61 -11
  24. package/dist/tools/shared.js +4 -0
  25. package/dist/tools/update-link.js +87 -0
  26. package/dist/tools/update-service.js +92 -0
  27. package/dist/ui.js +4 -1
  28. package/package.json +3 -1
  29. package/skills/starting-a-new-app/SKILL.md +71 -0
  30. package/widgets-dist/approvals.html +261 -57
  31. package/widgets-dist/builds.html +256 -52
  32. package/widgets-dist/create-app.html +252 -48
  33. package/widgets-dist/deployments.html +251 -47
  34. package/widgets-dist/find-apps.html +251 -47
  35. package/widgets-dist/logs.html +256 -52
  36. package/widgets-dist/manifest.json +16 -13
  37. package/widgets-dist/metrics.html +261 -57
  38. package/widgets-dist/np-panel.html +256 -52
  39. package/widgets-dist/overview.html +256 -52
  40. package/widgets-dist/params.html +257 -53
  41. package/widgets-dist/releases.html +257 -53
  42. package/widgets-dist/service-action.html +1118 -0
  43. package/widgets-dist/service-create.html +1117 -0
  44. package/widgets-dist/{playbook.html → service-delete.html} +258 -58
  45. package/widgets-dist/service-link.html +1117 -0
  46. package/widgets-dist/services.html +249 -45
@@ -0,0 +1,149 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { dashboardLink, linkLine, next, table } from "../md.js";
4
+ import { createServiceAction, getService, listAppServices, listDependencySpecs, provisionService, serviceCreateActionSpec, } from "../np/journey.js";
5
+ import { defineTool, fail, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { appArg, delays, requireApp, sleep } from "./shared.js";
8
+ const slug = (text) => text
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, "-")
11
+ .replace(/^-+|-+$/g, "");
12
+ /** Specs whose name / category / sub-category contain EVERY token of the query (fuzzy "postgres",
13
+ * "sql database", "redis"…). */
14
+ function matchSpecs(specs, query) {
15
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
16
+ return specs.filter((spec) => {
17
+ const haystack = `${spec.name} ${spec.category ?? ""} ${spec.subCategory ?? ""}`.toLowerCase();
18
+ return tokens.every((token) => haystack.includes(token));
19
+ });
20
+ }
21
+ const specCategory = (spec) => [spec.category, spec.subCategory].filter(Boolean).join(" › ");
22
+ /**
23
+ * Provision a dependency service (the service record + its create action). Linking it to an app or
24
+ * scope is a SEPARATE operation (`application_link_create`) — two distinct platform entities with
25
+ * distinct lifecycles, so two tools (the model provisions, then links).
26
+ */
27
+ export const createServiceTool = defineTool({
28
+ name: TOOL.applicationServiceCreate,
29
+ title: "Provision service",
30
+ description: 'Provision a dependency service (SQL database, queue, cache, …) for an application. Pass `type` to choose the dependency (e.g. "postgres", "redis"). Provisioning creates real cloud resources (cost-bearing) and runs asynchronously, so call WITHOUT `provision:true` first to open the form; the user confirms by submitting it. Once it is active, LINK it to a scope with application_link_create so its connection details flow in as parameters.',
31
+ annotations: { destructiveHint: false, openWorldHint: true },
32
+ widget: "service-create",
33
+ errorKey: "createService.errorLabel",
34
+ inputSchema: {
35
+ app: appArg,
36
+ type: z
37
+ .string()
38
+ .optional()
39
+ .describe('Dependency to provision — matched against the catalog (e.g. "postgres", "redis", "queue")'),
40
+ specification_id: z
41
+ .string()
42
+ .optional()
43
+ .describe("Exact dependency spec id (the form submits this for a deterministic match; else use `type`)"),
44
+ name: z.string().optional().describe("Service name (default: the dependency type)"),
45
+ parameters: z
46
+ .record(z.unknown())
47
+ .optional()
48
+ .describe("Values for the create-action parameters (per the spec's schema shown in the form)"),
49
+ provision: z
50
+ .boolean()
51
+ .optional()
52
+ .describe("Set true to actually provision (creates cloud resources). Omit to return the form to fill."),
53
+ },
54
+ async handler(args, context) {
55
+ const resolved = await requireApp(context, args);
56
+ if ("out" in resolved)
57
+ return resolved.out;
58
+ const app = resolved.app;
59
+ if (!app.nrn)
60
+ return fail(translate("resolve.noNrn", { app: app.name }));
61
+ const orgSlug = await context.org.organizationSlug();
62
+ const dashboard = dashboardLink(orgSlug, app.nrn);
63
+ // The provisionable catalog (core API — carries the action specs the write path needs).
64
+ const specs = await listDependencySpecs(context.np, app.nrn);
65
+ if (specs.length === 0) {
66
+ return fail(translate("createService.noSpecs", { app: app.name }) +
67
+ linkLine(translate("md.dashboard"), dashboard));
68
+ }
69
+ // Match the requested dependency: exact id (form submit) wins; else fuzzy `type`. None or
70
+ // ambiguous → return the catalog to pick from.
71
+ const direct = args.specification_id
72
+ ? specs.find((spec) => spec.id === args.specification_id)
73
+ : undefined;
74
+ const matches = direct ? [direct] : args.type ? matchSpecs(specs, args.type) : [];
75
+ if (matches.length !== 1) {
76
+ const list = matches.length > 1 ? matches : specs;
77
+ const heading = translate(matches.length > 1 ? "createService.matchAmbiguous" : "createService.pickSpec", {
78
+ app: app.name,
79
+ query: args.type ?? "",
80
+ count: specs.length,
81
+ });
82
+ const md = `${heading}\n\n${table([translate("header.name"), translate("createService.category"), translate("createService.provider")], list.map((spec) => [spec.name, specCategory(spec), spec.provider ?? ""]))}${next(translate("createService.pickHint"))}`;
83
+ return reply(md, { mode: "pick-spec", app: `#${app.id}`, app_name: app.name, specs: list, dashboard });
84
+ }
85
+ const spec = matches[0];
86
+ const createAction = await serviceCreateActionSpec(context.np, spec.id);
87
+ const name = (args.name ?? spec.name).trim();
88
+ // Form-first: no explicit provision OR a required create-action param still missing → show the
89
+ // form (the submit IS the cost confirmation — no silent money-spend).
90
+ const requiredKeys = createAction?.schema?.required ?? [];
91
+ const missing = requiredKeys.filter((key) => args.parameters?.[key] === undefined);
92
+ if (!args.provision || missing.length > 0) {
93
+ return reply(translate("createService.form", { spec: spec.name, app: app.name }) +
94
+ next(translate("createService.formHint")), {
95
+ mode: "form",
96
+ app: `#${app.id}`,
97
+ app_name: app.name,
98
+ spec: {
99
+ id: spec.id,
100
+ name: spec.name,
101
+ category: specCategory(spec),
102
+ provider: spec.provider ?? null,
103
+ },
104
+ parameters_schema: createAction?.schema ?? null,
105
+ name_default: name,
106
+ dashboard,
107
+ });
108
+ }
109
+ // ---- PROVISION (cost-bearing) ----
110
+ // Convergent under retries: a same-name dependency already on the app (not failed) → reuse it,
111
+ // never spin up a second one.
112
+ const existing = (await listAppServices(context.np, app.nrn)).find((service) => service.name.toLowerCase() === name.toLowerCase() && service.status !== "failed");
113
+ let service = existing
114
+ ? { id: existing.id, name: existing.name, status: existing.status }
115
+ : await provisionService(context.np, {
116
+ name,
117
+ specification_id: spec.id,
118
+ entity_nrn: app.nrn,
119
+ linkable_to: [app.nrn],
120
+ });
121
+ if (!existing && createAction) {
122
+ // THE create action — POST /service/{id}/action. Without it the service is `pending` forever
123
+ // (the external agent only processes the enqueued create action).
124
+ await createServiceAction(context.np, service.id, {
125
+ name: `create-${slug(name)}`,
126
+ specification_id: createAction.id,
127
+ parameters: args.parameters ?? {},
128
+ });
129
+ }
130
+ await sleep(delays.appPoll);
131
+ service = await getService(context.np, service.id).catch(() => service);
132
+ const headline = existing
133
+ ? translate("createService.reused", { name: service.name, status: service.status })
134
+ : translate("createService.provisioning", {
135
+ spec: spec.name,
136
+ name: service.name,
137
+ status: service.status,
138
+ });
139
+ const md = `${headline}${next(translate("createService.provisioningHint", { name: service.name }))}${linkLine(translate("md.dashboard"), dashboard)}`;
140
+ return reply(md, {
141
+ mode: "progress",
142
+ app: `#${app.id}`,
143
+ app_name: app.name,
144
+ service: { id: service.id, name: service.name, status: service.status, spec: spec.name },
145
+ reused: Boolean(existing),
146
+ dashboard,
147
+ });
148
+ },
149
+ });
@@ -0,0 +1,30 @@
1
+ import { translate } from "../i18n.js";
2
+ import { linkLine, next } from "../md.js";
3
+ import { reply } from "../tool.js";
4
+ /**
5
+ * The destructive delete flow shared by `application_service_delete` and `application_link_delete`:
6
+ * confirm-first (nothing is destroyed without `confirm:true`, the widget's danger button sets it),
7
+ * then run the delete and report whether it finished or is deprovisioning. Delete is naturally
8
+ * idempotent — deleting an already-gone entity is reported, never an error.
9
+ */
10
+ export async function deleteFlow(config, args) {
11
+ const base = {
12
+ entity: config.entity,
13
+ tool: config.tool,
14
+ app: config.appRef,
15
+ app_name: config.appName,
16
+ target: { id: config.entityId, name: config.entityName, status: config.entityStatus },
17
+ dashboard: config.dashboard,
18
+ };
19
+ // Confirm-first: a delete is destructive, so the submit IS the confirmation — no silent destroy.
20
+ if (!args.confirm) {
21
+ return reply(translate("deleteEntity.confirm", { name: config.entityName }) +
22
+ next(translate("deleteEntity.confirmHint")), { ...base, mode: "confirm" });
23
+ }
24
+ const { async } = await config.performDelete();
25
+ const headline = async
26
+ ? translate("deleteEntity.deprovisioning", { name: config.entityName })
27
+ : translate("deleteEntity.removed", { name: config.entityName });
28
+ const md = `${headline}${async ? next(translate("deleteEntity.deprovisioningHint")) : ""}${linkLine(translate("md.dashboard"), config.dashboard)}`;
29
+ return reply(md, { ...base, mode: "progress", removed: !async, deprovisioning: async });
30
+ }
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { dashboardLink, next, table } from "../md.js";
4
+ import { createLinkAction, deleteLink, linkActionSpecs, listLinks } from "../np/journey.js";
5
+ import { defineTool, fail, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { deleteFlow } from "./delete-flow.js";
8
+ import { appArg, requireApp } from "./shared.js";
9
+ const slug = (text) => text
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/^-+|-+$/g, "");
13
+ /**
14
+ * Delete a link (a service↔application connection). A link whose spec has a delete-action runs it so
15
+ * an agent deprovisions credentials (DB user, permissions) before the record is removed; a plain link
16
+ * (no delete-action) is removed directly. Deleting a link removes the parameters it exported — a
17
+ * redeploy applies that to the runtime.
18
+ */
19
+ export const deleteLinkTool = defineTool({
20
+ name: TOOL.applicationLinkDelete,
21
+ title: "Delete link",
22
+ description: "Delete a link between a service and an application, removing the parameters it exported. Pass `link` to choose which one (by name); call WITHOUT `confirm:true` first to preview, the user confirms by submitting. A credential link deprovisions its database user before the record is removed; a redeploy applies the parameter removal to the runtime.",
23
+ annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
24
+ widget: "service-delete",
25
+ errorKey: "deleteEntity.linkErrorLabel",
26
+ inputSchema: {
27
+ app: appArg,
28
+ link: z.string().optional().describe("Which link to delete, by name (or id)"),
29
+ confirm: z
30
+ .boolean()
31
+ .optional()
32
+ .describe("Set true to actually delete (the confirm button sets it). Omit to preview."),
33
+ },
34
+ async handler(args, context) {
35
+ const resolved = await requireApp(context, args);
36
+ if ("out" in resolved)
37
+ return resolved.out;
38
+ const app = resolved.app;
39
+ if (!app.nrn)
40
+ return fail(translate("resolve.noNrn", { app: app.name }));
41
+ const orgSlug = await context.org.organizationSlug();
42
+ const dashboard = dashboardLink(orgSlug, app.nrn);
43
+ const links = await listLinks(context.np, app.nrn);
44
+ if (links.length === 0) {
45
+ return fail(translate("deleteEntity.noLinks", { app: app.name }));
46
+ }
47
+ const wanted = args.link?.toLowerCase();
48
+ const matched = wanted
49
+ ? links.filter((link) => link.name.toLowerCase().includes(wanted) || link.id === args.link)
50
+ : [];
51
+ if (matched.length !== 1) {
52
+ const list = matched.length > 1 ? matched : links;
53
+ const md = `${translate(matched.length > 1 ? "deleteEntity.linkAmbiguous" : "deleteEntity.pickLink", {
54
+ app: app.name,
55
+ })}\n\n${table([translate("header.name"), translate("header.status")], list.map((link) => [link.name, link.status]))}${next(translate("deleteEntity.pickLinkHint"))}`;
56
+ return reply(md, {
57
+ mode: "pick-target",
58
+ entity: "link",
59
+ tool: TOOL.applicationLinkDelete,
60
+ app: `#${app.id}`,
61
+ app_name: app.name,
62
+ targets: list,
63
+ dashboard,
64
+ });
65
+ }
66
+ const link = matched[0];
67
+ const config = {
68
+ tool: TOOL.applicationLinkDelete,
69
+ entity: "link",
70
+ appRef: `#${app.id}`,
71
+ appName: app.name,
72
+ entityId: link.id,
73
+ entityName: link.name,
74
+ entityStatus: link.status,
75
+ dashboard,
76
+ performDelete: async () => {
77
+ // A delete-action deprovisions (DB user, grants); without one, a direct DELETE removes it.
78
+ const deleteSpec = link.specification_id
79
+ ? (await linkActionSpecs(context.np, link.specification_id)).find((spec) => spec.type === "delete")
80
+ : undefined;
81
+ if (deleteSpec) {
82
+ await createLinkAction(context.np, link.id, {
83
+ name: `delete-${slug(link.name)}`,
84
+ specification_id: deleteSpec.id,
85
+ parameters: {},
86
+ });
87
+ return { async: true };
88
+ }
89
+ await deleteLink(context.np, link.id);
90
+ return { async: false };
91
+ },
92
+ };
93
+ return deleteFlow(config, args);
94
+ },
95
+ });
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { next } from "../md.js";
4
+ import { deleteParameter, listParameters, listScopes } from "../np/journey.js";
5
+ import { defineTool, errorMessage, fail, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { appArg, requireApp } from "./shared.js";
8
+ /**
9
+ * Delete a configuration parameter (the whole definition and all its values). Link-injected
10
+ * parameters are `read_only` — they're removed by deleting the link, not here — so the tool refuses
11
+ * those and points at `application_link_delete`. The change applies on the next deploy.
12
+ */
13
+ export const deleteParamTool = defineTool({
14
+ name: TOOL.applicationParameterDelete,
15
+ title: "Delete parameter",
16
+ description: "Delete an application configuration parameter (the whole variable and all its values). Pass `name`; call WITHOUT `confirm:true` first to preview, the user confirms by submitting. Read-only parameters injected by a service link can't be deleted here — delete the link instead. Applies on the next deploy.",
17
+ annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
18
+ widget: "params",
19
+ errorKey: "deleteParam.errorLabel",
20
+ inputSchema: {
21
+ app: appArg,
22
+ name: z.string().describe("The parameter (variable) name to delete, e.g. MAX_PAGE_SIZE"),
23
+ confirm: z
24
+ .boolean()
25
+ .optional()
26
+ .describe("Set true to actually delete (the confirm button sets it). Omit to preview."),
27
+ },
28
+ async handler(args, context) {
29
+ const resolved = await requireApp(context, args);
30
+ if ("out" in resolved)
31
+ return resolved.out;
32
+ const app = resolved.app;
33
+ if (!app.nrn)
34
+ return fail(translate("resolve.noNrn", { app: app.name }));
35
+ const nrn = app.nrn;
36
+ const scopes = await listScopes(context.np, app.id).catch(() => []);
37
+ const scopeData = scopes.map((scope) => ({
38
+ id: scope.id,
39
+ name: scope.name,
40
+ dimensions: scope.dimensions ?? {},
41
+ }));
42
+ const renderSet = async (extra) => {
43
+ const remaining = await listParameters(context.np, nrn).catch(() => undefined);
44
+ return {
45
+ app: `#${app.id}`,
46
+ app_name: app.name,
47
+ scopes: scopeData,
48
+ ...(remaining === undefined ? { available: false } : { available: true, params: remaining }),
49
+ ...extra,
50
+ };
51
+ };
52
+ const parameters = (await listParameters(context.np, nrn)) ?? [];
53
+ const target = parameters.find((param) => param.name.toLowerCase() === args.name.toLowerCase());
54
+ if (!target) {
55
+ // Idempotent: a name that isn't there (already deleted, or a typo) reports done, never errors.
56
+ return reply(translate("deleteParam.absent", { name: args.name, app: app.name }), await renderSet({ deleted: args.name, removed: true }));
57
+ }
58
+ if (target.read_only) {
59
+ return fail(translate("deleteParam.readOnly", { name: target.name }) +
60
+ next(translate("deleteParam.readOnlyHint")));
61
+ }
62
+ // Confirm-first: deleting a parameter is destructive, so the submit IS the confirmation.
63
+ if (!args.confirm) {
64
+ return reply(translate("deleteParam.confirm", { name: target.name, app: app.name }) +
65
+ next(translate("deleteParam.confirmHint")), await renderSet({ pending_delete: target.name }));
66
+ }
67
+ try {
68
+ await deleteParameter(context.np, target.id);
69
+ }
70
+ catch (caught) {
71
+ return fail(translate("deleteParam.failed", { name: target.name, message: errorMessage(caught) }));
72
+ }
73
+ return reply(translate("deleteParam.deleted", { name: target.name, app: app.name }) +
74
+ next(translate("deleteParam.deletedHint")), await renderSet({ deleted: target.name, removed: true }));
75
+ },
76
+ });
@@ -0,0 +1,108 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { dashboardLink, next, table } from "../md.js";
4
+ import { createServiceAction, deleteService, listAppServices, listLinks, serviceActionSpecs, } from "../np/journey.js";
5
+ import { defineTool, fail, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { deleteFlow } from "./delete-flow.js";
8
+ import { appArg, requireApp } from "./shared.js";
9
+ const slug = (text) => text
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/^-+|-+$/g, "");
13
+ /**
14
+ * Delete a provisioned service and destroy its resources. A `failed` service (no provisioned
15
+ * resources) deletes directly; an active one runs its delete-action so an agent deprovisions
16
+ * (DB, pods, …) before the record is removed. A service can't be deleted while it has links — the
17
+ * tool surfaces that rather than orphaning them.
18
+ */
19
+ export const deleteServiceTool = defineTool({
20
+ name: TOOL.applicationServiceDelete,
21
+ title: "Delete service",
22
+ description: "Delete a provisioned service and destroy its cloud resources. Pass `service` to choose which one (by name); call WITHOUT `confirm:true` first to see what will be destroyed, the user confirms by submitting. A service with active links can't be deleted — remove the links first (application_link_delete).",
23
+ annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
24
+ widget: "service-delete",
25
+ errorKey: "deleteEntity.serviceErrorLabel",
26
+ inputSchema: {
27
+ app: appArg,
28
+ service: z.string().optional().describe("Which provisioned service to delete, by name (or id)"),
29
+ confirm: z
30
+ .boolean()
31
+ .optional()
32
+ .describe("Set true to actually delete (the confirm button sets it). Omit to preview."),
33
+ },
34
+ async handler(args, context) {
35
+ const resolved = await requireApp(context, args);
36
+ if ("out" in resolved)
37
+ return resolved.out;
38
+ const app = resolved.app;
39
+ if (!app.nrn)
40
+ return fail(translate("resolve.noNrn", { app: app.name }));
41
+ const orgSlug = await context.org.organizationSlug();
42
+ const dashboard = dashboardLink(orgSlug, app.nrn);
43
+ const services = await listAppServices(context.np, app.nrn);
44
+ if (services.length === 0) {
45
+ return fail(translate("deleteEntity.noServices", { app: app.name }));
46
+ }
47
+ const wanted = args.service?.toLowerCase();
48
+ const matched = wanted
49
+ ? services.filter((service) => service.name.toLowerCase().includes(wanted) || service.id === args.service)
50
+ : [];
51
+ if (matched.length !== 1) {
52
+ const list = matched.length > 1 ? matched : services;
53
+ const md = `${translate(matched.length > 1 ? "deleteEntity.serviceAmbiguous" : "deleteEntity.pickService", {
54
+ app: app.name,
55
+ })}\n\n${table([translate("header.name"), translate("header.status")], list.map((service) => [service.name, service.status]))}${next(translate("deleteEntity.pickServiceHint"))}`;
56
+ return reply(md, {
57
+ mode: "pick-target",
58
+ entity: "service",
59
+ tool: TOOL.applicationServiceDelete,
60
+ app: `#${app.id}`,
61
+ app_name: app.name,
62
+ targets: list,
63
+ dashboard,
64
+ });
65
+ }
66
+ const service = matched[0];
67
+ // A service can't be deleted while links depend on it — surface that instead of orphaning them.
68
+ if (args.confirm) {
69
+ const blockingLinks = (await listLinks(context.np, app.nrn)).filter((link) => link.service_id === service.id && link.status !== "failed");
70
+ if (blockingLinks.length > 0) {
71
+ return fail(translate("deleteEntity.hasLinks", { name: service.name, count: blockingLinks.length }) +
72
+ next(translate("deleteEntity.hasLinksHint")));
73
+ }
74
+ }
75
+ const config = {
76
+ tool: TOOL.applicationServiceDelete,
77
+ entity: "service",
78
+ appRef: `#${app.id}`,
79
+ appName: app.name,
80
+ entityId: service.id,
81
+ entityName: service.name,
82
+ entityStatus: service.status,
83
+ dashboard,
84
+ performDelete: async () => {
85
+ // A failed service has no provisioned resources → direct DELETE (200), no deprovisioning.
86
+ if (service.status === "failed") {
87
+ await deleteService(context.np, service.id);
88
+ return { async: false };
89
+ }
90
+ // An active service deprovisions via its delete-action; without one, force-delete the record.
91
+ const deleteSpec = service.specification_id
92
+ ? (await serviceActionSpecs(context.np, service.specification_id)).find((spec) => spec.type === "delete")
93
+ : undefined;
94
+ if (deleteSpec) {
95
+ await createServiceAction(context.np, service.id, {
96
+ name: `delete-${slug(service.name)}`,
97
+ specification_id: deleteSpec.id,
98
+ parameters: {},
99
+ });
100
+ return { async: true };
101
+ }
102
+ await deleteService(context.np, service.id, true);
103
+ return { async: false };
104
+ },
105
+ };
106
+ return deleteFlow(config, args);
107
+ },
108
+ });
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { translate } from "../i18n.js";
2
+ import { plural, translate } from "../i18n.js";
3
3
  import { ago, glyph, next, table } from "../md.js";
4
4
  import { isDeploymentTerminal, listDeployments, listReleases, listScopes, } from "../np/journey.js";
5
5
  import { defineTool, reply } from "../tool.js";
@@ -60,7 +60,7 @@ export const deploymentsTool = defineTool({
60
60
  return children[0]?.status ?? "pending";
61
61
  };
62
62
  const markdown = [
63
- translate("deploymentList.title", { app: app.name, count: rows.length }),
63
+ plural(rows.length, "deploymentList.title.one", "deploymentList.title.many", { app: app.name }),
64
64
  "",
65
65
  table([
66
66
  translate("header.scope"),
@@ -1,8 +1,13 @@
1
1
  import { approvalsTool } from "./approvals.js";
2
2
  import { buildsTool } from "./builds.js";
3
3
  import { createAppTool } from "./create-app.js";
4
+ import { createLinkTool } from "./create-link.js";
4
5
  import { createReleaseTool } from "./create-release.js";
5
6
  import { createScopeTool } from "./create-scope.js";
7
+ import { createServiceTool } from "./create-service.js";
8
+ import { deleteLinkTool } from "./delete-link.js";
9
+ import { deleteParamTool } from "./delete-param.js";
10
+ import { deleteServiceTool } from "./delete-service.js";
6
11
  import { deployTool } from "./deploy.js";
7
12
  import { deploymentsTool } from "./deployments.js";
8
13
  import { findAppsTool } from "./find-apps.js";
@@ -16,6 +21,8 @@ import { servicesTool } from "./services.js";
16
21
  import { setParamsTool } from "./set-params.js";
17
22
  import { statusTool } from "./status.js";
18
23
  import { trafficTool } from "./traffic.js";
24
+ import { updateLinkTool } from "./update-link.js";
25
+ import { updateServiceTool } from "./update-service.js";
19
26
  /**
20
27
  * The tool registry — the single extension point. To add a tool: create
21
28
  * src/tools/<name>.ts exporting a defineTool({...}) and list it here. Its widget
@@ -39,5 +46,12 @@ export const tools = [
39
46
  createScopeTool,
40
47
  approvalsTool,
41
48
  servicesTool,
49
+ createServiceTool,
50
+ updateServiceTool,
51
+ deleteServiceTool,
52
+ createLinkTool,
53
+ updateLinkTool,
54
+ deleteLinkTool,
55
+ deleteParamTool,
42
56
  playbookGetTool,
43
57
  ];
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { translate } from "../i18n.js";
2
+ import { plural, translate } from "../i18n.js";
3
3
  import { dashboardLink, linkLine, next } from "../md.js";
4
4
  import { listScopes, readLogs } from "../np/journey.js";
5
5
  import { defineTool, fail, reply } from "../tool.js";
@@ -71,7 +71,7 @@ export const logsTool = defineTool({
71
71
  return `${prefix}${(entry.message ?? "").trimEnd()}`;
72
72
  })
73
73
  .join("\n");
74
- const markdown = `${translate("logs.lastLines", { app: app.name, scope: scopeSuffix, count: entries.length })}\n\n\`\`\`\n${body}\n\`\`\``;
74
+ const markdown = `${plural(entries.length, "logs.lastLines.one", "logs.lastLines.many", { app: app.name, scope: scopeSuffix })}\n\n\`\`\`\n${body}\n\`\`\``;
75
75
  return reply(markdown, {
76
76
  count: entries.length,
77
77
  next_page_token: page.next_page_token ?? null,
@@ -34,6 +34,8 @@ export const metricsTool = defineTool({
34
34
  if ("out" in picked)
35
35
  return picked.out;
36
36
  const scope = picked.scope;
37
+ // The full scope list rides along so the widget can offer a scope switcher (same as logs).
38
+ const scopeList = scopes.map((candidate) => ({ name: candidate.name, status: candidate.status }));
37
39
  const window = args.window ?? "3h";
38
40
  const series = await readGoldenMetrics(context.bff, {
39
41
  application_id: app.id,
@@ -45,7 +47,7 @@ export const metricsTool = defineTool({
45
47
  if (withData.length === 0) {
46
48
  const orgSlug = await context.org.organizationSlug();
47
49
  return reply(translate("metrics.empty", { app: app.name, scope: scope.name, window }) +
48
- linkLine(translate("metrics.openInDashboard"), dashboardLink(orgSlug, scope.nrn)), { app: `#${app.id}`, scope: scope.name, window, series: [] });
50
+ linkLine(translate("metrics.openInDashboard"), dashboardLink(orgSlug, scope.nrn)), { app: `#${app.id}`, app_name: app.name, scope: scope.name, scopes: scopeList, window, series: [] });
49
51
  }
50
52
  const markdown = [
51
53
  translate("metrics.title", { app: app.name, scope: scope.name, window }),
@@ -68,6 +70,7 @@ export const metricsTool = defineTool({
68
70
  app: `#${app.id}`,
69
71
  app_name: app.name,
70
72
  scope: scope.name,
73
+ scopes: scopeList,
71
74
  window,
72
75
  series: series.map((metric) => ({
73
76
  id: metric.id,
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { translate } from "../i18n.js";
2
+ import { plural, translate } from "../i18n.js";
3
3
  import { ago, glyph, next, table } from "../md.js";
4
4
  import { pmap } from "../np/context.js";
5
5
  import { isDeploymentTerminal, listScopeDeployments, listScopes } from "../np/journey.js";
@@ -66,7 +66,7 @@ export const overviewTool = defineTool({
66
66
  });
67
67
  function renderOverview(args) {
68
68
  const { active, trouble, scanned, truncated } = args;
69
- const lines = [translate("overview.title", { count: scanned })];
69
+ const lines = [plural(scanned, "overview.title.one", "overview.title.many")];
70
70
  if (truncated)
71
71
  lines.push(translate("overview.truncated", { count: MAX_APPS }));
72
72
  lines.push("");