diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de1f9b9..cb4ecac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d35b399..fc04a14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ permissions: jobs: publish: name: Verify and publish npm package + if: github.ref == 'refs/heads/main' + environment: npm-release runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/README.md b/README.md index cb5c8cf..8186892 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ installation working on machines that route `@archastro` elsewhere. ```sh codex mcp add intern -- \ npx --yes --@archastro:registry=https://registry.npmjs.org \ - @archastro/intern-mcp@latest serve + @archastro/intern-mcp@0.1.0 serve codex mcp get intern ``` @@ -26,7 +26,7 @@ user configuration and starts the package when a session needs the server. ```sh claude mcp add --transport stdio --scope user intern -- \ npx --yes --@archastro:registry=https://registry.npmjs.org \ - @archastro/intern-mcp@latest serve + @archastro/intern-mcp@0.1.0 serve claude mcp get intern ``` @@ -38,15 +38,28 @@ The two harnesses launch the same local stdio executable. Authentication still uses `intern_login` and the TryIntern device page; no OAuth secret or service credential is placed in harness configuration. +Upgrades are explicit: remove the existing registration and add it again with +the reviewed version replacing `0.1.0`: + +```sh +codex mcp remove intern +claude mcp remove intern +``` + +Then rerun the corresponding command above. The harness never executes a newly +published package version merely because it restarted. + The repository is private and the npm package has not been published yet. The commands above become available after the first package release. Maintainers run the manual **release** workflow to publish the version in `package.json`. The first release uses a short-lived `NPM_TOKEN` repository -secret because npm cannot attach a trusted publisher to a package that does -not exist. After `0.1.0`, configure `ArchAstro/intern-mcp` and `release.yml` as -the npm trusted publisher, remove the secret, and later runs authenticate with -GitHub OIDC. +secret in the protected `npm-release` environment because npm cannot attach a +trusted publisher to a package that does not exist. That environment accepts +only protected branches; `main` itself requires CI and approving review. After +`0.1.0`, configure `ArchAstro/intern-mcp`, `release.yml`, and environment +`npm-release` as the npm trusted publisher, remove the secret, and later runs +authenticate with GitHub OIDC. ## Configure the server diff --git a/scripts/run-local.sh b/scripts/run-local.sh index 6929edc..dd356a2 100755 --- a/scripts/run-local.sh +++ b/scripts/run-local.sh @@ -64,9 +64,9 @@ ports=$(printf '%s' "$ports_json" | node --input-type=module -e ' // Use the Aster worktree allocation instead of assuming default local ports. const platform = instance.ports?.platform; const frontend = servicePort("intern-fe"); - // Aster 0.11 reports selected service ports. The SSH listener is an - // auxiliary port derived from the same worktree Platform allocation. - const gatewaySSH = instance.ports?.["intern-gateway-ssh"] ?? 2222 + Math.max(platform - 4000, 0); + // Require the auxiliary port reported by Aster. Deriving it locally can disagree + // with the collision-free Aster allocation and target the wrong SSH listener. + const gatewaySSH = instance.ports?.["intern-gateway-ssh"]; const validPort = (port) => Number.isInteger(port) && port >= 1 && port <= 65535; if (!validPort(platform) || !validPort(frontend) || !validPort(gatewaySSH)) { console.error("The active Intern stack did not report its platform, intern-fe, and intern-gateway-ssh ports."); diff --git a/src/auth.test.ts b/src/auth.test.ts new file mode 100644 index 0000000..c9f8d15 --- /dev/null +++ b/src/auth.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { assertHttpUrl, AuthClient } from "./auth.js"; + +const config = { + internBaseURL: "https://tryintern.dev", + archAstroBaseURL: "https://platform.archastro.ai", + publishableKey: "pk_test", + oauthClientID: "cc_test", + workspaceRoot: "/tmp/intern-auth-test/sites", + configRoot: "/tmp/intern-auth-test/config", +}; + +describe("assertHttpUrl", () => { + it("accepts HTTPS and loopback HTTP device pages", () => { + expect( + assertHttpUrl("https://tryintern.dev/device?code=ABCD", "verification URL"), + ).toBe("https://tryintern.dev/device?code=ABCD"); + expect(assertHttpUrl("http://127.0.0.1:3100/device", "verification URL")).toBe( + "http://127.0.0.1:3100/device", + ); + }); + + it("rejects non-HTTP schemes, credentials, and non-loopback HTTP", () => { + expect(() => assertHttpUrl("javascript:alert(1)", "verification URL")).toThrow( + "non-HTTP", + ); + expect(() => assertHttpUrl("file:///etc/passwd", "verification URL")).toThrow( + "non-HTTP", + ); + expect(() => + assertHttpUrl("https://user:pass@tryintern.dev/device", "verification URL"), + ).toThrow("embedded credentials"); + expect(() => + assertHttpUrl("http://evil.example/device", "verification URL"), + ).toThrow("non-HTTPS"); + }); +}); + +describe("AuthClient.startLogin", () => { + it("refuses a device-approval URL that is not http(s)", async () => { + const auth = new AuthClient(config, async () => + Response.json({ + device_code: "dc", + user_code: "ABCD-EFGH", + verification_uri: "javascript:alert(1)", + verification_uri_complete: "javascript:alert(1)", + expires_in: 600, + interval: 1, + }), + ); + await expect(auth.startLogin(false)).rejects.toThrow("non-HTTP"); + }); + + it("refuses verification URLs on different origins", async () => { + const auth = new AuthClient(config, async () => + Response.json({ + device_code: "dc", + user_code: "ABCD-EFGH", + verification_uri: "https://tryintern.dev/device", + verification_uri_complete: "https://evil.example/device?code=ABCD-EFGH", + expires_in: 600, + interval: 1, + }), + ); + await expect(auth.startLogin(false)).rejects.toThrow("different origins"); + }); +}); diff --git a/src/auth.ts b/src/auth.ts index 3bf8f6b..e91d659 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -58,11 +58,22 @@ export class AuthClient { const body = await json(response); if (!response.ok) throw new Error(`authorization start failed: ${errorMessage(body)}`); + const verificationURI = assertHttpUrl( + requiredString(body, "verification_uri"), + "verification URL", + ); + const verificationURIComplete = assertHttpUrl( + requiredString(body, "verification_uri_complete"), + "verification URL", + ); + if (new URL(verificationURI).origin !== new URL(verificationURIComplete).origin) { + throw new Error("Intern returned verification URLs on different origins"); + } const pending: PendingAuthorization = { deviceCode: requiredString(body, "device_code"), userCode: requiredString(body, "user_code"), - verificationURI: requiredString(body, "verification_uri"), - verificationURIComplete: requiredString(body, "verification_uri_complete"), + verificationURI, + verificationURIComplete, expiresAt: Date.now() + requiredNumber(body, "expires_in") * 1000, intervalSeconds: Math.max(requiredNumber(body, "interval"), 1), }; @@ -240,15 +251,35 @@ function errorMessage(value: Record): string { function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +export function assertHttpUrl(value: string, label: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`Intern returned an invalid ${label}`); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") + throw new Error(`Intern returned a non-HTTP ${label}`); + if (parsed.username || parsed.password) + throw new Error(`Intern returned a ${label} with embedded credentials`); + if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) + throw new Error(`Intern returned a non-HTTPS ${label}`); + return parsed.href; +} + +function isLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + function openBrowser(url: string): void { - const command = + const href = assertHttpUrl(url, "verification URL"); + const child = process.platform === "darwin" - ? "open" + ? spawn("open", [href], { detached: true, stdio: "ignore" }) : process.platform === "win32" - ? "cmd" - : "xdg-open"; - const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; - const child = spawn(command, args, { detached: true, stdio: "ignore" }); + ? spawn("explorer.exe", [href], { detached: true, stdio: "ignore" }) + : spawn("xdg-open", [href], { detached: true, stdio: "ignore" }); child.on("error", () => {}); child.unref(); } diff --git a/src/config.test.ts b/src/config.test.ts index 4dd069f..0c4fb05 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { DEFAULT_ARCHASTRO_API_URL, DEFAULT_ARCHASTRO_PUBLISHABLE_KEY, DEFAULT_INTERN_BASE_URL, DEFAULT_INTERN_OAUTH_CLIENT_ID, loadConfig, + PACKAGE_VERSION, } from "./config.js"; describe("loadConfig", () => { + it("uses the published package version for MCP server identity", () => { + const packed = JSON.parse( + readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"), + ) as { version: string }; + expect(PACKAGE_VERSION).toBe(packed.version); + }); + it("uses TryIntern production public values by default", () => { const config = loadConfig({ HOME: "/tmp/intern-config-test" }); diff --git a/src/config.ts b/src/config.ts index 317a2d4..92c6d17 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,13 @@ +import { readFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +export const PACKAGE_VERSION = ( + JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + version: string; + } +).version; + export const DEFAULT_INTERN_BASE_URL = "https://tryintern.dev"; export const DEFAULT_ARCHASTRO_API_URL = "https://platform.archastro.ai"; export const DEFAULT_ARCHASTRO_PUBLISHABLE_KEY = diff --git a/src/launcher.test.ts b/src/launcher.test.ts index 1b187dd..7af4d80 100644 --- a/src/launcher.test.ts +++ b/src/launcher.test.ts @@ -64,7 +64,12 @@ process.stdout.write(JSON.stringify({ { name: "intern-ctl-devbox", port_name: "intern-ctl", port: 5102 }, { name: "intern-fe", port_name: "intern-fe", port: ${frontendPort} } ], - ports: { platform: ${platformPort}, "intern-fe": ${frontendPort}, "intern-ctl": 5102 } + ports: { + platform: ${platformPort}, + "intern-fe": ${frontendPort}, + "intern-ctl": 5102, + "intern-gateway-ssh": 2224 + } }] })); `, diff --git a/src/server.test.ts b/src/server.test.ts index 8f2d61c..713662a 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -5,10 +5,12 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { Client } from "@modelcontextprotocol/client"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { afterEach, expect, test } from "vitest"; import type { SiteRuntimeContract } from "./api.js"; +import { PACKAGE_VERSION } from "./config.js"; +import { buildServer } from "./server.js"; const exec = promisify(execFile); const cleanups: Array<() => Promise> = []; @@ -16,6 +18,81 @@ afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); +test("advertises MCP titles, instructions, field descriptions, and workflow prompts", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = buildServer( + { + hasCredentials: async () => false, + } as never, + { + listSites: async () => [{ slug: "docs" }, { slug: "blog" }], + } as never, + {} as never, + ); + const client = new Client({ name: "intern-contract", version: "1.0.0" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + expect(client.getServerVersion()).toMatchObject({ + name: "intern", + version: PACKAGE_VERSION, + }); + expect(client.getInstructions()).toMatch(/intern_prepare_site/); + expect(client.getInstructions()).toMatch(/never stages or commits/i); + + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining([ + "intern_auth_status", + "intern_login", + "intern_complete_login", + "intern_logout", + "intern_list_sites", + "intern_prepare_site", + "intern_site_status", + "intern_validate_site", + "intern_test_site", + "intern_stop_test", + "intern_publish_site", + ]), + ); + for (const tool of tools.tools) expect(tool.title, tool.name).toBeTruthy(); + const prepare = tools.tools.find((tool) => tool.name === "intern_prepare_site"); + expect(prepare?.inputSchema).toMatchObject({ + properties: { + site: { description: expect.stringMatching(/slug/i) }, + createIfMissing: { description: expect.stringMatching(/create/i) }, + }, + }); + expect(prepare?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + + const prompts = await client.listPrompts(); + expect(prompts.prompts.map((prompt) => prompt.name)).toEqual( + expect.arrayContaining(["intern_sign_in", "intern_work_on_site"]), + ); + const workflow = await client.getPrompt({ + name: "intern_work_on_site", + arguments: { site: "docs" }, + }); + expect(workflow.messages[0]?.content).toMatchObject({ + type: "text", + text: expect.stringContaining('intern_prepare_site with site "docs"'), + }); + + const resources = await client.listResources(); + expect( + resources.resources.find((resource) => resource.uri === "intern://session") + ?.description, + ).toMatch(/does not include credentials/i); + } finally { + await client.close(); + await server.close(); + } +}); + async function previewTemporaryDirectories(): Promise> { const temporaryRoot = await fs.realpath(os.tmpdir()); return new Set( diff --git a/src/server.ts b/src/server.ts index 3d7e4fc..f377d6f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,27 @@ -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server"; +import { completable, McpServer, ResourceTemplate } from "@modelcontextprotocol/server"; import * as z from "zod/v4"; import type { AuthClient } from "./auth.js"; import type { InternAPI } from "./api.js"; +import { PACKAGE_VERSION } from "./config.js"; import type { WorkspaceManager } from "./workspace.js"; +const siteSlug = z + .string() + .regex(/^[a-z][a-z0-9-]{0,62}$/) + .describe( + "Intern site slug: a lowercase letter, then lowercase letters, digits, or hyphens", + ); + +const SERVER_INSTRUCTIONS = [ + "Work on Intern-hosted sites in guarded local Git checkouts. Intern never stages or commits files.", + "1. intern_auth_status — if unauthorized, intern_login then intern_complete_login after the user approves the device page.", + "2. intern_prepare_site — clone or reuse the checkout; edit files at the returned absolute path with the host's filesystem tools.", + "3. intern_test_site — preview the working tree (untracked included, ignored excluded) at a loopback URL. Call it again after further edits. intern_stop_test stops it.", + "4. Commit with the host's git, then intern_validate_site against Intern's runtime contract.", + "5. intern_publish_site — pushes only a clean, committed HEAD that passed validation.", + "Use intern_list_sites and intern_site_status to inspect. intern_logout removes credentials and stops previews; it does not delete sites or files.", +].join("\n"); + const sessionSchema = z.object({ user: z.object({ id: z.string(), @@ -66,15 +84,19 @@ export function buildServer( api: InternAPI, workspaces: WorkspaceManager, ): McpServer { - const server = new McpServer({ - name: "intern", - version: "0.1.0", - description: "Work on Intern-hosted sites in guarded local Git checkouts", - }); + const server = new McpServer( + { + name: "intern", + version: PACKAGE_VERSION, + description: "Work on Intern-hosted sites in guarded local Git checkouts", + }, + { instructions: SERVER_INSTRUCTIONS }, + ); server.registerTool( "intern_auth_status", { + title: "Check Intern authorization", description: "Check whether this local MCP is authorized with Intern and return the current user and organization without exposing credentials.", outputSchema: z.object({ @@ -94,9 +116,15 @@ export function buildServer( server.registerTool( "intern_login", { + title: "Start Intern sign-in", description: "Start Intern browser authorization. After approval, call intern_complete_login before using site tools.", - inputSchema: z.object({ openBrowser: z.boolean().default(true) }), + inputSchema: z.object({ + openBrowser: z + .boolean() + .default(true) + .describe("Open the device-approval page in the user's browser"), + }), outputSchema: z.object({ userCode: z.string(), verificationURI: z.string(), @@ -116,10 +144,17 @@ export function buildServer( server.registerTool( "intern_complete_login", { + title: "Finish Intern sign-in", description: "Finish a pending Intern browser authorization after the user approves it.", inputSchema: z.object({ - timeoutSeconds: z.number().int().min(1).max(300).default(120), + timeoutSeconds: z + .number() + .int() + .min(1) + .max(300) + .default(120) + .describe("Seconds to wait for the user to approve the device page"), }), outputSchema: z.object({ authorized: z.literal(true), session: sessionSchema }), annotations: { @@ -138,6 +173,7 @@ export function buildServer( server.registerTool( "intern_logout", { + title: "Sign out of Intern", description: "Remove this profile's local Intern credentials. This does not delete sites or files.", outputSchema: z.object({ authorized: z.literal(false) }), @@ -160,6 +196,7 @@ export function buildServer( server.registerTool( "intern_list_sites", { + title: "List Intern sites", description: "List sites in the authorized Intern organization.", outputSchema: z.object({ sites: z.array(siteSchema) }), annotations: { readOnlyHint: true, openWorldHint: true }, @@ -170,12 +207,19 @@ export function buildServer( server.registerTool( "intern_prepare_site", { + title: "Prepare Intern checkout", description: "Clone or validate an Intern site's guarded local checkout and return its absolute path. Remote creation occurs only when createIfMissing is true.", inputSchema: z.object({ - site: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), - createIfMissing: z.boolean().default(false), - siteType: z.string().default("vite"), + site: siteSlug, + createIfMissing: z + .boolean() + .default(false) + .describe("Create the remote Intern site if it does not exist"), + siteType: z + .string() + .default("vite") + .describe("Runtime site type used only when creating a missing site"), }), outputSchema: z.object({ site: siteSchema, @@ -211,8 +255,9 @@ export function buildServer( server.registerTool( "intern_site_status", { + title: "Inspect Intern checkout", description: "Inspect one prepared Intern checkout without changing it.", - inputSchema: z.object({ site: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/) }), + inputSchema: z.object({ site: siteSlug }), outputSchema: z.object({ site: siteSchema, workspace: workspaceSchema }), annotations: { readOnlyHint: true, openWorldHint: true }, }, @@ -228,15 +273,21 @@ export function buildServer( server.registerTool( "intern_validate_site", { + title: "Validate Intern commit", description: "Validate the prepared checkout's committed HEAD against Intern's authenticated runtime contract. Checks required and protected files, package support, entrypoint syntax, sandboxed startup, and an HTTP response without changing the checkout. Commit model edits first; dirty changes are reported in workspace status but are not part of validation.", - inputSchema: z.object({ site: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/) }), + inputSchema: z.object({ site: siteSlug }), outputSchema: z.object({ site: siteSchema, workspace: workspaceSchema, validation: validationSchema, }), - annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, }, async ({ site: slug }) => { const { session, site } = await resolveSite(api, slug); @@ -253,9 +304,10 @@ export function buildServer( server.registerTool( "intern_test_site", { + title: "Preview Intern working tree", description: "Validate the current working tree, replace any prior preview for this site, and serve a temporary snapshot at a loopback HTTP URL. This includes uncommitted tracked and untracked files but excludes ignored files; call it again after edits to refresh the snapshot.", - inputSchema: z.object({ site: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/) }), + inputSchema: z.object({ site: siteSlug }), outputSchema: z.object({ site: siteSchema, workspace: workspaceSchema, @@ -284,9 +336,10 @@ export function buildServer( server.registerTool( "intern_stop_test", { + title: "Stop Intern preview", description: "Stop and remove this site's local loopback test snapshot without contacting Intern. It works after logout or during a backend outage and never changes the Git checkout.", - inputSchema: z.object({ site: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/) }), + inputSchema: z.object({ site: siteSlug }), outputSchema: z.object({ site: z.string(), stopped: z.boolean() }), annotations: { readOnlyHint: false, @@ -303,9 +356,10 @@ export function buildServer( server.registerTool( "intern_publish_site", { + title: "Publish Intern site", description: "Validate and push the clean, committed HEAD of a prepared Intern checkout. Refuses runtime-incompatible changes, dirty trees, detached HEADs, unexpected remotes, and non-fast-forward pushes.", - inputSchema: z.object({ site: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/) }), + inputSchema: z.object({ site: siteSlug }), outputSchema: z.object({ site: siteSchema, workspace: workspaceSchema, @@ -334,6 +388,8 @@ export function buildServer( "intern://session", { title: "Intern authorization session", + description: + "Current Intern user and organization. Does not include credentials.", mimeType: "application/json", }, async (uri) => jsonResource(uri, await api.session()), @@ -344,6 +400,7 @@ export function buildServer( "intern://sites", { title: "Intern sites", + description: "Sites in the authorized Intern organization.", mimeType: "application/json", }, async (uri) => jsonResource(uri, { sites: await api.listSites() }), @@ -359,9 +416,13 @@ export function buildServer( mimeType: "application/json", })), }), + complete: { + slug: (value) => completeSiteSlugs(api, value), + }, }), { title: "Intern site workspace", + description: "Local Git checkout status for one Intern site.", mimeType: "application/json", }, async (uri, variables) => { @@ -376,9 +437,75 @@ export function buildServer( }, ); + server.registerPrompt( + "intern_sign_in", + { + title: "Sign in to Intern", + description: + "Authorize this local MCP with Intern using the device-approval page.", + }, + () => ({ + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: [ + "Authorize this local Intern MCP.", + "1. Call intern_login with openBrowser true.", + "2. Ask the user to open verificationURIComplete (or enter userCode at verificationURI) and approve the device page.", + "3. Call intern_complete_login and wait for authorized: true.", + "4. Confirm with intern_auth_status.", + "Do not put tokens or credentials in chat.", + ].join("\n"), + }, + }, + ], + }), + ); + + server.registerPrompt( + "intern_work_on_site", + { + title: "Work on an Intern site", + description: "Prepare, preview, validate, and publish one Intern site.", + argsSchema: z.object({ + site: completable(siteSlug, (value) => completeSiteSlugs(api, value)), + }), + }, + ({ site }) => ({ + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: [ + `Work on Intern site "${site}".`, + "1. Call intern_auth_status. If unauthorized, follow the Intern sign-in flow first.", + `2. Call intern_prepare_site with site "${site}". Edit files at the returned workspace.path using this host's filesystem tools.`, + "3. Intern never stages or commits files. After edits, call intern_test_site to preview the working tree (untracked included, ignored excluded). Call it again after further edits. intern_stop_test stops the preview.", + "4. Commit with this host's git, then call intern_validate_site.", + "5. Call intern_publish_site only when validation is valid and the worktree is clean.", + ].join("\n"), + }, + }, + ], + }), + ); + return server; } +async function completeSiteSlugs(api: InternAPI, value: string): Promise { + try { + return (await api.listSites()) + .map((site) => site.slug) + .filter((slug) => slug.startsWith(value)); + } catch { + return []; + } +} + async function resolveSite(api: InternAPI, slug: string) { const [session, sites] = await Promise.all([api.session(), api.listSites()]); const site = sites.find((candidate) => candidate.slug === slug);