From 5f8f5e8bf90f3991e0acc84aa9929536718251f7 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Thu, 16 Jul 2026 13:48:31 +0300 Subject: [PATCH] feat(dx): /kit-doctor and /kit-help commands - /kit-doctor: environment health check (node, kit version/channel, config source+values, env vars presence, env.sh, loaded command/skill counts) with pass/warn summary; read-only, offline-safe - /kit-help: catalog of package commands + skills (filtered to package path) + static guardrail list Verified live via pi RPC: both commands register and render. --- extensions/kit-cli.ts | 118 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 extensions/kit-cli.ts diff --git a/extensions/kit-cli.ts b/extensions/kit-cli.ts new file mode 100644 index 0000000..d9d886a --- /dev/null +++ b/extensions/kit-cli.ts @@ -0,0 +1,118 @@ +/** + * Kit CLI Extension + * + * WHAT: developer-experience commands for the corporate kit. + * /kit-doctor — environment health check (node, kit version, config, env vars, + * env.sh, loaded commands/skills). Read-only, offline-safe. + * /kit-help — catalog of everything this package provides (commands, skills, + * guardrails). + * + * CONFIGURE: nothing to configure. Values are read from config/company.json and + * the environment. (`/kit-config` — in company-context.ts — shows the + * effective corporate config.) + * + * DISABLE: remove/rename this file from the package `extensions/` folder. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.join(HERE, ".."); +const CONFIG_PATH = path.join(PKG_ROOT, "config", "company.json"); +const ENV_FILE = path.join(os.homedir(), ".config", "pi-kit", "env.sh"); + +const PROVIDER_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"]; + +// Guardrail extensions don't register commands, so list them statically here. +const GUARDRAILS: Array<[string, string]> = [ + ["protected-paths", "блок записи в секреты/ключи/.git/прод-конфиги (по пути)"], + ["permission-gate", "подтверждение опасных bash (rm -rf, force push, release-*/v* деплой, sudo …)"], + ["secret-scanner", "блок записи контента, похожего на секрет"], + ["commit-guard", "Conventional Commits + скан секретов в сообщении коммита"], + ["llm-redaction", "редакция секретов из payload перед отправкой в LLM"], + ["audit-log", "локальный аудит (~/.config/pi-kit/audit.jsonl)"], +]; + +function readVersion(): string { + try { + return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version ?? "?"; + } catch { + return "?"; + } +} + +function readConfig(): Record { + try { + return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + } catch { + return {}; + } +} + +/** Show lines in a dismissable selector (UI) or as a notification (no UI). */ +async function show(ctx: any, title: string, lines: string[]): Promise { + if (ctx.hasUI) await ctx.ui.select(title, lines); + else ctx.ui.notify(`${title}\n${lines.join("\n")}`, "info"); +} + +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 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})` : ""}`)); + + // 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 задан`)); + + // Env vars (presence only, never values) + for (const v of ["TRACKER_URL", "JIRA_TOKEN", "GITLAB_TOKEN"]) { + lines.push((process.env[v] ? ok : warn)(`${v} ${process.env[v] ? "задан" : "не задан"}`)); + } + const provider = PROVIDER_KEYS.find((k) => process.env[k]); + lines.push((provider ? ok : warn)(provider ? `LLM-ключ: ${provider}` : "LLM-ключ провайдера не найден")); + + // env.sh + lines.push((fs.existsSync(ENV_FILE) ? ok : warn)(`env.sh ${fs.existsSync(ENV_FILE) ? "есть" : "нет"} (${ENV_FILE})`)); + + // Loaded commands/skills + const cmds = pi.getCommands(); + const skills = cmds.filter((c) => c.source === "skill").length; + lines.push(ok(`команд загружено: ${cmds.length} (скилов: ${skills})`)); + + const warns = lines.filter((l) => l.startsWith("⚠")).length; + lines.push("", warns === 0 ? "Итог: всё ок." : `Итог: предупреждений — ${warns}.`); + await show(ctx, "pi-kit doctor", lines); + }, + }); + + pi.registerCommand("kit-help", { + description: "Каталог возможностей pi-kit (команды, скилы, guardrails)", + handler: async (_args, ctx) => { + const cmds = pi.getCommands(); + const mine = cmds.filter((c) => c.sourceInfo?.path?.startsWith(PKG_ROOT)); + const bySource = (src: string) => + mine + .filter((c) => c.source === src) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((c) => ` /${c.name}${c.description ? ` — ${c.description}` : ""}`); + + const lines: string[] = []; + lines.push("— Команды —", ...bySource("extension"), ...bySource("prompt")); + lines.push("", "— Скилы —", ...bySource("skill")); + lines.push("", "— Guardrails (расширения) —", ...GUARDRAILS.map(([n, d]) => ` ${n} — ${d}`)); + await show(ctx, "pi-kit help", lines); + }, + }); +}