Skip to content
Merged
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
18 changes: 10 additions & 8 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <path>` if your config isn't in `./vault-cortex`.

Expand Down
12 changes: 10 additions & 2 deletions cli/src/__tests__/command-stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
87 changes: 86 additions & 1 deletion cli/src/__tests__/configure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
])
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions cli/src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):",
)
})

Expand Down Expand Up @@ -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):",
)
})

Expand Down
151 changes: 151 additions & 0 deletions cli/src/__tests__/optional-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ describe("askOptionalSettings chooser", () => {
).toEqual([
"MEMORY_ENABLED",
"MEMORY_DIR",
"DAILY_NOTES_FOLDER",
"DAILY_NOTES_FORMAT",
"FILE_TOOLS_ENABLED",
"EMBEDDING_ENABLED",
"PORT",
Expand All @@ -164,6 +166,8 @@ describe("askOptionalSettings chooser", () => {
).toEqual([
"MEMORY_ENABLED",
"MEMORY_DIR",
"DAILY_NOTES_FOLDER",
"DAILY_NOTES_FORMAT",
"FILE_TOOLS_ENABLED",
"EMBEDDING_ENABLED",
"PORT",
Expand All @@ -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",
Expand Down Expand Up @@ -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" })
Expand Down Expand Up @@ -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",
)
})
})
5 changes: 4 additions & 1 deletion cli/src/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 5 additions & 4 deletions cli/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading