From cff3517406d0a1dbd62d4aeca3c3bb6330fc2d79 Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Thu, 29 Oct 2026 21:10:56 +1000 Subject: [PATCH] feat(cli): open approved onboarding after installation --- LOG.md | 6 ++ README.md | 3 +- install/install.ps1 | 5 +- install/install.sh | 11 ++- package.json | 1 + pnpm-lock.yaml | 3 + scripts/assert-pack-contents.mjs | 2 + scripts/assert-sea-artifact.mjs | 2 + scripts/copy-vendor.mjs | 14 +++ scripts/test-install-unix.sh | 6 ++ scripts/test-onboarding.py | 142 +++++++++++++++++++++++++++++++ src/cli/index.ts | 141 ++++++++++++++++++++++++++---- src/cli/prompts.ts | 12 +++ src/cli/search-select.ts | 82 ++++++++++++++---- src/cli/source-name-prompt.ts | 32 +++++++ src/skills/paths.ts | 10 ++- test/cli/search-select.test.ts | 75 ++++++++++++++++ 17 files changed, 507 insertions(+), 40 deletions(-) create mode 100644 scripts/test-onboarding.py create mode 100644 src/cli/source-name-prompt.ts create mode 100644 test/cli/search-select.test.ts diff --git a/LOG.md b/LOG.md index 7cee277..cb0dff1 100644 --- a/LOG.md +++ b/LOG.md @@ -151,3 +151,9 @@ rerun while the npm namespace permission remains unchanged. ## BUI-938 CLI release The user clarified the deployment boundary: publish a normal stable CLI containing BUI-938 and the latest Factory tools; Account and Web remain on dev. Remove the temporary CLI dev-prerelease workflow changes. The combined CLI passes 974 tests, types, lint/format and package checks. The existing demo's registration and free fake-data read both passed through the published candidate. Promote the CLI only through its normal main release workflow; do not promote Unity/Account or Personal Server main. Demo commands select VANA_ENV=dev. BUI-945 is not a prerequisite for own-app free reads. + +## Approved five-screen onboarding delivery + +Finish and ship only the approved welcome, searchable 12-row source picker, missing-source name, connector decision and guide handoff. Start from v0.46.0; preserve its public Factory 0.0.2 workflow and BUI-938. Do not merge #106's funding or collection-readiness work. Interactive standalone installation opens bare `vana` via the controlling terminal; automated installs skip prompts. Skills are bundled without installing into agent harnesses; the advertised optional setup uses existing explicit skill selection. Proof must exercise a fresh installer and all five actual terminal screens, including clean exit with guide visible. + +Proof: fresh piped standalone install in a disposable home reached welcome, source picker, Ctrl+O source-name input, Readwise connector decision and canonical Factory guide, then exited 0 with output retained. The first test reproduced the remaining forced-help entry override before repair. Full validation passes 976 tests; CLI preflight uses browser/network-blocked fixtures and passes. Packaging checks require the bundled current Factory guide. Structured Codex closeout review is clean at P0 (0.96). Funding/app commands, Factory adapter and the current Create Connector instructions have no diff from v0.46.0. Delivery is the scoped onboarding PR and normal stable release; #106's funding work stays unmerged. diff --git a/README.md b/README.md index 2ed59bd..49d03d1 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ Or install the standalone binary, which is signed and notarized on macOS: ```bash curl -fsSL https://raw.githubusercontent.com/vana-com/vana-cli/main/install/install.sh | sh -vana status ``` +At a human terminal, installation opens the welcome menu. Choose **Collect or manage my data** to search sources; press Ctrl+O for a missing source and follow the connector guide. Run `vana` to reopen the menu. Automated installs remain prompt-free. + Windows uses `install/install.ps1`. The Homebrew tap (`vana-com/tap`) is no longer updated and ships a months-old canary, so use one of the routes above instead. diff --git a/install/install.ps1 b/install/install.ps1 index 0b3f09e..628c323 100644 --- a/install/install.ps1 +++ b/install/install.ps1 @@ -173,7 +173,10 @@ try { Write-Host "" Write-Host "Installed vana to $WrapperPath" Write-Host "Next step:" - Write-Host " vana status" + Write-Host " vana" + if (-not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected -and -not $env:CI -and -not $env:AGENT) { + & $WrapperPath + } } finally { if (Test-Path $TempDir) { diff --git a/install/install.sh b/install/install.sh index 654bb1d..2fd5520 100644 --- a/install/install.sh +++ b/install/install.sh @@ -150,4 +150,13 @@ esac echo "" echo "Next step:" -echo " vana status" +echo " vana" + +# curl | sh consumes stdin; human interaction must use the controlling terminal. +if [ -t 1 ] && [ -z "${CI:-}" ] && [ -z "${AGENT:-}" ]; then + if [ -t 0 ]; then + "$BIN_DIR/vana" || true + elif [ -r /dev/tty ]; then + "$BIN_DIR/vana" =26.12.1" }, "dependencies": { + "@inquirer/core": "11.1.7", "@inquirer/prompts": "8.3.0", "@inquirer/search": "^4.1.6", "@modelcontextprotocol/sdk": "^1.27.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f037a3..5dd0d4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ overrides: importers: .: dependencies: + "@inquirer/core": + specifier: 11.1.7 + version: 11.1.7(@types/node@25.2.2) "@inquirer/prompts": specifier: 8.3.0 version: 8.3.0(@types/node@25.2.2) diff --git a/scripts/assert-pack-contents.mjs b/scripts/assert-pack-contents.mjs index b61de72..20794c0 100644 --- a/scripts/assert-pack-contents.mjs +++ b/scripts/assert-pack-contents.mjs @@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process"; const requiredPaths = [ "dist/cli/bin.js", + "dist/skills/bundled/registry.json", + "dist/skills/bundled/create-connector/SKILL.md", "dist/cli/index.js", "dist/runtime/managed-playwright.js", "dist/vendor/pdpp-connector-manager/index.mjs", diff --git a/scripts/assert-sea-artifact.mjs b/scripts/assert-sea-artifact.mjs index b336696..2503f22 100644 --- a/scripts/assert-sea-artifact.mjs +++ b/scripts/assert-sea-artifact.mjs @@ -29,6 +29,8 @@ await assertExists( const requiredDirectoryEntries = [ binaryName, "app/sea-entry.cjs", + "app/dist/skills/bundled/registry.json", + "app/dist/skills/bundled/create-connector/SKILL.md", "app/package.json", "app/dist/cli/bin.js", "app/dist/cli/main.js", diff --git a/scripts/copy-vendor.mjs b/scripts/copy-vendor.mjs index a521175..3152029 100644 --- a/scripts/copy-vendor.mjs +++ b/scripts/copy-vendor.mjs @@ -17,3 +17,17 @@ for (const relative of ASSET_DIRS) { await fs.rm(destination, { recursive: true, force: true }); await fs.cp(source, destination, { recursive: true }); } + +// Both npm and SEA ship dist/, including the public skills for offline setup. +await fs.rm(path.join(repoRoot, "dist/skills/bundled"), { + recursive: true, + force: true, +}); +await fs.cp( + path.join(repoRoot, "skills"), + path.join(repoRoot, "dist/skills/bundled"), + { + recursive: true, + force: true, + }, +); diff --git a/scripts/test-install-unix.sh b/scripts/test-install-unix.sh index 54d41b1..f07796a 100644 --- a/scripts/test-install-unix.sh +++ b/scripts/test-install-unix.sh @@ -22,3 +22,9 @@ sh "$ROOT_DIR/install/install.sh" PATH="$HOME_DIR/bin:$PATH" HOME="$HOME_DIR" "$HOME_DIR/bin/vana" status --json >/dev/null echo "Unix installer smoke test passed" + +python3 "$ROOT_DIR/scripts/test-onboarding.py" \ + --installer "$ROOT_DIR/install/install.sh" \ + --version test-release \ + --release-base-url "file://$WORK_DIR/local-release" \ + --evidence "$WORK_DIR/onboarding-proof.json" diff --git a/scripts/test-onboarding.py b/scripts/test-onboarding.py new file mode 100644 index 0000000..6ff388a --- /dev/null +++ b/scripts/test-onboarding.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Exercise the real piped Unix installer and five onboarding screens in a PTY. + +Use --release-base-url file:///... --version candidate for a local SEA release; +by default this downloads and tests the latest published release. No account, +provider, browser, grant or payment is used. State lives in a disposable home. +""" +import argparse +import errno +import fcntl +import json +import os +from pathlib import Path +import pty +import re +import select +import shlex +import signal +import struct +import tempfile +import termios +import time + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--installer", default="install/install.sh") +parser.add_argument("--release-base-url") +parser.add_argument("--version") +parser.add_argument("--evidence", default="/tmp/vana-onboarding-proof.json") +args = parser.parse_args() +ansi = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)") + + +class Terminal: + def __init__(self, command, env): + self.pid, self.fd = pty.fork() + if self.pid == 0: + os.execvpe("sh", ["sh", "-c", command], env) + fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", 44, 120, 0, 0)) + self.output = "" + self.offset = 0 + self.closed = False + self.reaped = False + + def expect(self, text, timeout=45): + deadline = time.monotonic() + timeout + while text not in ansi.sub("", self.output[self.offset:]): + if self.closed or time.monotonic() >= deadline: + raise AssertionError(f"Did not see {text!r}:\n{ansi.sub('', self.output)[-6000:]}") + ready, _, _ = select.select([self.fd], [], [], 0.2) + if ready: + try: + chunk = os.read(self.fd, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if not chunk: + self.closed = True + self.output += chunk.decode(errors="replace") + return ansi.sub("", self.output[self.offset:]) + + def send(self, data): + self.offset = len(self.output) + os.write(self.fd, data) + + def finish(self): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + pid, status = os.waitpid(self.pid, os.WNOHANG) + if pid: + self.reaped = True + assert os.waitstatus_to_exitcode(status) == 0, self.output[-3000:] + return + # Drain pending terminal output so the process can finish writing. + if not self.closed and select.select([self.fd], [], [], 0.1)[0]: + try: + chunk = os.read(self.fd, 65536) + self.output += chunk.decode(errors="replace") + self.closed = not chunk + except OSError as error: + if error.errno != errno.EIO: + raise + self.closed = True + raise AssertionError("CLI did not exit after the guide") + + def close(self): + if not self.reaped: + os.kill(self.pid, signal.SIGTERM) + os.waitpid(self.pid, 0) + os.close(self.fd) + + +proof = {} +with tempfile.TemporaryDirectory(prefix="vana-onboarding-") as directory: + home = Path(directory) + env = dict(os.environ) + for key in ("CI", "AGENT", "VANA_DETACHED", "VANA_HOME", "VANA_APP_ROOT", "VANA_SKILLS_DIR", "DATA_CONNECTORS_DIR"): + env.pop(key, None) + env.update(HOME=str(home), VANA_HOME=str(home / ".vana"), + VANA_INSTALL_ROOT=str(home / "release"), VANA_INSTALL_BIN_DIR=str(home / "bin"), + TERM="xterm-256color", VANA_TELEMETRY_DISABLED="1", VANA_NO_UPDATE_NOTIFIER="1") + if args.version: + env["VANA_VERSION"] = args.version + if args.release_base_url: + env["VANA_RELEASE_BASE_URL"] = args.release_base_url + installer = shlex.quote(str(Path(args.installer).resolve())) + terminal = Terminal(f"cd {shlex.quote(directory)} && cat {installer} | sh", env) + try: + welcome = terminal.expect("Up/Down: move | Enter: choose | Ctrl+C: exit", 180) + for label in ("[OK] Vana installed.", "Collect or manage my data", "Use or share data", + "Build or test a connector", "Manage my Personal Server", + "Agent skills are included. No installation needed.", "vana skills setup"): + assert label in welcome, label + assert welcome.index("Manage my Personal Server") < welcome.index("Help: vana --help") + proof["1-welcome-from-install"] = welcome + terminal.send(b"\r") + terminal.expect("Ctrl+O: Source not listed | Ctrl+C: exit") + proof["2-source-picker"] = terminal.expect("Amazon") + terminal.send(b"\x0f") + proof["3-source-name"] = terminal.expect("Enter: continue | Ctrl+C: exit") + assert "Which source are you looking for?" in proof["3-source-name"] + terminal.send(b"Readwise") + terminal.expect("Name or website: Readwise") + terminal.send(b"\r") + decision = terminal.expect("Choose another source") + assert "Readwise isn't in Vana's source list." in decision + assert "A connector is needed to collect your highlights." in decision + assert "Build a Readwise connector" in decision + proof["4-connector-decision"] = decision + terminal.send(b"\r") + proof["5-guide"] = terminal.expect("Follow the guide yourself, or share it with your agent.") + assert "https://github.com/vana-com/vana-cli/blob/main/skills/create-connector/SKILL.md" in proof["5-guide"] + terminal.finish() + # Discovery must not install agent skills or require sign-in. + assert not (home / ".agents/skills").exists() + assert not (home / ".claude/skills").exists() + proof["exit"] = 0 + print("PASS: fresh piped install -> welcome -> picker -> source name -> connector choice -> guide -> exit 0") + finally: + proof["raw-terminal"] = terminal.output + Path(args.evidence).write_text(json.dumps(proof, indent=2)) + terminal.close() diff --git a/src/cli/index.ts b/src/cli/index.ts index c8ec177..d4f1c12 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,7 +14,14 @@ import { } from "../core/source-export.js"; import type { SourceExportReceipt } from "../core/source-export.js"; -import { confirm, input, isPromptInputClosed, password } from "./prompts.js"; +import { + confirm, + input, + isPromptInputClosed, + password, + select, +} from "./prompts.js"; +import { sourceNamePrompt } from "./source-name-prompt.js"; import { searchSelect } from "./search-select.js"; import { Command, CommanderError, Option } from "commander"; @@ -374,9 +381,6 @@ export async function runCli(argv = process.argv): Promise { } const normalizedArgv = normalizeArgv(argv); - if (normalizedArgv.length <= 2) { - normalizedArgv.push("--help"); - } const parsedOptions = extractGlobalOptions(normalizedArgv); const cliVersion = getCliVersion(); const installMethod = getCliInstallMethod(); @@ -464,6 +468,26 @@ More: mergeParsedGlobalOptions(parsedOptions, actionCommand as Command); }); + program.action(async () => { + if ( + process.stdin.isTTY && + process.stdout.isTTY && + !process.env.CI && + !process.env.AGENT && + !process.env.VANA_DETACHED && + !parsedOptions.json && + !parsedOptions.noInput && + !parsedOptions.ipc && + !parsedOptions.yes && + !parsedOptions.quiet + ) { + process.exitCode = await runWelcome(parsedOptions); + } else { + program.outputHelp(); + process.exitCode = CliExitCode.OK; + } + }); + program .command("version") .description("Print CLI version") @@ -1230,6 +1254,13 @@ Examples: ); }); + skill + .command("setup") + .description("Choose an agent skill to install (optional)") + .action(async () => { + process.exitCode = await runSkillsGuidedPicker(parsedOptions); + }); + skill .command("list") .description("List available agent skills") @@ -2955,12 +2986,6 @@ async function runConnectEntry(options: GlobalOptions): Promise { return CliExitCode.USAGE; } - if (enrichedSources.length === 0) { - emit.info("No sources are available right now."); - emit.info("Run `vana sources` to verify the local connector registry."); - return 1; - } - // Build inquirer-compatible choices from enriched sources const choices = enrichedSources.map((item) => { const connected = hasCollectedData(item.dataState); @@ -2977,13 +3002,45 @@ async function runConnectEntry(options: GlobalOptions): Promise { }); try { - const source = await searchSelect({ - message: "Choose a source to connect.", - choices, - ...vanaPromptTheme, - }); - - return runConnect(source as string, options); + while (true) { + const selection = await searchSelect({ + message: "Choose a source", + choices, + allowMissingSource: true, + ...vanaPromptTheme, + }); + if (typeof selection === "string") return runConnect(selection, options); + const name = selection.missingSource.trim() || (await sourceNamePrompt()); + emit.blank(); + emit.info(`${name} isn't in Vana's source list.`); + emit.blank(); + emit.info( + `A connector is needed to collect your ${name.toLowerCase() === "readwise" ? "highlights" : "data"}.`, + ); + emit.blank(); + const decision = await select({ + message: "What would you like to do?", + choices: [ + { value: "build", name: `Build a ${name} connector` }, + { value: "back", name: "Choose another source" }, + ], + theme: { + ...vanaPromptTheme.theme, + icon: { cursor: ">" }, + style: { + ...vanaPromptTheme.theme.style, + keysHelpTip: () => + "--------------------------------------------------\nUp/Down: move | Enter: choose | Ctrl+C: exit", + }, + }, + }); + if (decision === "back") continue; + emit.blank(); + emit.info(`Build a ${name} connector`); + emit.blank(); + showConnectorGuide(emit); + return CliExitCode.OK; + } } catch (error) { if (isPromptCancelled(error)) { emit.info("Cancelled."); @@ -8085,6 +8142,56 @@ async function maybePromptSkillInstall( } } +const CONNECTOR_GUIDE_URL = + "https://github.com/vana-com/vana-cli/blob/main/skills/create-connector/SKILL.md"; + +async function runWelcome(options: GlobalOptions): Promise { + const emit = createEmitter(options); + emit.info("[OK] Vana installed."); + emit.blank(); + try { + const choice = await select({ + message: "What would you like to do?", + choices: [ + { value: "connect", name: "Collect or manage my data" }, + { value: "use", name: "Use or share data" }, + { value: "develop", name: "Build or test a connector" }, + { value: "server", name: "Manage my Personal Server" }, + ], + theme: { + ...vanaPromptTheme.theme, + icon: { cursor: ">" }, + style: { + ...vanaPromptTheme.theme.style, + keysHelpTip: () => + "\nHelp: vana --help\nAgent skills are included. No installation needed.\nOptional install for future sessions: vana skills setup\n\n--------------------------------------------------\nUp/Down: move | Enter: choose | Ctrl+C: exit", + }, + }, + }); + if (choice === "connect") return runConnectEntry(options); + if (choice === "use") { + emit.info("Approve access to your data: vana mcp requests"); + emit.info( + "Request consent, read data, pay or ask questions: vana app --help", + ); + } + if (choice === "develop") showConnectorGuide(emit); + if (choice === "server") return runServerStatus(options); + return CliExitCode.OK; + } catch (error) { + if (isPromptCancelled(error)) return CliExitCode.OK; + throw error; + } +} + +function showConnectorGuide(emit: ReturnType): void { + emit.info("Build and test the connector, then collect your data with Vana."); + emit.blank(); + emit.info(`Guide: ${CONNECTOR_GUIDE_URL}`); + emit.info("--------------------------------------------------"); + emit.info("Follow the guide yourself, or share it with your agent."); +} + async function runSkillsGuidedPicker(options: GlobalOptions): Promise { const emit = createEmitter(options); diff --git a/src/cli/prompts.ts b/src/cli/prompts.ts index e4a2498..8fe4461 100644 --- a/src/cli/prompts.ts +++ b/src/cli/prompts.ts @@ -14,6 +14,7 @@ import { confirm as inquirerConfirm, input as inquirerInput, password as inquirerPassword, + select as inquirerSelect, } from "@inquirer/prompts"; type PromptStream = NodeJS.ReadableStream & { @@ -119,3 +120,14 @@ export function password( context, ); } + +/** A finite choice, with the same closed-input handling as text prompts. */ +export function select( + config: Parameters>[0], + context?: PromptContext, +): Promise { + return withInputClosedGuard( + (guarded) => inquirerSelect(config, guarded), + context, + ); +} diff --git a/src/cli/search-select.ts b/src/cli/search-select.ts index 72d088b..e67c185 100644 --- a/src/cli/search-select.ts +++ b/src/cli/search-select.ts @@ -1,5 +1,6 @@ import search from "@inquirer/search"; import Fuse from "fuse.js"; +import type { Key } from "node:readline"; import { withInputClosedGuard } from "./prompts.js"; interface SearchSelectChoice { @@ -12,15 +13,26 @@ interface SearchSelectOptions { message: string; choices: SearchSelectChoice[]; theme?: Record; + allowMissingSource?: boolean; +} + +export interface MissingSourceSelection { + missingSource: string; } /** * Fuzzy search select prompt. Type to filter, arrow keys to navigate. * Uses fuse.js for fuzzy matching over the choice name and description. */ +export function searchSelect( + options: SearchSelectOptions & { allowMissingSource: true }, +): Promise; +export function searchSelect( + options: SearchSelectOptions & { allowMissingSource?: false }, +): Promise; export async function searchSelect( options: SearchSelectOptions, -): Promise { +): Promise { const { message, choices, theme } = options; const fuse = new Fuse(choices, { @@ -32,20 +44,58 @@ export async function searchSelect( ignoreLocation: true, }); - const result = await withInputClosedGuard((context) => - search( - { - message, - source: async (input: string | undefined) => { - if (!input) return choices; - const results = fuse.search(input); - return results.map((r) => r.item); + return withInputClosedGuard(async (context) => { + const shortcut = new AbortController(); + const stream: NodeJS.ReadableStream = context.input ?? process.stdin; + let query = ""; + let hasMatches = choices.length > 0; + let missing: MissingSourceSelection | undefined; + const onKey = (_text: string, key: Key) => { + if (!options.allowMissingSource) return; + if ( + (key.ctrl && key.name === "o") || + (key.name === "return" && !hasMatches) + ) { + missing = { missingSource: query }; + shortcut.abort(); + } + }; + stream.on("keypress", onKey); + const promptTheme = theme as Parameters[0]["theme"]; + try { + return await search( + { + message, + pageSize: 12, + source: async (input: string | undefined) => { + query = input?.trim() ?? ""; + const results = query + ? fuse.search(query).map((r) => r.item) + : choices; + hasMatches = results.length > 0; + return results; + }, + theme: options.allowMissingSource + ? { + ...promptTheme, + style: { + ...promptTheme?.style, + keysHelpTip: () => + `--------------------------------------------------\nType to search | Up/Down: browse | Enter: ${hasMatches ? "choose" : "source not listed"}\nCtrl+O: Source not listed | Ctrl+C: exit`, + }, + } + : promptTheme, }, - theme: theme as Parameters[0]["theme"], - }, - context, - ), - ); - - return result as T; + { + ...context, + signal: AbortSignal.any([context.signal!, shortcut.signal]), + }, + ); + } catch (error) { + if (missing) return missing; + throw error; + } finally { + stream.removeListener("keypress", onKey); + } + }); } diff --git a/src/cli/source-name-prompt.ts b/src/cli/source-name-prompt.ts new file mode 100644 index 0000000..0f6fa7d --- /dev/null +++ b/src/cli/source-name-prompt.ts @@ -0,0 +1,32 @@ +import { + createPrompt, + isEnterKey, + useKeypress, + useState, +} from "@inquirer/core"; +import { withInputClosedGuard } from "./prompts.js"; + +const prompt = createPrompt>((_config, done) => { + const [value, setValue] = useState(""); + const [finished, setFinished] = useState(false); + useKeypress((key, rl) => { + if (isEnterKey(key)) { + if (!value.trim()) return; + setFinished(true); + done(value.trim()); + } else { + setValue(rl.line); + } + }); + return [ + `Which source are you looking for?\n\nName or website: ${value}`, + finished + ? "" + : "--------------------------------------------------\nEnter: continue | Ctrl+C: exit", + ]; +}); + +/** Ask for the missing source, retaining the approved footer below the input. */ +export function sourceNamePrompt(): Promise { + return withInputClosedGuard((context) => prompt({}, context)); +} diff --git a/src/skills/paths.ts b/src/skills/paths.ts index 88738af..8ef3424 100644 --- a/src/skills/paths.ts +++ b/src/skills/paths.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { getVanaHome } from "../core/paths.js"; @@ -13,10 +14,9 @@ export function getSkillsCacheDir(): string { } /** - * Walks up from `cwd` looking for a `skills/registry.json` file. - * Returns the path to the `skills/` directory if found, or `null`. - * - * Also checks for the env override `VANA_SKILLS_DIR`. + * Resolve an explicit `VANA_SKILLS_DIR`, the release's bundled skills, or + * a source checkout's `skills/registry.json` found walking up from `cwd`. + * Returns the skills directory, or `null` when none exists. */ export function findSkillsDir(cwd = process.cwd()): string | null { const candidates: string[] = []; @@ -35,6 +35,8 @@ export function findSkillsDir(cwd = process.cwd()): string | null { }; pushCandidate(process.env.VANA_SKILLS_DIR); + // Installed releases prefer their own version over an unrelated working directory. + pushCandidate(fileURLToPath(new URL("./bundled/", import.meta.url))); let currentDir = path.resolve(cwd); while (true) { diff --git a/test/cli/search-select.test.ts b/test/cli/search-select.test.ts new file mode 100644 index 0000000..f0d9bad --- /dev/null +++ b/test/cli/search-select.test.ts @@ -0,0 +1,75 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { searchSelect } from "../../src/cli/search-select.js"; + +afterEach(() => vi.restoreAllMocks()); + +function terminal() { + const input = new PassThrough(); + let output = ""; + const waiting: Array<{ text: string; resolve: () => void }> = []; + vi.spyOn(process, "stdin", "get").mockReturnValue( + input as typeof process.stdin, + ); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output += String(chunk).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); + for (let i = waiting.length - 1; i >= 0; i -= 1) { + if (output.includes(waiting[i].text)) waiting.splice(i, 1)[0].resolve(); + } + return true; + }); + return { + input, + read: () => output, + clear: () => { + output = ""; + }, + rendered: (text: string) => + output.includes(text) + ? Promise.resolve() + : new Promise((resolve) => waiting.push({ text, resolve })), + }; +} + +const choices = Array.from({ length: 24 }, (_, i) => ({ + value: String(i + 1), + name: `Source ${String(i + 1).padStart(2, "0")}`, +})); +const options = { + message: "Choose a source", + choices, + allowMissingSource: true as const, + theme: { icon: { cursor: ">" } }, +}; + +describe("searchable source prompt", () => { + it("shows 12 source rows and keeps missing-source access available after scrolling", async () => { + const tty = terminal(); + const result = searchSelect(options); + await tty.rendered("> Source 01"); + expect(tty.read()).toContain("Source 12"); + expect(tty.read()).not.toContain("Source 13"); + expect(tty.read()).toContain("Ctrl+O: Source not listed"); + for (let i = 2; i <= 15; i += 1) { + tty.clear(); + tty.input.write("\x1b[B"); + await tty.rendered(`> Source ${String(i).padStart(2, "0")}`); + } + expect(tty.read()).toContain("Ctrl+O: Source not listed"); + tty.input.write("\x0f"); + expect(await result).toEqual({ missingSource: "" }); + }); + + it("continues a no-results search with its query when Enter is pressed", async () => { + const tty = terminal(); + const result = searchSelect(options); + await tty.rendered("> Source 01"); + tty.clear(); + tty.input.write("Readwise"); + await tty.rendered("No results found"); + expect(tty.read()).toContain("Enter: source not listed"); + expect(tty.read()).toContain("Ctrl+O: Source not listed"); + tty.input.write("\r"); + expect(await result).toEqual({ missingSource: "Readwise" }); + }); +});