From 597f18f7585ddd8ef01a46844285fa8618780024 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Mon, 23 Mar 2026 21:20:32 +0100 Subject: [PATCH] Add repos-devtools-server and AnythingLLM dev tools panel (0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Motivations:** - Clone or load repos under /home/ncantu/code with AnythingLLM workspace ensure/create from the editor **Root causes:** - N/A (new capability) **Correctifs:** - N/A **Evolutions:** - services/repos-devtools-server: POST /repos-clone, GET /repos-list, POST /repos-load (Bearer REPOS_DEVTOOLS_TOKEN) - Extension: Webview panel, slash commands, workspaceEnsure + POST /api/v1/workspace/new - Docs: feature note and index links **Pages affectées:** - services/repos-devtools-server/* - extensions/anythingllm-workspaces/* - docs/README.md - docs/features/repos-devtools-server-and-dev-panel.md - docs/features/anythingllm-vscode-extension.md --- docs/README.md | 2 + docs/features/anythingllm-vscode-extension.md | 1 + .../repos-devtools-server-and-dev-panel.md | 34 +++ extensions/anythingllm-workspaces/README.md | 36 +++- .../anythingllm-workspaces/media/devTools.js | 25 +++ .../anythingllm-workspaces/package.json | 16 +- .../src/anythingllmClient.ts | 44 ++++ .../src/commandParser.ts | 52 +++++ .../anythingllm-workspaces/src/config.ts | 36 ++++ .../src/devToolsExecutor.ts | 196 ++++++++++++++++++ .../src/devToolsPanel.ts | 103 +++++++++ .../anythingllm-workspaces/src/extension.ts | 21 +- .../src/reposApiClient.ts | 94 +++++++++ .../src/workspaceEnsure.ts | 27 +++ services/repos-devtools-server/.gitignore | 2 + services/repos-devtools-server/README.md | 36 ++++ .../repos-devtools-server/package-lock.json | 51 +++++ services/repos-devtools-server/package.json | 20 ++ services/repos-devtools-server/src/auth.ts | 21 ++ .../repos-devtools-server/src/gitSpawn.ts | 34 +++ .../repos-devtools-server/src/handlers.ts | 130 ++++++++++++ .../repos-devtools-server/src/httpUtil.ts | 25 +++ services/repos-devtools-server/src/paths.ts | 47 +++++ services/repos-devtools-server/src/server.ts | 56 +++++ services/repos-devtools-server/tsconfig.json | 16 ++ 25 files changed, 1106 insertions(+), 19 deletions(-) create mode 100644 docs/features/repos-devtools-server-and-dev-panel.md create mode 100644 extensions/anythingllm-workspaces/media/devTools.js create mode 100644 extensions/anythingllm-workspaces/src/commandParser.ts create mode 100644 extensions/anythingllm-workspaces/src/config.ts create mode 100644 extensions/anythingllm-workspaces/src/devToolsExecutor.ts create mode 100644 extensions/anythingllm-workspaces/src/devToolsPanel.ts create mode 100644 extensions/anythingllm-workspaces/src/reposApiClient.ts create mode 100644 extensions/anythingllm-workspaces/src/workspaceEnsure.ts create mode 100644 services/repos-devtools-server/.gitignore create mode 100644 services/repos-devtools-server/README.md create mode 100644 services/repos-devtools-server/package-lock.json create mode 100644 services/repos-devtools-server/package.json create mode 100644 services/repos-devtools-server/src/auth.ts create mode 100644 services/repos-devtools-server/src/gitSpawn.ts create mode 100644 services/repos-devtools-server/src/handlers.ts create mode 100644 services/repos-devtools-server/src/httpUtil.ts create mode 100644 services/repos-devtools-server/src/paths.ts create mode 100644 services/repos-devtools-server/src/server.ts create mode 100644 services/repos-devtools-server/tsconfig.json diff --git a/docs/README.md b/docs/README.md index 394051c..c216c22 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,8 @@ Operational, architectural, and UX-design notes for the local-AI IDE initiative | [../deploy/nginx/README-ia-enso.md](../deploy/nginx/README-ia-enso.md) | Proxy HTTPS `ia.enso.4nkweb.com` → Ollama / AnythingLLM (Bearer, script SSH, dépannage) | | [../extensions/anythingllm-workspaces/README.md](../extensions/anythingllm-workspaces/README.md) | Extension VS Code / Cursor : lister les workspaces AnythingLLM (API) et ouvrir l’UI | | [features/anythingllm-vscode-extension.md](./features/anythingllm-vscode-extension.md) | Fiche évolution : extension AnythingLLM, impacts, modalités | +| [features/repos-devtools-server-and-dev-panel.md](./features/repos-devtools-server-and-dev-panel.md) | API locale repos + panneau dev tools (clone, workspace AnythingLLM) | +| [../services/repos-devtools-server/README.md](../services/repos-devtools-server/README.md) | Serveur HTTP local : clone/list/load sous `REPOS_DEVTOOLS_ROOT` | | [fixKnowledge/anythingllm-extension-403-api-key.md](./fixKnowledge/anythingllm-extension-403-api-key.md) | 403 API AnythingLLM : clé nginx Ollama vs clé UI API Keys | | [features/ia-enso-nginx-proxy-ollama-anythingllm.md](./features/ia-enso-nginx-proxy-ollama-anythingllm.md) | Fiche évolution : objectifs, impacts, modalités du reverse proxy ia.enso | | [anythingllm-workspaces.md](./anythingllm-workspaces.md) | One AnythingLLM workspace per project; sync pipeline | diff --git a/docs/features/anythingllm-vscode-extension.md b/docs/features/anythingllm-vscode-extension.md index cc7644c..8e39c30 100644 --- a/docs/features/anythingllm-vscode-extension.md +++ b/docs/features/anythingllm-vscode-extension.md @@ -15,6 +15,7 @@ Fournir un point d’entrée minimal dans l’éditeur pour lister les **workspa - `package.json`, `tsconfig.json`, sources TypeScript (`src/extension.ts`, `src/anythingllmClient.ts`, `src/types.ts`). - `README.md` de l’extension : prérequis, configuration, commandes, lien vers `deploy/nginx/README-ia-enso.md`. +- Évolutions ultérieures (v0.2.0) : panneau dev tools, client `repos-devtools-server`, `POST /api/v1/workspace/new` — voir [repos-devtools-server-and-dev-panel.md](./repos-devtools-server-and-dev-panel.md). ## Modalités de déploiement diff --git a/docs/features/repos-devtools-server-and-dev-panel.md b/docs/features/repos-devtools-server-and-dev-panel.md new file mode 100644 index 0000000..355318f --- /dev/null +++ b/docs/features/repos-devtools-server-and-dev-panel.md @@ -0,0 +1,34 @@ +# repos-devtools-server + panneau « Dev tools » (extension AnythingLLM) + +**Author:** 4NK + +## Objectif + +Sur l’hôte qui porte les clones (ex. `192.168.1.164`, racine `/home/ncantu/code`) : + +- exposer une **API HTTP locale** (git clone branche `test`, liste des dépôts, résolution de chemin) ; +- depuis l’extension **AnythingLLM Workspaces**, fournir un **panneau Webview** pour saisir des commandes texte et afficher la réponse ; +- enchaîner avec l’**API développeur AnythingLLM** pour **vérifier / créer** un workspace dont le nom (ou slug) correspond au dépôt. + +## Impacts + +- Nouveau service : `services/repos-devtools-server/` (Node 20+, écoute `127.0.0.1`, Bearer obligatoire via `REPOS_DEVTOOLS_TOKEN`). +- Extension version **0.2.0** : réglages `anythingllm.reposApiBaseUrl`, `anythingllm.reposApiToken`, commande **AnythingLLM: Dev tools panel**, fichiers `media/devTools.js`, logique `workspaceEnsure`, `POST /api/v1/workspace/new`. + +## Modifications + +- Serveur : `POST /repos-clone`, `GET /repos-list`, `POST /repos-load`. +- Extension : parseur de lignes, client HTTP repos, `ensureWorkspaceForRepoName`, panneau Webview. + +## Modalités de déploiement + +1. Sur la machine des clones : définir `REPOS_DEVTOOLS_TOKEN`, optionnellement `REPOS_DEVTOOLS_ROOT`, `npm run build && npm start` (voir `services/repos-devtools-server/README.md`). +2. Dans Cursor / VS Code (même hôte ou tunnel vers `:37140`) : renseigner `anythingllm.reposApiBaseUrl` et `anythingllm.reposApiToken`. +3. Recompiler / réinstaller l’extension (`.vsix` ou workspace dev). + +## Modalités d’analyse + +- Erreur **401** sur l’API repos : token extension ≠ `REPOS_DEVTOOLS_TOKEN`. +- Erreur **403** AnythingLLM : clé API application (pas secret nginx Ollama). +- **409** clone : répertoire cible déjà présent sous `REPOS_DEVTOOLS_ROOT`. +- **git clone** échoue si la branche `test` n’existe pas sur le remote (comportement git nominal). diff --git a/extensions/anythingllm-workspaces/README.md b/extensions/anythingllm-workspaces/README.md index f05c475..c21d835 100644 --- a/extensions/anythingllm-workspaces/README.md +++ b/extensions/anythingllm-workspaces/README.md @@ -1,6 +1,6 @@ # AnythingLLM Workspaces (VS Code / Cursor extension) -Minimal extension to call the **AnythingLLM developer API** and open a workspace in the browser. +Extension for the **AnythingLLM developer API**, optional **local repos API** (`repos-devtools-server`), and a **dev tools panel** for slash-style commands. ## Prerequisites @@ -9,21 +9,44 @@ Minimal extension to call the **AnythingLLM developer API** and open a workspace **Do not** use the **nginx Bearer secret** for `/ollama/` (see `deploy/nginx/README-ia-enso.md`). That value is only for the Ollama reverse proxy. AnythingLLM validates API keys against its **own** database; a wrong secret yields `403` with `{"error":"No valid api key found."}`. +For **clone / repos-list / repos-load**, run **`services/repos-devtools-server`** on the host that owns the clone root (e.g. `192.168.1.164` with `REPOS_DEVTOOLS_ROOT=/home/ncantu/code`). The extension calls this API over HTTP (default `http://127.0.0.1:37140` when your editor runs on that same host or via port-forward). + ## Configuration | Setting | Description | |--------|-------------| | `anythingllm.baseUrl` | Base URL without trailing slash (default matches `deploy/nginx/README-ia-enso.md`). | -| `anythingllm.apiKey` | Secret from AnythingLLM **Settings → API Keys** (optional leading `Bearer ` is stripped). Use **User** settings to avoid committing secrets. | +| `anythingllm.apiKey` | Secret from AnythingLLM **Settings → API Keys** (optional leading `Bearer ` is stripped). Use **User** settings. | +| `anythingllm.reposApiBaseUrl` | `repos-devtools-server` base URL (no trailing slash), default `http://127.0.0.1:37140`. | +| `anythingllm.reposApiToken` | Same value as `REPOS_DEVTOOLS_TOKEN` on the server. **User** settings. | -## Commands +## Commands (palette) -- **AnythingLLM: List workspaces** — Fetches workspaces, then opens the selected one in the default browser (`/workspace/` under your base URL). +- **AnythingLLM: List workspaces** — `GET /api/v1/workspaces`, then open the chosen workspace in the browser. - **AnythingLLM: Open web UI** — Opens the AnythingLLM base URL. +- **AnythingLLM: Dev tools panel** — Webview: enter commands, **Run**, read JSON/text **Response**. + +## Dev tools panel — command lines + +One command per line: + +| Line | Behaviour | +|------|-----------| +| `/repos-clone ` | `POST /repos-clone` — clone into `REPOS_DEVTOOLS_ROOT`, branch **`test`** (override with JSON only via API, not this line). | +| `/repos-clone-sync ` | Clone + ensure AnythingLLM workspace named like the repo folder + **Open folder** + open workspace in browser. | +| `repos-list` or `/repos-list` | `GET /repos-list` — cloned git directories under the root. | +| `/repos-load ` | Verify repo folder + **Open folder** in the editor. | +| `/repos-load-sync ` | Open folder + ensure workspace + browser. | +| `/workspace-load ` | Ensure workspace exists (`GET /workspaces` then `POST /workspace/new` if missing) + browser. | +| `help` | Short built-in help text. | + +**Workspace matching:** name or slug must equal the folder/repo name you use (exact string match on `name` or `slug` from AnythingLLM). New workspaces are created with `POST /api/v1/workspace/new` and `{ "name": "" }`. + +**AnythingLLM “user”:** the developer API uses the **API key**; per-user workspace ownership follows AnythingLLM’s own multi-user rules — this extension does not impersonate a browser session. ## Ollama -This extension targets **AnythingLLM** only. For OpenAI-compatible Ollama behind the same proxy, use Cursor’s model settings with `https://ia.enso.4nkweb.com/ollama/v1` and the nginx Bearer (see `deploy/nginx/README-ia-enso.md`). +This extension does not call Ollama. For `https://ia.enso.4nkweb.com/ollama/v1`, use Cursor model settings and the nginx Bearer (see `deploy/nginx/README-ia-enso.md`). ## Build @@ -37,4 +60,5 @@ Load the folder in VS Code / Cursor with **Run Extension** or install the packag ## API reference -Upstream routes (mounted under `/api`): `GET /v1/workspaces` — see Mintplex-Labs anything-llm `server/endpoints/api/workspace/index.js`. +- AnythingLLM: `server/endpoints/api/workspace/index.js` (e.g. `GET /v1/workspaces`, `POST /v1/workspace/new` under `/api`). +- Local repos: `services/repos-devtools-server/README.md`. diff --git a/extensions/anythingllm-workspaces/media/devTools.js b/extensions/anythingllm-workspaces/media/devTools.js new file mode 100644 index 0000000..d0f3828 --- /dev/null +++ b/extensions/anythingllm-workspaces/media/devTools.js @@ -0,0 +1,25 @@ +(function () { + const vscode = acquireVsCodeApi(); + const input = document.getElementById("cmd"); + const out = document.getElementById("out"); + const runBtn = document.getElementById("run"); + const clearBtn = document.getElementById("clear"); + + function run() { + const text = input.value; + out.textContent = "…"; + vscode.postMessage({ type: "run", text: text }); + } + + runBtn.addEventListener("click", run); + clearBtn.addEventListener("click", function () { + out.textContent = ""; + }); + + window.addEventListener("message", function (event) { + const msg = event.data; + if (msg && msg.type === "result") { + out.textContent = typeof msg.text === "string" ? msg.text : String(msg.text); + } + }); +})(); diff --git a/extensions/anythingllm-workspaces/package.json b/extensions/anythingllm-workspaces/package.json index c2b0c76..9aee538 100644 --- a/extensions/anythingllm-workspaces/package.json +++ b/extensions/anythingllm-workspaces/package.json @@ -2,7 +2,7 @@ "name": "anythingllm-workspaces", "displayName": "AnythingLLM Workspaces (ia.enso)", "description": "List AnythingLLM workspaces via your proxied instance (e.g. ia.enso.4nkweb.com/anythingllm).", - "version": "0.1.1", + "version": "0.2.0", "publisher": "4nk", "license": "MIT", "engines": { @@ -28,6 +28,16 @@ "type": "string", "default": "", "markdownDescription": "AnythingLLM API key (UI: **Settings → API Keys**). Prefer **User** settings to avoid committing secrets." + }, + "anythingllm.reposApiBaseUrl": { + "type": "string", + "default": "http://127.0.0.1:37140", + "markdownDescription": "Base URL of **repos-devtools-server** (no trailing slash). Must match the machine where `/home/ncantu/code` (or `REPOS_DEVTOOLS_ROOT`) lives." + }, + "anythingllm.reposApiToken": { + "type": "string", + "default": "", + "markdownDescription": "Bearer token shared with `REPOS_DEVTOOLS_TOKEN` on the repos-devtools-server. **User** settings only." } } }, @@ -39,6 +49,10 @@ { "command": "anythingllm.openWebUi", "title": "AnythingLLM: Open web UI" + }, + { + "command": "anythingllm.openDevToolsPanel", + "title": "AnythingLLM: Dev tools panel" } ] }, diff --git a/extensions/anythingllm-workspaces/src/anythingllmClient.ts b/extensions/anythingllm-workspaces/src/anythingllmClient.ts index f95601d..08070db 100644 --- a/extensions/anythingllm-workspaces/src/anythingllmClient.ts +++ b/extensions/anythingllm-workspaces/src/anythingllmClient.ts @@ -80,3 +80,47 @@ export const listWorkspaces = async ( } return parseListWorkspaces(parseJson(text)); }; + +const parseWorkspaceEnvelope = (payload: unknown): AnythingWorkspace => { + if (!isRecord(payload)) { + throw new Error("AnythingLLM API: expected object body"); + } + const ws = payload.workspace; + if (!isWorkspace(ws)) { + throw new Error("AnythingLLM API: missing workspace in response"); + } + return ws; +}; + +export const createWorkspace = async ( + baseUrl: string, + apiKey: string, + name: string, +): Promise => { + const normalized = normalizeAnythingLlmBaseUrl(baseUrl); + const key = normalizeApiSecret(apiKey); + if (key.length === 0) { + throw new Error("anythingllm.apiKey is empty"); + } + const label = name.trim(); + if (label.length === 0) { + throw new Error("workspace name is empty"); + } + const url = `${normalized}/api/v1/workspace/new`; + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${key}`, + }, + body: JSON.stringify({ name: label }), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error( + `AnythingLLM API ${response.status}: ${text.slice(0, 500)}`, + ); + } + return parseWorkspaceEnvelope(parseJson(text)); +}; diff --git a/extensions/anythingllm-workspaces/src/commandParser.ts b/extensions/anythingllm-workspaces/src/commandParser.ts new file mode 100644 index 0000000..f5bbe67 --- /dev/null +++ b/extensions/anythingllm-workspaces/src/commandParser.ts @@ -0,0 +1,52 @@ +export type ParsedDevCommand = + | { readonly kind: "repos-clone"; readonly url: string; readonly sync: boolean } + | { readonly kind: "repos-list" } + | { readonly kind: "repos-load"; readonly name: string; readonly sync: boolean } + | { readonly kind: "workspace-load"; readonly name: string } + | { readonly kind: "help" } + | { readonly kind: "unknown"; readonly raw: string }; + +export const parseDevCommandLine = (line: string): ParsedDevCommand => { + const trimmed = line.trim(); + if (trimmed.length === 0) { + return { kind: "unknown", raw: line }; + } + const parts = trimmed.split(/\s+/); + const cmd = parts[0]; + const argRest = parts.slice(1).join(" ").trim(); + if (cmd === "/repos-clone-sync") { + return { kind: "repos-clone", url: argRest, sync: true }; + } + if (cmd === "/repos-clone") { + return { kind: "repos-clone", url: argRest, sync: false }; + } + if (cmd === "repos-list" || cmd === "/repos-list") { + return { kind: "repos-list" }; + } + if (cmd === "/repos-load-sync") { + return { kind: "repos-load", name: argRest, sync: true }; + } + if (cmd === "/repos-load") { + return { kind: "repos-load", name: argRest, sync: false }; + } + if (cmd === "/workspace-load") { + return { kind: "workspace-load", name: argRest }; + } + if (cmd === "help" || cmd === "/help") { + return { kind: "help" }; + } + return { kind: "unknown", raw: trimmed }; +}; + +export const devCommandsHelpText = (): string => { + return [ + "Commands (one per line):", + " /repos-clone — clone into REPOS_DEVTOOLS_ROOT, branch test (default)", + " /repos-clone-sync — clone + ensure AnythingLLM workspace (same name) + open folder + browser", + " repos-list — list cloned git repos under root", + " /repos-load — verify repo; open folder in editor", + " /repos-load-sync — open folder + ensure workspace + browser", + " /workspace-load — ensure workspace by name (create via API if missing) + browser", + " help — this list", + ].join("\n"); +}; diff --git a/extensions/anythingllm-workspaces/src/config.ts b/extensions/anythingllm-workspaces/src/config.ts new file mode 100644 index 0000000..ffe34a8 --- /dev/null +++ b/extensions/anythingllm-workspaces/src/config.ts @@ -0,0 +1,36 @@ +import * as vscode from "vscode"; + +const CONFIG_SECTION = "anythingllm"; + +export interface DevToolsConfigSnapshot { + readonly anythingBaseUrl: string; + readonly anythingApiKey: string; + readonly reposApiBaseUrl: string; + readonly reposApiToken: string; +} + +export const readAnythingConfig = (): { baseUrl: string; apiKey: string } => { + const cfg = vscode.workspace.getConfiguration(CONFIG_SECTION); + const baseUrl = typeof cfg.get("baseUrl") === "string" ? (cfg.get("baseUrl") as string) : ""; + const apiKey = typeof cfg.get("apiKey") === "string" ? (cfg.get("apiKey") as string) : ""; + return { baseUrl, apiKey }; +}; + +export const readDevToolsConfig = (): DevToolsConfigSnapshot => { + const cfg = vscode.workspace.getConfiguration(CONFIG_SECTION); + const { baseUrl, apiKey } = readAnythingConfig(); + const reposApiBaseUrl = + typeof cfg.get("reposApiBaseUrl") === "string" + ? (cfg.get("reposApiBaseUrl") as string) + : ""; + const reposApiToken = + typeof cfg.get("reposApiToken") === "string" + ? (cfg.get("reposApiToken") as string) + : ""; + return { + anythingBaseUrl: baseUrl, + anythingApiKey: apiKey, + reposApiBaseUrl, + reposApiToken, + }; +}; diff --git a/extensions/anythingllm-workspaces/src/devToolsExecutor.ts b/extensions/anythingllm-workspaces/src/devToolsExecutor.ts new file mode 100644 index 0000000..4578214 --- /dev/null +++ b/extensions/anythingllm-workspaces/src/devToolsExecutor.ts @@ -0,0 +1,196 @@ +import type * as vscode from "vscode"; +import { + devCommandsHelpText, + parseDevCommandLine, + type ParsedDevCommand, +} from "./commandParser"; +import { normalizeAnythingLlmBaseUrl } from "./anythingllmClient"; +import { reposApiClone, reposApiList, reposApiLoad } from "./reposApiClient"; +import { ensureWorkspaceForRepoName } from "./workspaceEnsure"; + +const DEFAULT_BRANCH = "test"; + +const fmt = (value: unknown): string => { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +export interface DevToolsRunnerContext { + readonly anythingBaseUrl: string; + readonly anythingApiKey: string; + readonly reposApiBaseUrl: string; + readonly reposApiToken: string; + readonly openFolder: (fsPath: string) => Thenable; + readonly openAnythingWorkspaceInBrowser: (slug: string) => Thenable; +} + +const assertReposConfig = (ctx: DevToolsRunnerContext): void => { + if (ctx.reposApiBaseUrl.trim().length === 0) { + throw new Error("Set anythingllm.reposApiBaseUrl (repos-devtools-server URL)."); + } + if (ctx.reposApiToken.trim().length === 0) { + throw new Error("Set anythingllm.reposApiToken (same value as REPOS_DEVTOOLS_TOKEN)."); + } +}; + +const assertAnythingConfig = (ctx: DevToolsRunnerContext): void => { + if (ctx.anythingApiKey.trim().length === 0) { + throw new Error("Set anythingllm.apiKey for workspace operations."); + } +}; + +const runOne = async ( + cmd: ParsedDevCommand, + ctx: DevToolsRunnerContext, +): Promise => { + if (cmd.kind === "help") { + return devCommandsHelpText(); + } + if (cmd.kind === "unknown") { + return `Unknown command: ${cmd.raw}\n${devCommandsHelpText()}`; + } + if (cmd.kind === "repos-list") { + assertReposConfig(ctx); + const data = await reposApiList(ctx.reposApiBaseUrl, ctx.reposApiToken); + return fmt(data); + } + if (cmd.kind === "repos-clone") { + assertReposConfig(ctx); + if (cmd.url.length === 0) { + throw new Error("/repos-clone requires a git URL."); + } + const data = await reposApiClone( + ctx.reposApiBaseUrl, + ctx.reposApiToken, + cmd.url, + DEFAULT_BRANCH, + ); + let out = fmt(data); + if (cmd.sync) { + assertAnythingConfig(ctx); + const rec = data as Record; + const name = rec.name; + const fsPath = rec.path; + if (typeof name !== "string") { + throw new Error("clone response missing name"); + } + if (typeof fsPath !== "string" || fsPath.length === 0) { + throw new Error("clone response missing path"); + } + const ensured = await ensureWorkspaceForRepoName( + ctx.anythingBaseUrl, + ctx.anythingApiKey, + name, + ); + out += `\n---\nAnythingLLM workspace: ${fmt({ + slug: ensured.workspace.slug, + name: ensured.workspace.name, + created: ensured.created, + })}`; + await ctx.openFolder(fsPath); + await ctx.openAnythingWorkspaceInBrowser(ensured.workspace.slug); + } + return out; + } + if (cmd.kind === "repos-load") { + assertReposConfig(ctx); + if (cmd.name.length === 0) { + throw new Error("/repos-load requires a repository folder name."); + } + const loaded = await reposApiLoad( + ctx.reposApiBaseUrl, + ctx.reposApiToken, + cmd.name, + ); + let out = fmt(loaded); + await ctx.openFolder(loaded.path); + if (cmd.sync) { + assertAnythingConfig(ctx); + const ensured = await ensureWorkspaceForRepoName( + ctx.anythingBaseUrl, + ctx.anythingApiKey, + loaded.name, + ); + out += `\n---\nAnythingLLM workspace: ${fmt({ + slug: ensured.workspace.slug, + name: ensured.workspace.name, + created: ensured.created, + })}`; + await ctx.openAnythingWorkspaceInBrowser(ensured.workspace.slug); + } + return out; + } + if (cmd.kind === "workspace-load") { + assertAnythingConfig(ctx); + if (cmd.name.length === 0) { + throw new Error("/workspace-load requires a workspace name."); + } + const ensured = await ensureWorkspaceForRepoName( + ctx.anythingBaseUrl, + ctx.anythingApiKey, + cmd.name, + ); + await ctx.openAnythingWorkspaceInBrowser(ensured.workspace.slug); + return fmt({ + slug: ensured.workspace.slug, + name: ensured.workspace.name, + created: ensured.created, + }); + } + return `Unhandled: ${JSON.stringify(cmd)}`; +}; + +export const runDevToolsScript = async ( + text: string, + ctx: DevToolsRunnerContext, +): Promise => { + const lines = text + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + if (lines.length === 0) { + return devCommandsHelpText(); + } + const parts: string[] = []; + for (const line of lines) { + const parsed = parseDevCommandLine(line); + try { + parts.push(await runOne(parsed, ctx)); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + parts.push(`ERROR: ${msg}`); + } + } + return parts.join("\n---\n"); +}; + +export const registerDevToolsOpenFolder = ( + vscodeApi: typeof vscode, +): ((fsPath: string) => Thenable) => { + return (fsPath: string) => { + if (fsPath.length === 0) { + return Promise.resolve(); + } + const uri = vscodeApi.Uri.file(fsPath); + return vscodeApi.commands.executeCommand("vscode.openFolder", uri, false); + }; +}; + +export const makeOpenAnythingHandler = ( + vscodeApi: typeof vscode, + baseUrl: string, +): ((slug: string) => Thenable) => { + return (slug: string) => { + const root = normalizeAnythingLlmBaseUrl(baseUrl); + const uri = vscodeApi.Uri.parse( + `${root}/workspace/${encodeURIComponent(slug)}`, + ); + return vscodeApi.env.openExternal(uri).then(() => undefined); + }; +}; diff --git a/extensions/anythingllm-workspaces/src/devToolsPanel.ts b/extensions/anythingllm-workspaces/src/devToolsPanel.ts new file mode 100644 index 0000000..538f681 --- /dev/null +++ b/extensions/anythingllm-workspaces/src/devToolsPanel.ts @@ -0,0 +1,103 @@ +import * as vscode from "vscode"; +import type { DevToolsConfigSnapshot } from "./config"; +import { runDevToolsScript, makeOpenAnythingHandler, registerDevToolsOpenFolder } from "./devToolsExecutor"; + +const PANEL_ID = "anythingllmDevTools"; + +const panelTitle = "AnythingLLM dev tools"; + +const buildHtml = ( + webview: vscode.Webview, + extensionUri: vscode.Uri, +): string => { + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, "media", "devTools.js"), + ); + const csp = webview.cspSource; + return ` + + + + + + ${panelTitle} + + + +
+ + +
+
+ + +
+ +

+  
+
+`;
+};
+
+export const showDevToolsPanel = (
+  context: vscode.ExtensionContext,
+  readConfig: () => DevToolsConfigSnapshot,
+): void => {
+  const column = vscode.ViewColumn.Beside;
+  const panel = vscode.window.createWebviewPanel(
+    PANEL_ID,
+    panelTitle,
+    column,
+    {
+      enableScripts: true,
+      retainContextWhenHidden: true,
+      localResourceRoots: [
+        vscode.Uri.joinPath(context.extensionUri, "media"),
+      ],
+    },
+  );
+
+  panel.webview.html = buildHtml(panel.webview, context.extensionUri);
+
+  const openFolder = registerDevToolsOpenFolder(vscode);
+
+  panel.webview.onDidReceiveMessage(
+    (msg: unknown) => {
+      void (async () => {
+        if (typeof msg !== "object" || msg === null) {
+          return;
+        }
+        const rec = msg as Record;
+        if (rec.type !== "run") {
+          return;
+        }
+        const text = typeof rec.text === "string" ? rec.text : "";
+        const use = readConfig();
+        const openBrowser = makeOpenAnythingHandler(vscode, use.anythingBaseUrl);
+        try {
+          const result = await runDevToolsScript(text, {
+            anythingBaseUrl: use.anythingBaseUrl,
+            anythingApiKey: use.anythingApiKey,
+            reposApiBaseUrl: use.reposApiBaseUrl,
+            reposApiToken: use.reposApiToken,
+            openFolder,
+            openAnythingWorkspaceInBrowser: openBrowser,
+          });
+          panel.webview.postMessage({ type: "result", text: result });
+        } catch (e) {
+          const m = e instanceof Error ? e.message : String(e);
+          panel.webview.postMessage({ type: "result", text: `ERROR: ${m}` });
+        }
+      })();
+    },
+    undefined,
+    context.subscriptions,
+  );
+};
diff --git a/extensions/anythingllm-workspaces/src/extension.ts b/extensions/anythingllm-workspaces/src/extension.ts
index 5b94c9f..3efe908 100644
--- a/extensions/anythingllm-workspaces/src/extension.ts
+++ b/extensions/anythingllm-workspaces/src/extension.ts
@@ -1,15 +1,8 @@
 import * as vscode from "vscode";
+import { readAnythingConfig, readDevToolsConfig } from "./config";
 import { listWorkspaces, normalizeAnythingLlmBaseUrl } from "./anythingllmClient";
 import type { AnythingWorkspace } from "./types";
-
-const CONFIG_SECTION = "anythingllm";
-
-const readConfig = (): { baseUrl: string; apiKey: string } => {
-  const cfg = vscode.workspace.getConfiguration(CONFIG_SECTION);
-  const baseUrl = typeof cfg.get("baseUrl") === "string" ? cfg.get("baseUrl") as string : "";
-  const apiKey = typeof cfg.get("apiKey") === "string" ? cfg.get("apiKey") as string : "";
-  return { baseUrl, apiKey };
-};
+import { showDevToolsPanel } from "./devToolsPanel";
 
 const workspaceLabel = (w: AnythingWorkspace): string => `${w.name} (${w.slug})`;
 
@@ -22,7 +15,7 @@ const openWorkspaceInBrowser = async (baseUrl: string, slug: string): Promise {
   const listCmd = vscode.commands.registerCommand("anythingllm.listWorkspaces", async () => {
-    const { baseUrl, apiKey } = readConfig();
+    const { baseUrl, apiKey } = readAnythingConfig();
     try {
       const workspaces = await listWorkspaces(baseUrl, apiKey);
       if (workspaces.length === 0) {
@@ -47,7 +40,7 @@ export const activate = (context: vscode.ExtensionContext): void => {
   });
 
   const openUiCmd = vscode.commands.registerCommand("anythingllm.openWebUi", async () => {
-    const { baseUrl } = readConfig();
+    const { baseUrl } = readAnythingConfig();
     try {
       const root = normalizeAnythingLlmBaseUrl(baseUrl);
       await vscode.env.openExternal(vscode.Uri.parse(root));
@@ -57,7 +50,11 @@ export const activate = (context: vscode.ExtensionContext): void => {
     }
   });
 
-  context.subscriptions.push(listCmd, openUiCmd);
+  const devPanelCmd = vscode.commands.registerCommand("anythingllm.openDevToolsPanel", () => {
+    showDevToolsPanel(context, readDevToolsConfig);
+  });
+
+  context.subscriptions.push(listCmd, openUiCmd, devPanelCmd);
 };
 
 export const deactivate = (): void => {
diff --git a/extensions/anythingllm-workspaces/src/reposApiClient.ts b/extensions/anythingllm-workspaces/src/reposApiClient.ts
new file mode 100644
index 0000000..e9805f0
--- /dev/null
+++ b/extensions/anythingllm-workspaces/src/reposApiClient.ts
@@ -0,0 +1,94 @@
+const trimSlash = (u: string): string => u.replace(/\/+$/, "");
+
+const normalizeReposToken = (raw: string): string => {
+  const trimmed = raw.trim();
+  const bearerPrefix = /^Bearer\s+/i;
+  return bearerPrefix.test(trimmed) ? trimmed.replace(bearerPrefix, "").trim() : trimmed;
+};
+
+const authHeaders = (token: string): Record => {
+  const key = normalizeReposToken(token);
+  return {
+    Accept: "application/json",
+    "Content-Type": "application/json",
+    Authorization: `Bearer ${key}`,
+  };
+};
+
+export const reposApiClone = async (
+  baseUrl: string,
+  token: string,
+  url: string,
+  branch: string,
+): Promise => {
+  const root = trimSlash(baseUrl.trim());
+  if (root.length === 0) {
+    throw new Error("anythingllm.reposApiBaseUrl is empty");
+  }
+  const res = await fetch(`${root}/repos-clone`, {
+    method: "POST",
+    headers: authHeaders(token),
+    body: JSON.stringify({ url, branch }),
+  });
+  const text = await res.text();
+  let body: unknown = text;
+  try {
+    body = JSON.parse(text) as unknown;
+  } catch {
+    /* keep text */
+  }
+  if (!res.ok) {
+    throw new Error(`repos API ${res.status}: ${typeof body === "string" ? body : JSON.stringify(body)}`);
+  }
+  return body;
+};
+
+export const reposApiList = async (baseUrl: string, token: string): Promise => {
+  const root = trimSlash(baseUrl.trim());
+  if (root.length === 0) {
+    throw new Error("anythingllm.reposApiBaseUrl is empty");
+  }
+  const res = await fetch(`${root}/repos-list`, {
+    method: "GET",
+    headers: {
+      Accept: "application/json",
+      Authorization: `Bearer ${normalizeReposToken(token)}`,
+    },
+  });
+  const text = await res.text();
+  if (!res.ok) {
+    throw new Error(`repos API ${res.status}: ${text}`);
+  }
+  return JSON.parse(text) as unknown;
+};
+
+export const reposApiLoad = async (
+  baseUrl: string,
+  token: string,
+  name: string,
+): Promise<{ path: string; name: string }> => {
+  const root = trimSlash(baseUrl.trim());
+  if (root.length === 0) {
+    throw new Error("anythingllm.reposApiBaseUrl is empty");
+  }
+  const res = await fetch(`${root}/repos-load`, {
+    method: "POST",
+    headers: authHeaders(token),
+    body: JSON.stringify({ name }),
+  });
+  const text = await res.text();
+  const body = JSON.parse(text) as unknown;
+  if (!res.ok) {
+    throw new Error(`repos API ${res.status}: ${text}`);
+  }
+  if (typeof body !== "object" || body === null) {
+    throw new Error("repos-load: invalid response");
+  }
+  const rec = body as Record;
+  const path = rec.path;
+  const n = rec.name;
+  if (typeof path !== "string" || typeof n !== "string") {
+    throw new Error("repos-load: missing path or name");
+  }
+  return { path, name: n };
+};
diff --git a/extensions/anythingllm-workspaces/src/workspaceEnsure.ts b/extensions/anythingllm-workspaces/src/workspaceEnsure.ts
new file mode 100644
index 0000000..51b732c
--- /dev/null
+++ b/extensions/anythingllm-workspaces/src/workspaceEnsure.ts
@@ -0,0 +1,27 @@
+import { createWorkspace, listWorkspaces } from "./anythingllmClient";
+import type { AnythingWorkspace } from "./types";
+
+export interface EnsuredWorkspace {
+  readonly workspace: AnythingWorkspace;
+  readonly created: boolean;
+}
+
+export const ensureWorkspaceForRepoName = async (
+  baseUrl: string,
+  apiKey: string,
+  repoName: string,
+): Promise => {
+  const key = repoName.trim();
+  if (key.length === 0) {
+    throw new Error("repo/workspace name is empty");
+  }
+  const all = await listWorkspaces(baseUrl, apiKey);
+  const byName = all.find((w) => w.name === key);
+  const bySlug = all.find((w) => w.slug === key);
+  const found = byName ?? bySlug;
+  if (found) {
+    return { workspace: found, created: false };
+  }
+  const created = await createWorkspace(baseUrl, apiKey, key);
+  return { workspace: created, created: true };
+};
diff --git a/services/repos-devtools-server/.gitignore b/services/repos-devtools-server/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/services/repos-devtools-server/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/services/repos-devtools-server/README.md b/services/repos-devtools-server/README.md
new file mode 100644
index 0000000..49827c8
--- /dev/null
+++ b/services/repos-devtools-server/README.md
@@ -0,0 +1,36 @@
+# repos-devtools-server
+
+Local HTTP API bound to **`127.0.0.1`** for git operations under **`REPOS_DEVTOOLS_ROOT`** (default `/home/ncantu/code`).
+
+## Environment
+
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `REPOS_DEVTOOLS_TOKEN` | yes | Shared secret; clients send `Authorization: Bearer `. |
+| `REPOS_DEVTOOLS_ROOT` | no | Absolute root for clones (default `/home/ncantu/code`). |
+| `REPOS_DEVTOOLS_HOST` | no | Bind address (default `127.0.0.1`). |
+| `REPOS_DEVTOOLS_PORT` | no | Port (default `37140`). |
+
+## Endpoints
+
+- `POST /repos-clone` — JSON `{ "url": "", "branch": "test" }` (`branch` optional, default `test`).
+- `GET /repos-list` — Lists immediate subdirectories of the root that contain `.git`.
+- `POST /repos-load` — JSON `{ "name": "" }` — Verifies the repo exists; returns absolute `path`.
+
+All endpoints require `Authorization: Bearer `.
+
+## Run
+
+```bash
+cd services/repos-devtools-server
+npm install
+npm run build
+export REPOS_DEVTOOLS_TOKEN='generate-a-long-random-secret'
+npm start
+```
+
+Use the same token in the VS Code / Cursor setting **`anythingllm.reposApiToken`**.
+
+## Integration
+
+The **AnythingLLM Workspaces** extension command **AnythingLLM: Dev tools panel** calls this API and the AnythingLLM HTTP API for workspace create/list.
diff --git a/services/repos-devtools-server/package-lock.json b/services/repos-devtools-server/package-lock.json
new file mode 100644
index 0000000..467707d
--- /dev/null
+++ b/services/repos-devtools-server/package-lock.json
@@ -0,0 +1,51 @@
+{
+  "name": "@4nk/repos-devtools-server",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "@4nk/repos-devtools-server",
+      "version": "0.1.0",
+      "license": "MIT",
+      "devDependencies": {
+        "@types/node": "^20.11.0",
+        "typescript": "^5.3.3"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "20.19.37",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
+      "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    }
+  }
+}
diff --git a/services/repos-devtools-server/package.json b/services/repos-devtools-server/package.json
new file mode 100644
index 0000000..6e34e2f
--- /dev/null
+++ b/services/repos-devtools-server/package.json
@@ -0,0 +1,20 @@
+{
+  "name": "@4nk/repos-devtools-server",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Local HTTP API: git clone/list under REPOS_DEVTOOLS_ROOT (e.g. /home/ncantu/code).",
+  "license": "MIT",
+  "type": "module",
+  "main": "dist/server.js",
+  "scripts": {
+    "build": "tsc -p ./",
+    "start": "node dist/server.js"
+  },
+  "engines": {
+    "node": ">=20"
+  },
+  "devDependencies": {
+    "@types/node": "^20.11.0",
+    "typescript": "^5.3.3"
+  }
+}
diff --git a/services/repos-devtools-server/src/auth.ts b/services/repos-devtools-server/src/auth.ts
new file mode 100644
index 0000000..1f79549
--- /dev/null
+++ b/services/repos-devtools-server/src/auth.ts
@@ -0,0 +1,21 @@
+import type { IncomingMessage, ServerResponse } from "node:http";
+
+export const readExpectedToken = (): string => {
+  return process.env.REPOS_DEVTOOLS_TOKEN?.trim() ?? "";
+};
+
+export const requireBearer = (
+  req: IncomingMessage,
+  res: ServerResponse,
+  expected: string,
+): boolean => {
+  const h = req.headers.authorization ?? "";
+  const match = /^Bearer\s+(.+)$/i.exec(h);
+  const got = match?.[1]?.trim() ?? "";
+  if (got !== expected) {
+    res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
+    res.end(JSON.stringify({ error: "Unauthorized" }));
+    return false;
+  }
+  return true;
+};
diff --git a/services/repos-devtools-server/src/gitSpawn.ts b/services/repos-devtools-server/src/gitSpawn.ts
new file mode 100644
index 0000000..d311132
--- /dev/null
+++ b/services/repos-devtools-server/src/gitSpawn.ts
@@ -0,0 +1,34 @@
+import { spawn } from "node:child_process";
+
+export interface GitSpawnResult {
+  readonly code: number;
+  readonly stdout: string;
+  readonly stderr: string;
+}
+
+export const runGit = (args: readonly string[], cwd: string): Promise => {
+  return new Promise((resolve, reject) => {
+    const child = spawn("git", [...args], {
+      cwd,
+      env: process.env,
+    });
+    const out: Buffer[] = [];
+    const err: Buffer[] = [];
+    child.stdout.on("data", (chunk: Buffer) => {
+      out.push(chunk);
+    });
+    child.stderr.on("data", (chunk: Buffer) => {
+      err.push(chunk);
+    });
+    child.on("error", (e) => {
+      reject(e);
+    });
+    child.on("close", (code) => {
+      resolve({
+        code: code ?? 1,
+        stdout: Buffer.concat(out).toString("utf8"),
+        stderr: Buffer.concat(err).toString("utf8"),
+      });
+    });
+  });
+};
diff --git a/services/repos-devtools-server/src/handlers.ts b/services/repos-devtools-server/src/handlers.ts
new file mode 100644
index 0000000..cda06e6
--- /dev/null
+++ b/services/repos-devtools-server/src/handlers.ts
@@ -0,0 +1,130 @@
+import * as fs from "node:fs/promises";
+import type { IncomingMessage, ServerResponse } from "node:http";
+import * as path from "node:path";
+import { runGit } from "./gitSpawn.js";
+import { readJsonBody } from "./httpUtil.js";
+import {
+  getCodeRoot,
+  isGitRepo,
+  repoDirForName,
+  repoNameFromGitUrl,
+} from "./paths.js";
+
+const json = (res: ServerResponse, status: number, body: unknown): void => {
+  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
+  res.end(JSON.stringify(body));
+};
+
+const isRecord = (v: unknown): v is Record =>
+  typeof v === "object" && v !== null && !Array.isArray(v);
+
+const readBranch = (body: unknown, fallback: string): string => {
+  if (!isRecord(body)) {
+    return fallback;
+  }
+  const b = body.branch;
+  return typeof b === "string" && b.trim().length > 0 ? b.trim() : fallback;
+};
+
+const readUrl = (body: unknown): string => {
+  if (!isRecord(body)) {
+    throw new Error("Expected JSON object with url");
+  }
+  const u = body.url;
+  if (typeof u !== "string" || u.trim().length === 0) {
+    throw new Error("Missing or invalid url");
+  }
+  return u.trim();
+};
+
+const readName = (body: unknown): string => {
+  if (!isRecord(body)) {
+    throw new Error("Expected JSON object with name");
+  }
+  const n = body.name;
+  if (typeof n !== "string" || n.trim().length === 0) {
+    throw new Error("Missing or invalid name");
+  }
+  return n.trim();
+};
+
+export const handleReposClone = async (
+  req: IncomingMessage,
+  res: ServerResponse,
+): Promise => {
+  const codeRoot = getCodeRoot();
+  const body = await readJsonBody(req);
+  const url = readUrl(body);
+  const branch = readBranch(body, "test");
+  const name = repoNameFromGitUrl(url);
+  const dest = path.join(codeRoot, name);
+  try {
+    await fs.access(dest);
+    json(res, 409, {
+      error: "Target directory already exists",
+      name,
+      path: dest,
+    });
+    return;
+  } catch {
+    /* absent */
+  }
+  const r = await runGit(
+    ["clone", "-b", branch, "--single-branch", url, dest],
+    codeRoot,
+  );
+  if (r.code !== 0) {
+    json(res, 500, {
+      error: "git clone failed",
+      code: r.code,
+      stderr: r.stderr,
+      stdout: r.stdout,
+    });
+    return;
+  }
+  json(res, 200, {
+    ok: true,
+    name,
+    path: dest,
+    branch,
+    url,
+  });
+};
+
+export const handleReposList = async (res: ServerResponse): Promise => {
+  const codeRoot = getCodeRoot();
+  const entries = await fs.readdir(codeRoot, { withFileTypes: true });
+  const repos: { name: string; path: string }[] = [];
+  for (const ent of entries) {
+    if (!ent.isDirectory()) {
+      continue;
+    }
+    const full = path.join(codeRoot, ent.name);
+    if (await isGitRepo(full)) {
+      repos.push({ name: ent.name, path: full });
+    }
+  }
+  repos.sort((a, b) => a.name.localeCompare(b.name));
+  json(res, 200, { repos, codeRoot });
+};
+
+export const handleReposLoad = async (
+  req: IncomingMessage,
+  res: ServerResponse,
+): Promise => {
+  const codeRoot = getCodeRoot();
+  const body = await readJsonBody(req);
+  const name = readName(body);
+  const dest = repoDirForName(codeRoot, name);
+  try {
+    await fs.access(dest);
+  } catch {
+    json(res, 404, { error: "Repository directory not found", name, path: dest });
+    return;
+  }
+  if (!(await isGitRepo(dest))) {
+    json(res, 400, { error: "Directory is not a git repository", name, path: dest });
+    return;
+  }
+  json(res, 200, { ok: true, name, path: dest });
+};
diff --git a/services/repos-devtools-server/src/httpUtil.ts b/services/repos-devtools-server/src/httpUtil.ts
new file mode 100644
index 0000000..698210e
--- /dev/null
+++ b/services/repos-devtools-server/src/httpUtil.ts
@@ -0,0 +1,25 @@
+import type { IncomingMessage } from "node:http";
+
+const MAX_BODY = 1_048_576;
+
+export const readJsonBody = async (req: IncomingMessage): Promise => {
+  const chunks: Buffer[] = [];
+  let total = 0;
+  for await (const chunk of req) {
+    const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+    total += buf.length;
+    if (total > MAX_BODY) {
+      throw new Error("Request body too large");
+    }
+    chunks.push(buf);
+  }
+  const raw = Buffer.concat(chunks).toString("utf8").trim();
+  if (raw.length === 0) {
+    return {};
+  }
+  try {
+    return JSON.parse(raw) as unknown;
+  } catch (cause) {
+    throw new Error("Invalid JSON body", { cause });
+  }
+};
diff --git a/services/repos-devtools-server/src/paths.ts b/services/repos-devtools-server/src/paths.ts
new file mode 100644
index 0000000..0a20313
--- /dev/null
+++ b/services/repos-devtools-server/src/paths.ts
@@ -0,0 +1,47 @@
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+
+const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$/;
+
+export const assertSafeRepoName = (name: string): string => {
+  const trimmed = name.trim();
+  if (!SAFE_NAME.test(trimmed)) {
+    throw new Error("Invalid repo name: use letters, digits, . _ - only; no path segments.");
+  }
+  if (trimmed.includes("..")) {
+    throw new Error("Invalid repo name: path traversal rejected.");
+  }
+  return trimmed;
+};
+
+export const getCodeRoot = (): string => {
+  const raw = process.env.REPOS_DEVTOOLS_ROOT ?? "/home/ncantu/code";
+  const resolved = path.resolve(raw);
+  return resolved;
+};
+
+export const repoDirForName = (codeRoot: string, name: string): string => {
+  const safe = assertSafeRepoName(name);
+  return path.join(codeRoot, safe);
+};
+
+export const isGitRepo = async (dir: string): Promise => {
+  try {
+    const stat = await fs.stat(path.join(dir, ".git"));
+    return stat.isDirectory() || stat.isFile();
+  } catch {
+    return false;
+  }
+};
+
+export const repoNameFromGitUrl = (rawUrl: string): string => {
+  const trimmed = rawUrl.trim();
+  const noQuery = trimmed.split("?")[0] ?? trimmed;
+  const base = noQuery.replace(/[/]+$/, "");
+  const segment = base.split("/").filter((s) => s.length > 0).pop() ?? "";
+  const withoutGit = segment.replace(/\.git$/i, "");
+  if (withoutGit.length === 0) {
+    throw new Error("Could not derive repository name from URL.");
+  }
+  return assertSafeRepoName(withoutGit);
+};
diff --git a/services/repos-devtools-server/src/server.ts b/services/repos-devtools-server/src/server.ts
new file mode 100644
index 0000000..4aeda7f
--- /dev/null
+++ b/services/repos-devtools-server/src/server.ts
@@ -0,0 +1,56 @@
+import * as http from "node:http";
+import { requireBearer, readExpectedToken } from "./auth.js";
+import {
+  handleReposClone,
+  handleReposList,
+  handleReposLoad,
+} from "./handlers.js";
+
+const HOST = process.env.REPOS_DEVTOOLS_HOST ?? "127.0.0.1";
+const PORT = Number(process.env.REPOS_DEVTOOLS_PORT ?? "37140");
+
+const main = (): void => {
+  const token = readExpectedToken();
+  if (token.length === 0) {
+    console.error("repos-devtools-server: set REPOS_DEVTOOLS_TOKEN (non-empty secret).");
+    process.exit(1);
+  }
+
+  const server = http.createServer((req, res) => {
+    void (async () => {
+      try {
+        if (!requireBearer(req, res, token)) {
+          return;
+        }
+        const url = new URL(req.url ?? "/", `http://${HOST}`);
+        const p = url.pathname;
+        if (req.method === "POST" && p === "/repos-clone") {
+          await handleReposClone(req, res);
+          return;
+        }
+        if (req.method === "GET" && p === "/repos-list") {
+          await handleReposList(res);
+          return;
+        }
+        if (req.method === "POST" && p === "/repos-load") {
+          await handleReposLoad(req, res);
+          return;
+        }
+        res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
+        res.end(JSON.stringify({ error: "Not found" }));
+      } catch (e) {
+        const msg = e instanceof Error ? e.message : String(e);
+        res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
+        res.end(JSON.stringify({ error: msg }));
+      }
+    })();
+  });
+
+  server.listen(PORT, HOST, () => {
+    console.error(
+      `repos-devtools-server listening on http://${HOST}:${PORT} (root=${process.env.REPOS_DEVTOOLS_ROOT ?? "/home/ncantu/code"})`,
+    );
+  });
+};
+
+main();
diff --git a/services/repos-devtools-server/tsconfig.json b/services/repos-devtools-server/tsconfig.json
new file mode 100644
index 0000000..ae73f32
--- /dev/null
+++ b/services/repos-devtools-server/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "outDir": "dist",
+    "rootDir": "src",
+    "strict": true,
+    "skipLibCheck": true,
+    "noImplicitReturns": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "declaration": false
+  },
+  "include": ["src/**/*.ts"]
+}