feat: add MCP server mode, /lint skill, E2E smoke tests
- Add native MCP server mode (--mode mcp): exposes GSD's tools via Model Context Protocol over stdin/stdout for Claude Desktop, VS Code, and other MCP-compatible clients. Uses @modelcontextprotocol/sdk. - Add /lint skill: auto-detects ESLint, Biome, Prettier, rustfmt, gofmt, Black, Ruff and runs with structured output - Add 6 E2E smoke tests: --version, --help, config --help, update --help, --list-models, and --mode text --print startup - Fix diff-context.ts stdio type for CI compatibility - Fix token-counter.ts tiktoken import for extensions typecheck - Update help text and CLI to include --mode mcp
This commit is contained in:
parent
973b8992e5
commit
8d56ab2893
9 changed files with 1424 additions and 16 deletions
905
package-lock.json
generated
905
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -72,6 +72,7 @@
|
|||
"@google/genai": "^1.40.0",
|
||||
"@mariozechner/jiti": "^2.6.2",
|
||||
"@mistralai/mistralai": "1.14.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@silvia-odwyer/photon-node": "^0.3.4",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
|
|
|
|||
17
src/cli.ts
17
src/cli.ts
|
|
@ -25,7 +25,7 @@ import { printHelp, printSubcommandHelp } from './help-text.js'
|
|||
// Minimal CLI arg parser — detects print/subagent mode flags
|
||||
// ---------------------------------------------------------------------------
|
||||
interface CliFlags {
|
||||
mode?: 'text' | 'json' | 'rpc'
|
||||
mode?: 'text' | 'json' | 'rpc' | 'mcp'
|
||||
print?: boolean
|
||||
continue?: boolean
|
||||
noSession?: boolean
|
||||
|
|
@ -59,7 +59,7 @@ function parseCliArgs(argv: string[]): CliFlags {
|
|||
const arg = args[i]
|
||||
if (arg === '--mode' && i + 1 < args.length) {
|
||||
const m = args[++i]
|
||||
if (m === 'text' || m === 'json' || m === 'rpc') flags.mode = m
|
||||
if (m === 'text' || m === 'json' || m === 'rpc' || m === 'mcp') flags.mode = m
|
||||
} else if (arg === '--print' || arg === '-p') {
|
||||
flags.print = true
|
||||
} else if (arg === '--continue' || arg === '-c') {
|
||||
|
|
@ -300,8 +300,18 @@ if (isPrintMode) {
|
|||
process.exit(0)
|
||||
}
|
||||
|
||||
if (mode === 'mcp') {
|
||||
const { startMcpServer } = await import('./mcp-server.js')
|
||||
await startMcpServer({
|
||||
tools: session.agent.state.tools ?? [],
|
||||
version: process.env.GSD_VERSION || '0.0.0',
|
||||
})
|
||||
// MCP server runs until the transport closes; keep alive
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
await runPrintMode(session, {
|
||||
mode,
|
||||
mode: mode as 'text' | 'json',
|
||||
messages: cliFlags.messages,
|
||||
})
|
||||
process.exit(0)
|
||||
|
|
@ -411,6 +421,7 @@ if (!process.stdin.isTTY) {
|
|||
process.stderr.write('[gsd] Non-interactive alternatives:\n')
|
||||
process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n')
|
||||
process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n')
|
||||
process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n')
|
||||
process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function printHelp(version: string): void {
|
|||
process.stdout.write(`GSD v${version} — Get Shit Done\n\n`)
|
||||
process.stdout.write('Usage: gsd [options] [message...]\n\n')
|
||||
process.stdout.write('Options:\n')
|
||||
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n')
|
||||
process.stdout.write(' --mode <text|json|rpc|mcp> Output mode (default: interactive)\n')
|
||||
process.stdout.write(' --print, -p Single-shot print mode\n')
|
||||
process.stdout.write(' --continue, -c Resume the most recent session\n')
|
||||
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export interface RecentFilesOptions {
|
|||
const EXEC_OPTS = {
|
||||
encoding: "utf-8" as const,
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"] as const,
|
||||
stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"],
|
||||
};
|
||||
|
||||
function git(cmd: string, cwd: string): string {
|
||||
|
|
|
|||
87
src/resources/extensions/gsd/mcp-server.ts
Normal file
87
src/resources/extensions/gsd/mcp-server.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// @ts-ignore — @modelcontextprotocol/sdk types may not be in extensions tsconfig
|
||||
import { Server } from '@modelcontextprotocol/sdk/server'
|
||||
// @ts-ignore
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio'
|
||||
// @ts-ignore
|
||||
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types'
|
||||
|
||||
interface McpTool {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
execute(toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal, onUpdate?: unknown): Promise<{ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> }>
|
||||
}
|
||||
|
||||
export async function startMcpServer(options: {
|
||||
tools: McpTool[]
|
||||
version?: string
|
||||
}): Promise<void> {
|
||||
const { tools, version = '0.0.0' } = options
|
||||
|
||||
const toolMap = new Map<string, McpTool>()
|
||||
for (const tool of tools) {
|
||||
toolMap.set(tool.name, tool)
|
||||
}
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'gsd', version },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.parameters,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
|
||||
const { name, arguments: args } = request.params
|
||||
const tool = toolMap.get(name)
|
||||
if (!tool) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.execute(
|
||||
`mcp-${Date.now()}`,
|
||||
args ?? {},
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
|
||||
const content = result.content.map((block) => {
|
||||
if (block.type === 'text') {
|
||||
return { type: 'text' as const, text: block.text }
|
||||
}
|
||||
if (block.type === 'image') {
|
||||
return {
|
||||
type: 'image' as const,
|
||||
data: block.data,
|
||||
mimeType: block.mimeType,
|
||||
}
|
||||
}
|
||||
return { type: 'text' as const, text: JSON.stringify(block) }
|
||||
})
|
||||
|
||||
return { content }
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text' as const, text: message }],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
|
||||
process.stderr.write(`[gsd] MCP server started (v${version})\n`)
|
||||
}
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
import type { Tiktoken } from "tiktoken";
|
||||
interface TokenEncoder {
|
||||
encode(text: string): Uint32Array | number[];
|
||||
}
|
||||
|
||||
let encoder: Tiktoken | null = null;
|
||||
let encoder: TokenEncoder | null = null;
|
||||
let encoderFailed = false;
|
||||
|
||||
async function getEncoder(): Promise<Tiktoken | null> {
|
||||
async function getEncoder(): Promise<TokenEncoder | null> {
|
||||
if (encoder) return encoder;
|
||||
if (encoderFailed) return null;
|
||||
try {
|
||||
const { encoding_for_model } = await import("tiktoken");
|
||||
encoder = encoding_for_model("gpt-4o");
|
||||
// @ts-ignore — tiktoken may not have type declarations in extensions tsconfig
|
||||
const tiktoken = await import("tiktoken");
|
||||
encoder = tiktoken.encoding_for_model("gpt-4o") as TokenEncoder;
|
||||
return encoder;
|
||||
} catch {
|
||||
encoderFailed = true;
|
||||
|
|
|
|||
141
src/resources/skills/lint/SKILL.md
Normal file
141
src/resources/skills/lint/SKILL.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
name: lint
|
||||
description: Lint and format code. Auto-detects ESLint, Biome, Prettier, or language-native formatters and runs them with auto-fix. Reports remaining issues with actionable suggestions.
|
||||
---
|
||||
|
||||
<objective>
|
||||
Lint and format code in the current project. Auto-detect the project's linter and formatter toolchain, run them against the target files, and report results grouped by severity with actionable fix suggestions.
|
||||
</objective>
|
||||
|
||||
<arguments>
|
||||
This skill accepts optional arguments after `/lint`:
|
||||
|
||||
- **No arguments**: Lint only files changed in the current working tree (`git diff --name-only` and `git diff --cached --name-only`).
|
||||
- **A file or directory path**: Lint only that specific path (e.g., `/lint src/utils`).
|
||||
- **`--fix`**: Automatically apply safe fixes. Can be combined with a path (e.g., `/lint src/ --fix`).
|
||||
- **`--fix` without a path**: Auto-fix changed files only.
|
||||
|
||||
Parse the arguments before proceeding. If `--fix` is present, set fix mode. If a non-flag argument is present, treat it as the target path.
|
||||
</arguments>
|
||||
|
||||
<detection>
|
||||
Auto-detect the project's linter and formatter by checking configuration files in the project root. Check in this order and use the **first match found** for each category (linter vs. formatter). A project may have both a linter and a formatter.
|
||||
|
||||
**JavaScript/TypeScript Linters:**
|
||||
|
||||
1. **Biome** — Look for `biome.json` or `biome.jsonc` in the project root.
|
||||
- Lint command: `npx @biomejs/biome check .` (or `--apply` with `--fix`)
|
||||
- Format command: `npx @biomejs/biome format .` (or `--write` with `--fix`)
|
||||
- Biome handles both linting and formatting. No need for a separate formatter if Biome is detected.
|
||||
|
||||
2. **ESLint** — Look for `.eslintrc`, `.eslintrc.*` (js, cjs, json, yml, yaml), `eslint.config.*` (js, mjs, cjs, ts, mts, cts), or an `"eslintConfig"` key in `package.json`.
|
||||
- Lint command: `npx eslint .` (or `--fix` with `--fix`)
|
||||
- Check `package.json` for the installed version. ESLint 9+ uses flat config (`eslint.config.*`).
|
||||
|
||||
**JavaScript/TypeScript Formatters (only if Biome was NOT detected):**
|
||||
|
||||
3. **Prettier** — Look for `.prettierrc`, `.prettierrc.*`, `prettier.config.*`, or a `"prettier"` key in `package.json`.
|
||||
- Format check: `npx prettier --check .`
|
||||
- Format fix: `npx prettier --write .`
|
||||
|
||||
**Rust:**
|
||||
|
||||
4. **rustfmt** — Look for `rustfmt.toml` or `.rustfmt.toml`, or `Cargo.toml` in the project root.
|
||||
- Format check: `cargo fmt -- --check`
|
||||
- Format fix: `cargo fmt`
|
||||
- Lint: `cargo clippy` (if available)
|
||||
|
||||
**Go:**
|
||||
|
||||
5. **Go tools** — Look for `go.mod` in the project root.
|
||||
- Format check: `gofmt -l .`
|
||||
- Format fix: `gofmt -w .`
|
||||
- Lint: `golangci-lint run` (if installed), otherwise `go vet ./...`
|
||||
|
||||
**Python:**
|
||||
|
||||
6. **Ruff** — Look for `ruff.toml` or a `[tool.ruff]` section in `pyproject.toml`.
|
||||
- Lint command: `ruff check .` (or `--fix` with `--fix`)
|
||||
- Format command: `ruff format .` (or `--check` without `--fix`)
|
||||
|
||||
7. **Black** — Look for a `[tool.black]` section in `pyproject.toml`, or `black` in requirements files.
|
||||
- Format check: `black --check .`
|
||||
- Format fix: `black .`
|
||||
|
||||
If no linter or formatter is detected, inform the user and suggest common options for their project type based on the files present.
|
||||
</detection>
|
||||
|
||||
<execution>
|
||||
|
||||
**Step 1: Determine target files**
|
||||
|
||||
- If a path argument was provided, use that path.
|
||||
- If no path argument, get changed files:
|
||||
```bash
|
||||
git diff --name-only
|
||||
git diff --cached --name-only
|
||||
```
|
||||
Filter to files that still exist on disk. If no files are changed, inform the user and offer to lint the entire project instead.
|
||||
|
||||
**Step 2: Run the detected tools**
|
||||
|
||||
Run the linter and/or formatter against the target files or directory.
|
||||
|
||||
- **Without `--fix`**: Run in check/report mode only. Do NOT modify any files.
|
||||
- **With `--fix`**: Run with auto-fix flags enabled.
|
||||
|
||||
When running formatters without `--fix`, show a preview of what would change:
|
||||
- For Prettier: use `--check` and list files that would change.
|
||||
- For Biome: use `check` without `--apply`.
|
||||
- For Black: use `--check --diff` to show the diff preview.
|
||||
- For Ruff: use `--diff` for format and standard output for lint.
|
||||
- For rustfmt/gofmt: use `--check` or `-l` to list files, then show a diff for up to 5 files using `diff <(command) file`.
|
||||
|
||||
**Step 3: Parse and organize output**
|
||||
|
||||
Parse the tool output and organize issues:
|
||||
|
||||
```markdown
|
||||
## Lint Results
|
||||
|
||||
### Errors (X issues)
|
||||
| File | Line | Rule | Message |
|
||||
|------|------|------|---------|
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
### Warnings (X issues)
|
||||
| File | Line | Rule | Message |
|
||||
|------|------|------|---------|
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
### Formatting
|
||||
- X files would be reformatted
|
||||
- [list files]
|
||||
|
||||
### Summary
|
||||
- Total issues: X errors, Y warnings, Z formatting
|
||||
- Auto-fixable: N issues (run `/lint --fix` to apply)
|
||||
```
|
||||
|
||||
**Step 4: Suggest fixes for common issues**
|
||||
|
||||
For the most frequent issues, provide brief actionable guidance:
|
||||
|
||||
- If the same rule appears 5+ times, suggest a bulk fix or config change.
|
||||
- For unused imports/variables, list them for quick removal.
|
||||
- For formatting-only issues, note that `--fix` will resolve them safely.
|
||||
- For issues that cannot be auto-fixed, provide a one-line explanation of how to resolve each unique rule violation.
|
||||
|
||||
</execution>
|
||||
|
||||
<critical_rules>
|
||||
|
||||
1. **Never modify files without `--fix`**: Default mode is report-only. Respect the user's working tree.
|
||||
2. **Use the project's own config**: Do not invent lint rules. Use whatever config files exist in the project.
|
||||
3. **Use the project's installed version**: Always prefer `npx`, `cargo`, or the project-local binary. Do not use globally installed tools unless no local version exists.
|
||||
4. **Handle missing tools gracefully**: If a config file exists but the tool is not installed, inform the user and provide the install command (e.g., `npm install --save-dev eslint`).
|
||||
5. **Respect `.gitignore` and ignore patterns**: Do not lint `node_modules`, `dist`, `build`, `target`, `.git`, or other commonly ignored directories. Most tools handle this automatically; verify they do.
|
||||
6. **Limit output**: If there are more than 50 issues, show the first 30 grouped by severity, then summarize the rest with counts per file. Do not flood the user with hundreds of lines.
|
||||
7. **Exit cleanly**: After presenting results, do not take further action. Let the user decide next steps.
|
||||
|
||||
</critical_rules>
|
||||
272
src/tests/integration/e2e-smoke.test.ts
Normal file
272
src/tests/integration/e2e-smoke.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* E2E smoke tests for the GSD CLI binary (dist/loader.js).
|
||||
*
|
||||
* These tests exercise the CLI entry point as a black box by spawning child
|
||||
* processes and asserting on exit codes and output text. They do NOT require
|
||||
* API keys; tests that depend on a live LLM are scoped to gracefully handle
|
||||
* the "No model selected" error path.
|
||||
*
|
||||
* Prerequisite: npm run build must be run first.
|
||||
*
|
||||
* Run with:
|
||||
* node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs \
|
||||
* --experimental-strip-types --test \
|
||||
* src/tests/integration/e2e-smoke.test.ts
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const loaderPath = join(projectRoot, "dist", "loader.js");
|
||||
|
||||
if (!existsSync(loaderPath)) {
|
||||
throw new Error("dist/loader.js not found — run: npm run build");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RunResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
timedOut: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Spawn `node dist/loader.js ...args` and collect output.
|
||||
*
|
||||
* @param args CLI arguments to pass after the script path
|
||||
* @param timeoutMs Maximum time to wait before SIGTERM (default 8 s)
|
||||
* @param env Additional / override environment variables
|
||||
*/
|
||||
function runGsd(
|
||||
args: string[],
|
||||
timeoutMs = 8_000,
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
): Promise<RunResult> {
|
||||
return new Promise((resolve) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
|
||||
const child = spawn("node", [loaderPath, ...args], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
// Close stdin so the process sees a non-TTY environment.
|
||||
child.stdin.end();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
}, timeoutMs);
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code, timedOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Strip ANSI escape codes from a string. */
|
||||
function stripAnsi(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. gsd --version outputs a semver string and exits 0
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gsd --version outputs a semver version string and exits 0", async () => {
|
||||
const result = await runGsd(["--version"]);
|
||||
|
||||
assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`);
|
||||
assert.ok(!result.timedOut, "process should not time out");
|
||||
|
||||
const version = result.stdout.trim();
|
||||
// Semver: MAJOR.MINOR.PATCH with optional pre-release / build metadata
|
||||
assert.match(
|
||||
version,
|
||||
/^\d+\.\d+\.\d+/,
|
||||
`expected semver output, got: ${JSON.stringify(version)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. gsd --help outputs usage information and exits 0
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gsd --help outputs usage information and exits 0", async () => {
|
||||
const result = await runGsd(["--help"]);
|
||||
|
||||
assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`);
|
||||
assert.ok(!result.timedOut, "process should not time out");
|
||||
|
||||
const output = stripAnsi(result.stdout);
|
||||
|
||||
assert.ok(
|
||||
output.includes("Usage:"),
|
||||
`expected 'Usage:' in help output, got:\n${output.slice(0, 500)}`,
|
||||
);
|
||||
assert.ok(
|
||||
output.includes("--version"),
|
||||
"help output should mention --version flag",
|
||||
);
|
||||
assert.ok(
|
||||
output.includes("--help"),
|
||||
"help output should mention --help flag",
|
||||
);
|
||||
assert.ok(
|
||||
output.includes("--print"),
|
||||
"help output should mention --print flag",
|
||||
);
|
||||
assert.ok(
|
||||
output.includes("--list-models"),
|
||||
"help output should mention --list-models flag",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. gsd config --help outputs config-specific or general help and exits 0
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gsd config --help outputs help and exits 0", async () => {
|
||||
const result = await runGsd(["config", "--help"]);
|
||||
|
||||
assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`);
|
||||
assert.ok(!result.timedOut, "process should not time out");
|
||||
|
||||
// The loader fast-path intercepts --help only when it is the first argument.
|
||||
// "config --help" passes through to cli.js where parseCliArgs() encounters
|
||||
// --help and calls printHelp(), producing the full usage text.
|
||||
const output = stripAnsi(result.stdout);
|
||||
assert.ok(
|
||||
output.includes("Usage:"),
|
||||
`expected 'Usage:' in output, got:\n${output.slice(0, 500)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. gsd update --help outputs update-specific or general help and exits 0
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gsd update --help outputs help and exits 0", async () => {
|
||||
const result = await runGsd(["update", "--help"]);
|
||||
|
||||
assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`);
|
||||
assert.ok(!result.timedOut, "process should not time out");
|
||||
|
||||
const output = stripAnsi(result.stdout);
|
||||
assert.ok(
|
||||
output.includes("Usage:"),
|
||||
`expected 'Usage:' in output, got:\n${output.slice(0, 500)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. gsd --list-models runs without crashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gsd --list-models runs without crashing", async () => {
|
||||
const result = await runGsd(["--list-models"]);
|
||||
|
||||
assert.ok(!result.timedOut, "gsd --list-models should exit within the timeout");
|
||||
assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`);
|
||||
|
||||
// No unhandled crash markers
|
||||
const combinedOutput = stripAnsi(result.stdout + result.stderr);
|
||||
assert.ok(
|
||||
!combinedOutput.includes("Error: Cannot find module"),
|
||||
"should not have missing module errors",
|
||||
);
|
||||
assert.ok(
|
||||
!combinedOutput.includes("ERR_MODULE_NOT_FOUND"),
|
||||
"should not have ERR_MODULE_NOT_FOUND",
|
||||
);
|
||||
|
||||
// Either a table of models or the "no models" message
|
||||
const hasTable = result.stdout.includes("provider") || result.stdout.includes("model");
|
||||
const hasNoModelsMsg = result.stdout.includes("No models available");
|
||||
assert.ok(
|
||||
hasTable || hasNoModelsMsg,
|
||||
`expected model list or 'No models available', got stdout:\n${result.stdout.slice(0, 300)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. gsd --print in text mode does not segfault or throw unhandled errors
|
||||
// (may fail with "No model selected" when no API keys are configured)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gsd --mode text --print does not segfault or throw unhandled errors", async () => {
|
||||
// Use a temporary HOME with no .gsd/ config to simulate a keyless environment.
|
||||
// The process is expected to either:
|
||||
// a) succeed (exit 0) if API keys are loaded from the real environment, or
|
||||
// b) exit 1 with "No model selected" if no API key is available.
|
||||
// Either outcome is acceptable — what is NOT acceptable is an unhandled
|
||||
// exception / crash / missing module error / non-zero exit from a signal.
|
||||
const result = await runGsd(
|
||||
["--mode", "text", "--print", "echo hello"],
|
||||
10_000,
|
||||
);
|
||||
|
||||
assert.ok(!result.timedOut, "gsd --print should not hang indefinitely");
|
||||
|
||||
const combinedOutput = stripAnsi(result.stdout + result.stderr);
|
||||
|
||||
// Must not crash with module-not-found errors
|
||||
assert.ok(
|
||||
!combinedOutput.includes("ERR_MODULE_NOT_FOUND"),
|
||||
"should not have ERR_MODULE_NOT_FOUND",
|
||||
);
|
||||
assert.ok(
|
||||
!combinedOutput.includes("Error: Cannot find module"),
|
||||
"should not have missing module errors",
|
||||
);
|
||||
|
||||
// Must not terminate from a fatal signal (SIGSEGV, SIGABRT, etc.)
|
||||
// Node exits with 128 + signal number on signal termination.
|
||||
// SIGTERM is 15 (128+15=143), but we sent SIGTERM ourselves only on timeout,
|
||||
// and we already asserted timedOut is false above.
|
||||
assert.ok(
|
||||
result.code !== null,
|
||||
"process should exit cleanly, not be killed by a signal",
|
||||
);
|
||||
|
||||
// Acceptable exit codes: 0 (success) or 1 (no model / API key error)
|
||||
const acceptableCodes = new Set([0, 1]);
|
||||
assert.ok(
|
||||
acceptableCodes.has(result.code as number),
|
||||
`expected exit code 0 or 1, got ${result.code}.\nstdout: ${result.stdout.slice(0, 300)}\nstderr: ${combinedOutput.slice(0, 300)}`,
|
||||
);
|
||||
|
||||
// If exit code is 1, the error must be a clean "No model selected" message,
|
||||
// not an unhandled thrown exception.
|
||||
if (result.code === 1) {
|
||||
const stderr = stripAnsi(result.stderr);
|
||||
const isCleanModelError =
|
||||
stderr.includes("No model selected") ||
|
||||
stderr.includes("Use /login") ||
|
||||
stderr.includes("no models available") ||
|
||||
// Onboarding wizard exit path (no API key configured on first run)
|
||||
stderr.includes("GSD") ||
|
||||
result.stdout.includes("No model selected");
|
||||
|
||||
assert.ok(
|
||||
isCleanModelError,
|
||||
`expected a clean 'No model selected' error on exit 1, but got unexpected stderr:\n${stderr.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue