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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ All notable changes to **basou** are recorded here. The project follows
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.
own surface. Both `basou portfolio` and `basou portfolio list` list; passing
`--check` (which belongs to `basou view`) prints a pointer to
`basou view --portfolio --check` instead of a bare `unknown option` error.

## 0.34.0 — 2026-07-14

Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/commands/portfolio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type PortfolioListContext,
type PortfolioListResult,
renderPortfolioList,
runPortfolioCommand,
runPortfolioList,
} from "./portfolio.js";

Expand Down Expand Up @@ -122,6 +123,53 @@ describe("doRunPortfolioList", () => {
});
});

describe("runPortfolioCommand", () => {
it("lists on the bare invocation (action undefined) and on the explicit `list`", async () => {
const a = join(getDir(), "alpha-planning");
const configPath = await writeConfig(`workspaces:\n - path: ${a}\n label: alpha\n`);
const ctx: PortfolioListContext = {
configPath,
pathExists: () => true,
isInitialized: () => true,
};
for (const action of [undefined, "list"] as const) {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
await runPortfolioCommand(action, {}, ctx);
expect(log.mock.calls[0]?.[0]).toContain("# Portfolio (workspaces you orient across)");
log.mockRestore();
}
});

it("redirects --check to `basou view --portfolio --check` (exit 1, never lists or reads the config)", async () => {
const prevExit = process.exitCode;
const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
// configPath points nowhere: a working redirect must short-circuit before any read.
await runPortfolioCommand(undefined, { check: true }, { configPath: "/nope/nope.yaml" });
expect(process.exitCode).toBe(1);
expect(err).toHaveBeenCalledWith(expect.stringMatching(/basou view --portfolio --check/));
expect(log).not.toHaveBeenCalled();
} finally {
process.exitCode = prevExit;
}
});

it("rejects an unknown action with a pointer (exit 1, never lists or reads the config)", async () => {
const prevExit = process.exitCode;
const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
await runPortfolioCommand("bogus", {}, { configPath: "/nope/nope.yaml" });
expect(process.exitCode).toBe(1);
expect(err).toHaveBeenCalledWith(expect.stringMatching(/Unknown portfolio action 'bogus'/));
expect(log).not.toHaveBeenCalled();
} finally {
process.exitCode = prevExit;
}
});
});

describe("renderPortfolioList", () => {
it("marks a healthy master as initialized", () => {
const out = renderPortfolioList({
Expand Down
53 changes: 51 additions & 2 deletions packages/cli/src/commands/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export type PortfolioListOptions = {
verbose?: boolean;
};

/** Options for the `basou portfolio` command surface: the list options plus the `--check` redirect flag. */
export type PortfolioCommandOptions = PortfolioListOptions & {
/** Set by the natural `basou portfolio --check` mistake; prints a pointer to `basou view --portfolio --check`. */
check?: boolean;
};

/**
* Injectable seams for {@link doRunPortfolioList} so tests stay hermetic: the
* config path to read, and the two on-disk probes (existence and `.basou`
Expand Down Expand Up @@ -63,10 +69,23 @@ export function registerPortfolioCommand(program: Command): void {
.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",
)
// Accept the explicit `list` spelling as an alias for the bare command, so
// both `basou portfolio` and `basou portfolio list` work (an agent reaching
// for a `list` sub-verb should not hit a `too many arguments` foot-gun). Any
// other word is a typo — rejected with a pointer, not silently listed.
.argument("[action]", "optional literal `list` (the only, and default, action)")
.option("--json", "Output the result as JSON")
// `--check` belongs to `basou view` (the redundancy/footprint safety
// preflight). It is declared here ONLY to turn the natural `basou portfolio
// --check` mistake into a pointer instead of a bare `unknown option` error;
// it never runs a preflight (the scope boundary vs `view --check` holds).
.option(
"--check",
"moved: the redundancy/footprint safety preflight is `basou view --portfolio --check` (this prints that pointer and exits)",
)
.option("-v, --verbose", "Show error causes")
.action(async (opts: PortfolioListOptions) => {
await runPortfolioList(opts);
.action(async (action: string | undefined, opts: PortfolioCommandOptions) => {
await runPortfolioCommand(action, opts);
});
}

Expand All @@ -79,6 +98,36 @@ function isDirectory(path: string): boolean {
}
}

/**
* Dispatch the `basou portfolio [action]` surface. Bare and the explicit `list`
* spelling both list; `--check` is redirected to `basou view --portfolio
* --check` (this command never runs the preflight); any other action word is a
* typo, rejected with a pointer. Non-list branches write to stderr and set
* exitCode 1 (the CLI's usage-error convention, matching commander's own
* unknown-option/argument exit) rather than throwing.
*/
export async function runPortfolioCommand(
action: string | undefined,
options: PortfolioCommandOptions,
ctx: PortfolioListContext = {},
): Promise<void> {
if (options.check === true) {
console.error(
"`basou portfolio` is a read-only listing; it has no safety preflight.\nRun `basou view --portfolio --check` for the redundancy/footprint check.",
);
process.exitCode = 1;
return;
}
if (action !== undefined && action !== "list") {
console.error(
`Unknown portfolio action '${action}'. Run \`basou portfolio\` or \`basou portfolio list\` to list the registered workspaces.`,
);
process.exitCode = 1;
return;
}
await runPortfolioList(options, ctx);
}

/** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunPortfolioList}. */
export async function runPortfolioList(
options: PortfolioListOptions,
Expand Down