import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateTail, type ExtensionAPI, } from "@earendil-works/pi-coding-agent"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Type } from "typebox"; /** Commands that are destructive/unrecoverable — refused unless forceDangerous is set. */ const DANGEROUS_COMMANDS = ["pages trash"]; /** * Flags the `ntn api` subcommand does NOT accept (it errors with * "unexpected argument"). The request path is positional and output is JSON * by default. Detected and rejected with the working form before exec. */ const API_UNSUPPORTED_FLAGS = new Set(["path", "json"]); /** Regex matching ntn's not-authenticated error messages. */ const NOT_AUTHED = /unauthorized|not authenticated|auth.*fail|token.*invalid|login.*required/i; export type NtnParams = { subcommand: string; args?: Record; data?: string; method?: string; timeoutSeconds?: number; forceDangerous?: boolean; }; export type RawNtnParams = Omit & { subcommand?: string; args?: NtnParams["args"] | string[]; }; /** Token flags in array-args that promote into their typed top-level slot when unset (mode #1). */ const PROMOTABLE_TOKEN_FLAGS = new Set(["data", "method"]); /** Slot type each harvestable nested key must match to harvest (mode #2); mismatches stay flags. */ const HARVESTABLE_SLOTS: Record = { subcommand: "string", data: "string", method: "string", timeoutSeconds: "number", forceDangerous: "boolean", }; /** * Parse mode #1 (array args): positional tokens extend the subcommand path, * `--flag[=value]` and `--flag value` tokens become flags, and --data/--method * promote into their top-level slot when that slot is unset — a set top-level * drops the parsed duplicate. */ function normalizeArrayArgs( subcommand: string, tokens: string[], topLevel: Pick, ): { subcommand: string; args?: NonNullable; data?: string; method?: string } { const parts: string[] = subcommand.trim().split(/\s+/).filter(Boolean); const flags: NonNullable = {}; const slots: { data?: string; method?: string } = { data: topLevel.data, method: topLevel.method }; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (!token.startsWith("--")) { parts.push(token); continue; } // --name=value | --name value | --name const eq = token.indexOf("="); let name: string; let value: string | undefined; if (eq >= 0) { name = token.slice(2, eq); value = token.slice(eq + 1); } else { name = token.slice(2); if (i + 1 < tokens.length && !tokens[i + 1].startsWith("--")) { value = tokens[++i]; } } if (PROMOTABLE_TOKEN_FLAGS.has(name)) { slots[name as "data" | "method"] ??= value; } else { flags[name] = value ?? true; } } return { subcommand: parts.join(" "), args: Object.keys(flags).length > 0 ? flags : undefined, data: slots.data, method: slots.method, }; } /** * Merge nested args-in-args into the harvest/flag sinks. Array form recurses * via mode #1 (positionals and promoted slots harvest only when their * top-level slot is unset); object form merges flags as-is. */ function mergeNestedArgs( subcommand: string, value: unknown, topLevel: Omit, harvested: Partial, remaining: NonNullable, ): void { if (Array.isArray(value)) { const inner = normalizeArrayArgs(subcommand, value, topLevel); if (inner.subcommand && subcommand === "") harvested.subcommand = inner.subcommand; if (inner.data !== undefined && topLevel.data === undefined) harvested.data = inner.data; if (inner.method !== undefined && topLevel.method === undefined) harvested.method = inner.method; if (inner.args) Object.assign(remaining, inner.args); return; } Object.assign(remaining, value as NonNullable); } /** * Assemble the mode #2 result: a harvested slot takes precedence over its * top-level fallback (harvest only ever fills an unset slot), and leftover * entries become the args flags. */ function applyHarvest( harvested: Partial, topLevel: Omit, subcommand: string, remaining: NonNullable, ): Pick { return { subcommand: harvested.subcommand ?? subcommand, args: Object.keys(remaining).length > 0 ? remaining : undefined, data: harvested.data ?? topLevel.data, method: harvested.method ?? topLevel.method, timeoutSeconds: harvested.timeoutSeconds ?? topLevel.timeoutSeconds, forceDangerous: harvested.forceDangerous ?? topLevel.forceDangerous, }; } /** * Normalize mode #2 (object args): promotable keys nested inside args harvest * into their top-level slot when the slot is unset and the type matches. * Top-level always wins — a set slot drops the nested duplicate entirely; a * mis-typed value on an unset slot stays a flag (e.g. {args:{data:42}} becomes * --data 42) instead of being silently dropped. */ function normalizeObjectArgs( subcommand: string, nested: NonNullable, topLevel: Omit, ): Pick { const harvested: Partial = {}; const remaining: NonNullable = {}; for (const [key, value] of Object.entries(nested)) { const expected = HARVESTABLE_SLOTS[key]; if (expected !== undefined) { // subcommand's slot is "set" when non-empty; the other slots when defined. const slotSet = key === "subcommand" ? subcommand !== "" : (topLevel as Record)[key] !== undefined; if (slotSet) continue; if (typeof value === expected) (harvested as Record)[key] = value; else remaining[key] = value; } else if (key === "args" && value !== null && typeof value === "object") { mergeNestedArgs(subcommand, value, topLevel, harvested, remaining); } else { remaining[key] = value; } } return applyHarvest(harvested, topLevel, subcommand, remaining); } /** * Normalize raw tool params into canonical NtnParams. Tolerates two mis-shaped * calls the model produces: args as a JSON array (mode #1) and known top-level * keys nested inside an object args (mode #2). Top-level fields always win. */ function normalizeParams(raw: RawNtnParams): NtnParams { const { args: rawArgs, ...rest } = raw; const subcommand = rest.subcommand ?? ""; if (Array.isArray(rawArgs)) return { ...rest, ...normalizeArrayArgs(subcommand, rawArgs, rest) }; if (rawArgs && typeof rawArgs === "object") return { ...rest, ...normalizeObjectArgs(subcommand, rawArgs, rest) }; return { ...rest, subcommand, args: rawArgs }; } /** * Serialize the args flags onto argv: `--flag value` token pairs; booleans * become bare `--flag` tokens; arrays become repeated `--flag value` pairs; * false/null/undefined are skipped. Keys without a `--` prefix are prefixed. */ function serializeFlags(argv: string[], args: NtnParams["args"]): void { for (const [key, value] of Object.entries(args ?? {})) { if (value === false || value === null || value === undefined) continue; const flag = key.startsWith("--") ? key : `--${key}`; if (value === true) { argv.push(flag); } else if (Array.isArray(value)) { for (const v of value) { argv.push(flag, String(v)); } } else { argv.push(flag, String(value)); } } } /** * Serialize params into the ntn CLI's argv format. * * The subcommand is split on whitespace (e.g. "pages get " → ["pages", "get", ""]). * Args flags are appended by serializeFlags. Global flags (--data, --method) * are appended after subcommand and args, in that order (--data before --method). * Throws the canonical empty-subcommand error — the single authority for * that predicate, so every caller surfaces one actionable message. */ export function buildArgv(params: RawNtnParams): string[] { const normalized = normalizeParams(params); const trimmed = normalized.subcommand.trim(); if (trimmed.length === 0) { throw new Error( "Pass an ntn subcommand, for example `subcommand: 'pages get '` or `subcommand: 'api /v1/search'`.", ); } const argv: string[] = trimmed.split(/\s+/); serializeFlags(argv, normalized.args); if (normalized.data) { argv.push("--data", normalized.data); } if (normalized.method) { argv.push("--method", normalized.method); } return argv; } /** Split a subcommand string into lowercase words — the one shared split. */ function subcommandWords(subcommand: string): string[] { return subcommand.trim().toLowerCase().split(/\s+/); } /** Canonical api-misuse error — one message for every detection route. */ function apiMisuseError(flag: string): Error { return new Error( `\`ntn api\` has no \`--${flag}\` flag (the CLI rejects it with "unexpected argument"). ` + `The request path is POSITIONAL and the output is JSON by default. ` + `Working form: subcommand: "api /v1/search" with the body in data: '{"query": "..."}' and ` + `the method override in method: "POST".`, ); } /** * Reject common mis-uses of the `api` subcommand before they reach the CLI: * the request path is POSITIONAL (there is no `--path` flag) and output is * JSON by default (there is no `--json` flag). All fail with a bare * "unexpected argument" that gives the model nothing to recover with. * * One seam, one canonical message, three detection routes, called once: * - raw top-level `path`/`json` keys — the flat mis-call shape, checked on * the raw params so the guard does not depend on normalization preserving * unknown keys; * - flag tokens embedded in the subcommand string (e.g. "api /v1/search * --json"), which buildArgv splits verbatim into argv; * - args keys — buildArgv tolerates keys that already carry dashes and the * model may inline values (e.g. `{"--path=/v1/search": true}`), so both * are normalized away here. */ export function assertApiUsage(raw: RawNtnParams, params: NtnParams): void { const words = subcommandWords(params.subcommand); if (words[0] !== "api") return; const rawRecord = raw as Record; for (const flag of API_UNSUPPORTED_FLAGS) { if (rawRecord[flag] !== undefined) throw apiMisuseError(flag); } for (const word of words) { const stripped = word.replace(/^-+/, "").split("=")[0]; if (API_UNSUPPORTED_FLAGS.has(stripped)) throw apiMisuseError(stripped); } const args = params.args ?? {}; for (const key of Object.keys(args)) { const bareKey = key.replace(/^-+/, "").split("=")[0].toLowerCase(); if (API_UNSUPPORTED_FLAGS.has(bareKey)) throw apiMisuseError(bareKey); } } /** * Guard against destructive ntn operations that are hard or impossible to * reverse. The tool refuses these unless the caller explicitly sets * `forceDangerous: true`, which keeps the LLM from trashing a page * by accident. */ export function assertSafeCommand(params: NtnParams): void { const words = subcommandWords(params.subcommand); for (let i = 0; i < words.length - 1; i++) { const pair = `${words[i]} ${words[i + 1]}`; if (DANGEROUS_COMMANDS.includes(pair)) { if (params.forceDangerous === true) return; throw new Error( `Refusing \`${pair}\` from the ntn tool — this operation is unrecoverable. ` + "To override, set `forceDangerous: true` and confirm with the user first.", ); } } } /** * Format stdout/stderr into a single human-readable string. * If both are empty/whitespace, returns a placeholder. */ export function formatOutput(stdout: string, stderr: string): string { const chunks: string[] = []; if (stdout.trim().length > 0) chunks.push(stdout.trimEnd()); if (stderr.trim().length > 0) chunks.push(`stderr:\n${stderr.trimEnd()}`); return chunks.join("\n\n") || "(no output)"; } /** Result shape returned by the injected exec boundary (compatible with pi.exec). */ export type ExecResult = { stdout?: string; stderr?: string; code?: number | null; killed?: boolean }; /** System boundary: spawns the ntn CLI. Injected for testing. */ export type NtnExec = ( command: string, args: string[], options: { signal?: AbortSignal; timeout?: number }, ) => Promise; /** * Core execution logic, separated from the Pi tool wiring so it can be tested * with an injected `exec` (the only system boundary). Returns the same shape * as a Pi tool result. */ export async function runNtn( rawParams: RawNtnParams, exec: NtnExec, signal?: AbortSignal, ): Promise<{ content: { type: "text"; text: string }[]; details: Record; isError: boolean; }> { // Normalize before guard: the model may nest subcommand inside args, // so we must harvest it before the guards can see it. The empty- // subcommand predicate is owned by buildArgv (the canonical message). const params = normalizeParams(rawParams); assertSafeCommand(params); assertApiUsage(rawParams, params); const argv = buildArgv(params); const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 120); let result: ExecResult; try { result = await exec("ntn", argv, { signal, timeout: timeoutSeconds * 1000 }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to run ntn CLI. Is it installed and on PATH? ${message}`); } const { stdout = "", stderr = "", code, killed } = result; // Detect the common "not authenticated" failure and give actionable guidance. if (code !== 0 && (NOT_AUTHED.test(stdout) || NOT_AUTHED.test(stderr))) { return { content: [ { type: "text", text: "You are not authenticated with the Notion CLI. Run `ntn login` to authenticate, " + "then retry the command.", }, ], details: { subcommand: params.subcommand, code, notAuthed: true }, isError: true, }; } const output = formatOutput(stdout, stderr); const truncation = truncateTail(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES, }); const commandLine = `ntn ${argv.join(" ")}`; const codeText = code ?? "unknown"; let text = `Command: ${commandLine}\nExit code: ${codeText}${killed ? " (killed)" : ""}\n\n${truncation.content}`; if (truncation.truncated) { text += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).]`; } return { content: [{ type: "text", text }], details: { subcommand: params.subcommand, argv, code, killed, truncated: truncation.truncated, }, isError: code !== 0, }; } const baseDir = dirname(fileURLToPath(import.meta.url)); const skillPath = join(baseDir, "skill", "SKILL.md"); const RELEVANT_PROMPT = /\b(notion|ntn|page|database|data source|workspace)\b/i; /** Canonical flat call-shape example — single source of truth for all prompt surfaces. */ export const NTN_CALL_EXAMPLE = { subcommand: "pages get ", args: { json: true }, } as const; export const NTN_ARGS_DESCRIPTION = "Command flags as a key/value object map (e.g. {json: true}). " + "Must be an object, not an array. " + "Never put subcommand, data, or method inside args — " + "those are top-level params, not nested inside args."; export const NTN_SUBCOMMAND_DESCRIPTION = "The ntn CLI subcommand as a top-level param, e.g. 'pages get ' or 'api /v1/search'. " + "Split on spaces into the command path. " + "This is a top-level param — never nest it inside args."; const NTN_CALL_EXAMPLE_JSON = JSON.stringify(NTN_CALL_EXAMPLE, null, 2); /** * Guidance injected into the system prompt when the user's message looks * Notion-related. Kept short — the full reference lives in the SKILL.md. */ export const NTN_GUIDANCE = `## Notion CLI (ntn) guidance The \`ntn\` tool wraps the Notion CLI (\`ntn\`) as a single typed tool. All params are top-level siblings — never nest subcommand, data, or method inside args. Call shape (all params flat at top level): \`\`\`json ${NTN_CALL_EXAMPLE_JSON} \`\`\` - \`subcommand\` (top-level): the ntn command, e.g. "pages get " or "api /v1/search". - \`args\` (top-level): key/value object map of flags only, e.g. {json: true}. Must be an object, not an array. - \`data\` (top-level): request body for API calls (translates to --data '...'). - \`method\` (top-level): HTTP method override for API calls (translates to --method GET|POST|PATCH|DELETE). Key patterns: - Get a page: \`subcommand: "pages get "\`. - Create a page: \`subcommand: "pages create"\`, \`args: { parent: "page:" }\`, \`data: "# Title\\n\\nBody"\`. - Search workspace: \`subcommand: "api /v1/search"\`, \`data: '{"query":"meeting notes"}'\`. The request path is POSITIONAL inside the subcommand — \`api\` has no \`--path\` and no \`--json\` flag; output is JSON by default. - Query a database: \`subcommand: "datasources query "\`. - Destructive ops (\`pages trash\`) require \`forceDangerous: true\` and explicit user confirmation. If the tool reports you are not authenticated, run \`ntn login\` via bash.`; export default function ntnExtension(pi: ExtensionAPI) { // Make the bundled SKILL.md discoverable as a skill. pi.on("resources_discover", () => ({ skillPaths: [skillPath], })); // Inject concise guidance when the prompt looks Notion-related. pi.on("before_agent_start", (event) => { if (!RELEVANT_PROMPT.test(event.prompt)) return; return { systemPrompt: `${event.systemPrompt}\n\n${NTN_GUIDANCE}\n`, }; }); pi.registerTool({ name: "ntn", label: "Notion CLI", description: "Call the Notion CLI (ntn) to interact with pages, databases, data sources, file uploads, workers, and the public Notion API. " + "Top-level subcommands: pages, datasources, files, workers (beta), api, auth, whoami, login, logout, doctor, update. " + "All params are top-level siblings: subcommand (e.g. 'pages get '), args (object of flags), data, method. " + "Never nest subcommand/data/method inside args — args is a flat key/value object of flags only.\n" + "Use `api ` as the escape hatch for any endpoint without a dedicated subcommand (`api ls` lists all endpoints).\n" + "Example call shape:\n" + NTN_CALL_EXAMPLE_JSON + "\n" + "Destructive operations (pages trash) require `forceDangerous: true`.", promptSnippet: "Interact with Notion (pages, databases, data sources, files, workers, API) via the ntn CLI.", promptGuidelines: [ "Use the `ntn` tool when the user asks about Notion — pages, databases, data sources, file uploads, workers, or the public API. It calls the ntn CLI directly. Top-level subcommands: pages, datasources, files, workers, api, auth, whoami, login, logout, doctor, update. Run `ntn --help` via bash for the authoritative list and `ntn api ls` for every API endpoint.", "All params are top-level siblings: subcommand, args, data, method, timeoutSeconds, forceDangerous. Never nest one inside another.", "`args` is a key/value object of flags only (e.g. {json: true}), never an array, and never contains subcommand/data/method.", "Use `data` for API request bodies (e.g. data: '{\"query\":\"meeting notes\"}' for search) and `method` for HTTP method override.", "Destructive operations (`pages trash`) require `forceDangerous: true`. Always confirm with the user before using it.", "If the tool reports you are not authenticated, tell the user to run `ntn login`.", ], parameters: Type.Object({ subcommand: Type.Optional( Type.String({ description: NTN_SUBCOMMAND_DESCRIPTION, }), ), args: Type.Optional( Type.Union([ Type.Record( Type.String(), Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Array(Type.String())]), ), Type.Array(Type.String()), ], { description: NTN_ARGS_DESCRIPTION, }), ), data: Type.Optional( Type.String({ description: "Request body for API calls (translates to --data ''). Use a JSON string for ntn api endpoints.", }), ), method: Type.Optional( Type.String({ description: "HTTP method override for API calls (translates to --method GET|POST|PATCH|DELETE)." }), ), timeoutSeconds: Type.Optional( Type.Integer({ minimum: 1, maximum: 120, default: 30, description: "Command timeout in seconds (default 30, max 120).", }), ), forceDangerous: Type.Optional( Type.Boolean({ description: "Opt-in flag to allow destructive commands (pages trash). Requires explicit user confirmation.", }), ), }), async execute(_toolCallId, params: RawNtnParams, signal) { return runNtn(params, (cmd, args, opts) => pi.exec(cmd, args, opts), signal); }, }); }