diff --git a/extensions/spockify/bundled-skills/commit/SKILL.md b/extensions/spockify/bundled-skills/commit/SKILL.md new file mode 100644 index 00000000..0c9e8a24 --- /dev/null +++ b/extensions/spockify/bundled-skills/commit/SKILL.md @@ -0,0 +1,15 @@ +--- +title: Commit messages +category: git +tags: [git, commit] +--- +# Commit messages + +Write a commit message for the current changes. + +1. Run `git status` and `git diff` (or use the git tools) to see exactly what changed. +2. Subject line: imperative mood, ≤72 chars, no trailing period. Example: `Fix model list falling back to catalog`. +3. Body (if needed): what changed and why, not a file list. Wrap at ~72. +4. Mention breaking changes or follow-ups explicitly. + +Do not invent changes that are not in the diff. Do not include secrets. diff --git a/extensions/spockify/bundled-skills/debug/SKILL.md b/extensions/spockify/bundled-skills/debug/SKILL.md new file mode 100644 index 00000000..f6dd7e2a --- /dev/null +++ b/extensions/spockify/bundled-skills/debug/SKILL.md @@ -0,0 +1,17 @@ +--- +title: Systematic debugging +category: engineering +tags: [debug, bugs, errors] +--- +# Systematic debugging + +When the user reports a bug or unexpected behavior: + +1. **Reproduce** — confirm the exact steps, input, and expected vs actual result. Ask if unclear. +2. **Locate** — use `grep`, `glob_file_search`, and `read_file` to find the relevant code. Do not guess. +3. **Hypothesize** — state one concrete hypothesis and the evidence for it. +4. **Verify** — read the surrounding code, check diagnostics (`read_diagnostics`), and run a focused test or command if it will confirm the hypothesis. +5. **Fix the cause** — change the smallest amount of code that fixes the root cause. Avoid drive-by refactors. +6. **Prove it** — re-run the failing case or the relevant test. Summarize what was wrong and why the fix works. + +Never claim a fix works without checking. If you cannot reproduce, say so and ask for a minimal example. diff --git a/extensions/spockify/bundled-skills/docs/SKILL.md b/extensions/spockify/bundled-skills/docs/SKILL.md new file mode 100644 index 00000000..d97cf3bf --- /dev/null +++ b/extensions/spockify/bundled-skills/docs/SKILL.md @@ -0,0 +1,16 @@ +--- +title: Write documentation +category: docs +tags: [docs, readme] +--- +# Write documentation + +Write docs that a new teammate can use. + +1. Start with what it is and when to use it. +2. Show the shortest working example. +3. Document options, defaults, and failure modes that actually matter. +4. Keep it accurate to the current code. Read the implementation; do not invent flags. +5. Prefer updating existing docs over creating parallel ones. + +Use plain language. Short paragraphs. Code samples that can be copied. diff --git a/extensions/spockify/bundled-skills/explain/SKILL.md b/extensions/spockify/bundled-skills/explain/SKILL.md new file mode 100644 index 00000000..f5c7b9f8 --- /dev/null +++ b/extensions/spockify/bundled-skills/explain/SKILL.md @@ -0,0 +1,16 @@ +--- +title: Explain code +category: learning +tags: [explain, docs] +--- +# Explain code + +Explain so a competent teammate can follow. + +1. One-sentence summary of what this does. +2. How it fits in (who calls it, what it returns, what it depends on). +3. Walk through the important path with the actual names from the code. +4. Call out non-obvious decisions, invariants, and failure modes. +5. If helpful, a tiny example of input → output. + +Do not restate every line. Skip boilerplate. If something is unclear in the code, say so. diff --git a/extensions/spockify/bundled-skills/refactor/SKILL.md b/extensions/spockify/bundled-skills/refactor/SKILL.md new file mode 100644 index 00000000..dbec96f5 --- /dev/null +++ b/extensions/spockify/bundled-skills/refactor/SKILL.md @@ -0,0 +1,17 @@ +--- +title: Safe refactor +category: engineering +tags: [refactor, cleanup] +--- +# Safe refactor + +Refactor without changing behavior. + +1. State the goal (simpler, smaller, clearer, faster) in one sentence. +2. Identify the exact scope. Do not expand it. +3. Make sure there is a way to verify behavior (existing tests, or a small characterization check). +4. Change in small steps. Prefer renaming and extracting over rewriting. +5. After each meaningful step, re-check that behavior is unchanged. +6. Summarize what moved and what stayed the same. + +Do not mix feature work into a refactor. If you spot a bug, call it out separately. diff --git a/extensions/spockify/bundled-skills/review/SKILL.md b/extensions/spockify/bundled-skills/review/SKILL.md new file mode 100644 index 00000000..d40bebb6 --- /dev/null +++ b/extensions/spockify/bundled-skills/review/SKILL.md @@ -0,0 +1,23 @@ +--- +title: Code review +category: engineering +tags: [review, quality] +--- +# Code review + +Review the requested code (or the current diff) with this checklist. Lead with the most important findings. + +- **Correctness** — logic errors, off-by-one, unhandled cases, race conditions +- **Security** — injection, authz gaps, secret leakage, unsafe deserialization +- **Error handling** — swallowed errors, missing fallbacks, bad error messages +- **API / contracts** — breaking changes, unclear types, missing validation +- **Tests** — missing coverage for the changed behavior +- **Clarity** — names, structure, comments that explain why (not what) + +Format findings as: +- **Severity** (blocker / should-fix / nit) +- **Where** (file:line) +- **What** and **why** +- **Suggested fix** (short) + +End with a one-line verdict: approve, approve with nits, or request changes. diff --git a/extensions/spockify/bundled-skills/security/SKILL.md b/extensions/spockify/bundled-skills/security/SKILL.md new file mode 100644 index 00000000..5dca29d9 --- /dev/null +++ b/extensions/spockify/bundled-skills/security/SKILL.md @@ -0,0 +1,19 @@ +--- +title: Security review +category: security +tags: [security, review] +--- +# Security review + +Review the relevant code for security issues. Prioritize real risks over style. + +Check for: +- Injection (SQL, command, template, path traversal) +- Authn/authz gaps and insecure direct object references +- Secret handling (hard-coded keys, logging tokens, insecure storage) +- Unsafe deserialization and prototype pollution +- XSS / CSRF where web-facing +- Dependency and supply-chain red flags in the changed files +- Overly broad permissions or missing input validation + +Report each finding with severity, location, impact, and a concrete remediation. If nothing notable, say so clearly. diff --git a/extensions/spockify/bundled-skills/test/SKILL.md b/extensions/spockify/bundled-skills/test/SKILL.md new file mode 100644 index 00000000..b5b71853 --- /dev/null +++ b/extensions/spockify/bundled-skills/test/SKILL.md @@ -0,0 +1,15 @@ +--- +title: Write and run tests +category: engineering +tags: [test, tdd] +--- +# Write and run tests + +1. Identify the behavior to lock in (happy path + one or two important edge cases). +2. Match the project's existing test style and runner. Look for `package.json` scripts, `pytest.ini`, `go.mod`, etc. +3. Write the failing test first when practical. +4. Implement the minimum code to make it pass. +5. Run the specific test (not the whole suite unless asked). +6. Report the command you ran and the result. + +Prefer focused unit tests. Add an integration test only when the behavior crosses a real boundary. diff --git a/extensions/spockify/package.json b/extensions/spockify/package.json index 3a188f65..c4ff77b2 100644 --- a/extensions/spockify/package.json +++ b/extensions/spockify/package.json @@ -1,8 +1,8 @@ { "name": "spockify", "displayName": "Spockify", - "description": "Self-hosted coding assistant for open-weight models — chat, Tab, Ctrl+K, Composer, and Agents on your stack.", - "version": "1.0.7", + "description": "Self-hosted coding assistant for open-weight models \u2014 chat, Tab, Ctrl+K, Composer, and Agents on your stack.", + "version": "1.0.11", "publisher": "spockify", "license": "MIT", "icon": "media/spockify-icon.png", @@ -508,12 +508,12 @@ }, { "command": "spockify.labAgents.run", - "title": "Spockify: Lab Agents (orch → exec)", + "title": "Spockify: Lab Agents (orch \u2192 exec)", "category": "Spockify" }, { "command": "spockify.labAgents.models", - "title": "Spockify: Lab Agents — Show Models", + "title": "Spockify: Lab Agents \u2014 Show Models", "category": "Spockify" }, { @@ -650,7 +650,7 @@ }, { "command": "spockify.agent.resume", - "title": "Spockify: Resume removed — send again", + "title": "Spockify: Resume removed \u2014 send again", "category": "Spockify" }, { @@ -1026,7 +1026,7 @@ "spockify.baseUrl": { "type": "string", "default": "http://127.0.0.1:3080", - "markdownDescription": "Spockify **product root** (not `/v1` — the IDE appends `/v1`). Default local compose Open WebUI: `http://127.0.0.1:3080`. Point this at any Spockify or OpenAI-compatible root. Tab/FIM needs Open WebUI on this URL, not router `:4100`. User keys: chat **Settings → Account → API key** (not LiteLLM `/ui/`)." + "markdownDescription": "Spockify **product root** (not `/v1` \u2014 the IDE appends `/v1`). Default local compose Open WebUI: `http://127.0.0.1:3080`. Point this at any Spockify or OpenAI-compatible root. Tab/FIM needs Open WebUI on this URL, not router `:4100`. User keys: chat **Settings \u2192 Account \u2192 API key** (not LiteLLM `/ui/`)." }, "spockify.provider": { "type": "string", @@ -1037,7 +1037,7 @@ ], "enumDescriptions": [ "HTTP to the base URL you configure (self-hosted Spockify or OpenAI-compatible)", - "Local-only stub — not for production yet" + "Local-only stub \u2014 not for production yet" ] }, "spockify.defaultModel": { @@ -1060,14 +1060,14 @@ "default": 4, "minimum": 1, "maximum": 16, - "markdownDescription": "Max parallel executor workers per round for **Spockify: Lab Agents (orch → exec)** (CLI: `--workers`)." + "markdownDescription": "Max parallel executor workers per round for **Spockify: Lab Agents (orch \u2192 exec)** (CLI: `--workers`)." }, "spockify.lab.maxRounds": { "type": "number", "default": 5, "minimum": 1, "maximum": 20, - "markdownDescription": "Max plan→exec→review rounds for **Lab Agents** (CLI: `--max-rounds`)." + "markdownDescription": "Max plan\u2192exec\u2192review rounds for **Lab Agents** (CLI: `--max-rounds`)." }, "spockify.lab.harnessRoot": { "type": "string", @@ -1113,7 +1113,7 @@ "strict" ], "enumDescriptions": [ - "Read-only — mutating tools (apply/terminal/write) blocked", + "Read-only \u2014 mutating tools (apply/terminal/write) blocked", "Full tools; terminal still uses terminalAgent.policy", "Only tools listed in spockify.agent.strictAllowlist" ], @@ -1124,7 +1124,7 @@ "default": 48, "minimum": 2, "maximum": 80, - "markdownDescription": "Max model↔tool iterations for **Agent** / Composer / Chat Agent (SWE-style long horizon). Ask mode stays short (~12). Raise toward 80 for hard multi-file fixes. CLI: `--max-turns` / `SPOCKIFY_MAX_TURNS`; `--yolo` defaults higher." + "markdownDescription": "Max model\u2194tool iterations for **Agent** / Composer / Chat Agent (SWE-style long horizon). Ask mode stays short (~12). Raise toward 80 for hard multi-file fixes. CLI: `--max-turns` / `SPOCKIFY_MAX_TURNS`; `--yolo` defaults higher." }, "spockify.agentPermissionMode": { "type": "string", @@ -1135,9 +1135,9 @@ "autoRunReviewFiles" ], "enumDescriptions": [ - "Allow all (unsandboxed) — auto-approve shell and file edits (YOLO)", - "Ask every time — confirm shell commands and review file edits", - "Auto-run tools, review file edits — shell auto-runs; Accept / Reject patches in the editor (default)" + "Allow all (unsandboxed) \u2014 auto-approve shell and file edits (YOLO)", + "Ask every time \u2014 confirm shell commands and review file edits", + "Auto-run tools, review file edits \u2014 shell auto-runs; Accept / Reject patches in the editor (default)" ], "markdownDescription": "Composer tool/file permission mode (Cursor-like). Default **autoRunReviewFiles** (fewer mid-loop Approves). **Ask** agent mode stays read-only. **Plan** still gates mutators until approved. **YOLO:** set `allowAll` (+ optionally raise `spockify.agent.maxTurns`). Legacy `spockify.runAllUnsandboxed` maps to `allowAll` when true." }, @@ -1175,13 +1175,13 @@ "heavy" ], "enumDescriptions": [ - "Never send think= — any model is OK", + "Never send think= \u2014 any model is OK", "Low effort, fast/cheap single worker", "Balanced auto route (single worker)", "High effort, best single thinking model (default for Agent / gpt-oss-120b)", - "High effort + 4-agent ensemble (Explorer/Analyst/Builder → Skeptic)" + "High effort + 4-agent ensemble (Explorer/Analyst/Builder \u2192 Skeptic)" ], - "markdownDescription": "Thinking cycle (Off → Low → Medium → High → Heavy). Off omits think=. Heavy is high effort plus the parallel ensemble. Default High for IDE Agent." + "markdownDescription": "Thinking cycle (Off \u2192 Low \u2192 Medium \u2192 High \u2192 Heavy). Off omits think=. Heavy is high effort plus the parallel ensemble. Default High for IDE Agent." }, "spockify.chat.pipeline.enabled": { "type": "boolean", @@ -1265,7 +1265,7 @@ "type": "number", "default": 30, "minimum": 0, - "description": "Base debounce before Ghost Tab LLM complete (adaptive ~15–80ms; local heuristics skip debounce)." + "description": "Base debounce before Ghost Tab LLM complete (adaptive ~15\u201380ms; local heuristics skip debounce)." }, "spockify.completions.adaptiveDebounce": { "type": "boolean", @@ -1281,7 +1281,7 @@ "spockify.completions.logLatency": { "type": "boolean", "default": true, - "description": "Log Tab complete latency to Output → Spockify." + "description": "Log Tab complete latency to Output \u2192 Spockify." }, "spockify.completions.warmOnStartup": { "type": "boolean", @@ -1480,12 +1480,12 @@ "custom" ], "enumDescriptions": [ - "Read-only: ls/git status/rg/cat — no writes or installs.", + "Read-only: ls/git status/rg/cat \u2014 no writes or installs.", "Read + test/typecheck/build scripts (npm test, pytest, cargo check).", "Dev + install/build pipelines (npm ci, make, docker build).", "Use only the custom allowlist array (no seeded tier)." ], - "markdownDescription": "Seeded allowlist **tier** for Claude Code–class autonomy. Unioned with `spockify.terminalAgent.allowlist` unless tier is `custom`. Dangerous patterns are never allowed." + "markdownDescription": "Seeded allowlist **tier** for Claude Code\u2013class autonomy. Unioned with `spockify.terminalAgent.allowlist` unless tier is `custom`. Dangerous patterns are never allowed." }, "spockify.terminalAgent.allowlist": { "type": "array", @@ -1507,14 +1507,14 @@ "type": "number", "default": 300000, "minimum": 1000, - "description": "Timeout for captured shell commands (milliseconds). Default 300s — raise for long builds. Chat terminal_run uses the same default unless timeoutMs is passed in the tool call." + "description": "Timeout for captured shell commands (milliseconds). Default 300s \u2014 raise for long builds. Chat terminal_run uses the same default unless timeoutMs is passed in the tool call." }, "spockify.terminalAgent.maxTurns": { "type": "number", "default": 48, "minimum": 2, "maximum": 80, - "description": "Max agent loop turns for Terminal Agent (plan → execute → adapt). Higher for Claude Code–class long-horizon runs." + "description": "Max agent loop turns for Terminal Agent (plan \u2192 execute \u2192 adapt). Higher for Claude Code\u2013class long-horizon runs." }, "spockify.terminalAgent.openTranscript": { "type": "boolean", @@ -1524,7 +1524,7 @@ "spockify.terminalAgent.planApproval": { "type": "boolean", "default": true, - "markdownDescription": "Claude Code–class **plan gate**: model drafts a numbered plan first; you Approve / Edit / Skip before tools run. Ask policy still applies to each command." + "markdownDescription": "Claude Code\u2013class **plan gate**: model drafts a numbered plan first; you Approve / Edit / Skip before tools run. Ask policy still applies to each command." }, "spockify.terminalAgent.showPolicyBadge": { "type": "boolean", @@ -1544,7 +1544,7 @@ "Linux: bubblewrap --unshare-net on captured terminal_run. Full filesystem; no outbound network. Needs host or AppImage-bundled bwrap.", "Linux: workspace-only writable bind (system paths read-only). **Network allowed** (curl, pip, health checks). Needs bwrap. Does not use --unshare-net." ], - "markdownDescription": "Optional **OS sandbox** for captured agent shell (not integrated-terminal sendText, not Remote SSH). Uses host `bwrap` when present, else AppImage `resources/helpers/bwrap` when bundled. Default **off** — ask policy unchanged. Prefer **workspace** when opting in. Run **Spockify: Check OS Sandbox**." + "markdownDescription": "Optional **OS sandbox** for captured agent shell (not integrated-terminal sendText, not Remote SSH). Uses host `bwrap` when present, else AppImage `resources/helpers/bwrap` when bundled. Default **off** \u2014 ask policy unchanged. Prefer **workspace** when opting in. Run **Spockify: Check OS Sandbox**." }, "spockify.terminalAgent.osSandboxFailClosed": { "type": "boolean", @@ -1564,14 +1564,15 @@ "dependencies": { "@spockify/codebase": "file:../../packages/spockify-codebase", "@spockify/harness": "file:../../packages/spockify-harness", + "@spockify/harness-host": "file:../../packages/spockify-harness-host", "@spockify/ide-client": "file:../../packages/spockify-ide-client", "@spockify/mcp": "file:../../packages/spockify-mcp", - "@spockify/shadow-workspace": "file:../../packages/spockify-shadow-workspace", - "@spockify/harness-host": "file:../../packages/spockify-harness-host" + "@spockify/shadow-workspace": "file:../../packages/spockify-shadow-workspace" }, "devDependencies": { "@types/node": "^20.14.0", "@types/vscode": "^1.85.0", + "esbuild": "^0.28.2", "tsx": "^4.19.0", "typescript": "^5.6.0" } diff --git a/extensions/spockify/src/account/register.ts b/extensions/spockify/src/account/register.ts index 8cd095c2..8d32c248 100644 --- a/extensions/spockify/src/account/register.ts +++ b/extensions/spockify/src/account/register.ts @@ -66,13 +66,25 @@ export function registerAccountCommands( vscode.commands.registerCommand('spockify.listModels', async () => { const transport = await getTransport(); if (!transport) { + void vscode.window.showErrorMessage( + 'Not connected. Use Spockify: Sign In or set base URL + API key, then try again.', + ); return; } - const models = await transport.listModels({ ossOnly: true }); + let models = await transport.listModels({ ossOnly: true }); + if (!models.length) { + models = await transport.listModels({ ossOnly: false }); + } const ids = models.map((m) => m.id); output.appendLine(`models (${ids.length}): ${ids.join(', ')}`); + if (!ids.length) { + void vscode.window.showWarningMessage( + 'Connected, but the server returned no models. Check your base URL and that models are loaded on the stack.', + ); + return; + } void vscode.window.showInformationMessage( - `Spockify: ${ids.length} OSS models (Output → Spockify)`, + `Spockify: ${ids.length} models — ${ids.slice(0, 8).join(', ')}${ids.length > 8 ? '…' : ''} (full list in Output → Spockify)`, ); }), ); diff --git a/extensions/spockify/src/chat/ChatPanelProvider.ts b/extensions/spockify/src/chat/ChatPanelProvider.ts index c94a15d2..5d766b06 100644 --- a/extensions/spockify/src/chat/ChatPanelProvider.ts +++ b/extensions/spockify/src/chat/ChatPanelProvider.ts @@ -187,9 +187,13 @@ export class ChatPanelProvider implements vscode.WebviewViewProvider { e.affectsConfiguration('spockify.agentPermissionMode') || e.affectsConfiguration('spockify.chat.maxMode') || e.affectsConfiguration('spockify.chat.thinking') || - e.affectsConfiguration('spockify.agent.mode') + e.affectsConfiguration('spockify.agent.mode') || + e.affectsConfiguration('spockify.baseUrl') || + e.affectsConfiguration('spockify.models') || + e.affectsConfiguration('spockify.defaultModel') ) { this.postModelPrefs(); + void this.refreshModels(); } }), ); @@ -204,6 +208,9 @@ export class ChatPanelProvider implements vscode.WebviewViewProvider { this.signedIn = signedIn; this.accountLabel = accountLabel; this.post({ type: 'auth', signedIn, accountLabel }); + // Credentials just changed — re-fetch models so the sidebar leaves the + // hard-coded fallback as soon as the server is reachable. + void this.refreshModels(); } /** @@ -1180,7 +1187,7 @@ export class ChatPanelProvider implements vscode.WebviewViewProvider { this.syncActiveTabStreamingUi(); } - /** Catalog for the model menu — never return empty when signed in. */ + /** Catalog for the model menu — prefer the live server list. */ private async loadModelsForPicker(): Promise { let models: ModelInfo[] = []; try { @@ -1190,7 +1197,19 @@ export class ChatPanelProvider implements vscode.WebviewViewProvider { `[chat] listModels failed: ${err instanceof Error ? err.message : String(err)}`, ); } - models = (models || []).filter((m) => m && m.id && m.oss !== false); + models = (models || []).filter((m) => m && m.id); + // If the transport returned real server models (not the labeled fallback), + // show them as-is. mergePickerModels always injects the hard-coded catalog + // and then filters to old coding pins, which is why the sidebar looked wrong. + const looksLikeFallback = + models.length > 0 && + models.every( + (m) => + typeof m.label === 'string' && m.label.includes('(fallback)'), + ); + if (models.length && !looksLikeFallback) { + return models; + } return mergePickerModels(models, codingPickerOptionsFromConfig()); } diff --git a/extensions/spockify/src/chat/transportAdapter.ts b/extensions/spockify/src/chat/transportAdapter.ts index 24686c61..997caba4 100644 --- a/extensions/spockify/src/chat/transportAdapter.ts +++ b/extensions/spockify/src/chat/transportAdapter.ts @@ -14,12 +14,36 @@ import { textFromContent } from './chatContent'; import { codingPickerOptionsFromConfig } from './codingPickerConfig'; import { mergePickerModels } from './modelCatalog'; +function withTimeout(p: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ); + p.then( + (v) => { + clearTimeout(timer); + resolve(v); + }, + (e) => { + clearTimeout(timer); + reject(e); + }, + ); + }); +} + export function adaptModelTransport( transport: ModelTransport, ): ChatModelTransport { return { async listModels(): Promise { - const models = await transport.listModels({ ossOnly: true }); + const cfg = codingPickerOptionsFromConfig(); + // Prefer the unfiltered server list so custom / local models appear. + let models = await transport.listModels({ ossOnly: false }); + if (!models.length) { + models = await transport.listModels({ ossOnly: true }); + } const mapped = models .filter((m) => m?.id) .map((m) => ({ @@ -27,7 +51,9 @@ export function adaptModelTransport( label: m.name || m.id, oss: true, })); - return mergePickerModels(mapped, codingPickerOptionsFromConfig()); + // Only merge the hard-coded catalog when the server returned nothing. + if (mapped.length) return mapped; + return mergePickerModels(mapped, cfg); }, chatCompletions( @@ -136,12 +162,28 @@ export function createFallbackChatTransport( return { async listModels(): Promise { try { - const live = await getLive(); - if (live) return adaptModelTransport(live).listModels(); - } catch { - /* mock */ + const live = await withTimeout(getLive(), 8000, 'getLive'); + if (live) { + const listed = await withTimeout( + adaptModelTransport(live).listModels(), + 8000, + 'listModels', + ); + if (listed.length) return listed; + } + } catch (err) { + console.warn( + '[spockify] live listModels failed, using fallback catalog:', + err instanceof Error ? err.message : err, + ); } - return mock.listModels(); + const fallback = await mock.listModels(); + return fallback.map((m) => ({ + ...m, + label: m.label?.includes('(fallback)') + ? m.label + : `${m.label || m.id} (fallback)`, + })); }, chatCompletions( diff --git a/extensions/spockify/src/extension.ts b/extensions/spockify/src/extension.ts index 95888cad..c4650793 100644 --- a/extensions/spockify/src/extension.ts +++ b/extensions/spockify/src/extension.ts @@ -56,6 +56,7 @@ import { registerAgentRuntime } from './runtime'; import { registerSync } from './sync'; import { registerUpdateCheck } from './update'; import { registerSettings } from './settings'; +import { seedBundledSkills } from './skills/seedBundled'; import { registerLabAgents } from './lab/register'; import { registerChatPanel, @@ -72,6 +73,25 @@ interface ResolveOptions { export function activate(context: vscode.ExtensionContext): void { const output = vscode.window.createOutputChannel('Spockify'); context.subscriptions.push(output); + + // Register Settings before anything else so the command always exists. + // The handler is a mutable delegate so we can wire the real panel later + // without double-registering. + let openSettingsImpl: (section?: string) => Promise = async () => { + await vscode.commands.executeCommand('workbench.action.openSettings', 'spockify'); + }; + context.subscriptions.push( + vscode.commands.registerCommand('spockify.settings.open', async (section?: string) => { + await openSettingsImpl(section); + }), + ); + // Status-bar gear points at the same command. + const gear = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 101); + gear.text = '$(gear)'; + gear.tooltip = 'Spockify Settings'; + gear.command = 'spockify.settings.open'; + gear.show(); + context.subscriptions.push(gear); const remote = vscode.env.remoteName ?? 'local'; output.appendLine( `Spockify extension activating… remote=${remote} (AI → configured base URL)`, @@ -158,10 +178,26 @@ export function activate(context: vscode.ExtensionContext): void { const getTransportQuiet = (): Promise => resolveTransport({ promptIfMissing: false }); + // Wire the real settings panel now that deps exist. + openSettingsImpl = async (section?: string) => { + const { openSettingsPanel } = await import('./settings/SettingsPanel'); + await openSettingsPanel( + { context, output, refreshAuthUi, getTransport: getTransportQuiet }, + section as import('./settings/protocol').SettingsSectionId | undefined, + ); + }; + // Chat commands first so palette entries work even if later setup throws. let chatProvider: ReturnType['provider'] | undefined; registerChatCommand(context, getTransport, output, () => chatProvider); + // Seed bundled skills into the workspace (never overwrites). + void seedBundledSkills(context, output).catch((err) => + output.appendLine(`[skills] seed failed: ${err}`), + ); + + // Settings already registered at the top of activate(). + try { activateRest(context, { output, @@ -304,12 +340,7 @@ function activateRest( refreshAuthUi, }); registerUpdateCheck(context, output); - registerSettings({ - context, - output, - refreshAuthUi, - getTransport: getTransportQuiet, - }); + // Settings already registered in activate() so the command exists even on partial startup. const checkpointStore = registerCheckpointCommands(context, output); const applyService = getApplyService(context); diff --git a/extensions/spockify/src/runtime/agentLoopTools.ts b/extensions/spockify/src/runtime/agentLoopTools.ts index f4e917bb..ea591959 100644 --- a/extensions/spockify/src/runtime/agentLoopTools.ts +++ b/extensions/spockify/src/runtime/agentLoopTools.ts @@ -76,8 +76,16 @@ function registerWorkspaceSkillTools( mutates: false, }, async (args, ctx) => { - const skills = await harness.loadSkills(skillsDir(args, ctx.cwd)); - const text = skills.map((s) => `${s.id}: ${s.title}`).join('\n'); + const local = await harness.loadSkills(skillsDir(args, ctx.cwd)); + const bundled = await harness.loadSkills( + require('path').join( + ctx.extensionPath || '', + 'bundled-skills', + ), + ).catch(() => []); + const seen = new Set(local.map((s) => s.id)); + const all = [...local, ...bundled.filter((s) => !seen.has(s.id))]; + const text = all.map((s) => `${s.id}: ${s.title}`).join('\n'); return { ok: true, content: text || '(no skills)' }; }, ); diff --git a/extensions/spockify/src/runtime/tools/builtins.ts b/extensions/spockify/src/runtime/tools/builtins.ts index 5c58bf79..170d3dc7 100644 --- a/extensions/spockify/src/runtime/tools/builtins.ts +++ b/extensions/spockify/src/runtime/tools/builtins.ts @@ -465,6 +465,132 @@ export function registerBuiltinTools( return { ok: true, content: text || '(no errors or warnings)' }; }, ); + + registry.register( + { + name: 'git_status', + description: + 'Show a short git status and recent log for the workspace. ' + + 'Use before writing commit messages or reviewing what changed.', + parameters: { type: 'object', properties: {} }, + mutates: false, + source: 'builtin', + }, + async (_args, ctx) => { + const { execFile } = await import('child_process'); + const { promisify } = await import('util'); + const exec = promisify(execFile); + const cwd = ctx.cwd || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!cwd) return { ok: false, content: '', error: 'no workspace' }; + try { + const status = await exec('git', ['status', '-sb'], { cwd }); + const log = await exec('git', ['log', '-5', '--oneline'], { cwd }); + return { + ok: true, + content: `status:\n${status.stdout}\nrecent:\n${log.stdout}`, + }; + } catch (err) { + return { + ok: false, + content: '', + error: err instanceof Error ? err.message : String(err), + }; + } + }, + ); + + registry.register( + { + name: 'open_file', + description: + 'Open a workspace file in the editor (optionally at a line). ' + + 'Use when the user asks to see or jump to a file.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'Workspace-relative or absolute path' }, + line: { type: 'number', description: '1-based line to reveal' }, + }, + required: ['path'], + }, + mutates: false, + source: 'builtin', + }, + async (args) => { + const raw = String(args.path || '').trim(); + if (!raw) return { ok: false, content: '', error: 'path required' }; + const folder = vscode.workspace.workspaceFolders?.[0]; + const uri = raw.startsWith('/') + ? vscode.Uri.file(raw) + : vscode.Uri.joinPath(folder?.uri ?? vscode.Uri.file('/'), raw); + const doc = await vscode.workspace.openTextDocument(uri); + const line = Math.max(0, Number(args.line || 1) - 1); + await vscode.window.showTextDocument(doc, { + selection: new vscode.Range(line, 0, line, 0), + }); + return { ok: true, content: `opened ${raw}` }; + }, + ); + + registry.register( + { + name: 'workspace_info', + description: + 'Summarize the open workspace: folders, open editors, and active file. ' + + 'Useful at the start of a task to orient.', + parameters: { type: 'object', properties: {} }, + mutates: false, + source: 'builtin', + }, + async () => { + const folders = (vscode.workspace.workspaceFolders || []).map((f) => f.uri.fsPath); + const editors = vscode.window.visibleTextEditors.map( + (e) => vscode.workspace.asRelativePath(e.document.uri), + ); + const active = vscode.window.activeTextEditor + ? vscode.workspace.asRelativePath(vscode.window.activeTextEditor.document.uri) + : '(none)'; + return { + ok: true, + content: [ + `folders: ${folders.join(', ') || '(none)'}`, + `active: ${active}`, + `open: ${editors.join(', ') || '(none)'}`, + ].join('\n'), + }; + }, + ); + + registry.register( + { + name: 'search_symbols', + description: + 'Search workspace symbols by name (functions, classes, types). ' + + 'Faster than grep when you know the symbol name.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Symbol name or prefix' }, + }, + required: ['query'], + }, + mutates: false, + source: 'builtin', + }, + async (args) => { + const query = String(args.query || '').trim(); + if (!query) return { ok: false, content: '', error: 'query required' }; + const symbols = await vscode.commands.executeCommand( + 'vscode.executeWorkspaceSymbolProvider', + query, + ); + const lines = (symbols || []).slice(0, 30).map((s) => { + const rel = vscode.workspace.asRelativePath(s.location.uri); + return `${s.name} (${rel}:${s.location.range.start.line + 1})`; + }); + return { ok: true, content: lines.join('\n') || '(no symbols)' }; + }, + ); } /** Tokens from a free-text query suitable as grep patterns (broaden on thin index). */ diff --git a/extensions/spockify/src/skills/seedBundled.ts b/extensions/spockify/src/skills/seedBundled.ts new file mode 100644 index 00000000..b3b3b4ce --- /dev/null +++ b/extensions/spockify/src/skills/seedBundled.ts @@ -0,0 +1,59 @@ +/** + * Seed the extension's bundled skills into the workspace .spockify/skills + * directory on activation. Existing user skills are never overwritten. + */ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +export async function seedBundledSkills( + context: vscode.ExtensionContext, + output: vscode.OutputChannel, +): Promise { + const folders = vscode.workspace.workspaceFolders; + if (!folders?.length) return; + + const bundledRoot = path.join(context.extensionPath, 'bundled-skills'); + let entries: string[]; + try { + entries = await fs.readdir(bundledRoot); + } catch { + output.appendLine('[skills] no bundled-skills directory found'); + return; + } + + for (const folder of folders) { + const destRoot = path.join(folder.uri.fsPath, '.spockify', 'skills'); + await fs.mkdir(destRoot, { recursive: true }); + let seeded = 0; + for (const name of entries) { + const src = path.join(bundledRoot, name, 'SKILL.md'); + const destDir = path.join(destRoot, name); + const dest = path.join(destDir, 'SKILL.md'); + try { + await fs.access(src); + } catch { + continue; + } + try { + await fs.access(dest); + continue; + } catch { + /* not present, seed it */ + } + await fs.mkdir(destDir, { recursive: true }); + await fs.copyFile(src, dest); + seeded++; + } + if (seeded) { + output.appendLine( + `[skills] seeded ${seeded} bundled skill(s) into ${destRoot}`, + ); + } + } +} + +/** Absolute path to the extension's bundled skills (always available). */ +export function bundledSkillsDir(context: vscode.ExtensionContext): string { + return path.join(context.extensionPath, 'bundled-skills'); +}