From a98f64ed1a4edcb19d0eff1eb1702fa1b264f099 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Sat, 7 Mar 2026 05:47:10 +0300 Subject: [PATCH] feat: expand VS Code extension with 12 new chat commands Add comprehensive chat participant command coverage: - changes: show what changed since last session - config: manage runtime configuration profiles - doctor: structural health check - guide: quick-reference cheat sheet - why: read the philosophy behind ctx - memory: bridge Claude Code auto memory into .context/ - prompt: manage reusable prompt templates - decisions: manage DECISIONS.md file - learnings: manage LEARNINGS.md file - deps: show package dependency graph - journal: analyze exported AI sessions - reindex: regenerate indices for DECISIONS.md and LEARNINGS.md Also includes: - splitArgs helper for quoted argument parsing - Activation event for chat participant - LICENSE file (Apache-2.0) - 830+ lines of new tests (53 -> 131 total) - Windows shell support in execFile calls Signed-off-by: ersan bilik --- editors/vscode/LICENSE | 207 ++++++ editors/vscode/README.md | 20 +- editors/vscode/package.json | 52 +- editors/vscode/src/extension.test.ts | 927 +++++++++++++++++++++++++++ editors/vscode/src/extension.ts | 683 +++++++++++++++++++- 5 files changed, 1855 insertions(+), 34 deletions(-) create mode 100644 editors/vscode/LICENSE diff --git a/editors/vscode/LICENSE b/editors/vscode/LICENSE new file mode 100644 index 00000000..be659d90 --- /dev/null +++ b/editors/vscode/LICENSE @@ -0,0 +1,207 @@ + / ctx: https://ctx.ist + ,'`./ do you remember? + `.,'\ + \ Copyright 2026-present Context contributors. + SPDX-License-Identifier: Apache-2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 402af09c..915d5778 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -14,10 +14,28 @@ Type `@ctx` in the VS Code Chat view, then use slash commands: | `@ctx /drift` | Detect stale or invalid context | | `@ctx /recall` | Browse and search AI session history | | `@ctx /hook` | Generate AI tool integration configs | -| `@ctx /add` | Add a task, decision, or learning | +| `@ctx /add` | Add a task, decision, learning, or convention | | `@ctx /load` | Output assembled context Markdown | | `@ctx /compact` | Archive completed tasks and clean up | | `@ctx /sync` | Reconcile context with codebase | +| `@ctx /complete` | Mark a task as completed | +| `@ctx /remind` | Manage session-scoped reminders | +| `@ctx /tasks` | Archive or snapshot tasks | +| `@ctx /pad` | Encrypted scratchpad for sensitive notes | +| `@ctx /notify` | Send webhook notifications | +| `@ctx /system` | System diagnostics and bootstrap | +| `@ctx /changes` | Show what changed since last session | +| `@ctx /config` | Manage runtime configuration profiles | +| `@ctx /doctor` | Structural health check | +| `@ctx /guide` | Quick-reference cheat sheet | +| `@ctx /why` | Read the philosophy behind ctx | +| `@ctx /memory` | Bridge Claude Code auto memory into .context/ | +| `@ctx /prompt` | Manage reusable prompt templates | +| `@ctx /decisions` | Manage DECISIONS.md file | +| `@ctx /learnings` | Manage LEARNINGS.md file | +| `@ctx /deps` | Show package dependency graph | +| `@ctx /journal` | Analyze exported AI sessions | +| `@ctx /reindex` | Regenerate indices for DECISIONS.md and LEARNINGS.md | ## Prerequisites diff --git a/editors/vscode/package.json b/editors/vscode/package.json index d8e3656a..2c7d71d0 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -34,7 +34,9 @@ "copilot", "chat" ], - "activationEvents": [], + "activationEvents": [ + "onChatParticipant:ctx.participant" + ], "main": "./dist/extension.js", "contributes": { "chatParticipants": [ @@ -108,6 +110,54 @@ { "name": "system", "description": "System diagnostics and bootstrap" + }, + { + "name": "changes", + "description": "Show what changed since last session" + }, + { + "name": "config", + "description": "Manage runtime configuration profiles" + }, + { + "name": "doctor", + "description": "Structural health check" + }, + { + "name": "guide", + "description": "Quick-reference cheat sheet" + }, + { + "name": "why", + "description": "Read the philosophy behind ctx" + }, + { + "name": "memory", + "description": "Bridge Claude Code auto memory into .context/" + }, + { + "name": "prompt", + "description": "Manage reusable prompt templates" + }, + { + "name": "decisions", + "description": "Manage DECISIONS.md file" + }, + { + "name": "learnings", + "description": "Manage LEARNINGS.md file" + }, + { + "name": "deps", + "description": "Show package dependency graph" + }, + { + "name": "journal", + "description": "Analyze exported AI sessions" + }, + { + "name": "reindex", + "description": "Regenerate indices for DECISIONS.md and LEARNINGS.md" } ], "disambiguation": [ diff --git a/editors/vscode/src/extension.test.ts b/editors/vscode/src/extension.test.ts index 4fb12792..7acf7c45 100644 --- a/editors/vscode/src/extension.test.ts +++ b/editors/vscode/src/extension.test.ts @@ -25,12 +25,31 @@ import { getCtxPath, getWorkspaceRoot, getPlatformInfo, + splitArgs, + handleAdd, + handleAgent, + handleLoad, + handleCompact, + handleSync, + handleRecall, handleComplete, handleRemind, handleTasks, handlePad, handleNotify, handleSystem, + handleChanges, + handleConfig, + handleDoctor, + handleGuide, + handleWhy, + handleMemory, + handlePrompt, + handleDecisions, + handleLearnings, + handleDeps, + handleJournal, + handleReindex, } from "./extension"; // Helper: create a fake CancellationToken @@ -700,3 +719,911 @@ describe("handleSystem", () => { expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); }); }); + +describe("handleChanges", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs changes without --since when no prompt", async () => { + mockRunCtxSuccess("3 files changed"); + const stream = fakeStream(); + const token = fakeToken(); + await handleChanges(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["changes", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --since when prompt provided", async () => { + mockRunCtxSuccess("2 files changed"); + const stream = fakeStream(); + const token = fakeToken(); + await handleChanges(stream as never, "24h", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["changes", "--no-color", "--since", "24h"], + expect.anything(), + expect.any(Function) + ); + }); + + it("shows 'No changes detected.' when output is empty", async () => { + mockRunCtxSuccess(""); + const stream = fakeStream(); + const token = fakeToken(); + await handleChanges(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith("No changes detected."); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("git error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleChanges(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleConfig", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows usage when no subcommand given", async () => { + const stream = fakeStream(); + const token = fakeToken(); + const result = await handleConfig(stream as never, "", "/test", token); + expect(result.metadata.command).toBe("config"); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); + }); + + it("runs status subcommand", async () => { + mockRunCtxSuccess("Profile: base"); + const stream = fakeStream(); + const token = fakeToken(); + await handleConfig(stream as never, "status", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["config", "status", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("runs switch subcommand with profile", async () => { + mockRunCtxSuccess("Switched to dev"); + const stream = fakeStream(); + const token = fakeToken(); + await handleConfig(stream as never, "switch dev", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["config", "switch", "dev", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("config error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleConfig(stream as never, "status", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleDoctor", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs doctor command", async () => { + mockRunCtxSuccess("All checks passed"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDoctor(stream as never, "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["doctor", "--no-color"], + expect.anything(), + expect.any(Function) + ); + expect(stream.progress).toHaveBeenCalledWith("Running health checks..."); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("doctor error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDoctor(stream as never, "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleGuide", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs guide without flags when no prompt", async () => { + mockRunCtxSuccess("ctx cheat sheet"); + const stream = fakeStream(); + const token = fakeToken(); + await handleGuide(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["guide", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --skills flag", async () => { + mockRunCtxSuccess("skills list"); + const stream = fakeStream(); + const token = fakeToken(); + await handleGuide(stream as never, "skills", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["guide", "--no-color", "--skills"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --commands flag", async () => { + mockRunCtxSuccess("commands list"); + const stream = fakeStream(); + const token = fakeToken(); + await handleGuide(stream as never, "commands", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["guide", "--no-color", "--commands"], + expect.anything(), + expect.any(Function) + ); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("guide error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleGuide(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleWhy", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs why with document name", async () => { + mockRunCtxSuccess("The ctx Manifesto"); + const stream = fakeStream(); + const token = fakeToken(); + await handleWhy(stream as never, "manifesto", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["why", "manifesto", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("runs why without document for interactive menu", async () => { + mockRunCtxSuccess("1. manifesto\n2. about"); + const stream = fakeStream(); + const token = fakeToken(); + await handleWhy(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["why", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("doc not found"); + const stream = fakeStream(); + const token = fakeToken(); + await handleWhy(stream as never, "manifesto", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleMemory", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows usage when no subcommand given", async () => { + const stream = fakeStream(); + const token = fakeToken(); + const result = await handleMemory(stream as never, "", "/test", token); + expect(result.metadata.command).toBe("memory"); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); + }); + + it("runs sync subcommand", async () => { + mockRunCtxSuccess("Memory synced"); + const stream = fakeStream(); + const token = fakeToken(); + await handleMemory(stream as never, "sync", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["memory", "sync", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("runs status subcommand", async () => { + mockRunCtxSuccess("Status: in sync"); + const stream = fakeStream(); + const token = fakeToken(); + await handleMemory(stream as never, "status", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["memory", "status", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("memory error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleMemory(stream as never, "sync", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handlePrompt", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lists prompts when no subcommand given", async () => { + mockRunCtxSuccess("review\nrefactor"); + const stream = fakeStream(); + const token = fakeToken(); + await handlePrompt(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["prompt", "list", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("shows a prompt by name", async () => { + mockRunCtxSuccess("Review the code..."); + const stream = fakeStream(); + const token = fakeToken(); + await handlePrompt(stream as never, "show review", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["prompt", "show", "review", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("shows usage when 'rm' has no name", async () => { + const stream = fakeStream(); + const token = fakeToken(); + await handlePrompt(stream as never, "rm", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); + }); + + it("shows 'No prompt templates found.' when empty", async () => { + mockRunCtxSuccess(""); + const stream = fakeStream(); + const token = fakeToken(); + await handlePrompt(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith("No prompt templates found."); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("prompt error"); + const stream = fakeStream(); + const token = fakeToken(); + await handlePrompt(stream as never, "show missing", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleDecisions", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs decisions command", async () => { + mockRunCtxSuccess("1. Use Redis"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDecisions(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["decisions", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes subcommand arguments", async () => { + mockRunCtxSuccess("Decision listed"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDecisions(stream as never, "list", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["decisions", "list", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("decisions error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDecisions(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleLearnings", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs learnings command", async () => { + mockRunCtxSuccess("1. Go embed trick"); + const stream = fakeStream(); + const token = fakeToken(); + await handleLearnings(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["learnings", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("learnings error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleLearnings(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleDeps", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs deps command", async () => { + mockRunCtxSuccess("internal/mcp -> internal/config"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDeps(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["deps", "--no-color"], + expect.anything(), + expect.any(Function) + ); + expect(stream.progress).toHaveBeenCalledWith("Analyzing dependencies..."); + }); + + it("shows fallback when no output", async () => { + mockRunCtxSuccess(""); + const stream = fakeStream(); + const token = fakeToken(); + await handleDeps(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith("No dependency information available."); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("deps error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDeps(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleJournal", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs journal command", async () => { + mockRunCtxSuccess("Session analysis"); + const stream = fakeStream(); + const token = fakeToken(); + await handleJournal(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["journal", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes subcommand arguments", async () => { + mockRunCtxSuccess("Analysis complete"); + const stream = fakeStream(); + const token = fakeToken(); + await handleJournal(stream as never, "synthesize", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["journal", "synthesize", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("journal error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleJournal(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleReindex", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs reindex command", async () => { + mockRunCtxSuccess("Indices updated"); + const stream = fakeStream(); + const token = fakeToken(); + await handleReindex(stream as never, "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["reindex", "--no-color"], + expect.anything(), + expect.any(Function) + ); + expect(stream.progress).toHaveBeenCalledWith("Regenerating indices..."); + }); + + it("shows fallback when no output", async () => { + mockRunCtxSuccess(""); + const stream = fakeStream(); + const token = fakeToken(); + await handleReindex(stream as never, "/test", token); + expect(stream.markdown).toHaveBeenCalledWith("Indices regenerated."); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("reindex error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleReindex(stream as never, "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("splitArgs", () => { + it("splits simple space-separated args", () => { + expect(splitArgs("task Fix login bug")).toEqual(["task", "Fix", "login", "bug"]); + }); + + it("handles double-quoted strings", () => { + expect(splitArgs('decision "Use PostgreSQL" --context "Need DB"')).toEqual([ + "decision", "Use PostgreSQL", "--context", "Need DB", + ]); + }); + + it("returns empty array for empty input", () => { + expect(splitArgs("")).toEqual([]); + }); + + it("handles single arg", () => { + expect(splitArgs("task")).toEqual(["task"]); + }); + + it("handles mixed quoted and unquoted args", () => { + expect(splitArgs('decision "Use Redis" --rationale ACID')).toEqual([ + "decision", "Use Redis", "--rationale", "ACID", + ]); + }); +}); + +describe("handleAdd", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows usage when no type provided", async () => { + const stream = fakeStream(); + const token = fakeToken(); + const result = await handleAdd(stream as never, "", "/test", token); + expect(result.metadata.command).toBe("add"); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); + }); + + it("adds a task", async () => { + mockRunCtxSuccess("Task added"); + const stream = fakeStream(); + const token = fakeToken(); + await handleAdd(stream as never, "task Fix login bug", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["add", "task", "Fix", "login", "bug", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("adds a decision with flags", async () => { + mockRunCtxSuccess("Decision added"); + const stream = fakeStream(); + const token = fakeToken(); + await handleAdd( + stream as never, + 'decision "Use PostgreSQL" --context "Need DB" --rationale "ACID" --consequences "Training"', + "/test", + token + ); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["add", "decision", "Use PostgreSQL", "--context", "Need DB", "--rationale", "ACID", "--consequences", "Training", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("shows fallback message when output is empty", async () => { + mockRunCtxSuccess(""); + const stream = fakeStream(); + const token = fakeToken(); + await handleAdd(stream as never, "task Test", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith("Added **task** entry."); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("add failed"); + const stream = fakeStream(); + const token = fakeToken(); + await handleAdd(stream as never, "task Test", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleAgent", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs agent command", async () => { + mockRunCtxSuccess("Context packet..."); + const stream = fakeStream(); + const token = fakeToken(); + await handleAgent(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["agent", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --budget flag", async () => { + mockRunCtxSuccess("Context packet..."); + const stream = fakeStream(); + const token = fakeToken(); + await handleAgent(stream as never, "--budget 4000", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["agent", "--no-color", "--budget", "4000"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --format flag", async () => { + mockRunCtxSuccess("{}"); + const stream = fakeStream(); + const token = fakeToken(); + await handleAgent(stream as never, "--format json", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["agent", "--no-color", "--format", "json"], + expect.anything(), + expect.any(Function) + ); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("agent error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleAgent(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleLoad", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs load command", async () => { + mockRunCtxSuccess("assembled context"); + const stream = fakeStream(); + const token = fakeToken(); + await handleLoad(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["load", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --budget flag", async () => { + mockRunCtxSuccess("trimmed context"); + const stream = fakeStream(); + const token = fakeToken(); + await handleLoad(stream as never, "--budget 2000", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["load", "--no-color", "--budget", "2000"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --raw flag", async () => { + mockRunCtxSuccess("raw context"); + const stream = fakeStream(); + const token = fakeToken(); + await handleLoad(stream as never, "--raw", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["load", "--no-color", "--raw"], + expect.anything(), + expect.any(Function) + ); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("load error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleLoad(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleCompact", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs compact command", async () => { + mockRunCtxSuccess("Compacted"); + const stream = fakeStream(); + const token = fakeToken(); + await handleCompact(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["compact", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --archive flag when keyword used", async () => { + mockRunCtxSuccess("Archived"); + const stream = fakeStream(); + const token = fakeToken(); + await handleCompact(stream as never, "archive", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["compact", "--no-color", "--archive"], + expect.anything(), + expect.any(Function) + ); + }); + + it("shows fallback when output is empty", async () => { + mockRunCtxSuccess(""); + const stream = fakeStream(); + const token = fakeToken(); + await handleCompact(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith("Context compacted successfully."); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("compact error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleCompact(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleSync", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs sync command", async () => { + mockRunCtxSuccess("Synced"); + const stream = fakeStream(); + const token = fakeToken(); + await handleSync(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["sync", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --dry-run flag when keyword used", async () => { + mockRunCtxSuccess("Would sync..."); + const stream = fakeStream(); + const token = fakeToken(); + await handleSync(stream as never, "dry-run", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["sync", "--no-color", "--dry-run"], + expect.anything(), + expect.any(Function) + ); + }); + + it("shows fallback when output is empty", async () => { + mockRunCtxSuccess(""); + const stream = fakeStream(); + const token = fakeToken(); + await handleSync(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith("Context synced with codebase."); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("sync error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleSync(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleRecall", () => { + beforeEach(() => vi.clearAllMocks()); + + it("defaults to list when no subcommand", async () => { + mockRunCtxSuccess("session 1\nsession 2"); + const stream = fakeStream(); + const token = fakeToken(); + await handleRecall(stream as never, "", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["recall", "list", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("routes show subcommand", async () => { + mockRunCtxSuccess("session details"); + const stream = fakeStream(); + const token = fakeToken(); + await handleRecall(stream as never, "show abc123", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["recall", "show", "abc123", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("routes export subcommand", async () => { + mockRunCtxSuccess("exported"); + const stream = fakeStream(); + const token = fakeToken(); + await handleRecall(stream as never, "export abc123", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["recall", "export", "--all", "abc123", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("routes lock subcommand", async () => { + mockRunCtxSuccess("locked"); + const stream = fakeStream(); + const token = fakeToken(); + await handleRecall(stream as never, "lock abc123", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["recall", "lock", "abc123", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("routes unlock subcommand with --all when no id", async () => { + mockRunCtxSuccess("unlocked"); + const stream = fakeStream(); + const token = fakeToken(); + await handleRecall(stream as never, "unlock", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["recall", "unlock", "--all", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("routes sync subcommand", async () => { + mockRunCtxSuccess("synced"); + const stream = fakeStream(); + const token = fakeToken(); + await handleRecall(stream as never, "sync", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["recall", "sync", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("treats unknown text as query for list", async () => { + mockRunCtxSuccess("matching sessions"); + const stream = fakeStream(); + const token = fakeToken(); + await handleRecall(stream as never, "refactoring work", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["recall", "list", "--query", "refactoring work", "--no-color"], + expect.anything(), + expect.any(Function) + ); + }); + + it("shows 'No session history found.' when empty", async () => { + mockRunCtxSuccess(""); + const stream = fakeStream(); + const token = fakeToken(); + await handleRecall(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith("No session history found."); + }); + + it("handles errors gracefully", async () => { + mockRunCtxError("recall error"); + const stream = fakeStream(); + const token = fakeToken(); + await handleRecall(stream as never, "", "/test", token); + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); + +describe("handleDeps with flags", () => { + beforeEach(() => vi.clearAllMocks()); + + it("passes --format flag", async () => { + mockRunCtxSuccess("json output"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDeps(stream as never, "--format json", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["deps", "--no-color", "--format", "json"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --external flag", async () => { + mockRunCtxSuccess("external deps"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDeps(stream as never, "--external", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["deps", "--no-color", "--external"], + expect.anything(), + expect.any(Function) + ); + }); + + it("passes --type flag", async () => { + mockRunCtxSuccess("go deps"); + const stream = fakeStream(); + const token = fakeToken(); + await handleDeps(stream as never, "--type go", "/test", token); + expect(cp.execFile).toHaveBeenCalledWith( + "ctx", + ["deps", "--no-color", "--type", "go"], + expect.anything(), + expect.any(Function) + ); + }); +}); diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 99391619..fbb4e7e2 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -8,6 +8,23 @@ import * as https from "https"; const PARTICIPANT_ID = "ctx.participant"; const GITHUB_REPO = "ActiveMemory/ctx"; +/** + * Split a string into args, respecting double-quoted segments. + */ +function splitArgs(input: string): string[] { + const args: string[] = []; + const regex = /(?:[^\s"]+|"[^"]*")+/g; + let match; + while ((match = regex.exec(input)) !== null) { + let arg = match[0]; + if (arg.startsWith('"') && arg.endsWith('"') && arg.length >= 2) { + arg = arg.slice(1, -1); + } + args.push(arg); + } + return args; +} + interface CtxResult extends vscode.ChatResult { metadata: { command: string; @@ -161,7 +178,8 @@ function downloadFile(url: string, destPath: string): Promise { */ function isCtxExecutable(binPath: string): Promise { return new Promise((resolve) => { - execFile(binPath, ["--version"], { timeout: 5000 }, (error) => { + const useShell = os.platform() === "win32"; + execFile(binPath, ["--version"], { timeout: 5000, shell: useShell }, (error, stdout) => { resolve(!error); }); }); @@ -179,7 +197,6 @@ async function ensureCtxAvailable(): Promise { resolvedCtxPath = configuredPath; return; } - // 2. Check if we already downloaded it to global storage if (extensionCtx) { const { ext } = getPlatformInfo(); @@ -259,6 +276,7 @@ async function bootstrap(): Promise { throw err; } ); + } else { } return bootstrapPromise; } @@ -301,6 +319,9 @@ function runCtx( resolve({ stdout, stderr }); } ); + // Close stdin immediately so the child process never blocks waiting + // for interactive input (e.g. y/n prompts that --force/--merge skip). + child.stdin?.end(); disposable = token?.onCancellationRequested(() => { child.kill(); }); @@ -314,7 +335,7 @@ async function handleInit( ): Promise { stream.progress("Initializing .context/ directory..."); try { - const { stdout, stderr } = await runCtx(["init", "--no-color"], cwd, token); + const { stdout, stderr } = await runCtx(["init", "--force", "--merge", "--no-color", "--caller", "vscode"], cwd, token); const output = (stdout + stderr).trim(); if (output) { stream.markdown("```\n" + output + "\n```"); @@ -380,12 +401,23 @@ async function handleStatus( async function handleAgent( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { + const args = ["agent", "--no-color"]; + const parts = prompt.trim().split(/\s+/); + for (let i = 0; i < parts.length; i++) { + const p = parts[i]?.toLowerCase(); + if ((p === "--budget" || p === "budget") && parts[i + 1]) { + args.push("--budget", parts[++i]); + } else if ((p === "--format" || p === "format") && parts[i + 1]) { + args.push("--format", parts[++i]); + } + } stream.progress("Generating AI-ready context packet..."); try { - const { stdout, stderr } = await runCtx(["agent", "--no-color"], cwd, token); + const { stdout, stderr } = await runCtx(args, cwd, token); const output = (stdout + stderr).trim(); stream.markdown(output); } catch (err: unknown) { @@ -420,12 +452,51 @@ async function handleRecall( cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Searching session history..."); + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "show": + args = rest.length ? ["recall", "show", ...rest] : ["recall", "show", "--latest"]; + progressMsg = "Showing session details..."; + break; + case "export": + args = ["recall", "export", "--all", ...rest]; + progressMsg = "Exporting sessions..."; + break; + case "lock": + args = rest.length ? ["recall", "lock", ...rest] : ["recall", "lock"]; + progressMsg = "Locking journal entries..."; + break; + case "unlock": + args = rest.length ? ["recall", "unlock", ...rest] : ["recall", "unlock", "--all"]; + progressMsg = "Unlocking journal entries..."; + break; + case "sync": + args = ["recall", "sync"]; + progressMsg = "Syncing lock state..."; + break; + case "list": + args = ["recall", "list", ...rest]; + progressMsg = "Listing sessions..."; + break; + default: + // No subcommand or unknown — default to list with optional query + args = ["recall", "list"]; + if (subcmd) { + args.push("--query", prompt.trim()); + } + progressMsg = "Searching session history..."; + break; + } + args.push("--no-color"); + + stream.progress(progressMsg); try { - const args = ["recall", "list", "--no-color"]; - if (prompt.trim()) { - args.push("--query", prompt.trim()); - } const { stdout, stderr } = await runCtx(args, cwd, token); const output = (stdout + stderr).trim(); if (output) { @@ -488,31 +559,29 @@ async function handleAdd( cwd: string, token: vscode.CancellationToken ): Promise { - const parts = prompt.trim().split(/\s+/); - const type = parts[0]; - const content = parts.slice(1).join(" "); + const parts = splitArgs(prompt.trim()); + const type = parts[0]?.toLowerCase(); if (!type) { stream.markdown( - "**Usage:** `@ctx /add `\n\n" + - "Types: `task`, `decision`, `learning`\n\n" + - "Example: `@ctx /add task Implement user authentication`" + "**Usage:** `@ctx /add [flags]`\n\n" + + "Types: `task`, `decision`, `learning`, `convention`\n\n" + + "**Task:** `@ctx /add task Implement auth --priority high`\n\n" + + "**Decision:**\n```\n@ctx /add decision \"Use PostgreSQL\" --context \"Need reliable DB\" --rationale \"ACID + JSON\" --consequences \"Team training\"\n```\n\n" + + "**Learning:**\n```\n@ctx /add learning \"Go embed trick\" --context \"Tried parent dir\" --lesson \"Same package only\" --application \"Put in internal/\"\n```" ); return { metadata: { command: "add" } }; } stream.progress(`Adding ${type}...`); try { - const args = ["add", type]; - if (content) { - args.push(content); - } + const args = ["add", type, ...parts.slice(1), "--no-color"]; const { stdout, stderr } = await runCtx(args, cwd, token); const output = (stdout + stderr).trim(); if (output) { stream.markdown("```\n" + output + "\n```"); } else { - stream.markdown(`Added **${type}**: ${content}`); + stream.markdown(`Added **${type}** entry.`); } } catch (err: unknown) { stream.markdown( @@ -524,12 +593,23 @@ async function handleAdd( async function handleLoad( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { + const args = ["load", "--no-color"]; + const parts = prompt.trim().split(/\s+/); + for (let i = 0; i < parts.length; i++) { + const p = parts[i]?.toLowerCase(); + if ((p === "--budget" || p === "budget") && parts[i + 1]) { + args.push("--budget", parts[++i]); + } else if (p === "--raw" || p === "raw") { + args.push("--raw"); + } + } stream.progress("Loading assembled context..."); try { - const { stdout, stderr } = await runCtx(["load", "--no-color"], cwd, token); + const { stdout, stderr } = await runCtx(args, cwd, token); const output = (stdout + stderr).trim(); stream.markdown(output); } catch (err: unknown) { @@ -542,12 +622,18 @@ async function handleLoad( async function handleCompact( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { + const args = ["compact", "--no-color"]; + const lower = prompt.trim().toLowerCase(); + if (lower.includes("archive") || lower.includes("--archive")) { + args.push("--archive"); + } stream.progress("Compacting context..."); try { - const { stdout, stderr } = await runCtx(["compact", "--no-color"], cwd, token); + const { stdout, stderr } = await runCtx(args, cwd, token); const output = (stdout + stderr).trim(); if (output) { stream.markdown("```\n" + output + "\n```"); @@ -564,12 +650,18 @@ async function handleCompact( async function handleSync( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { + const args = ["sync", "--no-color"]; + const lower = prompt.trim().toLowerCase(); + if (lower.includes("dry-run") || lower.includes("--dry-run") || lower.includes("dry run")) { + args.push("--dry-run"); + } stream.progress("Syncing context with codebase..."); try { - const { stdout, stderr } = await runCtx(["sync", "--no-color"], cwd, token); + const { stdout, stderr } = await runCtx(args, cwd, token); const output = (stdout + stderr).trim(); if (output) { stream.markdown("```\n" + output + "\n```"); @@ -927,6 +1019,433 @@ async function handleSystem( return { metadata: { command: "system" } }; } +async function handleChanges( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const args = ["changes", "--no-color"]; + const since = prompt.trim(); + if (since) { + args.push("--since", since); + } + stream.progress("Checking changes since last session..."); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No changes detected."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to check changes.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "changes" } }; +} + +async function handleConfig( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "switch": + args = parts[1] ? ["config", "switch", parts[1]] : ["config", "switch"]; + progressMsg = "Switching config profile..."; + break; + case "status": + args = ["config", "status"]; + progressMsg = "Checking config status..."; + break; + case "schema": + args = ["config", "schema"]; + progressMsg = "Printing config schema..."; + break; + default: + stream.markdown( + "**Usage:** `@ctx /config `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `switch [dev\\|base]` | Switch .ctxrc profile |\n" + + "| `status` | Show active profile |\n" + + "| `schema` | Print JSON Schema for .ctxrc |\n\n" + + "Example: `@ctx /config status` or `@ctx /config switch dev`" + ); + return { metadata: { command: "config" } }; + } + args.push("--no-color"); + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No output."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Config command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "config" } }; +} + +async function handleDoctor( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Running health checks..."); + try { + const { stdout, stderr } = await runCtx(["doctor", "--no-color"], cwd, token); + const output = (stdout + stderr).trim(); + stream.markdown("```\n" + output + "\n```"); + } catch (err: unknown) { + stream.markdown( + `**Error:** Doctor check failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "doctor" } }; +} + +async function handleGuide( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const args = ["guide", "--no-color"]; + const flag = prompt.trim().toLowerCase(); + if (flag === "skills" || flag === "--skills") { + args.push("--skills"); + } else if (flag === "commands" || flag === "--commands") { + args.push("--commands"); + } + stream.progress("Loading guide..."); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + stream.markdown(output); + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to load guide.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "guide" } }; +} + +async function handleWhy( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const doc = prompt.trim().toLowerCase(); + const args = ["why"]; + if (doc) { + args.push(doc); + } + args.push("--no-color"); + stream.progress("Loading philosophy document..."); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + stream.markdown(output); + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to load document.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "why" } }; +} + +async function handleMemory( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "sync": + args = ["memory", "sync"]; + progressMsg = "Syncing memory..."; + break; + case "status": + args = ["memory", "status"]; + progressMsg = "Checking memory status..."; + break; + case "diff": + args = ["memory", "diff"]; + progressMsg = "Showing memory diff..."; + break; + case "import": + args = ["memory", "import"]; + progressMsg = "Importing memory entries..."; + break; + case "publish": + args = ["memory", "publish"]; + progressMsg = "Publishing to MEMORY.md..."; + break; + case "unpublish": + args = ["memory", "unpublish"]; + progressMsg = "Removing published block..."; + break; + default: + stream.markdown( + "**Usage:** `@ctx /memory `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `sync` | Copy MEMORY.md to mirror |\n" + + "| `status` | Show drift and timestamps |\n" + + "| `diff` | Show changes since last sync |\n" + + "| `import` | Classify entries into .context/ files |\n" + + "| `publish` | Push context to MEMORY.md |\n" + + "| `unpublish` | Remove published block |\n\n" + + "Example: `@ctx /memory status` or `@ctx /memory sync`" + ); + return { metadata: { command: "memory" } }; + } + args.push("--no-color"); + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No output."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Memory command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "memory" } }; +} + +async function handlePrompt( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "list": + case "ls": + args = ["prompt", "list"]; + progressMsg = "Listing prompt templates..."; + break; + case "show": + args = rest ? ["prompt", "show", rest] : ["prompt", "list"]; + progressMsg = rest ? "Showing prompt template..." : "Listing prompt templates..."; + break; + case "add": + args = rest ? ["prompt", "add", rest] : ["prompt", "list"]; + progressMsg = rest ? "Creating prompt template..." : "Listing prompt templates..."; + break; + case "rm": + if (!rest) { + stream.markdown("**Usage:** `@ctx /prompt rm `"); + return { metadata: { command: "prompt" } }; + } + args = ["prompt", "rm", rest]; + progressMsg = "Removing prompt template..."; + break; + default: + args = ["prompt", "list"]; + progressMsg = "Listing prompt templates..."; + break; + } + args.push("--no-color"); + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No prompt templates found."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Prompt command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "prompt" } }; +} + +async function handleDecisions( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const args = ["decisions"]; + const subcmd = prompt.trim(); + if (subcmd) { + args.push(...subcmd.split(/\s+/)); + } + args.push("--no-color"); + stream.progress("Managing decisions..."); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No decisions found."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Decisions command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "decisions" } }; +} + +async function handleLearnings( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const args = ["learnings"]; + const subcmd = prompt.trim(); + if (subcmd) { + args.push(...subcmd.split(/\s+/)); + } + args.push("--no-color"); + stream.progress("Managing learnings..."); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No learnings found."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Learnings command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "learnings" } }; +} + +async function handleDeps( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const args = ["deps", "--no-color"]; + const parts = prompt.trim().split(/\s+/); + for (let i = 0; i < parts.length; i++) { + const p = parts[i]?.toLowerCase(); + if ((p === "--format" || p === "format") && parts[i + 1]) { + args.push("--format", parts[++i]); + } else if ((p === "--type" || p === "type") && parts[i + 1]) { + args.push("--type", parts[++i]); + } else if (p === "--external" || p === "external") { + args.push("--external"); + } + } + stream.progress("Analyzing dependencies..."); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No dependency information available."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to analyze dependencies.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "deps" } }; +} + +async function handleJournal( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const args = ["journal"]; + const subcmd = prompt.trim(); + if (subcmd) { + args.push(...subcmd.split(/\s+/)); + } + args.push("--no-color"); + stream.progress("Analyzing sessions..."); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = (stdout + stderr).trim(); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No journal data available."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Journal command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "journal" } }; +} + +async function handleReindex( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Regenerating indices..."); + try { + const { stdout, stderr } = await runCtx(["reindex", "--no-color"], cwd, token); + const output = (stdout + stderr).trim(); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("Indices regenerated."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to regenerate indices.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "reindex" } }; +} + async function handleFreeform( request: vscode.ChatRequest, stream: vscode.ChatResponseStream, @@ -942,6 +1461,18 @@ async function handleFreeform( if (prompt.includes("status")) { return handleStatus(stream, cwd, token); } + if (prompt.includes("agent") || prompt.includes("context packet")) { + return handleAgent(stream, request.prompt, cwd, token); + } + if (prompt.includes("load") || prompt.includes("assembled")) { + return handleLoad(stream, request.prompt, cwd, token); + } + if (prompt.includes("compact") || prompt.includes("archive")) { + return handleCompact(stream, request.prompt, cwd, token); + } + if (prompt.includes("sync") || prompt.includes("reconcile")) { + return handleSync(stream, request.prompt, cwd, token); + } if (prompt.includes("drift")) { return handleDrift(stream, cwd, token); } @@ -966,6 +1497,42 @@ async function handleFreeform( if (prompt.includes("system") || prompt.includes("resource") || prompt.includes("bootstrap")) { return handleSystem(stream, request.prompt, cwd, token); } + if (prompt.includes("change") || prompt.includes("diff") || prompt.includes("since")) { + return handleChanges(stream, request.prompt, cwd, token); + } + if (prompt.includes("config") || prompt.includes("profile")) { + return handleConfig(stream, request.prompt, cwd, token); + } + if (prompt.includes("doctor") || prompt.includes("health")) { + return handleDoctor(stream, cwd, token); + } + if (prompt.includes("guide") || prompt.includes("cheat")) { + return handleGuide(stream, request.prompt, cwd, token); + } + if (prompt.includes("why") || prompt.includes("philosophy") || prompt.includes("manifesto")) { + return handleWhy(stream, request.prompt, cwd, token); + } + if (prompt.includes("memory") || prompt.includes("mirror")) { + return handleMemory(stream, request.prompt, cwd, token); + } + if (prompt.includes("prompt") || prompt.includes("template")) { + return handlePrompt(stream, request.prompt, cwd, token); + } + if (prompt.includes("decision")) { + return handleDecisions(stream, request.prompt, cwd, token); + } + if (prompt.includes("learning")) { + return handleLearnings(stream, request.prompt, cwd, token); + } + if (prompt.includes("dep") || prompt.includes("dependency")) { + return handleDeps(stream, request.prompt, cwd, token); + } + if (prompt.includes("journal") || prompt.includes("session")) { + return handleJournal(stream, request.prompt, cwd, token); + } + if (prompt.includes("reindex") || prompt.includes("index")) { + return handleReindex(stream, cwd, token); + } // Default: show help with available commands stream.markdown( @@ -988,7 +1555,19 @@ async function handleFreeform( "| `/tasks` | Archive or snapshot tasks |\n" + "| `/pad` | Encrypted scratchpad |\n" + "| `/notify` | Webhook notifications |\n" + - "| `/system` | System diagnostics |\n\n" + + "| `/system` | System diagnostics |\n" + + "| `/changes` | What changed since last session |\n" + + "| `/config` | Manage runtime configuration |\n" + + "| `/doctor` | Structural health check |\n" + + "| `/guide` | Quick-reference cheat sheet |\n" + + "| `/why` | Philosophy behind ctx |\n" + + "| `/memory` | Bridge Claude Code auto memory |\n" + + "| `/prompt` | Manage prompt templates |\n" + + "| `/decisions` | Manage DECISIONS.md |\n" + + "| `/learnings` | Manage LEARNINGS.md |\n" + + "| `/deps` | Package dependency graph |\n" + + "| `/journal` | Analyze AI sessions |\n" + + "| `/reindex` | Regenerate indices |\n\n" + "Example: `@ctx /status` or `@ctx /add task Fix login bug`" ); return { metadata: { command: "help" } }; @@ -1007,7 +1586,6 @@ const handler: vscode.ChatRequestHandler = async ( ); return { metadata: { command: request.command || "none" } }; } - // Auto-bootstrap: ensure ctx binary is available before any command try { stream.progress("Checking ctx installation..."); @@ -1028,7 +1606,7 @@ const handler: vscode.ChatRequestHandler = async ( case "status": return handleStatus(stream, cwd, token); case "agent": - return handleAgent(stream, cwd, token); + return handleAgent(stream, request.prompt, cwd, token); case "drift": return handleDrift(stream, cwd, token); case "recall": @@ -1038,11 +1616,11 @@ const handler: vscode.ChatRequestHandler = async ( case "add": return handleAdd(stream, request.prompt, cwd, token); case "load": - return handleLoad(stream, cwd, token); + return handleLoad(stream, request.prompt, cwd, token); case "compact": - return handleCompact(stream, cwd, token); + return handleCompact(stream, request.prompt, cwd, token); case "sync": - return handleSync(stream, cwd, token); + return handleSync(stream, request.prompt, cwd, token); case "complete": return handleComplete(stream, request.prompt, cwd, token); case "remind": @@ -1055,6 +1633,30 @@ const handler: vscode.ChatRequestHandler = async ( return handleNotify(stream, request.prompt, cwd, token); case "system": return handleSystem(stream, request.prompt, cwd, token); + case "changes": + return handleChanges(stream, request.prompt, cwd, token); + case "config": + return handleConfig(stream, request.prompt, cwd, token); + case "doctor": + return handleDoctor(stream, cwd, token); + case "guide": + return handleGuide(stream, request.prompt, cwd, token); + case "why": + return handleWhy(stream, request.prompt, cwd, token); + case "memory": + return handleMemory(stream, request.prompt, cwd, token); + case "prompt": + return handlePrompt(stream, request.prompt, cwd, token); + case "decisions": + return handleDecisions(stream, request.prompt, cwd, token); + case "learnings": + return handleLearnings(stream, request.prompt, cwd, token); + case "deps": + return handleDeps(stream, request.prompt, cwd, token); + case "journal": + return handleJournal(stream, request.prompt, cwd, token); + case "reindex": + return handleReindex(stream, cwd, token); default: return handleFreeform(request, stream, cwd, token); } @@ -1065,10 +1667,9 @@ export function activate(extensionContext: vscode.ExtensionContext) { extensionCtx = extensionContext; // Kick off background bootstrap — don't block activation - bootstrap().catch(() => { + bootstrap().catch((err) => { // Errors will surface when user invokes a command }); - const participant = vscode.chat.createChatParticipant( PARTICIPANT_ID, handler @@ -1077,7 +1678,6 @@ export function activate(extensionContext: vscode.ExtensionContext) { extensionContext.extensionUri, "icon.png" ); - participant.followupProvider = { provideFollowups( result: CtxResult, @@ -1152,12 +1752,31 @@ export { ensureCtxAvailable, bootstrap, getPlatformInfo, + splitArgs, + handleAdd, + handleAgent, + handleLoad, + handleCompact, + handleSync, + handleRecall, handleComplete, handleRemind, handleTasks, handlePad, handleNotify, handleSystem, + handleChanges, + handleConfig, + handleDoctor, + handleGuide, + handleWhy, + handleMemory, + handlePrompt, + handleDecisions, + handleLearnings, + handleDeps, + handleJournal, + handleReindex, }; export function deactivate() {}