diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 80b11f08..4fa94e25 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -3,7 +3,7 @@ import { render } from "ink"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; +import { setShellIfWindows, getProjectCode, ensureUserSettingsFile } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check"; import { AppContainer } from "./ui"; import { parseArguments } from "./cli-args"; @@ -37,6 +37,22 @@ async function main(): Promise { process.exit(1); } + // Scaffold the user settings file on first run so a fresh install has a + // config file to edit instead of failing with "API key not found" and + // forcing the user to create ~/.deepcode/settings.json by hand. + try { + const { path: settingsPath, created } = ensureUserSettingsFile(); + if (created) { + writeStdoutLine( + `Created a default settings file at ${settingsPath}.\n` + + "Set your API_KEY there (and adjust BASE_URL / MODEL if needed) before running tasks.\n" + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeStderrLine(`deepcode: could not initialize settings file: ${message}\n`); + } + // Validate --resume before entering TUI if (typeof resumeSessionId === "string") { const projectCode = getProjectCode(projectRoot); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 832d2444..acb6e573 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,8 @@ export { modelConfigKey, getUserSettingsPath, getProjectSettingsPath, + buildDefaultSettings, + ensureUserSettingsFile, DEFAULT_MODEL, DEFAULT_BASE_URL, } from "./settings"; diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 5dab3b5a..7e185f83 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -637,6 +637,36 @@ export function writeSettings(settings: DeepcodingSettings): void { writeSettingsFile(settingsPath, settings); } +/** + * Default scaffold written to the user settings file on first run so that + * a freshly installed deepcode has a config file to edit instead of forcing + * the user to create one by hand (and guess the schema). + */ +export function buildDefaultSettings(): DeepcodingSettings { + return { + env: { + API_KEY: "", + BASE_URL: DEFAULT_BASE_URL, + MODEL: DEFAULT_MODEL, + }, + }; +} + +/** + * Ensure the user settings file exists, creating a template with placeholder + * values when it is missing. Never overwrites an existing file. + * + * @returns the settings path and whether a new file was created. + */ +export function ensureUserSettingsFile(): { path: string; created: boolean } { + const settingsPath = getUserSettingsPath(); + if (fs.existsSync(settingsPath)) { + return { path: settingsPath, created: false }; + } + writeSettingsFile(settingsPath, buildDefaultSettings()); + return { path: settingsPath, created: true }; +} + export function writeProjectSettings(settings: DeepcodingSettings, projectRoot: string = process.cwd()): void { const settingsPath = getProjectSettingsPath(projectRoot); writeSettingsFile(settingsPath, settings); diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index ceddc43e..7552a18e 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -1,5 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { buildNotifyEnv, formatDurationSeconds, @@ -7,10 +10,74 @@ import { type NotifyContext, type NotifySpawn, } from "../common/notify"; -import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings"; +import { + applyModelConfigSelection, + buildDefaultSettings, + ensureUserSettingsFile, + getUserSettingsPath, + resolveSettings, + resolveSettingsSources, +} from "../settings"; const TEST_PROCESS_ENV = {}; +function withTempHome(fn: () => T): T { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-home-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + try { + return fn(); + } finally { + if (prevHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = prevHome; + } + if (prevUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = prevUserProfile; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } +} + +test("ensureUserSettingsFile creates a template when settings file is missing", () => { + withTempHome(() => { + const settingsPath = getUserSettingsPath(); + assert.equal(fs.existsSync(settingsPath), false); + + const result = ensureUserSettingsFile(); + + assert.equal(result.created, true); + assert.equal(result.path, settingsPath); + assert.equal(fs.existsSync(settingsPath), true); + + const written = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + const expected = buildDefaultSettings(); + assert.deepEqual(written, expected); + assert.equal(expected.env?.API_KEY, ""); + assert.equal(typeof expected.env?.BASE_URL, "string"); + assert.equal(typeof expected.env?.MODEL, "string"); + }); +}); + +test("ensureUserSettingsFile never overwrites an existing settings file", () => { + withTempHome(() => { + const settingsPath = getUserSettingsPath(); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + const existing = { env: { API_KEY: "sk-existing", BASE_URL: "https://custom.example.com" } }; + fs.writeFileSync(settingsPath, JSON.stringify(existing), "utf8"); + + const result = ensureUserSettingsFile(); + + assert.equal(result.created, false); + assert.deepEqual(JSON.parse(fs.readFileSync(settingsPath, "utf8")), existing); + }); +}); + test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool", () => { const resolved = resolveSettings( {