Add repos-devtools-server and AnythingLLM dev tools panel (0.2.0)

**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
This commit is contained in:
Nicolas Cantu 2026-03-23 21:20:32 +01:00
parent 564b9d5576
commit 597f18f758
25 changed files with 1106 additions and 19 deletions

View File

@ -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 lUI |
| [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 |

View File

@ -15,6 +15,7 @@ Fournir un point dentré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 lextension : 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

View File

@ -0,0 +1,34 @@
# repos-devtools-server + panneau « Dev tools » (extension AnythingLLM)
**Author:** 4NK
## Objectif
Sur lhô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 lextension **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 lextension (`.vsix` ou workspace dev).
## Modalités danalyse
- Erreur **401** sur lAPI 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` nexiste pas sur le remote (comportement git nominal).

View File

@ -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/<slug>` 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 <git-url>` | `POST /repos-clone` — clone into `REPOS_DEVTOOLS_ROOT`, branch **`test`** (override with JSON only via API, not this line). |
| `/repos-clone-sync <url>` | 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 <name>` | Verify repo folder + **Open folder** in the editor. |
| `/repos-load-sync <name>` | Open folder + ensure workspace + browser. |
| `/workspace-load <name>` | 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": "<name>" }`.
**AnythingLLM “user”:** the developer API uses the **API key**; per-user workspace ownership follows AnythingLLMs 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 Cursors 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`.

View File

@ -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);
}
});
})();

View File

@ -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"
}
]
},

View File

@ -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<AnythingWorkspace> => {
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));
};

View File

@ -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 <git-url> — clone into REPOS_DEVTOOLS_ROOT, branch test (default)",
" /repos-clone-sync <url> — clone + ensure AnythingLLM workspace (same name) + open folder + browser",
" repos-list — list cloned git repos under root",
" /repos-load <name> — verify repo; open folder in editor",
" /repos-load-sync <name> — open folder + ensure workspace + browser",
" /workspace-load <name> — ensure workspace by name (create via API if missing) + browser",
" help — this list",
].join("\n");
};

View File

@ -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,
};
};

View File

@ -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<void>;
readonly openAnythingWorkspaceInBrowser: (slug: string) => Thenable<void>;
}
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<string> => {
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<string, unknown>;
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<string> => {
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<void>) => {
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<void>) => {
return (slug: string) => {
const root = normalizeAnythingLlmBaseUrl(baseUrl);
const uri = vscodeApi.Uri.parse(
`${root}/workspace/${encodeURIComponent(slug)}`,
);
return vscodeApi.env.openExternal(uri).then(() => undefined);
};
};

View File

@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${csp} 'unsafe-inline'; script-src ${csp};" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${panelTitle}</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0.75rem; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
label { display: block; margin-bottom: 0.35rem; font-weight: 600; }
textarea#cmd { width: 100%; min-height: 7rem; box-sizing: border-box; font-family: monospace; font-size: 12px; }
pre#out { white-space: pre-wrap; word-break: break-word; min-height: 4rem; padding: 0.5rem; border: 1px solid var(--vscode-panel-border); }
.row { margin-bottom: 0.5rem; }
button { margin-right: 0.5rem; }
</style>
</head>
<body>
<div class="row">
<label for="cmd">Commands (one per line)</label>
<textarea id="cmd" spellcheck="false" placeholder="/repos-clone https://…&#10;repos-list"></textarea>
</div>
<div class="row">
<button type="button" id="run">Run</button>
<button type="button" id="clear">Clear output</button>
</div>
<label for="out">Response</label>
<pre id="out"></pre>
<script src="${scriptUri}"></script>
</body>
</html>`;
};
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<string, unknown>;
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,
);
};

View File

@ -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<vo
export const activate = (context: vscode.ExtensionContext): void => {
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 => {

View File

@ -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<string, string> => {
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<unknown> => {
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<unknown> => {
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<string, unknown>;
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 };
};

View File

@ -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<EnsuredWorkspace> => {
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 };
};

View File

@ -0,0 +1,2 @@
node_modules/
dist/

View File

@ -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 <token>`. |
| `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": "<git url>", "branch": "test" }` (`branch` optional, default `test`).
- `GET /repos-list` — Lists immediate subdirectories of the root that contain `.git`.
- `POST /repos-load` — JSON `{ "name": "<folder name>" }` — Verifies the repo exists; returns absolute `path`.
All endpoints require `Authorization: Bearer <REPOS_DEVTOOLS_TOKEN>`.
## 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.

View File

@ -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"
}
}
}

View File

@ -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"
}
}

View File

@ -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;
};

View File

@ -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<GitSpawnResult> => {
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"),
});
});
});
};

View File

@ -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<string, unknown> =>
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<void> => {
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<void> => {
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<void> => {
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 });
};

View File

@ -0,0 +1,25 @@
import type { IncomingMessage } from "node:http";
const MAX_BODY = 1_048_576;
export const readJsonBody = async (req: IncomingMessage): Promise<unknown> => {
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 });
}
};

View File

@ -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<boolean> => {
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);
};

View File

@ -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();

View File

@ -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"]
}