diff --git a/extensions/confluence-tools.ts b/extensions/confluence-tools.ts index 6f5e705..b01ea16 100644 --- a/extensions/confluence-tools.ts +++ b/extensions/confluence-tools.ts @@ -6,69 +6,57 @@ * DISABLE: remove/rename this file from extensions/. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { registerTool, type ToolSpec } from "./lib/tools.ts"; import { search, pageGet, pageCreate } from "./lib/confluence.ts"; export default function confluenceToolsExtension(pi: ExtensionAPI) { - const register = (name: string, description: string, parameters: Record, run: (p: any) => Promise) => { - pi.registerTool({ - name, - label: name, - description, - parameters: parameters as any, - async execute(_id: string, params: unknown) { - try { - const out = await run(params ?? {}); - return { content: [{ type: "text", text: JSON.stringify(out ?? {}, null, 2) }], details: {} }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text", text: `confluence error: ${msg}` }], isError: true, details: {} }; - } - }, - }); - }; - - register( - "confluence_search", - "Найти страницы в Confluence по CQL-запросу (текст/заголовок/пространство).", + const tools: ToolSpec[] = [ { - type: "object", - properties: { - cql: { type: "string", description: 'CQL, например text="деплой" AND space = TECHRUN' }, - limit: { type: "number", description: "Макс. результатов (default 25)" }, - expandBody: { type: "boolean", description: "Вернуть полный текст страниц" }, + name: "confluence_search", + description: "Найти страницы в Confluence по CQL-запросу (текст/заголовок/пространство).", + errorPrefix: "confluence", + parameters: { + type: "object", + properties: { + cql: { type: "string", description: 'CQL, например text="деплой" AND space = TECHRUN' }, + limit: { type: "number", description: "Макс. результатов (default 25)" }, + expandBody: { type: "boolean", description: "Вернуть полный текст страниц" }, + }, + required: ["cql"], }, - required: ["cql"], + run: async (p) => search(p.cql, { limit: p.limit, expandBody: p.expandBody }), }, - async (p) => search(p.cql, { limit: p.limit, expandBody: p.expandBody }), - ); - - register( - "confluence_page_get", - "Получить страницу Confluence по ID (с содержимым, если expandBody).", { - type: "object", - properties: { - id: { type: "string", description: "ID страницы" }, - expandBody: { type: "boolean", description: "Вернуть тело страницы" }, + name: "confluence_page_get", + description: "Получить страницу Confluence по ID (с содержимым, если expandBody).", + errorPrefix: "confluence", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "ID страницы" }, + expandBody: { type: "boolean", description: "Вернуть тело страницы" }, + }, + required: ["id"], }, - required: ["id"], + run: async (p) => pageGet(p.id, { expandBody: p.expandBody }), }, - async (p) => pageGet(p.id, { expandBody: p.expandBody }), - ); - - register( - "confluence_page_create", - "Создать страницу в Confluence. body — HTML в Storage format.", { - type: "object", - properties: { - spaceKey: { type: "string", description: "Ключ пространства" }, - title: { type: "string", description: "Заголовок страницы" }, - body: { type: "string", description: "Содержимое страницы (HTML Storage format или текст)" }, - parentId: { type: "string", description: "ID родительской страницы (для вложенности)" }, + name: "confluence_page_create", + description: "Создать страницу в Confluence. body — HTML в Storage format.", + errorPrefix: "confluence", + parameters: { + type: "object", + properties: { + spaceKey: { type: "string", description: "Ключ пространства" }, + title: { type: "string", description: "Заголовок страницы" }, + body: { type: "string", description: "Содержимое страницы (HTML Storage format или текст)" }, + parentId: { type: "string", description: "ID родительской страницы (для вложенности)" }, + }, + required: ["spaceKey", "title", "body"], }, - required: ["spaceKey", "title", "body"], + run: async (p) => pageCreate(p), }, - async (p) => pageCreate(p), - ); + ]; + + for (const t of tools) registerTool(pi, t); } diff --git a/extensions/gitlab-tools.ts b/extensions/gitlab-tools.ts index aba3038..6913005 100644 --- a/extensions/gitlab-tools.ts +++ b/extensions/gitlab-tools.ts @@ -6,72 +6,68 @@ * DISABLE: remove/rename this file from extensions/. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { mrCreate, pipelineStatus, codeSearch, deriveProjectFromOrigin } from "./lib/gitlab.ts"; +import { registerTool, type ToolSpec } from "./lib/tools.ts"; +import { mrCreate, pipelineStatus, codeSearch } from "./lib/gitlab.ts"; export default function gitlabToolsExtension(pi: ExtensionAPI) { - const register = (name: string, description: string, parameters: Record, run: (p: any) => Promise) => { - pi.registerTool({ - name, - label: name, - description, - parameters: parameters as any, - async execute(_id: string, params: unknown) { - try { - const out = await run(params ?? {}); - return { content: [{ type: "text", text: JSON.stringify(out ?? {}, null, 2) }], details: {} }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text", text: `gitlab error: ${msg}` }], isError: true, details: {} }; - } - }, - }); - }; - - register( - "gitlab_mr_create", - "Создать merge request в GitLab. Проект определяется из git remote origin (или укажи project).", + const tools: ToolSpec[] = [ { - type: "object", - properties: { - title: { type: "string", description: "Заголовок MR" }, - sourceBranch: { type: "string", description: "Ветка-источник (по умолчанию текущая)" }, - targetBranch: { type: "string", description: "Целевая ветка (по умолчанию main)" }, - project: { type: "string", description: "Группа/репозиторий, например group/name. Если пусто — берётся из origin" }, - removeSourceBranch: { type: "boolean", description: "Удалить ветку после мержа (default true)" }, - mergeWhenPipelineSucceeds: { type: "boolean", description: "Мержить после успешного пайплайна" }, + name: "gitlab_mr_create", + description: "Создать merge request в GitLab. Проект определяется из git remote origin (или укажи project).", + errorPrefix: "gitlab", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Заголовок MR" }, + sourceBranch: { type: "string", description: "Ветка-источник (по умолчанию текущая)" }, + targetBranch: { type: "string", description: "Целевая ветка (по умолчанию main)" }, + project: { type: "string", description: "Группа/репозиторий, например group/name. Если пусто — берётся из origin" }, + removeSourceBranch: { type: "boolean", description: "Удалить ветку после мержа (default true)" }, + mergeWhenPipelineSucceeds: { type: "boolean", description: "Мержить после успешного пайплайна" }, + }, + required: ["title"], }, - required: ["title"], + run: async (p) => + mrCreate({ + title: p.title, + sourceBranch: p.sourceBranch || undefined, + targetBranch: p.targetBranch, + project: p.project, + removeSourceBranch: p.removeSourceBranch, + mergeWhenPipelineSucceeds: p.mergeWhenPipelineSucceeds, + }), }, - async (p) => mrCreate({ title: p.title, sourceBranch: p.sourceBranch || undefined, targetBranch: p.targetBranch, project: p.project, removeSourceBranch: p.removeSourceBranch, mergeWhenPipelineSucceeds: p.mergeWhenPipelineSucceeds }), - ); - - register( - "gitlab_pipeline_status", - "Показать статусы CI/CD пайплайнов проекта GitLab (по ветке).", { - type: "object", - properties: { - project: { type: "string", description: "Группа/репозиторий (обязательно; например group/name)" }, - ref: { type: "string", description: "Ветка/тег, по которому фильтровать" }, - perPage: { type: "number", description: "Сколько последних пайплайнов (default 10)" }, + name: "gitlab_pipeline_status", + description: "Показать статусы CI/CD пайплайнов проекта GitLab (по ветке).", + errorPrefix: "gitlab", + parameters: { + type: "object", + properties: { + project: { type: "string", description: "Группа/репозиторий (обязательно; например group/name)" }, + ref: { type: "string", description: "Ветка/тег, по которому фильтровать" }, + perPage: { type: "number", description: "Сколько последних пайплайнов (default 10)" }, + }, + required: ["project"], }, - required: ["project"], + run: async (p) => pipelineStatus(p.project, { ref: p.ref, perPage: p.perPage }), }, - async (p) => pipelineStatus(p.project, { ref: p.ref, perPage: p.perPage }), - ); - - register( - "gitlab_code_search", - "Поиск по коду в GitLab (blobs). Требует scope api/read_api у токена.", { - type: "object", - properties: { - query: { type: "string", description: "Строка поиска (до 512 символов)" }, - project: { type: "string", description: "Ограничить поиск группой/репозиторием" }, - perPage: { type: "number", description: "Макс. результатов (default 20)" }, + name: "gitlab_code_search", + description: "Поиск по коду в GitLab (blobs). Требует scope api/read_api у токена.", + errorPrefix: "gitlab", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Строка поиска (до 512 символов)" }, + project: { type: "string", description: "Ограничить поиск группой/репозиторием" }, + perPage: { type: "number", description: "Макс. результатов (default 20)" }, + }, + required: ["query"], }, - required: ["query"], + run: async (p) => codeSearch(p.query, { project: p.project, perPage: p.perPage }), }, - async (p) => codeSearch(p.query, { project: p.project, perPage: p.perPage }), - ); + ]; + + for (const t of tools) registerTool(pi, t); } diff --git a/extensions/jira-tools.ts b/extensions/jira-tools.ts index 779b845..d9232a1 100644 --- a/extensions/jira-tools.ts +++ b/extensions/jira-tools.ts @@ -9,6 +9,7 @@ * DISABLE: remove/rename this file from extensions/. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { registerTool, type ToolSpec } from "./lib/tools.ts"; import { issueGet, issueCreate, @@ -19,22 +20,20 @@ import { linkMr, } from "./lib/jira.ts"; -function tool(name: string, description: string, parameters: Record, run: (params: any) => Promise) { - return { name, description, parameters, run }; -} - export default function jiraToolsExtension(pi: ExtensionAPI) { - const tools = [ - tool( - "jira_issue_get", - "Получить тикет Jira по ключу (summary, status, description).", - { type: "object", properties: { key: { type: "string", description: "Ключ задачи, например PROJ-123" } }, required: ["key"] }, - async (p) => issueGet(p.key), - ), - tool( - "jira_issue_create", - "Создать задачу в Jira.", - { + const tools: ToolSpec[] = [ + { + name: "jira_issue_get", + description: "Получить тикет Jira по ключу (summary, status, description).", + errorPrefix: "jira", + parameters: { type: "object", properties: { key: { type: "string", description: "Ключ задачи, например PROJ-123" } }, required: ["key"] }, + run: async (p) => issueGet(p.key), + }, + { + name: "jira_issue_create", + description: "Создать задачу в Jira.", + errorPrefix: "jira", + parameters: { type: "object", properties: { projectKey: { type: "string", description: "Ключ проекта, например PROJ" }, @@ -47,12 +46,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) { }, required: ["projectKey", "summary", "issuetype"], }, - async (p) => issueCreate(p), - ), - tool( - "jira_issue_update", - "Обновить поля задачи Jira (summary/description/status и т.п.). Status переводит по доступным переходам.", - { + run: async (p) => issueCreate(p), + }, + { + name: "jira_issue_update", + description: "Обновить поля задачи Jira (summary/description/status и т.п.). Status переводит по доступным переходам.", + errorPrefix: "jira", + parameters: { type: "object", properties: { key: { type: "string", description: "Ключ задачи" }, @@ -65,12 +65,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) { }, required: ["key"], }, - async (p) => issueUpdate(p.key, p), - ), - tool( - "jira_issue_comment", - "Добавить комментарий к тикету Jira.", - { + run: async (p) => issueUpdate(p.key, p), + }, + { + name: "jira_issue_comment", + description: "Добавить комментарий к тикету Jira.", + errorPrefix: "jira", + parameters: { type: "object", properties: { key: { type: "string", description: "Ключ задачи" }, @@ -78,12 +79,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) { }, required: ["key", "text"], }, - async (p) => issueComment(p.key, p.text), - ), - tool( - "jira_issue_transition", - "Перевести тикет Jira в статус (To Do / In Progress / Review / Done).", - { + run: async (p) => issueComment(p.key, p.text), + }, + { + name: "jira_issue_transition", + description: "Перевести тикет Jira в статус (To Do / In Progress / Review / Done).", + errorPrefix: "jira", + parameters: { type: "object", properties: { key: { type: "string", description: "Ключ задачи" }, @@ -91,12 +93,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) { }, required: ["key", "target"], }, - async (p) => setStatus(p.key, p.target), - ), - tool( - "jira_search", - "Найти тикеты Jira по JQL-запросу.", - { + run: async (p) => setStatus(p.key, p.target), + }, + { + name: "jira_search", + description: "Найти тикеты Jira по JQL-запросу.", + errorPrefix: "jira", + parameters: { type: "object", properties: { jql: { type: "string", description: "JQL, например 'project = PROJ AND status != Done'" }, @@ -104,12 +107,13 @@ export default function jiraToolsExtension(pi: ExtensionAPI) { }, required: ["jql"], }, - async (p) => search(p.jql, p.maxResults), - ), - tool( - "jira_link_mr", - "Привязать ссылку на merge request к тикету Jira (комментарий со ссылкой).", - { + run: async (p) => search(p.jql, p.maxResults), + }, + { + name: "jira_link_mr", + description: "Привязать ссылку на merge request к тикету Jira (комментарий со ссылкой).", + errorPrefix: "jira", + parameters: { type: "object", properties: { key: { type: "string", description: "Ключ задачи" }, @@ -118,29 +122,9 @@ export default function jiraToolsExtension(pi: ExtensionAPI) { }, required: ["key", "mrUrl"], }, - async (p) => linkMr(p.key, p.mrUrl, p.title), - ), + run: async (p) => linkMr(p.key, p.mrUrl, p.title), + }, ]; - for (const t of tools) { - pi.registerTool({ - name: t.name, - label: t.name, - description: t.description, - parameters: t.parameters as any, - async execute(_id: string, params: unknown) { - try { - const out = await t.run(params ?? {}); - return { content: [{ type: "text", text: JSON.stringify(out ?? {}, null, 2) }], details: {} }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - content: [{ type: "text", text: `jira error: ${msg}` }], - isError: true, - details: {}, - }; - } - }, - }); - } + for (const t of tools) registerTool(pi, t); } diff --git a/extensions/kit-cli.ts b/extensions/kit-cli.ts index dc91f6e..8eb9493 100644 --- a/extensions/kit-cli.ts +++ b/extensions/kit-cli.ts @@ -63,40 +63,40 @@ export default function kitCliExtension(pi: ExtensionAPI) { pi.registerCommand("kit-doctor", { description: "Проверка окружения pi-kit (node, версия, конфиг, env, токены)", handler: async (_args, ctx) => { - const ok = (s: string) => `✓ ${s}`; - const warn = (s: string) => `⚠ ${s}`; + const status = (ok: boolean, text: string) => `${ok ? "✓" : "⚠"} ${text}`; const cfg = readConfig(); const lines: string[] = []; - lines.push(ok(`Node.js ${process.version}`)); - lines.push(ok(`pi-kit v${readVersion()}${process.env.PI_KIT_CHANNEL ? ` (канал: ${process.env.PI_KIT_CHANNEL})` : ""}`)); + lines.push(status(true, `Node.js ${process.version}`)); + lines.push(status(true, `pi-kit v${readVersion()}${process.env.PI_KIT_CHANNEL ? ` (канал: ${process.env.PI_KIT_CHANNEL})` : ""}`)); // Config const cfgOk = typeof cfg.gitHost === "string" && cfg.gitHost !== "TODO"; - lines.push((cfgOk ? ok : warn)(`config: gitHost=${cfg.gitHost ?? "?"}, tracker=${cfg.trackerUrl ?? "?"}, docs=${cfg.docsUrl ?? "?"}`)); - if (cfg.remoteConfigUrl) lines.push(ok(`remoteConfigUrl задан`)); + lines.push(status(cfgOk, `config: gitHost=${cfg.gitHost ?? "?"}, tracker=${cfg.trackerUrl ?? "?"}, docs=${cfg.docsUrl ?? "?"}`)); + if (cfg.remoteConfigUrl) lines.push(status(true, `remoteConfigUrl задан`)); // TODO-placeholder values are a readiness signal, not an error: warn so // maintainers notice they haven't filled the single source of truth yet. for (const [field, value] of Object.entries({ repoMap: cfg.repoMap, trackerUrl: cfg.trackerUrl, docsUrl: cfg.docsUrl })) { if (typeof value !== "string" || value === "TODO" || value.trim() === "" || value.startsWith("TODO")) { - lines.push(warn(`config: ${field} не заполнен (TODO) — скиллы repo-map/docs-map/jira будут просить уточнить`)); + lines.push(status(false, `config: ${field} не заполнен (TODO) — скиллы repo-map/docs-map/jira будут просить уточнить`)); } } // Env vars (presence only, never values) for (const v of ["TRACKER_URL", "JIRA_TOKEN", "CONFLUENCE_TOKEN", "GITLAB_TOKEN"]) { - lines.push((process.env[v] ? ok : warn)(`${v} ${process.env[v] ? "задан" : "не задан"}`)); + lines.push(status(Boolean(process.env[v]), `${v} ${process.env[v] ? "задан" : "не задан"}`)); } const provider = PROVIDER_KEYS.find((k) => process.env[k]); - lines.push((provider ? ok : warn)(provider ? `LLM-ключ: ${provider}` : "LLM-ключ провайдера не найден")); + lines.push(status(Boolean(provider), provider ? `LLM-ключ: ${provider}` : "LLM-ключ провайдера не найден")); // env.sh - lines.push((fs.existsSync(ENV_FILE) ? ok : warn)(`env.sh ${fs.existsSync(ENV_FILE) ? "есть" : "нет"} (${ENV_FILE})`)); + const envShExists = fs.existsSync(ENV_FILE); + lines.push(status(envShExists, `env.sh ${envShExists ? "есть" : "нет"} (${ENV_FILE})`)); // Loaded commands/skills const cmds = pi.getCommands(); const skills = cmds.filter((c) => c.source === "skill").length; - lines.push(ok(`команд загружено: ${cmds.length} (скилов: ${skills})`)); + lines.push(status(true, `команд загружено: ${cmds.length} (скилов: ${skills})`)); const warns = lines.filter((l) => l.startsWith("⚠")).length; lines.push("", warns === 0 ? "Итог: всё ок." : `Итог: предупреждений — ${warns}.`); diff --git a/extensions/lib/atlassian.ts b/extensions/lib/atlassian.ts index 0483ca0..8bc0b00 100644 --- a/extensions/lib/atlassian.ts +++ b/extensions/lib/atlassian.ts @@ -36,6 +36,18 @@ function base(url: string): string { return url.replace(/\/+$/, ""); } +/** + * Resolve an effective base URL from company.json + an env override. + * The env var wins when set (and not "TODO"); otherwise the config value + * (if set and not "TODO") is used. Trailing slashes are trimmed. Returns "" + * when neither is configured; callers decide whether to throw. + */ +export function resolveBaseUrl(envName: string, cfgValue: string | null | undefined): string { + const fromCfg = cfgValue && cfgValue !== "TODO" ? cfgValue : ""; + const env = process.env[envName]; + return (env && env !== "TODO" ? env : fromCfg).replace(/\/+$/, ""); +} + /** Build a URL with query params; undefined/empty values are dropped. */ export function buildUrl( baseUrl: string, diff --git a/extensions/lib/confluence.ts b/extensions/lib/confluence.ts index 4d9b5b4..8ed2307 100644 --- a/extensions/lib/confluence.ts +++ b/extensions/lib/confluence.ts @@ -3,7 +3,7 @@ * `docsUrl` comes from config/company.json; `CONFLUENCE_TOKEN` from the env. * No pi API — unit-testable. */ -import { apiRequest, requireToken, ApiError } from "./atlassian.ts"; +import { apiRequest, requireToken, ApiError, resolveBaseUrl } from "./atlassian.ts"; import { readLocalConfig } from "./company-config.ts"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,8 +14,7 @@ const CONFIG_PATH = path.join(HERE, "..", "..", "config", "company.json"); /** Resolve the Confluence base URL from company.json (env override for labs). */ export function confluenceBaseUrl(): string { const cfg = readLocalConfig(CONFIG_PATH); - const fromCfg = cfg.docsUrl && cfg.docsUrl !== "TODO" ? cfg.docsUrl : ""; - const url = (process.env.CONFLUENCE_URL && process.env.CONFLUENCE_URL !== "TODO" ? process.env.CONFLUENCE_URL : fromCfg).replace(/\/+$/, ""); + const url = resolveBaseUrl("CONFLUENCE_URL", cfg.docsUrl); if (!url) throw new ApiError("Не задан docsUrl в config/company.json и нет CONFLUENCE_URL."); return url; } diff --git a/extensions/lib/jira.ts b/extensions/lib/jira.ts index 16a9323..fbf23da 100644 --- a/extensions/lib/jira.ts +++ b/extensions/lib/jira.ts @@ -3,7 +3,7 @@ * `trackerUrl` comes from config/company.json; `JIRA_TOKEN` from the env. * No pi API — unit-testable (see test/api.test.ts). */ -import { apiRequest, requireToken, ApiError } from "./atlassian.ts"; +import { apiRequest, requireToken, ApiError, resolveBaseUrl } from "./atlassian.ts"; import { readLocalConfig } from "./company-config.ts"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,12 +11,11 @@ import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = path.join(HERE, "..", "..", "config", "company.json"); -/** Resolve the tracker base URL — prefer company.json; env override wins. */ +/** Resolve the tracker base URL — company.json default, env override wins. */ export function jiraBaseUrl(): string { const cfg = readLocalConfig(CONFIG_PATH); - const fromCfg = cfg.trackerUrl && cfg.trackerUrl !== "TODO" ? cfg.trackerUrl : ""; // env TRACKER_URL may override the packaged default for local labs. - return (process.env.TRACKER_URL && process.env.TRACKER_URL !== "TODO" ? process.env.TRACKER_URL : fromCfg).replace(/\/+$/, ""); + return resolveBaseUrl("TRACKER_URL", cfg.trackerUrl); } function token(): string { diff --git a/extensions/lib/secrets.ts b/extensions/lib/secrets.ts index cd241a9..ab3b3a0 100644 --- a/extensions/lib/secrets.ts +++ b/extensions/lib/secrets.ts @@ -29,12 +29,8 @@ export const SECRET_PATTERNS: SecretPattern[] = [ /** Return the names of secret patterns found in `text` (empty if none). */ export function scanSecrets(text: string): string[] { if (!text) return []; - const hits: string[] = []; - for (const p of SECRET_PATTERNS) { - // Use a non-stateful copy (no global flag) to avoid lastIndex surprises. - if (p.re.test(text)) hits.push(p.name); - } - return hits; + // No pattern carries the /g flag, so `test` stays stateless and order is preserved. + return SECRET_PATTERNS.filter((p) => p.re.test(text)).map((p) => p.name); } /** Replace every secret occurrence with a labelled placeholder. */ diff --git a/extensions/lib/tools.ts b/extensions/lib/tools.ts new file mode 100644 index 0000000..02e5bec --- /dev/null +++ b/extensions/lib/tools.ts @@ -0,0 +1,36 @@ +/** + * Shared native pi tool registration. + * The three *-tools.ts extensions each register tools whose `execute` bodies + * are byte-identical apart from the error prefix in the failure payload. + * This helper DRYs that boilerplate; extensions only supply the spec. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export interface ToolSpec { + name: string; + description: string; + parameters: Record; + /** Prefix in the error payload, e.g. "jira" → "jira error: …". */ + errorPrefix: string; + run: (params: any) => Promise; +} + +/** Register a native tool whose result/error payload is formatted uniformly. */ +export function registerTool(pi: ExtensionAPI, spec: ToolSpec): void { + pi.registerTool({ + name: spec.name, + label: spec.name, + description: spec.description, + parameters: spec.parameters as any, + async execute(_id: string, params: unknown) { + try { + const out = await spec.run(params ?? {}); + return { content: [{ type: "text", text: JSON.stringify(out ?? {}, null, 2) }], details: {} }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `${spec.errorPrefix} error: ${msg}` }], isError: true, details: {} }; + } + }, + }); +}