From 46fc3d21df5007b27b84d62f1bd66d47cc0b921c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 05:12:10 +0000 Subject: [PATCH 1/2] Add --config startup flag (Issue #81 Phase 1) Lets tecode read the user settings/keybindings layer from a caller-chosen directory instead of the home-directory default, without disturbing the workspace settings layer. argv.ts gains a pure resolveConfigDirOverride parser and resolveStartupTarget now skips --config's value when scanning for the positional file/directory argument. ConfigService accepts optional settingsPath/keybindingsPath overrides (same deps.path ?? getUserXPath() convention as themeSettingsWriter/keybindingsCommands), and main.ts derives both paths from --config's directory and threads them through buildAssemblyRoot/RunTecodeOptions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- README.md | 13 ++++ design.md | 2 + packages/cli/src/argv.test.ts | 59 +++++++++++++++++- packages/cli/src/argv.ts | 56 ++++++++++++++++- packages/cli/src/main.test.ts | 70 +++++++++++++++++++++ packages/cli/src/main.ts | 49 ++++++++++++++- packages/core/src/config/service.test.ts | 77 ++++++++++++++++++++++++ packages/core/src/config/service.ts | 25 +++++++- requirements.md | 1 + 9 files changed, 345 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 37145b6..11345f1 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,19 @@ live with no restart (Req 9.4). A workspace's own `samples/settings.json` (in this repository) is a working, commented starting point covering every key below. +Pass `--config ` at startup (e.g. +`tecode --config /path/to/cfg ./my-project`) to read the user settings +and user keybindings layers from `/settings.json` and +`/keybindings.json` instead (Req 9.6) — useful for an isolated +profile or a CI sandbox. Only the user layer moves; a workspace's own +`.tecode/settings.json` still overlays on top exactly as above. A +missing `` (or a missing file inside it) is treated the same as a +missing home-directory file: an empty layer, not an error. A relative +`` resolves against the current working directory. `--config` with +no directory argument after it is ignored (no override applied), and it +never consumes the directory/file argument that opens a workspace — +`tecode --config /path/to/cfg ./my-project` still opens `./my-project`. + Req 9.5 names six MVP settings; the table marks which of them a real `contributes.configuration` schema registers today, and which do not exist yet: diff --git a/design.md b/design.md index 2faed0c..ca37828 100644 --- a/design.md +++ b/design.md @@ -252,6 +252,8 @@ Pipeline (*Req 8*), in `core` with languages contributed by extensions: - **JSONC**: a small tolerant parser (strip comments + trailing commas, then `JSON.parse`) with error positions surfaced in the status bar; a broken file keeps the last good configuration. - **Layering**: defaults (from `contributes.configuration` schemas and core defaults) ← user `settings.json` ← workspace `.tecode/settings.json`. `tecode.config.get(key)` reads the merged view; the schema registry supplies types/defaults and (later) validation. - **Watch**: `fs.watch` on both settings files and `keybindings.json`; on change, re-parse, diff keys, fire `onDidChangeConfiguration({ affectsConfiguration })`, and notify dependent services (theme service on `workbench.colorTheme`, keymap service rebuilds its table) (*Req 9.4*). +- **`--config ` override** (*Req 9.6*): the CLI's `--config ` flag (`cli/argv.ts`'s `resolveConfigDirOverride`) redirects the USER layer only — `/settings.json` and `/keybindings.json` replace the home-directory defaults `ConfigServiceDeps.settingsPath`/`keybindingsPath` otherwise fall back to (`host/paths.ts`'s `getUserSettingsPath`/`getUserKeybindingsPath`); the workspace layer's own resolution is untouched. A directory argument still following `--config ` opens as the workspace exactly as before this flag existed; `--config` with no directory argument at all opens no workspace, same as no arguments given. + ## 12. Public API Assembly diff --git a/packages/cli/src/argv.test.ts b/packages/cli/src/argv.test.ts index e013e53..c30d788 100644 --- a/packages/cli/src/argv.test.ts +++ b/packages/cli/src/argv.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { createHostLog, type HostLogEntry } from "@tecode/core"; -import { resolveStartupTarget } from "./argv"; +import { resolveConfigDirOverride, resolveStartupTarget } from "./argv"; let dir: string | undefined; @@ -90,3 +90,60 @@ test("parent directory of a nested file resolves correctly", async () => { expect(target.workspaceRoot).toBe(dirname(filePath)); expect(target.initialFilePath).toBe(filePath); }); + +// --- resolveConfigDirOverride (Req 9.6, Issue #81 Phase 1) --- + +test("resolveConfigDirOverride returns the token immediately after --config", () => { + expect(resolveConfigDirOverride(["--config", "/tmp/cfg"])).toBe("/tmp/cfg"); +}); + +test("resolveConfigDirOverride returns undefined when --config is absent", () => { + expect(resolveConfigDirOverride([])).toBeUndefined(); + expect(resolveConfigDirOverride(["./src"])).toBeUndefined(); +}); + +test("resolveConfigDirOverride returns undefined when --config is the last token (no value follows)", () => { + expect(resolveConfigDirOverride(["--config"])).toBeUndefined(); + expect(resolveConfigDirOverride(["./src", "--config"])).toBeUndefined(); +}); + +test("resolveConfigDirOverride finds --config regardless of surrounding tokens", () => { + expect(resolveConfigDirOverride(["./src", "--config", "/tmp/cfg"])).toBe("/tmp/cfg"); + expect(resolveConfigDirOverride(["--config", "/tmp/cfg", "./src"])).toBe("/tmp/cfg"); +}); + +// --- resolveStartupTarget's --config non-confusion (Req 9.6, Issue #81 Phase 1) --- + +test("--config's value is not mistaken for the positional argument: a directory still follows it", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-argv-")); + const srcDir = join(dir, "src"); + await mkdir(srcDir, { recursive: true }); + + const log = createHostLog(); + const target = await resolveStartupTarget( + ["--config", "/tmp/some-config-dir", srcDir], + "/irrelevant", + log, + ); + expect(target).toEqual({ workspaceRoot: srcDir }); +}); + +test("--config with no following positional opens nothing (falls back to cwd)", async () => { + const log = createHostLog(); + const target = await resolveStartupTarget( + ["--config", "/tmp/some-config-dir"], + "/fallback-cwd", + log, + ); + expect(target).toEqual({ workspaceRoot: "/fallback-cwd" }); + // The config dir's value itself was never treated as a bad startup + // path — no warning should have been logged about it. + expect(log.entries()).toEqual([]); +}); + +test("a plain positional argument still opens normally when --config is entirely absent", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-argv-")); + const log = createHostLog(); + const target = await resolveStartupTarget([dir], "/irrelevant", log); + expect(target).toEqual({ workspaceRoot: dir }); +}); diff --git a/packages/cli/src/argv.ts b/packages/cli/src/argv.ts index ebeccaa..0598dd3 100644 --- a/packages/cli/src/argv.ts +++ b/packages/cli/src/argv.ts @@ -4,6 +4,10 @@ * first step; tasks.md's Task 1.15 "Argv parsing (file/directory)"). * `--version` is handled by `main.ts` itself, before this module is even * reached (it must not touch the filesystem or build any services). + * `--config ` (Req 9.6, Issue #81 Phase 1) is parsed here too, by + * {@link resolveConfigDirOverride} — a separate, synchronous, pure helper + * (it does no I/O and never throws) that `main.ts` calls alongside + * {@link resolveStartupTarget}. */ import { stat as nodeStat } from "node:fs/promises"; @@ -50,6 +54,42 @@ function describeError(err: unknown): string { } } +/** The index of the token immediately following the first `--config` flag + * in `argv`, or `undefined` when `--config` is absent — shared by + * {@link resolveConfigDirOverride} and {@link resolveStartupTarget} so both + * agree on exactly which token is `--config`'s value (Issue #81 Phase 1). + * Only the first `--config` occurrence is considered; a repeated flag's + * later value is ignored, matching this module's "first token wins" + * treatment of the positional argument below. */ +function findConfigValueIndex(argv: readonly string[]): number | undefined { + const flagIndex = argv.indexOf("--config"); + if (flagIndex === -1) return undefined; + return flagIndex + 1; +} + +/** + * Resolve `--config `'s value from argv (Req 9.6, design.md §11's + * `--config` note; Issue #81 Phase 1). Returns the token immediately + * following the first `--config` flag, or `undefined` when `--config` is + * absent from `argv` entirely, or when it is present but is the very last + * token (no value follows). Never throws (matches this module's + * never-throwing, degrade-to-`undefined` policy) — it does no I/O and + * cannot fail. Does not validate that the returned string names a real, + * readable directory; that check happens where the value is actually used + * (`@tecode/core`'s `ConfigService`, which degrades a missing/unreadable + * settings or keybindings file to an empty layer exactly as it does for + * the un-overridden home-directory default). + * + * `--version` is still handled by `main.ts` itself before this module (or + * `resolveStartupTarget`) ever sees argv (this module's top-of-file + * TSDoc) — nothing here needs to special-case it. + */ +export function resolveConfigDirOverride(argv: readonly string[]): string | undefined { + const valueIndex = findConfigValueIndex(argv); + if (valueIndex === undefined) return undefined; + return argv[valueIndex]; +} + /** * Resolve the CLI's one positional argument (CodeRabbit's Phase 1 plan): a * directory becomes `workspaceRoot` with no initial document; a file's @@ -59,6 +99,17 @@ function describeError(err: unknown): string { * the caller — this function only ever looks for the first token that * does not start with `-`. * + * **`--config `'s value is never mistaken for the positional + * argument** (Req 9.6, Issue #81 Phase 1): `--config`'s own value token + * (whatever immediately follows it, even a bare directory name with no + * leading `-`) is skipped when scanning for the positional, using the same + * {@link findConfigValueIndex} lookup {@link resolveConfigDirOverride} + * uses — so `tecode --config /tmp/cfg ./src` still opens `./src`, and + * `tecode --config /tmp/cfg` (no further token) opens nothing, exactly as + * if `--config /tmp/cfg` had been omitted. This function does not itself + * read or act on `--config`'s value — that is `resolveConfigDirOverride`'s + * job, called separately by `main.ts`. + * * Never throws (matches `core`'s never-throwing service boundaries): a * path that does not exist, or can't be `stat`-ed, is reported to `log` as * a warning and treated as if no argument had been given (`cwd`) — a @@ -72,7 +123,10 @@ export async function resolveStartupTarget( log: HostLog, fs: ArgvResolutionFs = createNodeArgvFs(), ): Promise { - const positional = argv.find((arg) => !arg.startsWith("-")); + const configValueIndex = findConfigValueIndex(argv); + const positional = argv.find( + (arg, index) => !arg.startsWith("-") && index !== configValueIndex, + ); if (!positional) return { workspaceRoot: cwd }; const resolved = resolvePath(cwd, positional); diff --git a/packages/cli/src/main.test.ts b/packages/cli/src/main.test.ts index a0b38f9..4338822 100644 --- a/packages/cli/src/main.test.ts +++ b/packages/cli/src/main.test.ts @@ -163,6 +163,76 @@ test("buildAssemblyRoot wires every core service and registers the 'tecode' modu } }); +// --- buildAssemblyRoot's `configDir` deps (Req 9.6, Issue #81 Phase 1's +// `--config ` flag) — the end-to-end proof that a `--config` +// directory's `settings.json`/`keybindings.json` genuinely take effect, +// not just that the string was threaded through unchanged. --- + +test("buildAssemblyRoot's configDir makes a --config directory's settings.json genuinely take effect", async () => { + const workspaceDir = await mkdtemp(join(tmpdir(), "tecode-cli-ws-")); + const configDir = await mkdtemp(join(tmpdir(), "tecode-cli-config-")); + await writeFile( + join(configDir, "settings.json"), + JSON.stringify({ "editor.tabSize": 2 }), + "utf8", + ); + + let root: ReturnType; + try { + root = buildAssemblyRoot(workspaceDir, { configDir }); + await root.config.ready; + + // The value actually came from configDir's settings.json, not from + // core's own default (4, `config/coreDefaults.ts`) — proof the + // override was genuinely read, not merely accepted and ignored. + expect(root.config.get("editor.tabSize")).toBe(2); + } finally { + root!.config.dispose(); + root!.chordMachine.dispose(); + root!.editorSession.dispose(); + root!.editorLangIdSync.dispose(); + root!.themeConfigSync.dispose(); + root!.themeSelectCommand.dispose(); + await rm(workspaceDir, { recursive: true, force: true }); + await rm(configDir, { recursive: true, force: true }); + } +}); + +test("buildAssemblyRoot's configDir makes a --config directory's keybindings.json genuinely take effect", async () => { + const workspaceDir = await mkdtemp(join(tmpdir(), "tecode-cli-ws-")); + const configDir = await mkdtemp(join(tmpdir(), "tecode-cli-config-")); + await writeFile( + join(configDir, "keybindings.json"), + JSON.stringify([{ key: "ctrl+alt+k", command: "fixture.fromConfigDir" }]), + "utf8", + ); + + let root: ReturnType; + try { + root = buildAssemblyRoot(workspaceDir, { configDir }); + await root.config.ready; + + expect(root.config.getKeybindingEntries()).toEqual([ + { key: "ctrl+alt+k", command: "fixture.fromConfigDir" }, + ]); + // buildAssemblyRoot wires onKeybindingsChange straight into + // keymap.setUserEntries — this proves the whole chain, not just + // ConfigService's own raw entry array. + const resolved = root.keymap.getTable().lookup("ctrl+alt+k", () => undefined); + expect(resolved?.command).toBe("fixture.fromConfigDir"); + expect(resolved?.layer).toBe("user"); + } finally { + root!.config.dispose(); + root!.chordMachine.dispose(); + root!.editorSession.dispose(); + root!.editorLangIdSync.dispose(); + root!.themeConfigSync.dispose(); + root!.themeSelectCommand.dispose(); + await rm(workspaceDir, { recursive: true, force: true }); + await rm(configDir, { recursive: true, force: true }); + } +}); + test("forward-referenced activateExtension is a safe no-op before the deferred phase assigns hostRef", async () => { const dir = await mkdtemp(join(tmpdir(), "tecode-cli-root-")); const savedHome = process.env["HOME"]; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 271fd66..dbd11af 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -81,7 +81,8 @@ import { builtinManifests, builtinThemeAssets, } from "@tecode/builtin"; -import { resolveStartupTarget, type StartupTarget } from "./argv"; +import { join as joinPath } from "node:path"; +import { resolveConfigDirOverride, resolveStartupTarget, type StartupTarget } from "./argv"; import { buildExtensionDirMap, buildExtensionRecords } from "./extensionRecords"; import { createKeymapState, type KeymapState } from "./keymapState"; import { createBuiltinLanguageAssetsFs } from "./languageAssetsFs"; @@ -498,6 +499,17 @@ export function buildAssemblyRoot( * hermeticity" shape. */ loadFallbackKeybindings?: () => Promise; + /** + * `--config `'s resolved value (Req 9.6, Issue #81 Phase 1) — + * `runTecode` threads `RunTecodeOptions.configDir` through to here. + * When set, `/settings.json` and `/keybindings.json` + * override `createConfigService`'s USER-layer defaults + * (`getUserSettingsPath()`/`getUserKeybindingsPath()`) — the + * workspace layer (`/.tecode/settings.json`) is + * entirely unaffected. `undefined` (the default) leaves both at their + * ordinary home-directory paths, exactly as before this flag existed. + */ + configDir?: string; } = {}, ): AssemblyRoot { const log = deps.log ?? createHostLog(); @@ -641,10 +653,25 @@ export function buildAssemblyRoot( if (generation !== kittyVerdictGeneration) return; keymap.setFallbackEntries(entries); } + // `--config ` (Req 9.6, Issue #81 Phase 1): a caller-supplied + // directory overrides where the USER settings/keybindings layer is + // read from — `deps.configDir` derives both file paths from that ONE + // directory, matching the home-directory default's own "one directory, + // two well-known filenames" shape (`host/paths.ts`'s + // `getUserSettingsPath`/`getUserKeybindingsPath`). `undefined` when + // `deps.configDir` is unset, so `createConfigService` falls through to + // its own `getUserSettingsPath()`/`getUserKeybindingsPath()` defaults + // exactly as before this flag existed. + const settingsPath = deps.configDir ? joinPath(deps.configDir, "settings.json") : undefined; + const keybindingsPath = deps.configDir + ? joinPath(deps.configDir, "keybindings.json") + : undefined; const config = createConfigService({ log, sink, workspaceRoot, + settingsPath, + keybindingsPath, onKeybindingsChange: (entries) => keymap.setUserEntries(entries), }); // Core's own settings (`editor.lineNumbers`, `editor.tabSize` — Req 9.5, @@ -1072,6 +1099,12 @@ export interface RunTecodeOptions { builtins?: Manifest[]; /** Overrides `process.cwd()` — tests only. */ cwd?: string; + /** `--config `'s resolved value (Req 9.6, Issue #81 Phase 1), + * already parsed by `main()`'s `resolveConfigDirOverride(argv)` call — + * threaded straight through to {@link buildAssemblyRoot}'s own + * `configDir` deps field (see that field's TSDoc for what it does). + * `undefined` (the default) is the ordinary "no override" case. */ + configDir?: string; } /** Sets up graceful-shutdown handling (Phase 3's "wire process-exit @@ -1197,7 +1230,7 @@ export async function runTecode( // `renderShell`'s `onCapabilitiesResolved` callback, once the render // seam has actually opened (or not opened, for `renderShellHeadless`) a // real terminal. - const root = buildAssemblyRoot(target.workspaceRoot, { log }); + const root = buildAssemblyRoot(target.workspaceRoot, { log, configDir: options.configDir }); await root.config.ready; emitVerboseStep(startedAt, "config-ready"); @@ -1348,12 +1381,22 @@ export async function runTecode( return { root, extensionHost: deferred.extensionHost, loadResult: deferred.loadResult, firstFrameMs }; } +/** + * The CLI entry point (Req 12.1, Issue #81 Phase 1's `--config ` + * flag): handles `--version` first, exiting before any other argv + * handling ever runs (`argv.ts`'s top-of-file TSDoc: `resolveStartupTarget` + * must never see it either, for the same reason). `--config` is parsed + * right after, at that same early, synchronous, no-I/O position — via + * `resolveConfigDirOverride(argv)` — and its value is threaded through to + * {@link runTecode} as `RunTecodeOptions.configDir`. + */ async function main(argv: string[]): Promise { if (argv.includes("--version")) { console.log(pkg.version); process.exit(0); } - await runTecode(argv); + const configDir = resolveConfigDirOverride(argv); + await runTecode(argv, { configDir }); } // `import.meta.main` is Bun's "am I the entry point" check (true only when diff --git a/packages/core/src/config/service.test.ts b/packages/core/src/config/service.test.ts index be1b144..ae92ec4 100644 --- a/packages/core/src/config/service.test.ts +++ b/packages/core/src/config/service.test.ts @@ -155,6 +155,83 @@ describe("ConfigService.get — layering (Req 9.2, 9.3)", () => { }); }); +describe("ConfigService — settingsPath/keybindingsPath overrides (Req 9.6, Issue #81 Phase 1)", () => { + test("settingsPath overrides where the user settings layer is read from, leaving the workspace layer untouched", async () => { + const overriddenSettingsPath = "/override/settings.json"; + const workspaceRoot = "/fake-workspace"; + const workspacePath = getWorkspaceSettingsPath(workspaceRoot); + // The ordinary default location is seeded too, to prove it is genuinely + // NOT read once settingsPath is supplied — a service that ignored the + // override and fell back to the default would report tabSize 99, not 2. + const defaultUserPath = getUserSettingsPath(); + const fake = createFakeFs({ + [overriddenSettingsPath]: JSON.stringify({ "editor.tabSize": 2 }), + [defaultUserPath]: JSON.stringify({ "editor.tabSize": 99 }), + [workspacePath]: JSON.stringify({ "editor.insertSpaces": false }), + }); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const service = createConfigService({ + log, + sink, + workspaceRoot, + settingsPath: overriddenSettingsPath, + fs: fake.fs, + }); + await service.ready; + + expect(service.get("editor.tabSize")).toBe(2); + // The workspace layer is a completely separate path, resolved from + // workspaceRoot exactly as always — settingsPath overrides the USER + // layer only (this describe block's TSDoc). + expect(service.get("editor.insertSpaces")).toBe(false); + expect(fake.watchedPaths()).toContain(overriddenSettingsPath); + expect(fake.watchedPaths()).not.toContain(defaultUserPath); + service.dispose(); + }); + + test("keybindingsPath overrides where the user keybindings layer is read from", async () => { + const overriddenKeybindingsPath = "/override/keybindings.json"; + const defaultKeybindingsPath = getUserKeybindingsPath(); + const entries = [{ key: "ctrl+k", command: "fixture.command" }]; + const fake = createFakeFs({ + [overriddenKeybindingsPath]: JSON.stringify(entries), + [defaultKeybindingsPath]: JSON.stringify([{ key: "ctrl+q", command: "should.not.load" }]), + }); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const service = createConfigService({ + log, + sink, + keybindingsPath: overriddenKeybindingsPath, + fs: fake.fs, + }); + await service.ready; + + expect(service.getKeybindingEntries()).toEqual(entries); + expect(fake.watchedPaths()).toContain(overriddenKeybindingsPath); + expect(fake.watchedPaths()).not.toContain(defaultKeybindingsPath); + service.dispose(); + }); + + test("with no settingsPath/keybindingsPath given, the ordinary home-directory defaults are used", async () => { + const defaultUserPath = getUserSettingsPath(); + const defaultKeybindingsPath = getUserKeybindingsPath(); + const fake = createFakeFs({ + [defaultUserPath]: JSON.stringify({ "editor.tabSize": 7 }), + [defaultKeybindingsPath]: JSON.stringify([{ key: "a", command: "b" }]), + }); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const service = createConfigService({ log, sink, fs: fake.fs }); + await service.ready; + + expect(service.get("editor.tabSize")).toBe(7); + expect(service.getKeybindingEntries()).toEqual([{ key: "a", command: "b" }]); + service.dispose(); + }); +}); + describe("ConfigService.registerConfiguration (Req 9.3)", () => { test("populates defaults for properties that declare one", async () => { const fake = createFakeFs(); diff --git a/packages/core/src/config/service.ts b/packages/core/src/config/service.ts index 4f775fe..f60eb12 100644 --- a/packages/core/src/config/service.ts +++ b/packages/core/src/config/service.ts @@ -9,6 +9,14 @@ * convention — matches `createCommandRegistry`, `createDocumentManager`, * `createContextService`). * + * **User-layer path overrides** (Req 9.6, Issue #81 Phase 1): + * {@link ConfigServiceDeps.settingsPath}/`keybindingsPath` let a caller + * (the CLI's `--config ` flag) redirect the USER `settings.json`/ + * `keybindings.json` layer to a different directory, without touching how + * the workspace layer (`/.tecode/settings.json`) is + * resolved at all — same `deps.path ?? getUserXPath()` convention as + * `ui/themeSettingsWriter.ts`/`ui/keybindingsCommands.ts`. + * * **Initialization design choice**: `createConfigService` returns * synchronously (it does no I/O before returning), then kicks off the * initial file reads and watcher setup in the background. Callers that need @@ -100,6 +108,19 @@ export interface ConfigServiceDeps { * this is provided — a single-file session with no workspace has no * third layer. */ workspaceRoot?: string; + /** Overrides the user `settings.json` path (Req 9.6, Issue #81 Phase 1: + * the CLI's `--config ` flag) — tests use a temp file; production + * defaults to {@link getUserSettingsPath}. Matches + * `ui/themeSettingsWriter.ts`'s `ThemeSettingsWriterDeps.path`/ + * `ui/keybindingsCommands.ts`'s own `deps.path ?? getUserXPath()` + * convention. Leaves the workspace settings layer + * (`/.tecode/settings.json`) completely untouched — + * `--config` overrides the USER layer only, never the workspace one. */ + settingsPath?: string; + /** Overrides the user `keybindings.json` path (Req 9.6, Issue #81 Phase + * 1) — same override convention and same "user layer only" scope as + * {@link settingsPath}. Defaults to {@link getUserKeybindingsPath}. */ + keybindingsPath?: string; /** Filesystem seam — see {@link ConfigServiceFs}. Defaults to * `node:fs/promises` + `node:fs.watch`. */ fs?: ConfigServiceFs; @@ -229,11 +250,11 @@ export function createConfigService(deps: ConfigServiceDeps): ConfigService { const { log, sink, workspaceRoot } = deps; const fs = deps.fs ?? createNodeConfigFs(); - const userSettingsPath = getUserSettingsPath(); + const userSettingsPath = deps.settingsPath ?? getUserSettingsPath(); const workspaceSettingsPath = workspaceRoot ? getWorkspaceSettingsPath(workspaceRoot) : undefined; - const keybindingsPath = getUserKeybindingsPath(); + const keybindingsPath = deps.keybindingsPath ?? getUserKeybindingsPath(); const schemas = new Map(); const defaultsLayer: Record = {}; diff --git a/requirements.md b/requirements.md index 03760ae..4d10bbb 100644 --- a/requirements.md +++ b/requirements.md @@ -146,6 +146,7 @@ The following points were open in the draft specification and are resolved here 3. Extensions SHALL declare their settings schema via `contributes.configuration`, and SHALL read values via `tecode.config.get(key)`. 4. WHEN a settings file is saved, THE system SHALL apply the changes immediately and fire `onDidChangeConfiguration`. 5. THE MVP settings SHALL include at least: `workbench.colorTheme`, `editor.tabSize`, `editor.insertSpaces`, `editor.wordWrap`, `editor.lineNumbers`, `explorer.showHidden`, and `files.autoSave`. +6. WHEN tecode is launched with `--config `, THE system SHALL read the user settings and user keybindings layers from `/settings.json` and `/keybindings.json` instead of their home-directory defaults, leaving the workspace settings layer (`.tecode/settings.json`) unaffected. ### Requirement 10: Public Extension API From fae8b7832f057e392f813eeb7cf02d586ee704ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 04:09:23 +0000 Subject: [PATCH 2/2] Exclude every --config value from the positional scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findConfigValueIndex used argv.indexOf, so only the FIRST --config's value was excluded from the positional-argument scan. A repeated flag's value was left looking like a bare positional, so tecode --config /a --config /b silently opened /b as the WORKSPACE — a different thing entirely from what was asked. Which --config wins is a separate question from which tokens are values: the override still takes the first occurrence, matching this module's "first token wins" treatment of the positional argument, but every occurrence's value is now excluded from the scan. Mutation-verified: reverting to first-occurrence-only fails the new test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- packages/cli/src/argv.test.ts | 24 +++++++++++++++++++ packages/cli/src/argv.ts | 44 +++++++++++++++++++++-------------- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/argv.test.ts b/packages/cli/src/argv.test.ts index c30d788..8c3b29f 100644 --- a/packages/cli/src/argv.test.ts +++ b/packages/cli/src/argv.test.ts @@ -128,6 +128,30 @@ test("--config's value is not mistaken for the positional argument: a directory expect(target).toEqual({ workspaceRoot: srcDir }); }); +test("a REPEATED --config's value is not mistaken for the positional argument either", async () => { + // Which `--config` wins is a separate question from which tokens are + // values. Excluding only the FIRST occurrence's value left the second one + // looking like a bare positional, so `--config /a --config /b` silently + // opened `/b` as the WORKSPACE — a different thing entirely from what was + // asked (CodeRabbit finding on PR #85). + const dir = await mkdtemp(join(tmpdir(), "tecode-argv-repeat-")); + const otherDir = await mkdtemp(join(tmpdir(), "tecode-argv-repeat-other-")); + const log = createHostLog(); + try { + const target = await resolveStartupTarget( + ["--config", dir, "--config", otherDir], + process.cwd(), + log, + ); + expect(target).toEqual({ workspaceRoot: process.cwd() }); + // The override itself still takes the first occurrence. + expect(resolveConfigDirOverride(["--config", dir, "--config", otherDir])).toBe(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(otherDir, { recursive: true, force: true }); + } +}); + test("--config with no following positional opens nothing (falls back to cwd)", async () => { const log = createHostLog(); const target = await resolveStartupTarget( diff --git a/packages/cli/src/argv.ts b/packages/cli/src/argv.ts index 0598dd3..3080584 100644 --- a/packages/cli/src/argv.ts +++ b/packages/cli/src/argv.ts @@ -54,17 +54,27 @@ function describeError(err: unknown): string { } } -/** The index of the token immediately following the first `--config` flag - * in `argv`, or `undefined` when `--config` is absent — shared by - * {@link resolveConfigDirOverride} and {@link resolveStartupTarget} so both - * agree on exactly which token is `--config`'s value (Issue #81 Phase 1). - * Only the first `--config` occurrence is considered; a repeated flag's - * later value is ignored, matching this module's "first token wins" - * treatment of the positional argument below. */ -function findConfigValueIndex(argv: readonly string[]): number | undefined { - const flagIndex = argv.indexOf("--config"); - if (flagIndex === -1) return undefined; - return flagIndex + 1; +/** Every index in `argv` holding a `--config` flag's value — i.e. the token + * immediately after each `--config` occurrence (Issue #81 Phase 1). Shared + * by {@link resolveConfigDirOverride} and {@link resolveStartupTarget} so + * both agree on exactly which tokens are flag values rather than the + * positional argument. + * + * **Every occurrence, not just the first**: which `--config` *wins* is a + * separate question from which tokens are values. The override itself takes + * the first occurrence (see {@link resolveConfigDirOverride}, matching this + * module's "first token wins" treatment of the positional argument below), + * but a repeated flag's value must STILL be excluded from the positional + * scan. Considering only the first occurrence would leave the second value + * looking like a bare positional, so `tecode --config /a --config /b` would + * silently open `/b` as the workspace — a different thing entirely from + * what was asked (CodeRabbit finding on PR #85). */ +function findConfigValueIndices(argv: readonly string[]): ReadonlySet { + const indices = new Set(); + for (const [index, arg] of argv.entries()) { + if (arg === "--config" && index + 1 < argv.length) indices.add(index + 1); + } + return indices; } /** @@ -85,9 +95,9 @@ function findConfigValueIndex(argv: readonly string[]): number | undefined { * TSDoc) — nothing here needs to special-case it. */ export function resolveConfigDirOverride(argv: readonly string[]): string | undefined { - const valueIndex = findConfigValueIndex(argv); - if (valueIndex === undefined) return undefined; - return argv[valueIndex]; + const flagIndex = argv.indexOf("--config"); + if (flagIndex === -1) return undefined; + return argv[flagIndex + 1]; } /** @@ -103,7 +113,7 @@ export function resolveConfigDirOverride(argv: readonly string[]): string | unde * argument** (Req 9.6, Issue #81 Phase 1): `--config`'s own value token * (whatever immediately follows it, even a bare directory name with no * leading `-`) is skipped when scanning for the positional, using the same - * {@link findConfigValueIndex} lookup {@link resolveConfigDirOverride} + * {@link findConfigValueIndices} lookup {@link resolveConfigDirOverride} * uses — so `tecode --config /tmp/cfg ./src` still opens `./src`, and * `tecode --config /tmp/cfg` (no further token) opens nothing, exactly as * if `--config /tmp/cfg` had been omitted. This function does not itself @@ -123,9 +133,9 @@ export async function resolveStartupTarget( log: HostLog, fs: ArgvResolutionFs = createNodeArgvFs(), ): Promise { - const configValueIndex = findConfigValueIndex(argv); + const configValueIndices = findConfigValueIndices(argv); const positional = argv.find( - (arg, index) => !arg.startsWith("-") && index !== configValueIndex, + (arg, index) => !arg.startsWith("-") && !configValueIndices.has(index), ); if (!positional) return { workspaceRoot: cwd };