From e1ed8b675dc764a155ba95d532a4c5d8a9867c63 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:49:13 +0000 Subject: [PATCH] feat(skill): switch TMA knowledge skill to Node --- README.md | 1 + .../skills/tma-knowledge-search/SKILL.md | 9 +- .../scripts/search_tma_knowledge.mjs | 336 ++++++++++++++++++ .../scripts/search_tma_knowledge.py | 183 ---------- .../app/template-nextjs-overlay/AGENTS.md | 2 + .../app/template-nextjs-overlay/CLAUDE.md | 1 + packages/app/tests/bootstrap.test.ts | 4 + 7 files changed, 349 insertions(+), 187 deletions(-) create mode 100644 packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs delete mode 100755 packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.py diff --git a/README.md b/README.md index 905fba6..892b9ae 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ The package also ships a built-in TMA overlay and applies it after cloning - `AGENTS.md` is shipped by the template for repo-level AI agent instructions. - `CLAUDE.md` is shipped by the template for Claude Code project memory. - `.agents/skills/tma-knowledge-search` is shipped by the template as the local TMA knowledge-search skill for compatible agents. +- The bundled TMA knowledge-search skill is Node-based and runs via `node .agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs ""`. - if `codex` is installed locally, bootstrap also registers the same MCP server in the global Codex MCP config automatically. - bootstrap also mirrors `tma-knowledge-search` into `~/.codex/skills` so Codex can discover the same skill natively. diff --git a/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/SKILL.md b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/SKILL.md index b5afa1c..b0f2613 100644 --- a/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/SKILL.md +++ b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/SKILL.md @@ -10,9 +10,10 @@ Use this skill when local repo context is not enough for a Telegram Mini App que ## Workflow 1. Form a focused English query about the TMA implementation detail you need. -2. Run `scripts/search_tma_knowledge.py ""`. -3. Read the returned `answer` first, then inspect any `sources`. -4. Use the API result as the primary TMA-specific reference in your answer or implementation plan. +2. From the project root, run `node .agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs ""`. +3. If you are already inside the skill directory, run `node scripts/search_tma_knowledge.mjs ""`. +4. Read the returned `answer` first, then inspect any `sources`. +5. Use the API result as the primary TMA-specific reference in your answer or implementation plan. ## Query Rules @@ -30,6 +31,6 @@ Use this skill when local repo context is not enough for a Telegram Mini App que ## Resources -- `scripts/search_tma_knowledge.py`: sends the POST request and prints a readable summary or raw JSON. +- `scripts/search_tma_knowledge.mjs`: sends the POST request and prints a readable summary or raw JSON. - The script automatically uses `SPAWNDOCK_API_TOKEN`, `API_TOKEN`, or the nearest `spawndock.config.json` `apiToken` when available. - `references/api.md`: request and response contract for the knowledge endpoint. diff --git a/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs new file mode 100644 index 0000000..1d6dadf --- /dev/null +++ b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs @@ -0,0 +1,336 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from "node:fs" +import { dirname, join, resolve } from "node:path" +import process from "node:process" + +const API_URL = "https://spawn-dock.w3voice.net/knowledge/api/v1/search" + +const printUsage = () => { + console.log(`Usage: node scripts/search_tma_knowledge.mjs "" [options] + +Options: + --locale Response locale (default: en) + --api-token Optional Bearer token override + --config Optional path to spawndock.config.json + --timeout HTTP timeout in seconds (default: 20) + --retries Retry count for transient HTTP 5xx failures (default: 2) + --raw Print raw JSON response + -h, --help Show this help text`) +} + +const readOptionValue = (argv, index, optionName) => { + const value = argv[index] + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Missing value for ${optionName}`) + } + + return value +} + +const parseTimeout = (rawValue) => { + const timeout = Number.parseFloat(rawValue) + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new Error("Timeout must be a positive number.") + } + + return timeout +} + +const parseRetries = (rawValue) => { + const retries = Number.parseInt(rawValue, 10) + if (!Number.isInteger(retries) || retries < 0) { + throw new Error("Retries must be a non-negative integer.") + } + + return retries +} + +const parseArgs = (argv) => { + const options = { + help: false, + locale: "en", + apiToken: "", + config: "", + timeoutSeconds: 20, + raw: false, + retries: 2, + query: "", + } + const positionals = [] + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + + switch (arg) { + case "-h": + case "--help": + options.help = true + break + case "--locale": + index += 1 + options.locale = readOptionValue(argv, index, arg) + break + case "--api-token": + index += 1 + options.apiToken = readOptionValue(argv, index, arg) + break + case "--config": + index += 1 + options.config = readOptionValue(argv, index, arg) + break + case "--timeout": + index += 1 + options.timeoutSeconds = parseTimeout(readOptionValue(argv, index, arg)) + break + case "--retries": + index += 1 + options.retries = parseRetries(readOptionValue(argv, index, arg)) + break + case "--raw": + options.raw = true + break + default: + if (arg.startsWith("--")) { + throw new Error(`Unknown option: ${arg}`) + } + positionals.push(arg) + break + } + } + + if (!options.help) { + if (positionals.length === 0) { + throw new Error("Missing query.") + } + + options.query = positionals.join(" ") + } + + return options +} + +const expandHomePath = (inputPath) => { + if (!inputPath.startsWith("~")) { + return inputPath + } + + const home = process.env["HOME"] + if (!home || home.length === 0) { + return inputPath + } + + if (inputPath === "~") { + return home + } + + if (inputPath.startsWith("~/")) { + return join(home, inputPath.slice(2)) + } + + return inputPath +} + +const findConfigPath = (explicitPath) => { + if (explicitPath.length > 0) { + const resolvedPath = resolve(expandHomePath(explicitPath)) + return existsSync(resolvedPath) ? resolvedPath : null + } + + let currentDir = process.cwd() + + while (true) { + const candidate = join(currentDir, "spawndock.config.json") + if (existsSync(candidate)) { + return candidate + } + + const parentDir = dirname(currentDir) + if (parentDir === currentDir) { + break + } + currentDir = parentDir + } + + return null +} + +const readConfigApiToken = (configPath) => { + if (!configPath) { + return null + } + + try { + const data = JSON.parse(readFileSync(configPath, "utf8")) + const token = data?.apiToken + return typeof token === "string" && token.trim().length > 0 ? token.trim() : null + } catch { + return null + } +} + +const resolveApiToken = (cliToken, configPath) => { + if (cliToken.trim().length > 0) { + return cliToken.trim() + } + + for (const key of ["SPAWNDOCK_API_TOKEN", "API_TOKEN"]) { + const value = process.env[key] + if (typeof value === "string" && value.trim().length > 0) { + return value.trim() + } + } + + return readConfigApiToken(configPath) +} + +const sleep = (ms) => new Promise((resolveSleep) => { + setTimeout(resolveSleep, ms) +}) + +const parseJsonResponse = (text) => { + try { + return JSON.parse(text) + } catch { + throw new Error(`Invalid JSON response from knowledge API:\n${text}`) + } +} + +const requestKnowledge = async (query, locale, timeoutSeconds, retries, apiToken) => { + const payload = JSON.stringify({ query, locale }) + + for (let attempt = 0; attempt <= retries; attempt += 1) { + const headers = { + accept: "application/json", + "content-type": "application/json", + } + + if (apiToken) { + headers.authorization = `Bearer ${apiToken}` + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000) + + try { + const response = await fetch(API_URL, { + method: "POST", + headers, + body: payload, + signal: controller.signal, + }) + const responseText = await response.text() + + if (!response.ok) { + if (response.status >= 500 && attempt < retries) { + await sleep(Math.min(2 ** attempt, 5) * 1000) + continue + } + + throw new Error(`HTTP error: ${response.status}\n${responseText}`) + } + + return parseJsonResponse(responseText) + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Request timed out after ${timeoutSeconds} seconds.`) + } + + throw error + } finally { + clearTimeout(timeoutId) + } + } + + throw new Error("Unreachable retry loop") +} + +const formatResponse = (data) => { + const lines = [] + const answer = data?.answer + const sources = Array.isArray(data?.sources) ? data.sources : [] + const meta = data?.meta && typeof data.meta === "object" ? data.meta : null + + lines.push("Answer:") + lines.push(typeof answer === "string" && answer.length > 0 ? answer : "(empty)") + + if (sources.length > 0) { + lines.push("") + lines.push("Sources:") + + sources.forEach((source, index) => { + if (source && typeof source === "object") { + const title = source.title || source.name || `Source ${index + 1}` + const url = source.url || source.href || "" + const snippet = source.snippet || source.text || "" + let line = `${index + 1}. ${title}` + + if (url) { + line += ` - ${url}` + } + + lines.push(line) + + if (snippet) { + lines.push(` ${snippet}`) + } + + return + } + + lines.push(`${index + 1}. ${String(source)}`) + }) + } + + if (meta) { + lines.push("") + lines.push("Meta:") + lines.push(JSON.stringify(meta, null, 2)) + } + + return lines.join("\n") +} + +const main = async () => { + let options + + try { + options = parseArgs(process.argv.slice(2)) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + console.error("") + printUsage() + return 1 + } + + if (options.help) { + printUsage() + return 0 + } + + const configPath = findConfigPath(options.config) + const apiToken = resolveApiToken(options.apiToken, configPath) + + try { + const data = await requestKnowledge( + options.query, + options.locale, + options.timeoutSeconds, + options.retries, + apiToken, + ) + + if (options.raw) { + console.log(JSON.stringify(data, null, 2)) + } else { + console.log(formatResponse(data)) + } + + return 0 + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + return 1 + } +} + +process.exitCode = await main() diff --git a/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.py b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.py deleted file mode 100755 index ecedfec..0000000 --- a/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import os -import sys -import time -import urllib.error -import urllib.request -from pathlib import Path - - -API_URL = "https://spawn-dock.w3voice.net/knowledge/api/v1/search" - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Query the SpawnDock TMA knowledge API." - ) - parser.add_argument("query", help="Knowledge search query") - parser.add_argument("--locale", default="en", help="Response locale (default: en)") - parser.add_argument( - "--api-token", - help="Optional Bearer token override. Defaults to SPAWNDOCK_API_TOKEN/API_TOKEN or spawndock.config.json", - ) - parser.add_argument( - "--config", - help="Optional path to spawndock.config.json. Defaults to the nearest config found from cwd upward.", - ) - parser.add_argument( - "--timeout", - type=float, - default=20.0, - help="HTTP timeout in seconds (default: 20)", - ) - parser.add_argument( - "--raw", - action="store_true", - help="Print raw JSON response instead of a formatted summary", - ) - parser.add_argument( - "--retries", - type=int, - default=2, - help="Retry count for transient HTTP 5xx failures (default: 2)", - ) - return parser.parse_args() - - -def find_config_path(explicit_path: str | None) -> Path | None: - if explicit_path: - path = Path(explicit_path).expanduser() - return path if path.is_file() else None - - for base in [Path.cwd(), *Path.cwd().parents]: - candidate = base / "spawndock.config.json" - if candidate.is_file(): - return candidate - - return None - - -def read_config_api_token(config_path: Path | None) -> str | None: - if config_path is None: - return None - - try: - data = json.loads(config_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return None - - token = data.get("apiToken") - return token.strip() if isinstance(token, str) and token.strip() else None - - -def resolve_api_token(cli_token: str | None, config_path: Path | None) -> str | None: - if cli_token and cli_token.strip(): - return cli_token.strip() - - for key in ("SPAWNDOCK_API_TOKEN", "API_TOKEN"): - value = os.environ.get(key, "").strip() - if value: - return value - - return read_config_api_token(config_path) - - -def request_knowledge( - query: str, - locale: str, - timeout: float, - retries: int, - api_token: str | None, -) -> dict: - payload = json.dumps({"query": query, "locale": locale}).encode("utf-8") - for attempt in range(retries + 1): - headers = { - "accept": "application/json", - "content-type": "application/json", - } - if api_token: - headers["authorization"] = f"Bearer {api_token}" - - req = urllib.request.Request( - API_URL, - data=payload, - headers=headers, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as response: - charset = response.headers.get_content_charset() or "utf-8" - return json.loads(response.read().decode(charset)) - except urllib.error.HTTPError as exc: - if exc.code < 500 or attempt == retries: - raise - time.sleep(min(2**attempt, 5)) - - raise RuntimeError("Unreachable retry loop") - - -def format_response(data: dict) -> str: - lines: list[str] = [] - answer = data.get("answer") - sources = data.get("sources") or [] - meta = data.get("meta") or {} - - lines.append("Answer:") - lines.append(answer if answer else "(empty)") - - if sources: - lines.append("") - lines.append("Sources:") - for idx, source in enumerate(sources, start=1): - if isinstance(source, dict): - title = source.get("title") or source.get("name") or f"Source {idx}" - url = source.get("url") or source.get("href") or "" - snippet = source.get("snippet") or source.get("text") or "" - line = f"{idx}. {title}" - if url: - line += f" - {url}" - lines.append(line) - if snippet: - lines.append(f" {snippet}") - else: - lines.append(f"{idx}. {source}") - - if meta: - lines.append("") - lines.append("Meta:") - lines.append(json.dumps(meta, ensure_ascii=False, sort_keys=True)) - - return "\n".join(lines) - - -def main() -> int: - args = parse_args() - config_path = find_config_path(args.config) - api_token = resolve_api_token(args.api_token, config_path) - try: - data = request_knowledge( - args.query, - args.locale, - args.timeout, - args.retries, - api_token, - ) - except urllib.error.HTTPError as exc: - body = exc.read().decode("utf-8", errors="replace") - print(f"HTTP error: {exc.code}\n{body}", file=sys.stderr) - return 1 - except urllib.error.URLError as exc: - print(f"Request failed: {exc}", file=sys.stderr) - return 1 - - if args.raw: - print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)) - else: - print(format_response(data)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/packages/app/template-nextjs-overlay/AGENTS.md b/packages/app/template-nextjs-overlay/AGENTS.md index d297fc8..0d7bc1e 100644 --- a/packages/app/template-nextjs-overlay/AGENTS.md +++ b/packages/app/template-nextjs-overlay/AGENTS.md @@ -31,6 +31,8 @@ Your job is to build and improve a production-ready Telegram Mini App in this re - When local repo context is not enough for a Telegram Mini App or SpawnDock-specific implementation question, use the local `tma-knowledge-search` skill before generic web search. - In generated projects the local skill lives at `.agents/skills/tma-knowledge-search`. +- Run the bundled search script with Node from the project root: `node .agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs ""`. +- Do not use Python for this skill. - Query the skill in English and ask one focused implementation question at a time. - Read the returned `answer` first, then inspect `sources` when they are present. - SpawnDock bootstrap also mirrors the same skill into `~/.codex/skills/tma-knowledge-search` when possible so Codex can discover it natively. diff --git a/packages/app/template-nextjs-overlay/CLAUDE.md b/packages/app/template-nextjs-overlay/CLAUDE.md index 26af1f2..f859fc9 100644 --- a/packages/app/template-nextjs-overlay/CLAUDE.md +++ b/packages/app/template-nextjs-overlay/CLAUDE.md @@ -3,4 +3,5 @@ - Treat this repository as a Telegram Mini App project first, not as a generic web app. - Prefer `pnpm run dev` for the main local workflow because it starts both Next.js and the SpawnDock dev tunnel. - When local repo context is not enough for a Telegram Mini App or SpawnDock-specific implementation question, use the local `tma-knowledge-search` skill at `.agents/skills/tma-knowledge-search`. +- Run that skill with Node from the project root: `node .agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs ""`. - Query that skill in English, keep the question focused, and use its answer before falling back to generic web guidance. diff --git a/packages/app/tests/bootstrap.test.ts b/packages/app/tests/bootstrap.test.ts index 6b54d4a..477222d 100644 --- a/packages/app/tests/bootstrap.test.ts +++ b/packages/app/tests/bootstrap.test.ts @@ -129,9 +129,13 @@ describe("template overlay", () => { const skillPath = fileURLToPath( new URL("../template-nextjs-overlay/.agents/skills/tma-knowledge-search/SKILL.md", import.meta.url), ) + const skillScriptPath = fileURLToPath( + new URL("../template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs", import.meta.url), + ) expect(existsSync(claudePath)).toBe(true) expect(existsSync(skillPath)).toBe(true) + expect(existsSync(skillScriptPath)).toBe(true) }) })