Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/init-vscode-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-cli": minor
---

`squad init` now defaults to writing `"chat.newSession.defaultMode": "Squad"` into `.vscode/settings.json`, so new VS Code chat sessions open in Squad mode automatically. The edit is JSONC-aware (preserves comments, trailing commas, and existing keys), idempotent, and skipped when the key already exists. Pass `--no-vscode-default` to opt out entirely.
33 changes: 33 additions & 0 deletions docs/src/content/docs/features/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,39 @@ See [Getting Started](../get-started/first-session.md) for your first VS Code se

---

## Default Chat Session Mode

When you run `squad init`, Squad automatically configures VS Code to open new chat
sessions in Squad mode by adding the following to `.vscode/settings.json`:

```json
{
"chat.newSession.defaultMode": "Squad"
}
```

**Behavior:**

- If `.vscode/settings.json` does not exist, Squad creates it (and the `.vscode/` directory
if needed) with this single setting.
- If the file already exists and `chat.newSession.defaultMode` is absent, Squad adds the key
while preserving all existing settings, comments, and formatting (JSONC-safe edit).
- If `chat.newSession.defaultMode` already has **any** value, Squad leaves it untouched —
your setting always takes precedence.
- The operation is **idempotent**: running `squad init` again produces no changes.

**Opt out:**

Pass `--no-vscode-default` to skip this step entirely:

```bash
squad init --no-vscode-default
```

With this flag, Squad will not create or modify `.vscode/settings.json` at all.

---

## Extension Developer Guide

If you're building a VS Code extension that integrates with Squad, follow these patterns.
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ squad init
| `squad init` | Initialize Squad in the current repo (idempotent — safe to run multiple times) | No |
| `squad init --state-backend <type>` | Initialize with a specific state backend (`local`, `orphan`, `two-layer`) | No |
| `squad init --global` | Create a personal squad in your platform-specific directory | No |
| `squad init --no-vscode-default` | Skip writing `chat.newSession.defaultMode` to `.vscode/settings.json` | No |
| `squad init --mode remote <path>` | Initialize linked to a remote team root (dual-root mode) | No |
| `squad link <team-repo-path>` | Link project to a remote team root | Yes |
| `squad loop` | Run a prompt-driven work loop from `loop.md` | Yes |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/squad-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"@modelcontextprotocol/sdk": "^1.30.0",
"ink": "^7.1.1",
"react": "^19.2.8",
"jsonc-parser": "^3.3.1",
"vscode-jsonrpc": "^9.0.1"
},
"devDependencies": {
Expand Down
8 changes: 5 additions & 3 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ async function main(): Promise<void> {
console.log(` Flags: --sdk (SDK builder syntax)`);
console.log(` --roles (use base roles)`);
console.log(` --global (personal squad dir)`);
console.log(` --no-workflows (skip CI setup)`);
console.log(` --no-workflows (skip CI setup)`);
console.log(` --no-vscode-default (skip .vscode/settings.json update)`);
console.log(` --preset <name> (apply a preset after init)`);
Comment thread
tamirdresher marked this conversation as resolved.
console.log(` --state-backend <type> (local|orphan|two-layer)`);
console.log(` Usage: init --mode remote <team-repo-path>`);
Expand Down Expand Up @@ -352,7 +353,8 @@ async function main(): Promise<void> {

const sdkMod = hasGlobal ? await lazySquadSdk() : null;
const dest = hasGlobal ? sdkMod!.resolveGlobalSquadPath() : process.cwd();
const noWorkflows = args.includes('--no-workflows');
const noWorkflows = args.includes('--no-workflows');
const noVscodeDefault = args.includes('--no-vscode-default');
const mcpFrontmatter = args.includes('--mcp-frontmatter');
const sdk = args.includes('--sdk');
const roles = args.includes('--roles');
Expand All @@ -362,7 +364,7 @@ async function main(): Promise<void> {
const sbIdx = args.indexOf('--state-backend');
const initStateBackend = (sbIdx !== -1 && args[sbIdx + 1]) ? args[sbIdx + 1] : undefined;
// Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad
runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter }).then(async () => {
runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal, stateBackend: initStateBackend, mcpFrontmatter, includeVscodeDefault: !noVscodeDefault }).then(async () => {
if (presetName) {
const { seedBuiltinPresets, applyPreset } = await import('@bradygaster/squad-sdk/presets');
const { resolvePresetsDir, ensureSquadHome } = await import('@bradygaster/squad-sdk/resolution');
Expand Down
51 changes: 51 additions & 0 deletions packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,51 @@ import {
hasCopilot,
insertCopilotSection,
} from './team-md.js';
import { modify, applyEdits, parse as parseJsonc } from 'jsonc-parser';

const storage = new FSStorageProvider();

const CYAN = '\x1b[36m';

/**
* Applies "chat.newSession.defaultMode": "Squad" to .vscode/settings.json.
* JSONC-aware: preserves existing comments, trailing commas, and formatting.
* Idempotent: if the key already exists (any value), leaves it untouched.
* On parse failure of an existing file, warns and skips without corruption.
*/
async function applyVscodeDefaultMode(dest: string): Promise<void> {
const settingsPath = path.join(dest, '.vscode', 'settings.json');
const KEY = 'chat.newSession.defaultMode';
const VALUE = 'Squad';

if (!storage.existsSync(settingsPath)) {
// Create new file with just the one key
storage.writeSync(settingsPath, `{\n "${KEY}": "${VALUE}"\n}\n`);
success(`.vscode/settings.json created — ${KEY}: "${VALUE}"`);
return;
}

const content = storage.readSync(settingsPath) ?? '';
const parseErrors: Array<{ error: number; offset: number; length: number }> = [];
const parsed = parseJsonc(content, parseErrors, { allowTrailingComma: true });

if (parseErrors.length > 0) {
console.warn(`${YELLOW}⚠ .vscode/settings.json could not be parsed — skipping VS Code default mode setup${RESET}`);
return;
}

if (parsed && Object.prototype.hasOwnProperty.call(parsed, KEY)) {
// Already set — respect user ownership, no change
return;
}

const edits = modify(content, [KEY], VALUE, {
formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' },
});
storage.writeSync(settingsPath, applyEdits(content, edits));
success(`.vscode/settings.json updated — ${KEY}: "${VALUE}"`);
}

/**
* Detect if the target directory is inside a parent git repo.
* Returns the normalized git root path if a parent repo is detected,
Expand Down Expand Up @@ -123,6 +163,8 @@ export interface RunInitOptions {
stateBackend?: string;
/** If true, write MCP server config into squad.agent.md frontmatter instead of .copilot/mcp-config.json */
mcpFrontmatter?: boolean;
/** If false, skip writing "chat.newSession.defaultMode": "Squad" to .vscode/settings.json (default: true) */
includeVscodeDefault?: boolean;
}

/**
Expand Down Expand Up @@ -440,6 +482,15 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
// best-effort: .mcp.json write failure does not block init
}

// Apply VS Code default chat session mode (opt-out: --no-vscode-default)
if (options.includeVscodeDefault !== false && !options.isGlobal) {
try {
await applyVscodeDefaultMode(dest);
} catch {
console.warn(`${YELLOW}⚠ could not apply VS Code default mode setting${RESET}`);
}
}
Comment thread
tamirdresher marked this conversation as resolved.

// Report .init-prompt storage
if (options.prompt) {
success(`.init-prompt stored — team will be cast when you run ${CYAN}${BOLD}copilot --agent squad${RESET}`);
Expand Down
205 changes: 205 additions & 0 deletions test/init-vscode-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* Tests for VS Code default chat session mode injection (feat(init): #vscode-default).
*
* Verifies that `runInit()` writes `"chat.newSession.defaultMode": "Squad"` to
* `.vscode/settings.json` with JSONC-safe, idempotent, and opt-out semantics.
*
* Cases:
* 1. Missing .vscode/settings.json (and dir) → created with the key.
* 2. Existing file without the key → key added, existing settings preserved.
* 3. Existing key with a different value → left untouched.
* 4. File has comments and trailing commas → both preserved after edit.
* 5. --no-vscode-default passed → file not created or modified.
* 6. Idempotent rerun → second run produces no further diff.
* 7. isGlobal:true passed → .vscode/settings.json not created (global skips vscode).
* 8. Malformed file → init does not crash; warning shown; file unchanged.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync } from 'node:child_process';

import { runInit } from '../packages/squad-cli/src/cli/core/init.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeTempRepo(prefix = 'squad-vscode-test-'): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
execFileSync('git', ['init', '--quiet', '-b', 'main'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.name', 'Squad Test'], { cwd: dir, stdio: 'pipe' });
// Seed a commit so HEAD exists
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
execFileSync('git', ['add', 'README.md'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir, stdio: 'pipe' });
return dir;
}

function cleanDir(dir: string): void {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
}

function settingsPath(repoDir: string): string {
return path.join(repoDir, '.vscode', 'settings.json');
}

const INIT_OPTS = { includeWorkflows: false };
const KEY = 'chat.newSession.defaultMode';

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('runInit — VS Code default chat session mode', () => {
let tmpDir: string;
let homeTmpDir: string;

beforeEach(() => {
tmpDir = makeTempRepo();
homeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-vscode-home-'));
process.env.SQUAD_HOME_DIR_OVERRIDE = homeTmpDir;
});

afterEach(() => {
delete process.env.SQUAD_HOME_DIR_OVERRIDE;
cleanDir(tmpDir);
cleanDir(homeTmpDir);
});

// ── Case 1: missing .vscode/settings.json ─────────────────────────────
it('creates .vscode/settings.json when the file does not exist', async () => {
await runInit(tmpDir, INIT_OPTS);

const sp = settingsPath(tmpDir);
expect(fs.existsSync(sp), '.vscode/settings.json should be created').toBe(true);

const content = fs.readFileSync(sp, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed[KEY]).toBe('Squad');
});

// ── Case 2: existing file without the key ─────────────────────────────
it('adds the key to an existing settings.json that lacks it', async () => {
const vsDir = path.join(tmpDir, '.vscode');
fs.mkdirSync(vsDir, { recursive: true });
fs.writeFileSync(path.join(vsDir, 'settings.json'), JSON.stringify({
'editor.tabSize': 2,
'files.autoSave': 'off',
}, null, 2) + '\n', 'utf-8');

await runInit(tmpDir, INIT_OPTS);

const sp = settingsPath(tmpDir);
const content = fs.readFileSync(sp, 'utf-8');
const parsed = JSON.parse(content);

expect(parsed[KEY]).toBe('Squad');
expect(parsed['editor.tabSize']).toBe(2);
expect(parsed['files.autoSave']).toBe('off');
});

// ── Case 3: key already set to a different value ───────────────────────
it('does not overwrite an existing chat.newSession.defaultMode value', async () => {
const vsDir = path.join(tmpDir, '.vscode');
fs.mkdirSync(vsDir, { recursive: true });
fs.writeFileSync(path.join(vsDir, 'settings.json'), JSON.stringify({
[KEY]: 'Custom',
}, null, 2) + '\n', 'utf-8');

await runInit(tmpDir, INIT_OPTS);

const sp = settingsPath(tmpDir);
const content = fs.readFileSync(sp, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed[KEY]).toBe('Custom');
});

// ── Case 4: file has JSONC comments and trailing commas ────────────────
it('preserves comments and trailing commas in settings.json', async () => {
const vsDir = path.join(tmpDir, '.vscode');
fs.mkdirSync(vsDir, { recursive: true });
const jsoncContent = `{
// My VS Code settings
"editor.tabSize": 2, // two spaces
"editor.formatOnSave": true,
}\n`;
fs.writeFileSync(path.join(vsDir, 'settings.json'), jsoncContent, 'utf-8');

await runInit(tmpDir, INIT_OPTS);

const sp = settingsPath(tmpDir);
const updatedContent = fs.readFileSync(sp, 'utf-8');

// Comments must be preserved
expect(updatedContent).toContain('// My VS Code settings');
expect(updatedContent).toContain('// two spaces');

// The new key must be present
expect(updatedContent).toContain(`"${KEY}"`);
expect(updatedContent).toContain('"Squad"');

// Original keys must still be present
expect(updatedContent).toContain('"editor.tabSize"');
expect(updatedContent).toContain('"editor.formatOnSave"');
});

// ── Case 5: --no-vscode-default passed ────────────────────────────────
it('does not create .vscode/settings.json when includeVscodeDefault is false', async () => {
await runInit(tmpDir, { ...INIT_OPTS, includeVscodeDefault: false });

const sp = settingsPath(tmpDir);
expect(fs.existsSync(sp), '.vscode/settings.json must NOT be created').toBe(false);
});

it('does not modify an existing settings.json when includeVscodeDefault is false', async () => {
const vsDir = path.join(tmpDir, '.vscode');
fs.mkdirSync(vsDir, { recursive: true });
const original = JSON.stringify({ 'editor.tabSize': 4 }, null, 2) + '\n';
fs.writeFileSync(path.join(vsDir, 'settings.json'), original, 'utf-8');

await runInit(tmpDir, { ...INIT_OPTS, includeVscodeDefault: false });

const sp = settingsPath(tmpDir);
expect(fs.readFileSync(sp, 'utf-8')).toBe(original);
});

// ── Case 6: idempotent rerun ───────────────────────────────────────────
it('produces no further diff when run a second time', async () => {
await runInit(tmpDir, INIT_OPTS);

const sp = settingsPath(tmpDir);
const afterFirst = fs.readFileSync(sp, 'utf-8');

await runInit(tmpDir, INIT_OPTS);

const afterSecond = fs.readFileSync(sp, 'utf-8');
expect(afterSecond).toBe(afterFirst);
});

// ── Case 7 (regression): isGlobal:true skips .vscode entirely ────────
it('does not create .vscode/settings.json when isGlobal is true', async () => {
await runInit(tmpDir, { ...INIT_OPTS, isGlobal: true });

const sp = settingsPath(tmpDir);
expect(fs.existsSync(sp), '.vscode/settings.json must NOT be created for global init').toBe(false);
});

// ── Case 8: malformed / unparseable settings.json ─────────────────────
it('does not crash or corrupt a malformed settings.json', async () => {
const vsDir = path.join(tmpDir, '.vscode');
fs.mkdirSync(vsDir, { recursive: true });
const malformed = '{ this is not valid json at all !!! ';
fs.writeFileSync(path.join(vsDir, 'settings.json'), malformed, 'utf-8');

// Should not throw
await expect(runInit(tmpDir, INIT_OPTS)).resolves.not.toThrow();

// File content must be untouched
const sp = settingsPath(tmpDir);
expect(fs.readFileSync(sp, 'utf-8')).toBe(malformed);
});
});
Loading