From be33a489d1dc17e39226017e872c0adff21dfb5b Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 15 Jul 2026 00:11:06 +0900 Subject: [PATCH 1/2] feat(cli): add read-only `basou portfolio` listing command An agent oriented in one project had no headless way to discover where a sibling project lives: the portfolio was reachable only via the `basou view --portfolio` localhost GUI (unreachable in a non-interactive session) or by reading ~/.basou/portfolio.yaml by hand. So an agent structurally falls back to globbing the filesystem even though basou already holds the mapping. `basou portfolio` closes that gap, and fixes the "unknown command 'portfolio'" foot-gun (the name an agent naturally reaches for). - New top-level `basou portfolio` (read-only): lists each registered planning master's label + path, and whether it exists / owns a `.basou/` store. - `--json` for machine consumption (stable, minimal shape: `{ configPath, workspaces: [{ label, path, exists, initialized }] }`). - Reuses `loadPortfolioConfig`; probes only existence + `.basou` presence. Deliberately does NOT run the `basou view --check` redundancy/footprint safety preflight; that stays its own surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++ packages/cli/src/commands/portfolio.test.ts | 137 +++++++++++++++++ packages/cli/src/commands/portfolio.ts | 157 ++++++++++++++++++++ packages/cli/src/program.test.ts | 1 + packages/cli/src/program.ts | 2 + 5 files changed, 311 insertions(+) create mode 100644 packages/cli/src/commands/portfolio.test.ts create mode 100644 packages/cli/src/commands/portfolio.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82180f8..75c356d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to **basou** are recorded here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0. +## Unreleased + +### Added + +- `basou portfolio` — a read-only, headless listing of the workspaces + registered in `~/.basou/portfolio.yaml` (each entry's label, path, and + whether it exists / is an initialized planning master), with `--json`. It is + the text/JSON counterpart to the `basou view --portfolio` GUI: an agent + oriented in one project can now discover where a sibling project lives + without opening a browser or reading the config file by hand. It reads only + the config and probes existence + `.basou` presence — it never runs the + `basou view --check` redundancy/footprint safety preflight, which stays its + own surface. + ## 0.34.0 — 2026-07-14 ### Added diff --git a/packages/cli/src/commands/portfolio.test.ts b/packages/cli/src/commands/portfolio.test.ts new file mode 100644 index 0000000..c2d17c3 --- /dev/null +++ b/packages/cli/src/commands/portfolio.test.ts @@ -0,0 +1,137 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + doRunPortfolioList, + type PortfolioListContext, + type PortfolioListResult, + renderPortfolioList, +} from "./portfolio.js"; + +let dir: string | undefined; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "basou-portfolio-cmd-")); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + if (dir !== undefined) await rm(dir, { recursive: true, force: true }); + dir = undefined; +}); + +function getDir(): string { + if (dir === undefined) throw new Error("dir not initialized"); + return dir; +} + +async function writeConfig(body: string): Promise { + const path = join(getDir(), "portfolio.yaml"); + await writeFile(path, body); + return path; +} + +describe("doRunPortfolioList", () => { + it("resolves each entry's label / path / exists / initialized", async () => { + const a = join(getDir(), "alpha-planning"); + const b = join(getDir(), "beta-planning"); + const configPath = await writeConfig( + `version: 1\nworkspaces:\n - path: ${a}\n label: alpha\n - path: ${b}\n`, + ); + // Injected probes: alpha exists + initialized, beta exists but no .basou. + const ctx: PortfolioListContext = { + configPath, + pathExists: (p) => p === a || p === b, + isInitialized: (p) => p === a, + }; + const result = await doRunPortfolioList({}, ctx); + expect(result).toEqual({ + configPath, + workspaces: [ + { label: "alpha", path: a, exists: true, initialized: true }, + { label: null, path: b, exists: true, initialized: false }, + ], + }); + }); + + it("reports a stale entry (path gone) as exists:false, initialized:false — never probing .basou", async () => { + const gone = join(getDir(), "moved-away"); + const configPath = await writeConfig(`workspaces:\n - path: ${gone}\n label: stale\n`); + const isInitialized = vi.fn(() => true); // would lie; must not be consulted + const result = await doRunPortfolioList( + {}, + { configPath, pathExists: () => false, isInitialized }, + ); + expect(result.workspaces).toEqual([ + { label: "stale", path: gone, exists: false, initialized: false }, + ]); + expect(isInitialized).not.toHaveBeenCalled(); + }); + + it("prints JSON when --json is set", async () => { + const a = join(getDir(), "alpha-planning"); + const configPath = await writeConfig(`workspaces:\n - path: ${a}\n label: alpha\n`); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await doRunPortfolioList( + { json: true }, + { configPath, pathExists: () => true, isInitialized: () => true }, + ); + expect(log).toHaveBeenCalledTimes(1); + const printed = JSON.parse(log.mock.calls[0]?.[0] as string) as PortfolioListResult; + expect(printed).toEqual({ + configPath, + workspaces: [{ label: "alpha", path: a, exists: true, initialized: true }], + }); + }); + + it("prints the human listing when --json is not set", async () => { + const a = join(getDir(), "alpha-planning"); + const configPath = await writeConfig(`workspaces:\n - path: ${a}\n label: alpha\n`); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await doRunPortfolioList({}, { configPath, pathExists: () => true, isInitialized: () => true }); + const out = log.mock.calls[0]?.[0] as string; + expect(out).toContain("# Portfolio (workspaces you orient across)"); + expect(out).toContain("1 workspace registered"); + expect(out).toContain(a); + expect(out).toContain("basou view --portfolio"); + }); + + it("propagates a helpful error when the config is missing", async () => { + await expect( + doRunPortfolioList({}, { configPath: join(getDir(), "nope.yaml") }), + ).rejects.toThrow(/No portfolio config/); + }); +}); + +describe("renderPortfolioList", () => { + it("marks a healthy master as initialized", () => { + const out = renderPortfolioList({ + configPath: "/x", + workspaces: [{ label: "acme", path: "/p/acme-planning", exists: true, initialized: true }], + }); + expect(out).toContain("acme"); + expect(out).toContain("/p/acme-planning"); + expect(out).toContain("✓ initialized"); + }); + + it("marks a missing path and an uninitialized path distinctly", () => { + const out = renderPortfolioList({ + configPath: "/x", + workspaces: [ + { label: "gone", path: "/p/gone", exists: false, initialized: false }, + { label: "bare", path: "/p/bare", exists: true, initialized: false }, + ], + }); + expect(out).toContain("⚠ path not found"); + expect(out).toContain("⚠ no .basou (not a planning master?)"); + }); + + it("falls back to (no label) when an entry has none", () => { + const out = renderPortfolioList({ + configPath: "/x", + workspaces: [{ label: null, path: "/p/x", exists: true, initialized: true }], + }); + expect(out).toContain("(no label)"); + }); +}); diff --git a/packages/cli/src/commands/portfolio.ts b/packages/cli/src/commands/portfolio.ts new file mode 100644 index 0000000..9df2728 --- /dev/null +++ b/packages/cli/src/commands/portfolio.ts @@ -0,0 +1,157 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { Command } from "commander"; +import { isVerbose, renderCliError } from "../lib/error-render.js"; +import { DEFAULT_PORTFOLIO_CONFIG_PATH, loadPortfolioConfig } from "../lib/portfolio-config.js"; + +export type PortfolioListOptions = { + json?: boolean; + verbose?: boolean; +}; + +/** + * Injectable seams for {@link doRunPortfolioList} so tests stay hermetic: the + * config path to read, and the two on-disk probes (existence and `.basou` + * ownership). Defaults hit the real filesystem. + */ +export type PortfolioListContext = { + /** Config file to read (default: ~/.basou/portfolio.yaml). */ + configPath?: string; + /** Whether the workspace path resolves to something on disk (default: fs.existsSync). */ + pathExists?: (path: string) => boolean; + /** Whether the workspace path owns a `.basou/` store (default: fs.existsSync of `/.basou`). */ + isInitialized?: (path: string) => boolean; +}; + +/** + * One resolved portfolio entry as reported by `basou portfolio`. The `--json` + * shape is part of the CLI contract, so this is kept deliberately minimal and + * stable: label + path + two cheap on-disk facts, and nothing from the + * `view --check` safety preflight (that stays its own surface). + */ +export type PortfolioEntry = { + /** The display label from the config, or null when the entry declared none. */ + label: string | null; + /** The workspace path, absolute and `~`-expanded (as `loadPortfolioConfig` returns it). */ + path: string; + /** Whether `path` resolves to something on disk (a stale entry points nowhere). */ + exists: boolean; + /** Whether `path` owns a `.basou/` store — i.e. it is an initialized planning master. */ + initialized: boolean; +}; + +/** Result of {@link doRunPortfolioList}: the config that was read plus its resolved entries. */ +export type PortfolioListResult = { + /** The config file that was read. */ + configPath: string; + /** Every registered workspace, in config order (deduped by resolved path upstream). */ + workspaces: PortfolioEntry[]; +}; + +/** + * Wire `basou portfolio` onto `program`: a read-only, headless listing of the + * workspaces registered in `~/.basou/portfolio.yaml`. It is the text/JSON + * counterpart to the `basou view --portfolio` GUI — an agent oriented in one + * project can discover where a sibling project lives without opening a browser + * (the localhost GUI is unreachable in a non-interactive session). It reads + * nothing but the config, probes only existence + `.basou` presence, and never + * runs the redundancy/footprint safety preflight (that is `basou view --check`). + */ +export function registerPortfolioCommand(program: Command): void { + program + .command("portfolio") + .description( + "List the workspaces you orient across (read-only): every planning master registered in ~/.basou/portfolio.yaml, with its path and whether it exists / is initialized. The headless text/JSON counterpart to the `basou view --portfolio` GUI — for discovering where a sibling project lives without opening a browser", + ) + .option("--json", "Output the result as JSON") + .option("-v, --verbose", "Show error causes") + .action(async (opts: PortfolioListOptions) => { + await runPortfolioList(opts); + }); +} + +/** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunPortfolioList}. */ +export async function runPortfolioList( + options: PortfolioListOptions, + ctx: PortfolioListContext = {}, +): Promise { + try { + await doRunPortfolioList(options, ctx); + } catch (error: unknown) { + renderCliError(error, { verbose: isVerbose(options) }); + process.exitCode = 1; + } +} + +/** + * Load the portfolio config and resolve each entry against disk. A path that + * does not resolve reports `exists:false` (and therefore `initialized:false` — + * a missing path cannot be a master), so a stale entry surfaces instead of + * throwing. A missing/empty/malformed config propagates `loadPortfolioConfig`'s + * user-facing error to {@link runPortfolioList}. + */ +export async function doRunPortfolioList( + options: PortfolioListOptions, + ctx: PortfolioListContext, +): Promise { + const configPath = ctx.configPath ?? DEFAULT_PORTFOLIO_CONFIG_PATH; + const pathExists = ctx.pathExists ?? ((p: string) => existsSync(p)); + const isInitialized = ctx.isInitialized ?? ((p: string) => existsSync(join(p, ".basou"))); + + const workspaces = await loadPortfolioConfig(configPath); + const result: PortfolioListResult = { + configPath, + workspaces: workspaces.map((w) => { + const exists = pathExists(w.path); + return { + label: w.label ?? null, + path: w.path, + exists, + initialized: exists && isInitialized(w.path), + }; + }), + }; + + if (options.json === true) { + console.log(JSON.stringify(result)); + } else { + console.log(renderPortfolioList(result)); + } + return result; +} + +const LABEL_COLUMN_CAP = 24; + +/** + * Render the listing. Leads with the count, then one line per workspace + * (label, path, and a status marker for a stale or uninitialized entry), and + * closes by pointing at the GUI / safety-preflight surfaces so the read-only + * framing is not over-read as the whole portfolio story. + */ +export function renderPortfolioList(result: PortfolioListResult): string { + const lines: string[] = []; + const n = result.workspaces.length; + lines.push("# Portfolio (workspaces you orient across)"); + lines.push(""); + lines.push(`${n} workspace${n === 1 ? "" : "s"} registered in ~/.basou/portfolio.yaml:`); + lines.push(""); + + const labelWidth = Math.min( + LABEL_COLUMN_CAP, + Math.max(0, ...result.workspaces.map((w) => (w.label ?? "(no label)").length)), + ); + for (const w of result.workspaces) { + const label = (w.label ?? "(no label)").padEnd(labelWidth); + const status = !w.exists + ? "⚠ path not found" + : w.initialized + ? "✓ initialized" + : "⚠ no .basou (not a planning master?)"; + lines.push(`- ${label} ${w.path} ${status}`); + } + lines.push(""); + lines.push( + "Note: read-only listing of ~/.basou/portfolio.yaml. Run `basou view --portfolio` for the cross-workspace GUI, or `basou view --portfolio --check` for the redundancy/footprint safety preflight.", + ); + return lines.join("\n"); +} diff --git a/packages/cli/src/program.test.ts b/packages/cli/src/program.test.ts index ccbe4da..27c46e7 100644 --- a/packages/cli/src/program.test.ts +++ b/packages/cli/src/program.test.ts @@ -44,6 +44,7 @@ describe("buildProgram", () => { "init", "note", "orient", + "portfolio", "project", "protocol", "refresh", diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index ef65b6f..cee7477 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -10,6 +10,7 @@ import { registerImportCommand } from "./commands/import.js"; import { registerInitCommand } from "./commands/init.js"; import { registerNoteCommand } from "./commands/note.js"; import { registerOrientCommand } from "./commands/orient.js"; +import { registerPortfolioCommand } from "./commands/portfolio.js"; import { registerProjectCommand } from "./commands/project.js"; import { registerProtocolCommand } from "./commands/protocol.js"; import { registerRefreshCommand } from "./commands/refresh.js"; @@ -71,6 +72,7 @@ export function buildProgram(): Command { registerDecisionsCommand(program); registerReportCommand(program); registerOrientCommand(program); + registerPortfolioCommand(program); registerReviewCommand(program); registerReviewGapsCommand(program); registerProjectCommand(program); From b971120ca1aa6f553afc857e2859b485b26b24ce Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 15 Jul 2026 00:50:17 +0900 Subject: [PATCH 2/2] fix(cli): address portfolio review findings (A1 dir-check, A2/A3 tests) Adversarial review follow-ups on `basou portfolio`: - A1: `initialized` now requires `.basou` to be a DIRECTORY (statSync isDirectory), matching the canonical `hasBasouStore` probe, so a stray regular file named `.basou` is not mis-reported as an initialized master. - A2: pin the `--json` output to single-line minified JSON (no pretty-print), so line-oriented consumers stay stable. - A3: cover the wired `runPortfolioList` entry: a missing config renders the error and sets exitCode 1. Design-reversal findings B1-B7 were reviewed and kept as-is (operator call): the read-only scope boundary vs `basou view --check` and the exit-code contract consistent with `basou view --portfolio` stand. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/portfolio.test.ts | 20 +++++++++++++++++++- packages/cli/src/commands/portfolio.ts | 17 +++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/portfolio.test.ts b/packages/cli/src/commands/portfolio.test.ts index c2d17c3..7dfc782 100644 --- a/packages/cli/src/commands/portfolio.test.ts +++ b/packages/cli/src/commands/portfolio.test.ts @@ -7,6 +7,7 @@ import { type PortfolioListContext, type PortfolioListResult, renderPortfolioList, + runPortfolioList, } from "./portfolio.js"; let dir: string | undefined; @@ -78,11 +79,16 @@ describe("doRunPortfolioList", () => { { configPath, pathExists: () => true, isInitialized: () => true }, ); expect(log).toHaveBeenCalledTimes(1); - const printed = JSON.parse(log.mock.calls[0]?.[0] as string) as PortfolioListResult; + const raw = log.mock.calls[0]?.[0] as string; + const printed = JSON.parse(raw) as PortfolioListResult; expect(printed).toEqual({ configPath, workspaces: [{ label: "alpha", path: a, exists: true, initialized: true }], }); + // Contract: single-line minified JSON (no pretty-print), so line-oriented + // consumers (e.g. `basou portfolio --json | jq -c`) stay stable. + expect(raw).not.toContain("\n"); + expect(raw).toBe(JSON.stringify(printed)); }); it("prints the human listing when --json is not set", async () => { @@ -102,6 +108,18 @@ describe("doRunPortfolioList", () => { doRunPortfolioList({}, { configPath: join(getDir(), "nope.yaml") }), ).rejects.toThrow(/No portfolio config/); }); + + it("runPortfolioList (wired entry) renders the error and sets exitCode 1 on a missing config", async () => { + const prevExit = process.exitCode; + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + await runPortfolioList({}, { configPath: join(getDir(), "nope.yaml") }); + expect(process.exitCode).toBe(1); + expect(err).toHaveBeenCalledWith(expect.stringMatching(/No portfolio config/)); + } finally { + process.exitCode = prevExit; + } + }); }); describe("renderPortfolioList", () => { diff --git a/packages/cli/src/commands/portfolio.ts b/packages/cli/src/commands/portfolio.ts index 9df2728..a804fb5 100644 --- a/packages/cli/src/commands/portfolio.ts +++ b/packages/cli/src/commands/portfolio.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; import type { Command } from "commander"; import { isVerbose, renderCliError } from "../lib/error-render.js"; @@ -70,6 +70,15 @@ export function registerPortfolioCommand(program: Command): void { }); } +/** Whether `path` resolves to a directory on disk (a missing/other-type path → false). */ +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + /** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunPortfolioList}. */ export async function runPortfolioList( options: PortfolioListOptions, @@ -96,7 +105,11 @@ export async function doRunPortfolioList( ): Promise { const configPath = ctx.configPath ?? DEFAULT_PORTFOLIO_CONFIG_PATH; const pathExists = ctx.pathExists ?? ((p: string) => existsSync(p)); - const isInitialized = ctx.isInitialized ?? ((p: string) => existsSync(join(p, ".basou"))); + // A `.basou` store is a DIRECTORY. Match basou's canonical `hasBasouStore` + // (repo-root.ts) `isDirectory()` check rather than a bare `existsSync`, so a + // stray regular file named `.basou` is not mis-reported as an initialized + // master (which the real resolver would reject). + const isInitialized = ctx.isInitialized ?? ((p: string) => isDirectory(join(p, ".basou"))); const workspaces = await loadPortfolioConfig(configPath); const result: PortfolioListResult = {