Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/flows-list-interactive-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": minor
---

Open a searchable flow table by default at an interactive terminal. Type to filter by name, path, target, environment, or tag; press Enter to print matches or Esc to leave. Use --no-interactive for printed output. Explicit --interactive is rejected before remote authentication when the terminal cannot support it.
8 changes: 8 additions & 0 deletions skills/qawolf-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ commit, then read the flow's `flowId` and `url` from
`qawolf --json flows list --remote --env <environment> --include-drafts`. Send
that `url`; never guess a route and never send a repository link in its place.

## Flow lists

At an interactive terminal, `qawolf flows list` opens a searchable table.
Search by flow name, path, target, environment, or tag; press Enter to print the
matches or Esc to leave. Use `--no-interactive` to print directly. Agents and
JSON output print directly by default; `-i` requires an interactive terminal.
Pulled flows include cached IDs in JSON when the last pull recorded them.

## Commands

<!-- commands-table:start — generated by `bun run generate`, do not edit -->
Expand Down
4 changes: 4 additions & 0 deletions src/commands/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,17 @@ Options:
--tag <name> Only list flows carrying this tag; repeat for
several. Without --remote, matches against tags
cached by the last pull (default: [])
-i, --interactive Open the flow table to filter as you type; the
default at a terminal
--no-interactive Print the flow table instead of opening it
--ai-task-id <aiTaskId> List the flows on this AI task's branch, including
drafts, instead of the ones in the environment
(requires --remote) (env: QAWOLF_AI_TASK_ID)
-h, --help display help for command

Examples:
$ qawolf flows list
$ qawolf flows list --no-interactive
$ qawolf flows list "flows/checkout/**"
$ qawolf flows list --remote --env staging
$ qawolf flows list --tag auth
Expand Down
9 changes: 8 additions & 1 deletion src/commands/flows/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,14 @@ async function runList(args: string[]): Promise<string> {
spyOn(process.stdout, "write").mockImplementation(capture);
spyOn(process.stderr, "write").mockImplementation(capture);

await makeProgram().parseAsync(["flows", "list", ...args], { from: "user" });
// Printed, never the interactive table: this runs through a real context,
// and from a developer's terminal the default would open a prompt and wait.
await makeProgram().parseAsync(
["flows", "list", "--no-interactive", ...args],
{
from: "user",
},
);
return writes.join("");
}

Expand Down
130 changes: 7 additions & 123 deletions src/commands/flows/index.ts
Original file line number Diff line number Diff line change
@@ -1,143 +1,27 @@
import { Option, type Command } from "commander";
import type { Command } from "commander";

import { declareCommandKind } from "~/commands/commandKind.js";
import { withContext } from "~/commands/context.js";
import { flowsMessages } from "~/core/messages/index.js";
import { collectValue } from "~/domains/runner/runFlagParsers.js";
import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js";

import { handleFlowsList } from "~/domains/flows/listDefaults.js";
import { flowsListRemote } from "~/domains/flows/listRemote.js";
import {
type ListCommandDeps,
registerFlowsListCommand,
} from "./list.register.js";
import { registerFlowsPullCommand } from "./pull.register.js";
import { registerFlowsRunCommand } from "./run.register.js";
import { registerRunWorkerCommand } from "./runWorker.register.js";
import { withResolvedEnv } from "./withResolvedEnv.js";

const listExamples = `
Examples:
$ qawolf flows list
$ qawolf flows list "flows/checkout/**"
$ qawolf flows list --remote --env staging
$ qawolf flows list --tag auth
$ qawolf flows list --env staging --tag auth
$ qawolf flows list --remote --env staging --tag auth --tag smoke
$ qawolf flows list "**/checkout/**" --remote --env staging --include-drafts
$ qawolf flows list --remote --env staging --ai-task-id ait_123`;

type FlowsListOptions = {
readonly remote: boolean;
readonly env: string | undefined;
readonly includeDrafts: boolean;
readonly aiTaskId: string | undefined;
readonly tag: string[];
};

type Deps = {
// The remote listing resolves its environment (and its auth) through this.
// A test stands in its own to drive the command without a platform.
readonly withResolvedEnv: typeof withResolvedEnv;
};

export function registerFlowsCommand(
program: Command,
signals: SignalRegistry,
deps: Deps = { withResolvedEnv },
deps: ListCommandDeps = { withResolvedEnv },
): void {
const flows = program
.command("flows")
.description("Manage and run QA Wolf flows");

registerFlowsRunCommand(flows, signals);
registerRunWorkerCommand(flows, signals);

declareCommandKind(flows.command("list [pattern]"), "local", {
kindNote: "read with --remote",
})
.description(
"List flows matching [pattern] from the local project, or from a QA Wolf environment with --remote",
)
.option(
"--remote",
"List flows from the QA Wolf platform instead of the local project",
false,
)
.option(
"--env <env>",
"Environment to list flows from: a QA Wolf environment with --remote, otherwise a pulled one by slug or id",
)
.option(
"--include-drafts",
"Include draft flows in the listing (requires --remote)",
false,
)
.option(
"--tag <name>",
"Only list flows carrying this tag; repeat for several. Without --remote, matches against tags cached by the last pull",
collectValue,
[],
)
.addOption(
new Option(
"--ai-task-id <aiTaskId>",
"List the flows on this AI task's branch, including drafts, instead of the ones in the environment (requires --remote)",
).env("QAWOLF_AI_TASK_ID"),
)
.addHelpText("after", listExamples)
.action(
(
pattern: string | undefined,
opts: FlowsListOptions,
command: Command,
) => {
const tags = opts.tag;
if (opts.remote) {
return deps.withResolvedEnv(
signals,
{
explicit: opts.env,
requiredMessage: flowsMessages.list.remoteRequiresEnv,
},
(ctx, env) =>
flowsListRemote(
ctx,
pattern,
{
env,
includeDrafts: opts.includeDrafts,
aiTaskId: opts.aiTaskId,
tags,
},
{ columns: process.stdout.columns },
),
)(opts, command);
}
// Only an explicitly passed --ai-task-id is a usage error here:
// QAWOLF_AI_TASK_ID is ambient in AI task runners, and a local
// listing must not fail just because it is set.
if (command.getOptionValueSource("aiTaskId") === "cli") {
return withContext(signals, async () => ({
error: flowsMessages.list.aiTaskIdRequiresRemote,
}))(opts, command);
}
// --include-drafts is a platform concept; --env is not, so without
// --remote it names a pulled environment and is answered from disk.
if (opts.includeDrafts) {
return withContext(signals, async () => ({
error: flowsMessages.list.draftsRequireRemote,
}))(opts, command);
}
// Without --remote the tags come from the pull cache, so this works
// offline; it cannot validate names against the team's tag list.
return withContext(signals, (ctx) =>
handleFlowsList(
ctx,
pattern,
{ tags, env: opts.env },
{ columns: process.stdout.columns },
),
)(opts, command);
},
);

registerFlowsListCommand(flows, signals, deps);
registerFlowsPullCommand(flows, signals);
}
41 changes: 41 additions & 0 deletions src/commands/flows/list.interactive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterEach, expect, it, mock, spyOn } from "bun:test";
import { Command } from "commander";

import { flowsMessages } from "~/core/messages/index.js";
import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js";

import { registerFlowsListCommand } from "./list.register.js";
import type { withResolvedEnv } from "./withResolvedEnv.js";

afterEach(() => {
process.exitCode = 0;
mock.restore();
});

for (const mode of ["--json", "--agent"]) {
it(`rejects -i ${mode} before remote authentication or environment resolution`, async () => {
const output: string[] = [];
const capture = (chunk: unknown): boolean => {
output.push(String(chunk));
return true;
};
spyOn(process.stdout, "write").mockImplementation(capture);
spyOn(process.stderr, "write").mockImplementation(capture);
const resolve = mock<typeof withResolvedEnv>(() => async () => {});
const program = new Command().option("--json").option("--agent");
registerFlowsListCommand(program.command("flows"), makeNoopSignals(), {
withResolvedEnv: resolve,
});

await program.parseAsync(
["flows", "list", "--remote", "--env", "staging", "-i", mode],
{ from: "user" },
);

expect(resolve).not.toHaveBeenCalled();
expect(process.exitCode).toBe(2);
expect(output.join("")).toContain(
flowsMessages.list.interactiveRequiresTerminal,
);
});
}
142 changes: 142 additions & 0 deletions src/commands/flows/list.register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Option, type Command } from "commander";

import { declareCommandKind } from "~/commands/commandKind.js";
import { withContext } from "~/commands/context.js";
import { flowsMessages } from "~/core/messages/index.js";
import { handleFlowsList } from "~/domains/flows/listDefaults.js";
import { flowsListRemote } from "~/domains/flows/listRemote.js";
import { collectValue } from "~/domains/runner/runFlagParsers.js";
import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js";

import {
terminalListView,
unavailableTerminalList,
} from "./terminalListView.js";
import type { withResolvedEnv } from "./withResolvedEnv.js";

const listExamples = `
Examples:
$ qawolf flows list
$ qawolf flows list --no-interactive
$ qawolf flows list "flows/checkout/**"
$ qawolf flows list --remote --env staging
$ qawolf flows list --tag auth
$ qawolf flows list --env staging --tag auth
$ qawolf flows list --remote --env staging --tag auth --tag smoke
$ qawolf flows list "**/checkout/**" --remote --env staging --include-drafts
$ qawolf flows list --remote --env staging --ai-task-id ait_123`;

type FlowsListOptions = {
readonly remote: boolean;
readonly env: string | undefined;
readonly includeDrafts: boolean;
readonly aiTaskId: string | undefined;
readonly tag: string[];
// Undefined unless -i or --no-interactive was passed; the terminal decides.
readonly interactive: boolean | undefined;
};

export type ListCommandDeps = {
readonly withResolvedEnv: typeof withResolvedEnv;
};

export function registerFlowsListCommand(
flows: Command,
signals: SignalRegistry,
deps: ListCommandDeps,
): void {
declareCommandKind(flows.command("list [pattern]"), "local", {
kindNote: "read with --remote",
})
.description(
"List flows matching [pattern] from the local project, or from a QA Wolf environment with --remote",
)
.option(
"--remote",
"List flows from the QA Wolf platform instead of the local project",
false,
)
.option(
"--env <env>",
"Environment to list flows from: a QA Wolf environment with --remote, otherwise a pulled one by slug or id",
)
.option(
"--include-drafts",
"Include draft flows in the listing (requires --remote)",
false,
)
.option(
"--tag <name>",
"Only list flows carrying this tag; repeat for several. Without --remote, matches against tags cached by the last pull",
collectValue,
[],
)
// Declared before --no-interactive, so neither passed leaves it undefined.
.option(
"-i, --interactive",
"Open the flow table to filter as you type; the default at a terminal",
)
.option("--no-interactive", "Print the flow table instead of opening it")
.addOption(
new Option(
"--ai-task-id <aiTaskId>",
"List the flows on this AI task's branch, including drafts, instead of the ones in the environment (requires --remote)",
).env("QAWOLF_AI_TASK_ID"),
)
.addHelpText("after", listExamples)
.action(
(
pattern: string | undefined,
opts: FlowsListOptions,
command: Command,
) => {
const unavailable = unavailableTerminalList(opts.interactive, command);
if (unavailable !== undefined) {
return withContext(signals, async () => unavailable)(opts, command);
}
const tags = opts.tag;
if (opts.remote) {
return deps.withResolvedEnv(
signals,
{
explicit: opts.env,
requiredMessage: flowsMessages.list.remoteRequiresEnv,
},
(ctx, env) =>
flowsListRemote(
ctx,
pattern,
{
env,
includeDrafts: opts.includeDrafts,
aiTaskId: opts.aiTaskId,
tags,
},
terminalListView(opts.interactive, ctx),
),
)(opts, command);
}
// An inherited env-var default must not prevent a local listing.
if (command.getOptionValueSource("aiTaskId") === "cli") {
return withContext(signals, async () => ({
error: flowsMessages.list.aiTaskIdRequiresRemote,
}))(opts, command);
}
if (opts.includeDrafts) {
return withContext(signals, async () => ({
error: flowsMessages.list.draftsRequireRemote,
}))(opts, command);
}
// Without --remote the tags come from the pull cache, so this works
// offline; it cannot validate names against the team's tag list.
return withContext(signals, (ctx) =>
handleFlowsList(
ctx,
pattern,
{ tags, env: opts.env },
terminalListView(opts.interactive, ctx),
),
)(opts, command);
},
);
}
Loading
Loading