From 4e29217d993b9e8334adc5cc76ccbdd982ac7ed3 Mon Sep 17 00:00:00 2001 From: Lars Baunwall Date: Wed, 13 Aug 2025 00:01:17 +0200 Subject: [PATCH] Add icon and refresh the readme --- AGENTS.md | 336 ---------------------------------------------- README.md | 258 ++++++++++++++++++++--------------- images/icon.png | Bin 0 -> 16614 bytes package-lock.json | 5 +- package.json | 15 ++- 5 files changed, 162 insertions(+), 452 deletions(-) delete mode 100644 AGENTS.md create mode 100644 images/icon.png diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 86cebc9..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,336 +0,0 @@ -# Inference‑Only Copilot Bridge (VS Code Desktop) - -## Scope & Goals - -Expose a **local, OpenAI‑compatible chat endpoint** inside a **running VS Code Desktop** session that forwards requests to **GitHub Copilot Chat** via the **VS Code Chat provider**. No workspace tools (no search/edit), no VS Code Server. - -- Endpoints: - - `POST /v1/chat/completions` (supports streaming via SSE) - - `GET /v1/models` (synthetic listing) - - `GET /healthz` (status) -- Local only (`127.0.0.1`), single user, opt‑in via VS Code settings/command. -- Minimal state: one Copilot session per request; no history persisted by the bridge. - -**Non‑goals:** multi‑tenant proxying, private endpoint scraping, file I/O tools, function/tool calling emulation. - ---- - -## Architecture (Desktop‑only, in‑process) - -``` -VS Code Desktop (running) -┌──────────────────────────────────────────────────────────────┐ -│ Bridge Extension (TypeScript, Extension Host) │ -│ - HTTP server on 127.0.0.1: │ -│ - POST /v1/chat/completions → Copilot Chat provider │ -│ - GET /v1/models (synthetic) │ -│ - GET /healthz │ -│ Copilot pipe: │ -│ vscode.chat.requestChatAccess('copilot') │ -│ → access.startSession().sendRequest({ prompt, ... }) │ -└──────────────────────────────────────────────────────────────┘ -``` - -### Data flow -Client (OpenAI API shape) → Bridge HTTP → normalize messages → `requestChatAccess('copilot')` → `startSession().sendRequest` → stream chunks → SSE to client. - ---- - -## API Contract (subset, OpenAI‑compatible) - -### POST `/v1/chat/completions` -**Accepted fields** -- `model`: string (ignored internally, echoed back as synthetic id). -- `messages`: array of `{role, content}`; roles: `system`, `user`, `assistant`. -- `stream`: boolean (default `true`). If `false`, return a single JSON completion. - -**Ignored fields** -`tools`, `function_call/tool_choice`, `temperature`, `top_p`, `logprobs`, `seed`, penalties, `response_format`, `stop`, `n`. - -**Prompt normalization** -- Keep the last **system** message and the last **N** user/assistant turns (configurable, default 3) to bound prompt size. -- Render into a single text prompt: - -``` -[SYSTEM] - - -[DIALOG] -user: ... -assistant: ... -user: ... -``` - -**Streaming response (SSE)** -- For each Copilot content chunk: - -``` -data: {"id":"cmp_","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":""}}]} -``` - -- Terminate with: - -``` -data: [DONE] -``` - -**Non‑streaming response** - -```json -{ - "id": "cmpl_", - "object": "chat.completion", - "choices": [ - { "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" } - ] -} -``` - -### GET `/v1/models` - -```json -{ - "data": [ - { "id": "gpt-4o-copilot", "object": "model", "owned_by": "vscode-bridge" } - ] -} -``` - -### GET `/healthz` - -```json -{ "ok": true, "copilot": "ok", "version": "" } -``` - -### Error envelope (OpenAI‑style) - -```json -{ "error": { "message": "Copilot unavailable", "type": "server_error", "code": "copilot_unavailable" } } -``` - ---- - -## Extension Design - -### `package.json` (relevant) -- `activationEvents`: `onStartupFinished` (and commands). -- `contributes.commands`: `bridge.enable`, `bridge.disable`, `bridge.status`. -- `contributes.configuration` (under `bridge.*`): - - `enabled` (bool; default `false`) - - `host` (string; default `"127.0.0.1"`) - - `port` (int; default `0` = random ephemeral) - - `token` (string; optional bearer; empty means no auth, still loopback only) - - `historyWindow` (int; default `3`) - -### Lifecycle -- On activate: - 1. Check `bridge.enabled`; if false, return. - 2. Attempt `vscode.chat.requestChatAccess('copilot')`; cache access if granted. - 3. Start HTTP server bound to loopback. - 4. Status bar item: `Copilot Bridge: OK/Unavailable @ :`. -- On deactivate/disable: close server, dispose listeners. - -### Copilot Hook - -```ts -const access = await vscode.chat.requestChatAccess('copilot'); // per enable or per request -const session = await access.startSession(); -const stream = await session.sendRequest({ prompt, attachments: [] }); -// stream.onDidProduceContent(text => ...) -// stream.onDidEnd(() => ...) -``` - ---- - -## Implementation Notes -- **HTTP server:** Node `http` or a tiny `express` router. Keep it minimal to reduce dependencies. -- **Auth:** optional `Authorization: Bearer `; recommended for local automation. Reject mismatches with 401. -- **Backpressure:** serialize requests or cap concurrency (configurable). If Copilot throttles, return 429 with `Retry-After`. -- **Message normalization:** - - Coerce content variants (`string`, arrays, objects with `text`) into plain strings. - - Join multi‑part content with `\n`. -- **Streaming:** - - Set headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`. - - Flush after each chunk; handle client disconnect by disposing stream subscriptions. -- **Non‑stream:** buffer chunks; return a single completion object. -- **Errors:** `503` when Copilot access unavailable; `400` for invalid payloads; `500` for unexpected failures. -- **Logging:** VS Code Output channel: start/stop, port, errors (no prompt bodies unless user enables verbose logging). -- **UX:** `bridge.status` shows availability, bound address/port, and whether a token is required; status bar indicator toggles on availability. - ---- - -## Security & Compliance -- Local only: default bind to `127.0.0.1`; no remote exposure. -- Single user: relies on the user’s authenticated VS Code Copilot session; bridge does not handle tokens. -- No scraping/private endpoints: all calls go through the VS Code Chat provider. -- No multi‑tenant/proxying: do not expose to others; treat as a personal developer convenience. - ---- - -## Testing Plan -1. **Health** - ```bash - curl http://127.0.0.1:/healthz - ``` - Expect `{ ok: true, copilot: "ok" }` when signed in. - -2. **Streaming completion** - ```bash - curl -N -H "Content-Type: application/json" \ - -d '{"model":"gpt-4o-copilot","stream":true,"messages":[{"role":"user","content":"hello"}]}' \ - http://127.0.0.1:/v1/chat/completions - ``` - Expect multiple `data:` chunks and `[DONE]`. - -3. **Non‑stream** (`"stream": false`) → single JSON completion. - -4. **Bearer** (when configured): missing/incorrect token → `401`. - -5. **Unavailable**: sign out of Copilot → `/healthz` shows `unavailable`; POST returns `503`. - -6. **Concurrency/throttle**: fire two requests; verify cap or serialized handling. - ---- - -## Minimal Code Skeleton - -### `src/extension.ts` - -```ts -import * as vscode from 'vscode'; -import * as http from 'http'; - -let server: http.Server | undefined; -let access: vscode.ChatAccess | undefined; - -export async function activate(ctx: vscode.ExtensionContext) { - const cfg = vscode.workspace.getConfiguration('bridge'); - if (!cfg.get('enabled')) return; - - try { access = await vscode.chat.requestChatAccess('copilot'); } - catch { access = undefined; } - - const host = cfg.get('host') ?? '127.0.0.1'; - const portCfg = cfg.get('port') ?? 0; - const token = (cfg.get('token') ?? '').trim(); - const hist = cfg.get('historyWindow') ?? 3; - - server = http.createServer(async (req, res) => { - try { - if (token && req.headers.authorization !== `Bearer ${token}`) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error:{ message:'unauthorized' } })); - return; - } - if (req.method === 'GET' && req.url === '/healthz') { - res.writeHead(200, { 'Content-Type':'application/json' }); - res.end(JSON.stringify({ ok: !!access, copilot: access ? 'ok':'unavailable', version: vscode.version })); - return; - } - if (req.method === 'GET' && req.url === '/v1/models') { - res.writeHead(200, { 'Content-Type':'application/json' }); - res.end(JSON.stringify({ data:[{ id:'gpt-4o-copilot', object:'model', owned_by:'vscode-bridge' }] })); - return; - } - if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) { - if (!access) { - res.writeHead(503, { 'Content-Type':'application/json' }); - res.end(JSON.stringify({ error:{ message:'Copilot unavailable', type:'server_error', code:'copilot_unavailable' } })); - return; - } - const body = await readJson(req); - const prompt = normalizeMessages(body?.messages ?? [], hist); - const streamMode = body?.stream !== false; // default=true - const session = await access.startSession(); - const chatStream = await session.sendRequest({ prompt, attachments: [] }); - - if (streamMode) { - res.writeHead(200, { - 'Content-Type':'text/event-stream', - 'Cache-Control':'no-cache', - 'Connection':'keep-alive' - }); - const id = `cmp_${Math.random().toString(36).slice(2)}`; - const h1 = chatStream.onDidProduceContent((chunk) => { - res.write(`data: ${JSON.stringify({ - id, object:'chat.completion.chunk', - choices:[{ index:0, delta:{ content: chunk } }] - })}\n\n`); - }); - const endAll = () => { - res.write('data: [DONE]\n\n'); res.end(); - h1.dispose(); h2.dispose(); - }; - const h2 = chatStream.onDidEnd(endAll); - req.on('close', endAll); - return; - } else { - let buf = ''; - const h1 = chatStream.onDidProduceContent((chunk) => { buf += chunk; }); - await new Promise(resolve => { - const h2 = chatStream.onDidEnd(() => { h1.dispose(); h2.dispose(); resolve(); }); - }); - res.writeHead(200, { 'Content-Type':'application/json' }); - res.end(JSON.stringify({ - id:`cmpl_${Math.random().toString(36).slice(2)}`, - object:'chat.completion', - choices:[{ index:0, message:{ role:'assistant', content: buf }, finish_reason:'stop' }] - })); - return; - } - } - res.writeHead(404).end(); - } catch (e:any) { - res.writeHead(500, { 'Content-Type':'application/json' }); - res.end(JSON.stringify({ error:{ message: e?.message ?? 'internal_error', type:'server_error', code:'internal_error' } })); - } - }); - - server.listen(portCfg, host, () => { - const addr = server!.address(); - const shown = typeof addr === 'object' && addr ? `${addr.address}:${addr.port}` : `${host}:${portCfg}`; - vscode.window.setStatusBarMessage(`Copilot Bridge: ${access ? 'OK' : 'Unavailable'} @ ${shown}`); - }); - - ctx.subscriptions.push({ dispose: () => server?.close() }); -} - -export function deactivate() { - server?.close(); -} - -function readJson(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let data = ''; req.on('data', c => data += c); - req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } }); - req.on('error', reject); - }); -} - -function normalizeMessages(messages: any[], histWindow: number): string { - const system = messages.filter((m:any) => m.role === 'system').pop()?.content; - const turns = messages.filter((m:any) => m.role === 'user' || m.role === 'assistant').slice(-histWindow*2); - const dialog = turns.map((m:any) => `${m.role}: ${asText(m.content)}`).join('\n'); - return `${system ? `[SYSTEM]\n${asText(system)}\n\n` : ''}[DIALOG]\n${dialog}`; -} - -function asText(content: any): string { - if (typeof content === 'string') return content; - if (Array.isArray(content)) return content.map(asText).join('\n'); - if ((content as any)?.text) return (content as any).text; - try { return JSON.stringify(content); } catch { return String(content); } -} -``` - ---- - -## Delivery Checklist -- Extension skeleton with settings + commands. -- HTTP server (loopback), `/healthz`, `/v1/models`, `/v1/chat/completions`. -- Copilot access + session streaming. -- Prompt normalization (system + last N turns). -- SSE mapping and non‑stream fallback. -- Optional bearer token check. -- Status bar + Output channel diagnostics. -- Tests: health, streaming, non‑stream, auth, unavailability. -- \ No newline at end of file diff --git a/README.md b/README.md index b87e3f0..cf7a822 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,192 @@ -# VS Code Copilot Bridge (Desktop, Inference-only) + -Local OpenAI-compatible HTTP facade to GitHub Copilot via the VS Code Language Model API. +# Copilot Bridge (VS Code Extension) -- Endpoints (local-only, default bind 127.0.0.1): - - POST /v1/chat/completions (SSE streaming; use "stream": false for non-streaming) - - GET /v1/models (dynamic listing of available Copilot models) - - GET /healthz (ok/unavailable + vscode.version) +[![Visual Studio Marketplace](https://vsmarketplacebadges.dev/version/thinkability.copilot-bridge.svg)](https://marketplace.visualstudio.com/items?itemName=thinkability.copilot-bridge) -- Copilot pipe: vscode.lm.selectChatModels({ vendor: "copilot", family: "gpt-4o" }) → model.sendRequest(messages) +Expose GitHub Copilot as a local, OpenAI-compatible HTTP endpoint running inside VS Code. The bridge forwards chat requests to Copilot using the VS Code Language Model API and streams results back to you. -- Prompt normalization: last system message + last N user/assistant turns (default 3) rendered as: - [SYSTEM] - … - [DIALOG] - user: … - assistant: … +What you get: -- Security: loopback-only binding by default; optional Authorization: Bearer . +- Local HTTP server (loopback-only by default) +- OpenAI-style endpoints and payloads +- Server-Sent Events (SSE) streaming for chat completions +- Dynamic listing of available Copilot chat models -## Install, Build, and Run +Endpoints exposed: -Prerequisites: -- VS Code Desktop (stable), GitHub Copilot signed in -- Node.js 18+ (recommended) -- npm +- POST /v1/chat/completions — OpenAI-style chat API (streaming by default) +- GET /v1/models — lists available Copilot models +- GET /healthz — health + VS Code version -- Auto-recovery: the bridge re-requests Copilot access on each chat request if missing; no restart required after signing in. `/healthz` will best-effort recheck only when `bridge.verbose` is true. +The extension will autostart and requires VS Code to be running. +### Don't break your Copilot license +This extension allows you to use your Copilot outside of VS Code **for your own personal use only** obeying the same terms as set forth in the VS Code and Github Copilot terms of service. +## Quick start -Steps: -1) Install deps and compile: - npm install - npm run compile +Requirements: -2) Launch in VS Code (recommended for debugging): - - Open this folder in VS Code - - Press F5 to run the extension in a new Extension Development Host +- VS Code Desktop with GitHub Copilot signed in +- If building locally: Node.js 18+ and npm -3) Enable the bridge: - - Command Palette → “Copilot Bridge: Enable” - - Or set in settings: bridge.enabled = true - - “Copilot Bridge: Status” shows the bound address/port and token requirement +Steps (dev run): -Optional: Packaging a VSIX -- You can package with vsce (not included): - npm i -g @vscode/vsce - vsce package -- Then install the generated .vsix via “Extensions: Install from VSIX…” -## Enabling the Language Model API (if required) +1. Install and compile -If your VS Code build requires enabling proposed APIs for the Language Model API, start with: -### Models and Selection +```bash +npm install +npm run compile +``` -- The bridge lists available GitHub Copilot chat models using the VS Code Language Model API. Example: - curl http://127.0.0.1:<port>/v1/models - → { "data": [ { "id": "gpt-4o-copilot", ... }, ... ] } +1. Press F5 in VS Code to launch an Extension Development Host -- To target a specific model for inference, set the "model" field in your POST body. The bridge accepts: - - IDs returned by /v1/models (e.g., "gpt-4o-copilot") - - A Copilot family name (e.g., "gpt-4o") - - "copilot" to allow default selection +1. In the Dev Host, enable the bridge -Examples: +- Command Palette → “Copilot Bridge: Enable” +- Or set setting bridge.enabled = true + +1. Check status + +- Command Palette → “Copilot Bridge: Status” (shows bound address/port and whether a token is required) + +Optional: package a VSIX + +```bash +npm run package +``` +Then install the generated .vsix via “Extensions: Install from VSIX…”. + +## Use it + +Replace PORT with what “Copilot Bridge: Status” shows. + +- List models + +```bash +curl http://127.0.0.1:$PORT/v1/models +``` + +- Stream a completion + +```bash curl -N -H "Content-Type: application/json" \ -d '{"model":"gpt-4o-copilot","messages":[{"role":"user","content":"hello"}]}' \ - http://127.0.0.1:<port>/v1/chat/completions + http://127.0.0.1:$PORT/v1/chat/completions +``` -curl -N -H "Content-Type: application/json" \ - -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}' \ - http://127.0.0.1:<port>/v1/chat/completions +- Non-streaming -- If a requested model is unavailable, the bridge returns: - 404 with { "error": { "code": "model_not_found", "reason": "not_found" } }. -- Stable VS Code: `code --enable-proposed-api thinkability.copilot-bridge` -- VS Code Insiders or Extension Development Host (F5) also works. +```bash +curl -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o-copilot","stream":false,"messages":[{"role":"user","content":"hello"}]}' \ + http://127.0.0.1:$PORT/v1/chat/completions +``` -When the API is not available, the Output (“Copilot Bridge”) will show: -“VS Code Language Model API not available; update VS Code or enable proposed API.” +Tip: You can also pass a family like "gpt-4o" as model. If unavailable you’ll get 404 with code model_not_found. -## Troubleshooting +### Using OpenAI SDK (Node.js) -- /healthz shows `copilot: "unavailable"` with a `reason`: - - `missing_language_model_api`: Language Model API not available - - `copilot_model_unavailable`: No Copilot models selectable - - `consent_required`: User consent/sign-in required for Copilot models - - `rate_limited`: Provider throttling - - `not_found`: Requested model not found - - `copilot_unavailable`: Other provider errors -- POST /v1/chat/completions returns 503 with the same `reason` codes. +Point your client to the bridge and use your token (if set) as apiKey. +```ts +import OpenAI from "openai"; + +const client = new OpenAI({ + baseURL: `http://127.0.0.1:${process.env.PORT}/v1`, + apiKey: process.env.BRIDGE_TOKEN || "not-used-when-empty", +}); + +const rsp = await client.chat.completions.create({ + model: "gpt-4o-copilot", + messages: [{ role: "user", content: "hello" }], + stream: false, +}); +console.log(rsp.choices[0].message?.content); +``` + +## How it works + +The extension uses VS Code’s Language Model API to select a GitHub Copilot chat model and forward your conversation. Messages are normalized to preserve the last system prompt and the most recent user/assistant turns (configurable window). Responses are streamed back via SSE or returned as a single JSON payload. ## Configuration (bridge.*) -- bridge.enabled (boolean; default false): auto-start on VS Code startup -- bridge.host (string; default "127.0.0.1"): bind address (keep on loopback) -- bridge.port (number; default 0): 0 = ephemeral port -- bridge.token (string; default ""): optional bearer token; empty disables auth -- bridge.historyWindow (number; default 3): number of user/assistant turns to keep -- bridge.maxConcurrent (number; default 1): max concurrent chat requests; excess → 429 -## Viewing logs +Settings live under “Copilot Bridge” in VS Code settings: -To see verbose logs: -1) Enable: Settings → search “Copilot Bridge” → enable “bridge.verbose” -2) Open: View → Output → select “Copilot Bridge” in the dropdown -3) Trigger a request (e.g., curl /v1/chat/completions). You’ll see: - - HTTP request lines (method/path) - - Model selection attempts (“Copilot model selected.”) - - SSE lifecycle (“SSE start …”, “SSE end …”) - - Health checks (best-effort model check when verbose is on) - - API diagnostics (e.g., missing Language Model API) -- bridge.verbose (boolean; default false): verbose logs to “Copilot Bridge” output channel +| Setting | Default | Description | +|---|---|---| +| bridge.enabled | false | Start the bridge automatically when VS Code launches. | +| bridge.host | 127.0.0.1 | Bind address. Keep on loopback for safety. | +| bridge.port | 0 | Port for the HTTP server. 0 picks an ephemeral port. | +| bridge.token | "" | Optional bearer token. If set, requests must include `Authorization: Bearer `. | +| bridge.historyWindow | 3 | Number of user/assistant turns kept (system message is tracked separately). | +| bridge.maxConcurrent | 1 | Maximum concurrent /v1/chat/completions; excess return 429. | +| bridge.verbose | false | Verbose logs in the “Copilot Bridge” Output channel. | -## Manual Testing (curl) +Status bar: Shows availability and bound address (e.g., “Copilot Bridge: OK @ 127.0.0.1:12345”). -Replace with the port shown in “Copilot Bridge: Status”. +## Endpoints -Health: -curl http://127.0.0.1:/healthz +### GET /healthz -Models: -curl http://127.0.0.1:/v1/models +Returns `{ ok: true, copilot: "ok" | "unavailable", reason?: string, version: }`. -Streaming completion: -curl -N -H "Content-Type: application/json" \ - -d '{"model":"gpt-4o-copilot","stream":true,"messages":[{"role":"user","content":"hello"}]}' \ - http://127.0.0.1:/v1/chat/completions +### GET /v1/models -Non-stream: -curl -H "Content-Type: application/json" \ - -d '{"model":"gpt-4o-copilot","stream":false,"messages":[{"role":"user","content":"hello"}]}' \ - http://127.0.0.1:/v1/chat/completions +Returns `{ data: [{ id, object: "model", owned_by: "vscode-bridge" }] }`. -Auth (when bridge.token is set): -- Missing/incorrect Authorization: Bearer → 401 +### POST /v1/chat/completions -Copilot unavailable: -- Sign out of GitHub Copilot; /healthz shows unavailable; POST returns 503 envelope +OpenAI-style body with `messages` and optional `model` and `stream`. Streaming uses SSE with `data: { ... }` events and a final `data: [DONE]`. -Concurrency: -- Fire 2+ concurrent requests; above bridge.maxConcurrent → 429 rate_limit_error +Accepted model values: -## Notes +- IDs from /v1/models (e.g., `gpt-4o-copilot`) +- Copilot family names (e.g., `gpt-4o`) +- `copilot` to allow default selection -- Desktop-only, in-process. No VS Code Server dependency. -- Single-user, local loopback. Do not expose to remote interfaces. -- Non-goals: tools/function calling emulation, workspace file I/O, multi-tenant proxying. +Common errors: + +- 401 unauthorized when token is set but header is missing/incorrect +- 404 model_not_found when the requested family/ID isn’t available +- 429 rate_limit_exceeded when above bridge.maxConcurrent +- 503 copilot_unavailable when the Language Model API or Copilot model isn’t available + +## Logs and diagnostics + +To view logs: + +1. Enable “bridge.verbose” (optional) +1. View → Output → “Copilot Bridge” +1. Trigger requests to see HTTP lines, model selection, SSE lifecycle, and health messages + +If the Language Model API is missing or your VS Code build doesn’t support it, you’ll see a message in the Output channel. Use a recent VS Code build and make sure GitHub Copilot is signed in. + +## Security notes + +- Binds to 127.0.0.1 by default. Do not expose to remote interfaces. +- Set `bridge.token` to require `Authorization: Bearer ` on every request. +- Single-user, local process; intended for local tooling and experiments. + +## Troubleshooting + +The `/healthz` endpoint may report `copilot: "unavailable"` for reasons like: + +- missing_language_model_api — VS Code API not available +- copilot_model_unavailable — No Copilot models selectable +- not_found — Requested model/family not found +- consent_required, rate_limited, copilot_unavailable — provider-specific or transient issues + +POST /v1/chat/completions returns 503 with similar reason codes when Copilot isn’t usable. ## Development -- Build: npm run compile -- Watch: npm run watch -- Main: src/extension.ts -- Note: Previously used Chat API shims are no longer needed; the bridge now uses the Language Model API. +- Build: `npm run compile` +- Watch: `npm run watch` +- Entry point: `src/extension.ts` + +## License + +Apache-2.0 diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..acea970aa9aeade117aabc0e564662e0c0946aca GIT binary patch literal 16614 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∋qm&G57N`_&vY(HTDnOKGGApphLiD+$Dt}C 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