diff --git a/cli/README.md b/cli/README.md index 2e466608a..d102b6d7d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -49,9 +49,9 @@ What it does: - **Remote** — a VPS with [Obsidian Sync](https://obsidian.md/sync), reachable from any device 2. Offers the most common optional settings — memory layer and folder, - file tools, semantic search, port, timezone (plus sync direction for - remote) — press enter to keep the defaults, or pick the ones you want to - change + daily notes folder and format, file tools, semantic search, port, + timezone (plus sync direction for remote) — press enter to keep the + defaults, or pick the ones you want to change 3. Generates a `.env` file with a securely generated `MCP_AUTH_TOKEN` 4. Optionally starts the container and waits for the health check 5. Prints your connection details — the MCP URL, your auth token, and how to @@ -85,11 +85,13 @@ Change optional settings on an existing setup: npx vault-cortex@latest configure ``` -Shows the same settings chooser as [`init`](#init) — memory layer and -folder, file tools, semantic search, port, timezone (plus sync direction -for remote) — pre-filled with your current values, saves your picks to -`.env`, and offers to restart the container so they take effect. Settings not in the chooser -live in `.env` too: edit the value there, then run [`restart`](#restart). +Shows the same settings chooser as [`init`](#init), pre-filled with your +current values, saves your picks to `.env`, and offers to restart the +container so they take effect. + +Settings not in the chooser live in `.env` too: edit the value there, then +run [`restart`](#restart). That's also how you clear a daily notes setting +back to your vault's own configuration — comment out or delete its line. Use `--dir ` if your config isn't in `./vault-cortex`. diff --git a/cli/src/__tests__/command-stubs.ts b/cli/src/__tests__/command-stubs.ts index 51ec57a9c..5c2be01a2 100644 --- a/cli/src/__tests__/command-stubs.ts +++ b/cli/src/__tests__/command-stubs.ts @@ -10,7 +10,11 @@ export type ScriptedAnswer = string | boolean | string[] export type MultiselectCall = { message: string; options: SelectOption[] } export type ConfirmCall = { message: string; initialValue: boolean } export type SelectCall = { message: string; initialValue: string } -export type TextCall = { message: string; defaultValue: string | undefined } +export type TextCall = { + message: string + defaultValue: string | undefined + placeholder: string | undefined +} export type ScriptedPrompts = { prompts: Prompts @@ -115,7 +119,11 @@ export const createScriptedPrompts = ( return answer }, text: async (message, options) => { - textCalls.push({ message, defaultValue: options?.defaultValue }) + textCalls.push({ + message, + defaultValue: options?.defaultValue, + placeholder: options?.placeholder, + }) const answer = nextStringAnswer(message) // Mirrors @clack/prompts: an empty submission resolves to defaultValue. if (answer === "" && options?.defaultValue !== undefined) diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index 0519b2d27..d6d0dd796 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -77,7 +77,7 @@ describe("runConfigure with nothing picked", () => { expect(exitCode).toBe(0) expect(readFileSync(envFilePath, "utf8")).toBe(LOCAL_ENV_CONTENT) - expect(scripted.logs).toEqual(["No settings selected — nothing changed."]) + expect(scripted.logs).toEqual(["No changes to apply."]) expect(scripted.asked).toEqual([ "Any optional settings to change? (press enter to skip)", ]) @@ -231,6 +231,91 @@ describe("runConfigure with picked settings", () => { ) }) + it("replaces an active daily notes folder with a new value", async () => { + const targetDir = makeTempTargetDir() + const envFilePath = join(targetDir, ".env") + const envWithDailyNotes = `${LOCAL_ENV_CONTENT}DAILY_NOTES_FOLDER=Journal\n` + writeFileSync(envFilePath, envWithDailyNotes) + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "Planner"]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker: dockerDown, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(readFileSync(envFilePath, "utf8")).toBe( + `${LOCAL_ENV_CONTENT}DAILY_NOTES_FOLDER=Planner\n`, + ) + expect(scripted.logs).toEqual([ + `Updated DAILY_NOTES_FOLDER in ${targetDir}/.env.`, + ]) + }) + + it("uncomments the template's daily notes folder line on a typed value", async () => { + // The generated .env carries the var as a commented template line — the + // chooser's value must land by uncommenting it, not by appending a + // duplicate. + const targetDir = makeTempTargetDir() + const envFilePath = join(targetDir, ".env") + writeFileSync( + envFilePath, + `${LOCAL_ENV_CONTENT}\n# DAILY_NOTES_FOLDER=Journal\n# DAILY_NOTES_FORMAT=YYYY-MM-DD\n`, + ) + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "Planner"]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker: dockerDown, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(readFileSync(envFilePath, "utf8")).toBe( + `${LOCAL_ENV_CONTENT}\nDAILY_NOTES_FOLDER=Planner\n# DAILY_NOTES_FORMAT=YYYY-MM-DD\n`, + ) + }) + + it("treats a picked-then-blanked daily notes setting as no changes to apply", async () => { + const targetDir = makeTempTargetDir() + const envFilePath = writeLocalEnv(targetDir) + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker: dockerDown, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(readFileSync(envFilePath, "utf8")).toBe(LOCAL_ENV_CONTENT) + expect(scripted.logs).toEqual([ + "Left unset — the server reads this setting from your vault's own config.", + "No changes to apply.", + ]) + }) + + it("neither claims an update nor offers a restart when a set daily notes folder is blank-kept", async () => { + // A blank submit on a set value keeps it — configure must not log + // "Updated ..." or offer a restart for a byte-identical file. + const targetDir = makeTempTargetDir() + const envFilePath = join(targetDir, ".env") + const envWithDailyNotes = `${LOCAL_ENV_CONTENT}DAILY_NOTES_FOLDER=Journal\n` + writeFileSync(envFilePath, envWithDailyNotes) + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker: dockerDown, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(readFileSync(envFilePath, "utf8")).toBe(envWithDailyNotes) + expect(scripted.logs).toEqual([ + "Kept the current value (Journal).", + "No changes to apply.", + ]) + expect(scripted.warnings).toEqual([]) + }) + it("keeps the saved change and exits 1 when the .env cannot start a container", async () => { const targetDir = makeTempTargetDir() const envFilePath = join(targetDir, ".env") diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 269a57988..16bb9ef31 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -86,7 +86,7 @@ describe("runInit --yes (non-interactive local)", () => { expect(envContent).toMatch(/^MCP_AUTH_TOKEN=[0-9a-f]{64}$/m) expect(envContent).toContain(`VAULT_PATH=${vaultDir}\n`) expect(scripted.prints[0]).toContain( - "Adjust optional settings (memory layer and folder, file tools,\nsemantic search, port, timezone):", + "Adjust optional settings (memory layer and folder, daily notes\nfolder and format, file tools, semantic search, port, timezone):", ) }) @@ -544,7 +544,7 @@ describe("runInit remote flow", () => { expect(envContent).toContain("VAULT_NAME=MyVault\n") expect(envContent).toContain("OBSIDIAN_AUTH_TOKEN=sync-token-xyz\n") expect(scripted.prints[0]).toContain( - "Adjust optional settings (memory layer and folder, file tools,\nsemantic search, port, timezone, sync direction):", + "Adjust optional settings (memory layer and folder, daily notes\nfolder and format, file tools, semantic search, port, timezone,\nsync direction):", ) }) diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 1fb256ada..efdcf568d 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -144,6 +144,8 @@ describe("askOptionalSettings chooser", () => { ).toEqual([ "MEMORY_ENABLED", "MEMORY_DIR", + "DAILY_NOTES_FOLDER", + "DAILY_NOTES_FORMAT", "FILE_TOOLS_ENABLED", "EMBEDDING_ENABLED", "PORT", @@ -164,6 +166,8 @@ describe("askOptionalSettings chooser", () => { ).toEqual([ "MEMORY_ENABLED", "MEMORY_DIR", + "DAILY_NOTES_FOLDER", + "DAILY_NOTES_FORMAT", "FILE_TOOLS_ENABLED", "EMBEDDING_ENABLED", "PORT", @@ -185,6 +189,8 @@ describe("askOptionalSettings chooser", () => { ).toEqual([ "MEMORY_ENABLED · currently false", "MEMORY_DIR · currently not set · not used while Memory layer is off", + "DAILY_NOTES_FOLDER · currently not set", + "DAILY_NOTES_FORMAT · currently not set", "FILE_TOOLS_ENABLED · currently not set", "EMBEDDING_ENABLED · currently not set", "PORT · currently 9000", @@ -338,6 +344,7 @@ describe("askOptionalSettings per-setting prompts", () => { { message: "Vault folder for the memory files:", defaultValue: "About Me", + placeholder: "About Me", }, ]) expect(overrides).toEqual({ MEMORY_DIR: "Memory Bank" }) @@ -403,4 +410,148 @@ describe("askOptionalSettings per-setting prompts", () => { ]) expect(overrides).toEqual({ TZ: "Europe/London" }) }) + + it("skips an unset daily notes folder left blank, writing nothing", async () => { + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + // No pre-filled default when unset — the real default is the vault's + // own config, so the prompt must not offer a concrete value to accept. + expect(scripted.textCalls).toEqual([ + { + message: "Vault folder for daily notes:", + defaultValue: undefined, + placeholder: "blank = use your vault's daily notes settings", + }, + ]) + expect(overrides).toEqual({}) + expect(scripted.logs).toEqual([ + "Left unset — the server reads this setting from your vault's own config.", + ]) + }) + + it("keeps a set daily notes folder on a blank submit without recording a no-op", async () => { + // The prompt resolves an empty submit to its defaultValue (the current + // value), so blank never destroys an existing setting — and the unchanged + // value is not recorded, so the caller won't rewrite the file or offer a + // restart for a no-op. The placeholder states what blank actually does. + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + scripted.prompts, + ) + + expect(scripted.textCalls).toEqual([ + { + message: "Vault folder for daily notes:", + defaultValue: "Journal", + placeholder: "blank = keep the current value", + }, + ]) + expect(overrides).toEqual({}) + expect(scripted.logs).toEqual(["Kept the current value (Journal)."]) + }) + + it("collects a typed daily notes format, trimmed", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FORMAT"], + " DD-MM-YYYY ", + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(scripted.textCalls).toEqual([ + { + message: "Filename date format for daily notes (e.g. YYYY-MM-DD):", + defaultValue: undefined, + placeholder: "blank = use your vault's daily notes settings", + }, + ]) + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "DD-MM-YYYY" }) + }) + + it("does not clobber a set daily notes folder on whitespace input", async () => { + // Whitespace defeats the empty-submit-resolves-to-default behavior and + // trims to empty — the keep path must leave the existing value alone + // rather than write an empty one, and must say "kept", not "left unset": + // the override stays active in .env. + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], " "]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + scripted.prompts, + ) + + expect(overrides).toEqual({}) + expect(scripted.logs).toEqual(["Kept the current value (Journal)."]) + }) + + it("updates a set daily notes folder to a new value", async () => { + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "Planner"]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + scripted.prompts, + ) + + expect(scripted.textCalls).toEqual([ + { + message: "Vault folder for daily notes:", + defaultValue: "Journal", + placeholder: "blank = keep the current value", + }, + ]) + expect(overrides).toEqual({ DAILY_NOTES_FOLDER: "Planner" }) + }) + + it("does not record retyping the value a daily notes folder already has", async () => { + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "Journal"]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + scripted.prompts, + ) + + expect(overrides).toEqual({}) + expect(scripted.logs).toEqual(["Kept the current value (Journal)."]) + }) + + it("records only the typed setting when one of a pair is left blank", async () => { + const scripted = createScriptedPrompts([ + ["DAILY_NOTES_FOLDER", "DAILY_NOTES_FORMAT"], + "", // folder left blank — skipped + "YYYY/MM/DD", // format typed + ]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY/MM/DD" }) + }) + + it("shows a set daily notes folder in its chooser hint", async () => { + const scripted = createScriptedPrompts([[]]) + + await askOptionalSettings( + { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + scripted.prompts, + ) + + const dailyNotesFolderOption = scripted.multiselectCalls[0].options.find( + (option) => option.value === "DAILY_NOTES_FOLDER", + ) + expect(dailyNotesFolderOption?.hint).toBe( + "DAILY_NOTES_FOLDER · currently Journal", + ) + }) }) diff --git a/cli/src/configure.ts b/cli/src/configure.ts index c56aa7c69..712ad3b7d 100644 --- a/cli/src/configure.ts +++ b/cli/src/configure.ts @@ -53,7 +53,10 @@ export const runConfigure = async ( prompts, ) if (Object.keys(pickedOverrides).length === 0) { - prompts.log("No settings selected — nothing changed.") + // Covers both empty-overrides paths: nothing picked in the chooser, and + // picked-but-kept (an optionalText prompt left blank logs its own + // "Kept the current value" line, which this must not contradict). + prompts.log("No changes to apply.") prompts.outro("Done.") return 0 } diff --git a/cli/src/messages.ts b/cli/src/messages.ts index dc81b9420..bff9c7700 100644 --- a/cli/src/messages.ts +++ b/cli/src/messages.ts @@ -239,8 +239,8 @@ ${nonOauthBlocks} ${sectionRule("Settings")} -Adjust optional settings (memory layer and folder, file tools, -semantic search, port, timezone): +Adjust optional settings (memory layer and folder, daily notes +folder and format, file tools, semantic search, port, timezone): npx vault-cortex@latest configure --dir "${targetDir}" Or edit ${targetDir}/.env directly — change a value (uncommenting it @@ -324,8 +324,9 @@ ${remoteHealthCheckBlock(`${publicUrl}/healthz`, started)} ${sectionRule("Settings")} -Adjust optional settings (memory layer and folder, file tools, -semantic search, port, timezone, sync direction): +Adjust optional settings (memory layer and folder, daily notes +folder and format, file tools, semantic search, port, timezone, +sync direction): npx vault-cortex@latest configure --dir "${targetDir}" Or edit ${targetDir}/.env directly — change a value (uncommenting it diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 8efd1cd03..06e750fdc 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -19,7 +19,8 @@ type OptionalSettingBase = { /** * One optional .env setting the guided flow can change. `kind` selects the * prompt shape a picked setting gets: toggles use a yes/no confirm, port and - * timezone use validated text inputs, and choice uses a single select. + * timezone use validated text inputs, folder requires a non-empty name, + * optionalText writes nothing when left blank, and choice uses a single select. */ type OptionalSetting = | (OptionalSettingBase & { kind: "toggle"; question: string }) @@ -30,6 +31,12 @@ type OptionalSetting = question: string defaultValue: string }) + | (OptionalSettingBase & { + kind: "optionalText" + question: string + /** Ghost text while unset — no defaultValue because pre-filling would silently shadow the vault's own config. */ + placeholder: string + }) | (OptionalSettingBase & { kind: "choice" question: string @@ -55,6 +62,20 @@ const OPTIONAL_SETTINGS: OptionalSetting[] = [ defaultValue: "About Me", requiresToggle: "MEMORY_ENABLED", }, + { + kind: "optionalText", + name: "DAILY_NOTES_FOLDER", + label: "Daily notes folder", + question: "Vault folder for daily notes:", + placeholder: "blank = use your vault's daily notes settings", + }, + { + kind: "optionalText", + name: "DAILY_NOTES_FORMAT", + label: "Daily notes format", + question: "Filename date format for daily notes (e.g. YYYY-MM-DD):", + placeholder: "blank = use your vault's daily notes settings", + }, { kind: "toggle", name: "FILE_TOOLS_ENABLED", @@ -259,11 +280,50 @@ const askFolder = async ( return askFolder(params, prompts) } -/** Routes a picked setting to its kind's prompt and returns the .env value. */ +/** + * Text prompt for a setting whose absence is meaningful — the server falls + * back to the vault's own config when the var is unset. Blank-when-unset + * writes nothing; blank-when-set keeps the current value. Returns undefined + * on skip or no-op so the caller never rewrites for nothing. No removal + * path — clearing is a manual .env edit. + */ +const askOptionalText = async ( + params: { + question: string + placeholder: string + currentValue: string | undefined + }, + prompts: Prompts, +): Promise => { + const { question, placeholder, currentValue } = params + const answer = ( + await prompts.text(question, { + defaultValue: currentValue, + placeholder: + currentValue === undefined + ? placeholder + : "blank = keep the current value", + }) + ).trim() + if (answer !== "" && answer !== currentValue) return answer + if (currentValue === undefined) { + prompts.log( + "Left unset — the server reads this setting from your vault's own config.", + ) + return undefined + } + prompts.log(`Kept the current value (${currentValue}).`) + return undefined +} + +/** + * Routes a picked setting to its kind's prompt and returns the .env value — + * or undefined when an optionalText prompt was left blank (nothing to write). + */ const askSettingValue = async ( params: { setting: OptionalSetting; currentValue: string | undefined }, prompts: Prompts, -): Promise => { +): Promise => { const { setting, currentValue } = params switch (setting.kind) { case "toggle": { @@ -286,6 +346,15 @@ const askSettingValue = async ( }, prompts, ) + case "optionalText": + return askOptionalText( + { + question: setting.question, + placeholder: setting.placeholder, + currentValue, + }, + prompts, + ) case "choice": return prompts.select( setting.question, @@ -332,14 +401,16 @@ export const askOptionalSettings = async ( ) // Sequential prompting: answers are gathered one at a time in the curated - // order, so the record builds up inside an honest loop. + // order, so the record builds up inside an honest loop. An undefined answer + // (an optionalText prompt left blank) writes nothing. const overrides: Record = {} for (const setting of offeredSettings) { if (!pickedNames.includes(setting.name)) continue - overrides[setting.name] = await askSettingValue( + const value = await askSettingValue( { setting, currentValue: readOptionalValue(envContent, setting.name) }, prompts, ) + if (value !== undefined) overrides[setting.name] = value } return overrides }