diff --git a/README.md b/README.md index d483841..905fba6 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,18 @@ npx --yes github:SpawnDock/create#main --token [project-dir] - `spawndock.dev-tunnel.json` - `public/tonconnect-manifest.json` +`spawndock.config.json` may include `apiToken`, and `.env.local` may include +`SPAWNDOCK_API_TOKEN`, so the bundled TMA knowledge-search skill can use the +authenticated API tier immediately after bootstrap. + ## Built-in Overlay The package also ships a built-in TMA overlay and applies it after cloning `SpawnDock/tma-project`. This overlay is responsible for: +- `AGENTS.md` +- `CLAUDE.md` +- `.agents/skills/tma-knowledge-search` - `spawndock/dev.mjs` - `spawndock/next.mjs` - `spawndock/tunnel.mjs` @@ -51,8 +58,12 @@ The package also ships a built-in TMA overlay and applies it after cloning - `opencode.json` is shipped by the template for OpenCode. - `.mcp.json` is shipped by the template for Claude Code. +- `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. - 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. ## Development diff --git a/packages/app/src/core/bootstrap.ts b/packages/app/src/core/bootstrap.ts index 84749b6..269d038 100644 --- a/packages/app/src/core/bootstrap.ts +++ b/packages/app/src/core/bootstrap.ts @@ -41,6 +41,7 @@ export interface BootstrapClaim { readonly telegramMiniAppUrl?: string readonly deviceSecret: string readonly mcpApiKey: string + readonly apiToken?: string readonly localPort: number } @@ -210,6 +211,7 @@ export const buildGeneratedFiles = ( deviceSecret: claim.deviceSecret, mcpServerUrl, mcpApiKey: claim.mcpApiKey, + ...(claim.apiToken ? { apiToken: claim.apiToken } : {}), } const env = { @@ -223,6 +225,7 @@ export const buildGeneratedFiles = ( SPAWNDOCK_PROJECT_ID: claim.projectId, SPAWNDOCK_PROJECT_SLUG: claim.projectSlug, SPAWNDOCK_ALLOWED_DEV_ORIGINS: claim.previewOrigin, + ...(claim.apiToken ? { SPAWNDOCK_API_TOKEN: claim.apiToken } : {}), } return [ diff --git a/packages/app/src/shell/bootstrap.ts b/packages/app/src/shell/bootstrap.ts index 43d9065..4f1dee9 100644 --- a/packages/app/src/shell/bootstrap.ts +++ b/packages/app/src/shell/bootstrap.ts @@ -1,9 +1,10 @@ -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs" import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns, } from "node:child_process" +import { homedir } from "node:os" import { fileURLToPath } from "node:url" import { dirname, join, resolve } from "node:path" import { Console, Effect } from "effect" @@ -24,6 +25,7 @@ import { } from "../core/bootstrap.js" const TEMPLATE_OVERLAY_DIR = resolveTemplateOverlayDir() +const TMA_KNOWLEDGE_SKILL_NAME = "tma-knowledge-search" interface BootstrapPreflightTarget { readonly projectDir: string @@ -314,6 +316,7 @@ const registerAgentIntegrations = ( ): Effect.Effect, Error> => Effect.gen(function* () { const integrations: string[] = [...DEFAULT_MCP_AGENTS] + yield* installCodexSkill(projectDir) const mcpServerUrl = buildMcpServerUrl(claim.controlPlaneUrl) const codexRegistered = yield* registerCodexIntegration(projectDir, mcpServerUrl, claim.mcpApiKey) @@ -324,6 +327,29 @@ const registerAgentIntegrations = ( return integrations }) +const installCodexSkill = (projectDir: string): Effect.Effect => + Effect.try({ + try: () => { + const sourceDir = join(projectDir, ".agents", "skills", TMA_KNOWLEDGE_SKILL_NAME) + if (!existsSync(sourceDir)) { + return false + } + + const codexHome = process.env["CODEX_HOME"] + const codexRoot = codexHome?.length + ? codexHome + : join(homedir(), ".codex") + const targetDir = join(codexRoot, "skills", TMA_KNOWLEDGE_SKILL_NAME) + + rmSync(targetDir, { recursive: true, force: true }) + mkdirSync(dirname(targetDir), { recursive: true }) + copyOverlayTreeSync(sourceDir, targetDir) + + return true + }, + catch: toError, + }).pipe(Effect.catchAll(() => Effect.succeed(false))) + const registerCodexIntegration = ( projectDir: string, mcpServerUrl: string, @@ -407,6 +433,9 @@ const parseClaimResponse = ( const mcpApiKey = readString(input, "mcpApiKey") ?? readString(input, "mcpToken") + const apiToken = + readString(input, "apiToken") ?? + readString(input, "api_token") const localPort = readNumber(input, "localPort") ?? (typeof fallbackLocalPort === "number" ? fallbackLocalPort : 3000) @@ -429,6 +458,7 @@ const parseClaimResponse = ( ...(telegramMiniAppUrl ? { telegramMiniAppUrl } : {}), deviceSecret, mcpApiKey, + ...(apiToken ? { apiToken } : {}), localPort, } } 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 new file mode 100644 index 0000000..b5afa1c --- /dev/null +++ b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/SKILL.md @@ -0,0 +1,35 @@ +--- +name: tma-knowledge-search +description: Search the SpawnDock TMA knowledge API for Telegram Mini App and SpawnDock-specific implementation guidance. Use when Codex needs authoritative TMA workflow details, Telegram WebApp API usage, SpawnDock TMA template behavior, or wants to verify how a feature should be built for Telegram Mini Apps before answering or coding. +--- + +# TMA Knowledge Search + +Use this skill when local repo context is not enough for a Telegram Mini App question and the answer should come from the SpawnDock TMA knowledge base. + +## 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. + +## Query Rules + +- Prefer English queries even if the user writes in another language. +- Ask about one concrete problem at a time. +- Include key TMA terms in the query: `Telegram Mini App`, `WebApp`, `MainButton`, `theme`, `viewport`, `SpawnDock`, `Next.js template`, and similar domain words when relevant. +- Re-query with a narrower prompt if the first result is generic. +- Avoid unnecessary repeat calls: the endpoint can rate-limit quickly on the free tier. + +## Output Handling + +- Treat the API response as TMA-specific guidance, not as a generic web best-practices source. +- If the API returns no useful sources, say that clearly and fall back to repo code or official Telegram docs as needed. +- Keep citations lightweight: mention the knowledge API result and summarize the relevant guidance rather than dumping raw JSON. + +## Resources + +- `scripts/search_tma_knowledge.py`: 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/agents/openai.yaml b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/agents/openai.yaml new file mode 100644 index 0000000..56958bd --- /dev/null +++ b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "TMA Knowledge Search" + short_description: "Search TMA implementation knowledge" + default_prompt: "Use $tma-knowledge-search to look up Telegram Mini App and SpawnDock TMA implementation details before answering." + +policy: + allow_implicit_invocation: true diff --git a/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/references/api.md b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/references/api.md new file mode 100644 index 0000000..83e555c --- /dev/null +++ b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/references/api.md @@ -0,0 +1,44 @@ +# SpawnDock TMA Knowledge API + +Use this endpoint when you need Telegram Mini App or SpawnDock-specific implementation guidance: + +```bash +curl -X POST \ + 'https://spawn-dock.w3voice.net/knowledge/api/v1/search' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "query": "How do I use MainButton in a Telegram Mini App?", + "locale": "en" + }' +``` + +## Request + +- Method: `POST` +- URL: `https://spawn-dock.w3voice.net/knowledge/api/v1/search` +- Content-Type: `application/json` +- Body fields: + - `query` string, required + - `locale` string, optional in practice for the script, default `en` + +## Observed response shape + +```json +{ + "answer": "Human-readable answer", + "sources": [], + "meta": { + "locale_requested": "en" + } +} +``` + +## Notes + +- The skill defaults to `locale=en`. +- `Authorization: Bearer ` is optional and enables the higher-tier limits when the token is valid. +- SpawnDock bootstrap can write that token into `spawndock.config.json` as `apiToken` and into `.env.local` as `SPAWNDOCK_API_TOKEN`. +- The API can return an empty `sources` array. +- Use the answer as TMA-specific guidance, then inspect `sources` when present. +- The endpoint can return `429 rate_limit exceeded (minute)` after a small number of requests on the free tier. 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 new file mode 100755 index 0000000..ecedfec --- /dev/null +++ b/packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.py @@ -0,0 +1,183 @@ +#!/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 new file mode 100644 index 0000000..d297fc8 --- /dev/null +++ b/packages/app/template-nextjs-overlay/AGENTS.md @@ -0,0 +1,68 @@ +# SpawnDock TMA Template + +You are an AI agent working inside the SpawnDock Telegram Mini App template. + +Your job is to build and improve a production-ready Telegram Mini App in this repository. Treat this as a real TMA project, not as a generic web app and not as a docs-only exercise. + +## Default Mode + +- Work end-to-end inside the repo: design, implement, and validate when the user is asking for a result. +- Prefer concrete code and file changes over abstract advice unless the user explicitly wants brainstorming only. +- If requirements are incomplete, choose the smallest sensible TMA-first default and keep moving. +- Ask a question only when missing information would materially change the product flow or create a high risk of doing the wrong work. + +## Project Contract + +- Preserve the current stack: Next.js, App Router, TypeScript, SpawnDock scripts, and the existing Telegram/TMA integrations. +- Do not replace the framework, routing model, or core dev workflow unless the user explicitly asks for that change. +- Prefer changes that fit the existing `src/app` structure, shared styling approach, and `spawndock/*.mjs` wrappers. +- Use `pnpm` commands, not `npm`, unless a task explicitly requires otherwise. + +## TMA Rules + +- Always treat the app as a Telegram Mini App first. +- Prefer mobile-first, touch-first UX and layouts that work well inside Telegram WebView. +- Use Telegram WebApp APIs where appropriate: `ready`, `expand`, `MainButton`, `BackButton`, `themeParams`, `HapticFeedback`, `openLink`, `openTelegramLink`, `sendData`, and viewport APIs. +- Respect Telegram theming. Do not hardcode colors where Telegram theme params should drive the UI. +- Avoid browser patterns that are fragile inside Telegram WebView: `alert()`, `confirm()`, `prompt()`, `target=\"_blank\"`, `window.open()`, and desktop-first navigation patterns. +- Do not introduce `BrowserRouter`-style assumptions for TMA navigation. + +## TMA Knowledge Search + +- 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`. +- 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. + +## Dev Flow + +- `pnpm run dev` is the primary local workflow. It starts the Next.js dev server and the SpawnDock dev tunnel together. +- `pnpm run dev:next` starts only the local Next.js server. +- `pnpm run dev:tunnel` starts only the SpawnDock tunnel client. +- `pnpm run agent` starts the Next.js server, the tunnel, and the local agent runtime launcher. +- If the user asks to run the project, preview the app, or get a tunnel URL, prefer `pnpm run dev`. +- Do not describe `pnpm run dev` as “just Next.js dev”; in this template it is the combined app-plus-tunnel flow. +- If `spawndock.config.json` or `spawndock.dev-tunnel.json` is missing or invalid, the project is probably not fully bootstrapped yet. + +## Implementation Expectations + +- Build only what is needed for the requested feature set. +- Keep the architecture simple, shippable, and easy to extend. +- Reuse existing components and patterns before introducing new abstractions. +- Add loading, empty, and error states for user-facing flows when they matter. +- Make the smallest set of changes that fully solves the task. + +## Validation + +- Run the narrowest relevant checks after changes. +- Prefer `pnpm run build` for code validation. +- Use `pnpm run dev` when the task depends on runtime behavior, preview behavior, or the SpawnDock tunnel. +- If you could not run a relevant check, say so explicitly. + +## Success Criteria + +- The result fits the current template and preserves its workflow. +- The app behaves like a proper Telegram Mini App and respects Telegram constraints. +- The main local development flow, especially `pnpm run dev`, remains intact. +- The code is ready for real iteration, not just for demo output. diff --git a/packages/app/template-nextjs-overlay/CLAUDE.md b/packages/app/template-nextjs-overlay/CLAUDE.md new file mode 100644 index 0000000..26af1f2 --- /dev/null +++ b/packages/app/template-nextjs-overlay/CLAUDE.md @@ -0,0 +1,6 @@ +# SpawnDock TMA Template + +- 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`. +- 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 0f4fa70..6b54d4a 100644 --- a/packages/app/tests/bootstrap.test.ts +++ b/packages/app/tests/bootstrap.test.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs" +import { fileURLToPath } from "node:url" import { describe, expect, it } from "vitest" import { buildCodexMcpCommandArgs, @@ -100,6 +102,7 @@ describe("buildGeneratedFiles", () => { telegramMiniAppUrl: "https://t.me/TMASpawnerBot/tma?startapp=demo-project", deviceSecret: "secret_123", mcpApiKey: "mcp_key_123", + apiToken: "api_key_123", localPort: 3000, }, ) @@ -110,7 +113,9 @@ describe("buildGeneratedFiles", () => { expect(fileMap.has(".mcp.json")).toBe(true) expect(fileMap.get("spawndock.config.json")).toContain("\"mcpServerUrl\": \"https://api.example.com/mcp/sse\"") expect(fileMap.get("spawndock.config.json")).toContain("\"mcpApiKey\": \"mcp_key_123\"") + expect(fileMap.get("spawndock.config.json")).toContain("\"apiToken\": \"api_key_123\"") expect(fileMap.get("spawndock.config.json")).toContain("\"telegramMiniAppUrl\": \"https://t.me/TMASpawnerBot/tma?startapp=demo-project\"") + expect(fileMap.get(".env.local")).toContain("SPAWNDOCK_API_TOKEN=api_key_123") expect(fileMap.get("opencode.json")).toContain("./spawndock/mcp.mjs") expect(fileMap.get(".mcp.json")).toContain("./spawndock/mcp.mjs") expect(fileMap.get("public/tonconnect-manifest.json")).toContain("\"url\": \"https://api.example.com/preview/demo-project\"") @@ -118,6 +123,18 @@ describe("buildGeneratedFiles", () => { }) }) +describe("template overlay", () => { + it("ships Claude memory and the local TMA knowledge skill", () => { + const claudePath = fileURLToPath(new URL("../template-nextjs-overlay/CLAUDE.md", import.meta.url)) + const skillPath = fileURLToPath( + new URL("../template-nextjs-overlay/.agents/skills/tma-knowledge-search/SKILL.md", import.meta.url), + ) + + expect(existsSync(claudePath)).toBe(true) + expect(existsSync(skillPath)).toBe(true) + }) +}) + describe("patchPackageJsonContent", () => { it("injects pnpm metadata, overlay scripts and tunnel dependency", () => { const output = patchPackageJsonContent(