diff --git a/apps/portal/src/components/CliCommand.stories.tsx b/apps/portal/src/components/CliCommand.stories.tsx new file mode 100644 index 00000000..158ce05d --- /dev/null +++ b/apps/portal/src/components/CliCommand.stories.tsx @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent } from "storybook/test"; +import { CliCommand } from "./CliCommand"; +import { + buildRunGet, + buildRunList, + buildRunSubmit, + buildRunBulk, +} from "@/lib/cli/buildCommand"; + +const meta = { + component: CliCommand, + tags: ["ai-generated", "needs-work"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Simple single-command modal (e.g. a run detail page). */ +export const RunGet: Story = { + args: { command: buildRunGet("req_12345") }, + play: async ({ canvas }) => { + const trigger = canvas.getByRole("button", { name: /show cli equivalent/i }); + await expect(trigger).toBeInTheDocument(); + await userEvent.click(trigger); + }, +}; + +/** List command reflecting the active filters and sort. */ +export const RunListFiltered: Story = { + args: { + command: buildRunList({ + workers: ["coder-vscode-web"], + statuses: ["done"], + outcomes: ["failed"], + turns: "5", + turnsOp: "gte", + sortBy: "duration", + sortDir: "desc", + }), + }, +}; + +/** Labeled trigger used in the Submit Run footer, with a parity note. */ +export const SubmitLabeled: Story = { + args: { + label: "CLI", + title: "Submit from the CLI", + command: buildRunSubmit({ + task: "Create a Snake game using React", + criteria: ["has-tests"], + worker: "coder-acp-claude-code", + model: "claude-sonnet-4.5", + occurrences: 5, + priority: 3, + }), + }, +}; + +/** Base profile + variations: the variations go through a JSON file. */ +export const SubmitProfileVariations: Story = { + args: { + label: "CLI", + title: "Submit from the CLI", + command: buildRunSubmit({ + task: "Create a Snake game using React", + criteria: ["has-tests"], + baseProfileId: "prof_base@2", + profileVariations: ["prof_fast", "prof_thorough@4"], + occurrences: 3, + }), + }, +}; + +/** Bulk delete over a multi-id selection (loop form). */ +export const BulkLoop: Story = { + args: { + title: "Bulk action from the CLI", + command: buildRunBulk("delete", ["req_a", "req_b", "req_c"]), + }, +}; diff --git a/apps/portal/src/components/CliCommand.test.tsx b/apps/portal/src/components/CliCommand.test.tsx new file mode 100644 index 00000000..1613e713 --- /dev/null +++ b/apps/portal/src/components/CliCommand.test.tsx @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ProjectProvider } from "@/contexts/ProjectContext"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; +import { CLI_INSTALL_COMMAND, buildRunSubmit } from "@/lib/cli/buildCommand"; +import { CliCommand } from "./CliCommand"; + +const command = buildRunSubmit({ task: "t", worker: "coder-acp-copilot", priority: 2 }); + +async function openModal(withProvider: boolean) { + const ui = ( + + + + ); + render(withProvider ? {ui} : ui); + await userEvent.click(screen.getByRole("button", { name: /show cli equivalent/i })); + return screen.getByRole("dialog").textContent ?? ""; +} + +describe("CliCommand", () => { + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it("shows the public installer, project env and generated command with notes", async () => { + localStorage.setItem(PROJECT_STORAGE_KEY, "proj_42"); + const text = await openModal(true); + + expect(text).toContain(CLI_INSTALL_COMMAND); + expect(text).toContain("export SCOPE_PROJECT=proj_42"); + expect(text).toContain("scope run submit"); + expect(text).toContain("Priority (2)"); + expect(text).not.toMatch(/SCOPE_TOKEN=/); + }); + + it("omits SCOPE_PROJECT outside a ProjectProvider", async () => { + const text = await openModal(false); + + expect(text).toContain("export SCOPE_API_URL="); + expect(text).not.toContain("SCOPE_PROJECT"); + }); +}); diff --git a/apps/portal/src/components/CliCommand.tsx b/apps/portal/src/components/CliCommand.tsx new file mode 100644 index 00000000..38683980 --- /dev/null +++ b/apps/portal/src/components/CliCommand.tsx @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState } from "react"; +import { Terminal, Copy, Check, Info, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { CLI_INSTALL_COMMAND, buildEnvCommand, type CliCommand as CliCommandValue } from "@/lib/cli/buildCommand"; +import { useOptionalSelectedProjectId } from "@/contexts/ProjectContext"; + +export interface CliCommandProps { + /** The command to display. Recompute in the parent so it tracks live state. */ + command: CliCommandValue; + /** Optional label shown next to the terminal glyph (e.g. "CLI"). */ + label?: string; + /** Modal heading. */ + title?: string; + /** Tooltip on the trigger. */ + tooltip?: string; + className?: string; + /** + * When true, the trigger is disabled and the modal cannot be opened. Use this + * when the equivalent action (e.g. the Submit form) is not yet valid. + */ + disabled?: boolean; + /** + * Kept for backwards compatibility with the previous popover API. No longer + * used now that the affordance is a centered modal. + */ + align?: "start" | "center" | "end"; +} + +/** Public documentation for installing and using the Scope CLI. */ +const DOCS_URL = "https://microsoft.github.io/scope/getting-started/install-cli/"; + +/** + * The API base URL to suggest for `SCOPE_API_URL`. + * + * Every supported deployment fronts the API on the same origin as the Portal: + * nginx proxies `/api/*` in production, and the Vite dev server proxies `/api` + * in local dev (`src/lib/api.ts` calls it via the relative `/api/v1` base). + * Because the CLI requests `${SCOPE_API_URL}/api/v1/...`, the current browser + * origin is the correct, environment-specific value in every case — no + * hardcoding or per-environment branching needed. + */ +function apiUrl(): string { + if (typeof window === "undefined" || !window.location?.origin) { + return "http://localhost:5106"; + } + return window.location.origin; +} + +/** A single copyable command block with its own copy button (GitHub-style). */ +function CommandBlock({ + copyValue, + display, + toastLabel, +}: { + /** Exact string written to the clipboard (single line). */ + copyValue: string; + /** Optional multi-line rendition for display; defaults to copyValue. */ + display?: string; + toastLabel: string; +}) { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(copyValue); + setCopied(true); + toast.success(toastLabel); + setTimeout(() => setCopied(false), 1500); + } catch { + toast.error("Couldn't copy to clipboard"); + } + }; + return ( +
+
+        {display ?? copyValue}
+      
+ +
+ ); +} + +/** A numbered step: bold "Step N" lead-in + description, then its content. */ +function Step({ n, label, children }: { n: number; label: string; children: React.ReactNode }) { + return ( +
+

+ Step {n} {label} +

+ {children} +
+ ); +} + +/** + * "Copy as CLI" affordance: a terminal-glyph button that opens a GitHub-style + * modal with step-by-step instructions to reproduce the user's current Portal + * state from the `scope` CLI. See lib/cli/buildCommand.ts for the builders. + */ +export function CliCommand({ + command, + label, + title = "Run this from the CLI", + tooltip = "Show CLI equivalent", + className, + disabled = false, +}: CliCommandProps) { + const projectId = useOptionalSelectedProjectId(); + const trigger = label ? ( + + ) : ( + + ); + + return ( + + + + {trigger} + + {tooltip} + + + + {title} + + Reproduce your current selection from the terminal or wire it into CI. Everything below maps to the state + you have configured in the Portal. + + + +
+ + +

+ Requires Node.js 20+ and curl. +

+
+ + + +

+ If this deployment requires sign-in, also export SCOPE_TOKEN with an + access token for the API. Credentials are never included here. +

+
+ + + + {command.notes.length > 0 && ( +
    + {command.notes.map((note, i) => ( +
  • + + {note} +
  • + ))} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/apps/portal/src/contexts/ProjectContext.tsx b/apps/portal/src/contexts/ProjectContext.tsx index bf9877cd..2959fe37 100644 --- a/apps/portal/src/contexts/ProjectContext.tsx +++ b/apps/portal/src/contexts/ProjectContext.tsx @@ -91,3 +91,8 @@ export function useProjectContext(): ProjectContextValue { export function useSelectedProjectId(): string | undefined { return useProjectContext().selectedProjectId; } + +/** Like {@link useSelectedProjectId}, but returns undefined outside a ProjectProvider. */ +export function useOptionalSelectedProjectId(): string | undefined { + return useContext(ProjectContext)?.selectedProjectId; +} diff --git a/apps/portal/src/lib/cli/buildCommand.test.ts b/apps/portal/src/lib/cli/buildCommand.test.ts new file mode 100644 index 00000000..353ea531 --- /dev/null +++ b/apps/portal/src/lib/cli/buildCommand.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; +import { + shellQuote, + buildRunSubmit, + buildRunList, + buildRunGet, + buildRunAction, + buildRunBulk, + buildEnvCommand, + CLI_INSTALL_COMMAND, + PROFILE_VARIATIONS_FILE, +} from "./buildCommand"; + +describe("shellQuote", () => { + it("leaves safe bare words untouched", () => { + expect(shellQuote("coder-acp-copilot")).toBe("coder-acp-copilot"); + expect(shellQuote("req_123")).toBe("req_123"); + expect(shellQuote("vercel-labs/agent-skills/my-skill")).toBe("vercel-labs/agent-skills/my-skill"); + }); + + it("single-quotes values with spaces", () => { + expect(shellQuote("hello world")).toBe("'hello world'"); + }); + + it("escapes embedded single quotes", () => { + expect(shellQuote("it's")).toBe(`'it'\\''s'`); + }); + + it("quotes empty strings explicitly", () => { + expect(shellQuote("")).toBe("''"); + }); +}); + +describe("setup commands", () => { + it("installs from the public microsoft/scope installer", () => { + expect(CLI_INSTALL_COMMAND).toBe( + "curl --fail --location https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh | bash", + ); + }); + + it("exports the API URL and, when selected, the project", () => { + expect(buildEnvCommand("https://scope.example.com")).toBe("export SCOPE_API_URL=https://scope.example.com"); + expect(buildEnvCommand("http://localhost:5106", "proj_1")).toBe( + "export SCOPE_API_URL=http://localhost:5106\nexport SCOPE_PROJECT=proj_1", + ); + }); +}); + +describe("buildRunSubmit", () => { + it("always emits the worker and omits the API default max-iterations", () => { + const { command, notes } = buildRunSubmit({ + task: " build a snake game ", + worker: "coder-acp-copilot", + maxIterations: 10, + }); + expect(command).toBe("scope run submit -m 'build a snake game' -w coder-acp-copilot"); + expect(notes).toEqual([]); + }); + + it("emits model, criteria, tool selections and --count", () => { + const { command } = buildRunSubmit({ + task: "task", + worker: "coder-acp-claude-code", + model: "claude-sonnet-4.5", + reasoningEffort: "high", + maxIterations: 20, + occurrences: 5, + criteria: ["has-tests", "compiles"], + mcpServers: ["github"], + skills: ["a/b/c"], + extensions: ["ms-python.python"], + agentVersion: "copilot-0.0.415", + baseProfileId: "prof_1", + }); + expect(command).toBe( + "scope run submit -m task -c has-tests compiles -w coder-acp-claude-code " + + "--model claude-sonnet-4.5 --reasoning-effort high --max-iterations 20 --count 5 " + + "--mcp-servers github --skills a/b/c --extensions ms-python.python " + + "--agent-version copilot-0.0.415 --profile prof_1", + ); + }); + + it("omits --count for a single occurrence", () => { + expect(buildRunSubmit({ task: "t", worker: "w", occurrences: 1 }).command).toBe("scope run submit -m t -w w"); + }); + + it("emits codebase, resources with params, AGENTS.md and gates", () => { + const gates = [ + { gate: "select", promptId: "", criteria: ["c1"], maxIterations: 10 }, + { gate: "test", promptText: "run the tests", criteria: [], maxIterations: 3 }, + ]; + const { command, notes } = buildRunSubmit({ + task: "t", + worker: "w", + codebase: "my-repo@r2", + resources: [{ ref: "postgres@r1", params: { DB_NAME: "app db" } }, { ref: "redis" }], + agentsMd: "# Rules\nBe nice", + gates, + }); + expect(command).toBe( + "scope run submit -m t -w w --codebase my-repo@r2 --resources postgres@r1 redis " + + "--resource-param 'postgres@r1:DB_NAME=app db' --agents-md '# Rules\nBe nice' " + + `--gates '${JSON.stringify(gates)}'`, + ); + expect(notes).toEqual([]); + }); + + it("notes AGENTS.md content the CLI would read as a file path", () => { + const { command, notes } = buildRunSubmit({ task: "t", worker: "w", agentsMd: "@team rules" }); + expect(command).toBe("scope run submit -m t -w w"); + expect(notes[0]).toContain("--agents-md @"); + }); + + it("surfaces Portal-only knobs as notes without dropping the command", () => { + const { command, notes } = buildRunSubmit({ task: "t", worker: "w", occurrences: 12, priority: 3 }); + expect(command).toBe("scope run submit -m t -w w"); + expect(notes).toHaveLength(2); + expect(notes[0]).toContain("Occurrences (12)"); + expect(notes[1]).toContain("Priority (3)"); + }); + + it("includes the profile version in --profile when selected", () => { + const { command } = buildRunSubmit({ task: "t", baseProfileId: "prof_1@3" }); + expect(command).toBe("scope run submit -m t --profile prof_1@3"); + }); + + it("suppresses per-run worker/model/tool fields in variation mode", () => { + const { command, notes } = buildRunSubmit({ + task: "t", + worker: "coder-acp-claude-code", + model: "claude-sonnet-4.5", + reasoningEffort: "high", + mcpServers: ["github"], + skills: ["a/b/c"], + extensions: ["ms-python.python"], + agentVersion: "copilot-0.0.415", + codebase: "repo", + occurrences: 2, + baseProfileId: "prof_1@2", + profileVariations: ["prof_2@1", "prof_3"], + }); + // Task, run-level fields and profiles remain; the profiles supply the rest. + expect(command).toBe( + `scope run submit -m t --count 2 --codebase repo --profile prof_1@2 --profile-variations-file ${PROFILE_VARIATIONS_FILE}`, + ); + expect(notes).toEqual([ + `Save the profile variations to ${PROFILE_VARIATIONS_FILE} first: ` + + `echo '["prof_2@1","prof_3"]' > ${PROFILE_VARIATIONS_FILE}`, + ]); + }); + + it("ignores variations without a base profile", () => { + const { command } = buildRunSubmit({ task: "t", worker: "w", profileVariations: ["prof_2"] }); + expect(command).toBe("scope run submit -m t -w w"); + }); +}); + +describe("buildRunList", () => { + it("is a bare command with no filters", () => { + expect(buildRunList({}).command).toBe("scope run list"); + }); + + it("maps every Portal filter, sort and comparator to its CLI flag", () => { + const { command, notes } = buildRunList({ + workers: ["coder-vscode-web", "coder-acp-copilot"], + statuses: ["done"], + outcomes: ["failed", "__empty__"], + task: "tp_1", + profiles: ["prof_1"], + criteria: "crit_1", + models: ["gpt-5"], + os: ["linux"], + priorities: ["5"], + agentVersions: ["copilot-0.0.415"], + search: " snake game ", + createdAfter: "2026-09-01T00:00:00.000Z", + createdBefore: "2026-09-30T23:59:59.999Z", + submissionId: "sub_9", + turns: "5", + turnsOp: "gte", + maxIter: "10", + maxIterOp: "lte", + sortBy: "duration", + sortDir: "asc", + includeDeleted: true, + }); + expect(command).toBe( + "scope run list -w coder-vscode-web coder-acp-copilot --status done --outcome failed __empty__ " + + "--task tp_1 --profile prof_1 --criteria crit_1 --model gpt-5 --os linux --priority 5 " + + "--agent-version copilot-0.0.415 --search 'snake game' " + + "--created-after 2026-09-01T00:00:00.000Z --created-before 2026-09-30T23:59:59.999Z " + + "--submission-id sub_9 --turns '>=5' --max-iterations '<=10' --sort-by duration --sort-dir asc --include-deleted", + ); + expect(notes).toEqual([]); + }); + + it("drops --sort-dir without --sort-by", () => { + expect(buildRunList({ sortDir: "asc" }).command).toBe("scope run list"); + }); + + it("notes Portal-only filters that the CLI cannot express", () => { + const { command, notes } = buildRunList({ + workers: ["coder-acp-copilot"], + unsupportedFilters: ["foo", "bar"], + }); + expect(command).toBe("scope run list -w coder-acp-copilot"); + expect(notes[0]).toContain("foo, bar"); + }); +}); + +describe("buildRunGet / buildRunAction", () => { + it("builds get", () => { + expect(buildRunGet("req_1").command).toBe("scope run get -i req_1"); + }); + + it("builds retry with force", () => { + expect(buildRunAction("retry", "req_1", { force: true }).command).toBe("scope run retry -i req_1 -f"); + }); + + it("builds delete without force flag", () => { + expect(buildRunAction("delete", "req_1", { force: true }).command).toBe("scope run delete -i req_1"); + }); +}); + +describe("buildRunBulk", () => { + it("uses a single variadic command for cancel", () => { + expect(buildRunBulk("cancel", ["a", "b", "c"]).command).toBe("scope run cancel -i a b c"); + }); + + it("collapses a single id to the plain action command", () => { + expect(buildRunBulk("delete", ["a"]).command).toBe("scope run delete -i a"); + }); + + it("emits a shell loop for multi-id delete (single-id CLI verb)", () => { + expect(buildRunBulk("delete", ["a", "b"]).command).toBe( + 'for id in a b; do scope run delete -i "$id"; done', + ); + }); + + it("uses download-batch for a multi-id download", () => { + expect(buildRunBulk("download", ["a", "b"]).command).toBe("scope run download-batch -i a b"); + }); + + it("uses plain download for a single id", () => { + expect(buildRunBulk("download", ["a"]).command).toBe("scope run download -i a"); + }); + + it("carries force into the retry loop", () => { + expect(buildRunBulk("retry", ["a", "b"], { force: true }).command).toBe( + 'for id in a b; do scope run retry -i "$id" -f; done', + ); + }); +}); diff --git a/apps/portal/src/lib/cli/buildCommand.ts b/apps/portal/src/lib/cli/buildCommand.ts new file mode 100644 index 00000000..896cabdb --- /dev/null +++ b/apps/portal/src/lib/cli/buildCommand.ts @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Builders that translate the Portal's current state into the equivalent + * `scope` CLI command. These power the "Copy as CLI" affordance so a user who + * configures something visually can switch to the CLI (or wire it into CI) + * without hunting for the right flags. + * + * Design goals: + * - Accuracy over completeness: only emit flags the CLI actually supports + * (mirrors `apps/cli/src/commands/run.ts`). + * - Honesty: when the Portal can do something the CLI can't express, surface a + * `note` instead of silently dropping it (see issue #1004 parity tracking). + * - No secrets: token/account values are never embedded. Project and API URL + * are supplied via `SCOPE_PROJECT` / `SCOPE_API_URL` by the CliCommand modal. + */ + +export interface CliCommand { + /** Exact command written to the clipboard. */ + command: string; + /** Multi-line, backslash-continued rendition for readable display. */ + display: string; + /** Caveats: Portal-only state that the CLI cannot currently express. */ + notes: string[]; +} + +const BINARY = "scope"; + +/** + * Quote a token for POSIX shells. Bare words that only contain safe characters + * are left untouched; everything else is wrapped in single quotes with embedded + * single quotes escaped using the `'\''` idiom. + */ +export function shellQuote(value: string): string { + if (value === "") return "''"; + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value; + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** A single-valued flag. Returns [] when the value is empty/nullish. */ +function flag(name: string, value: string | number | null | undefined): string[] { + if (value === null || value === undefined) return []; + const str = String(value); + if (str === "") return []; + return [name, shellQuote(str)]; +} + +/** A variadic flag (`--criteria a b c`). Returns [] for empty lists. */ +function variadicFlag(name: string, values: readonly string[] | null | undefined): string[] { + if (!values || values.length === 0) return []; + return [name, ...values.map(shellQuote)]; +} + +/** A boolean flag, emitted only when `on` is true. */ +function boolFlag(name: string, on: boolean | undefined): string[] { + return on ? [name] : []; +} + +/** Assemble tokens into a {command, display, notes} triple. */ +function assemble(tokens: string[], notes: string[] = []): CliCommand { + const command = tokens.join(" "); + // For display, break before every top-level flag (tokens starting with `-`) + // so long commands wrap readably with trailing backslashes. + const lines: string[] = []; + for (const tok of tokens) { + if (tok.startsWith("-") && lines.length > 0) { + lines.push(tok); + } else if (lines.length === 0) { + lines.push(tok); + } else { + lines[lines.length - 1] += ` ${tok}`; + } + } + const display = lines.join(" \\\n "); + return { command, display, notes }; +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +/** The CLI installer one-liner (see docs/architecture/cli-distribution.md). */ +export const CLI_INSTALL_COMMAND = + "curl --fail --location https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh | bash"; + +/** + * Environment setup pointing the CLI at an API and (optionally) a project. + * `run submit` / `run list` require a project. Never includes credentials. + */ +export function buildEnvCommand(apiBaseUrl: string, projectId?: string): string { + const lines = [`export SCOPE_API_URL=${shellQuote(apiBaseUrl)}`]; + if (projectId) lines.push(`export SCOPE_PROJECT=${shellQuote(projectId)}`); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Runs +// --------------------------------------------------------------------------- + +/** Mirrors `ResourceBindingSpec` (`--resources` + `--resource-param`). */ +export interface CliResourceSpec { + ref: string; + params?: Record; +} + +export interface RunSubmitState { + task?: string; + criteria?: readonly string[]; + worker?: string; + model?: string; + reasoningEffort?: string; + maxIterations?: number; + mcpServers?: readonly string[]; + skills?: readonly string[]; + extensions?: readonly string[]; + agentVersion?: string; + codebase?: string | null; + resources?: readonly CliResourceSpec[]; + agentsMd?: string; + /** `GateConfig[]` as sent to the API; serialised inline for `--gates`. */ + gates?: readonly object[]; + /** Base profile spec (`id` or `id@version`). */ + baseProfileId?: string | null; + /** + * Profile variation specs (`id` or `id@version`). With a base profile these + * switch the submission to variation mode: the Portal (and API) ignore + * per-run worker/model/tool fields in favour of the profiles, so they are + * suppressed here too and the variations go through a JSON file. + */ + profileVariations?: readonly string[]; + /** Runs per profile (`--count`, 1-10). */ + occurrences?: number; + /** Portal-only: `run submit` has no --priority flag. */ + priority?: number; +} + +/** Matches the API's `MULTI_TURN_DEFAULTS.MAX_ITERATIONS`. */ +const DEFAULT_MAX_ITERATIONS = 10; +const MAX_COUNT = 10; +export const PROFILE_VARIATIONS_FILE = "profile-variations.json"; + +export function buildRunSubmit(state: RunSubmitState): CliCommand { + const variations = state.profileVariations ?? []; + const variationMode = !!state.baseProfileId && variations.length > 0; + const notes: string[] = []; + const tokens = [BINARY, "run", "submit"]; + + tokens.push(...flag("-m", state.task?.trim())); + tokens.push(...variadicFlag("-c", state.criteria)); + // In variation mode the Portal omits per-run worker/model/tool fields; the + // profiles supply them, so the CLI command must omit them as well. + if (!variationMode) { + // The CLI has no default worker: `--worker` is required unless a profile + // supplies it, so always emit it when the Portal has one selected. + tokens.push(...flag("-w", state.worker)); + tokens.push(...flag("--model", state.model)); + tokens.push(...flag("--reasoning-effort", state.reasoningEffort)); + } + if (state.maxIterations !== undefined && state.maxIterations !== DEFAULT_MAX_ITERATIONS) { + tokens.push(...flag("--max-iterations", state.maxIterations)); + } + if (state.occurrences !== undefined && state.occurrences > 1) { + if (state.occurrences <= MAX_COUNT) { + tokens.push(...flag("--count", state.occurrences)); + } else { + notes.push(`Occurrences (${state.occurrences}) exceed the CLI's --count limit of ${MAX_COUNT}.`); + } + } + if (!variationMode) { + tokens.push(...variadicFlag("--mcp-servers", state.mcpServers)); + tokens.push(...variadicFlag("--skills", state.skills)); + tokens.push(...variadicFlag("--extensions", state.extensions)); + tokens.push(...flag("--agent-version", state.agentVersion)); + } + tokens.push(...flag("--codebase", state.codebase ?? undefined)); + + const resources = (state.resources ?? []).filter((r) => r.ref.trim() !== ""); + tokens.push(...variadicFlag("--resources", resources.map((r) => r.ref))); + for (const resource of resources) { + for (const [key, value] of Object.entries(resource.params ?? {})) { + tokens.push(...flag("--resource-param", `${resource.ref}:${key}=${value}`)); + } + } + + if (state.agentsMd && state.agentsMd.trim()) { + // The CLI reads `--agents-md @path` from a file, so literal content that + // starts with `@` cannot be passed inline. + if (state.agentsMd.startsWith("@")) { + notes.push("AGENTS.md content starts with `@`, which the CLI reads as a file path — save it to a file and pass `--agents-md @`."); + } else { + tokens.push(...flag("--agents-md", state.agentsMd)); + } + } + if (state.gates && state.gates.length > 0) { + tokens.push(...flag("--gates", JSON.stringify(state.gates))); + } + tokens.push(...flag("--profile", state.baseProfileId ?? undefined)); + if (variationMode) { + tokens.push(...flag("--profile-variations-file", PROFILE_VARIATIONS_FILE)); + notes.push( + `Save the profile variations to ${PROFILE_VARIATIONS_FILE} first: ` + + `echo ${shellQuote(JSON.stringify(variations))} > ${PROFILE_VARIATIONS_FILE}`, + ); + } + + if (state.priority !== undefined && state.priority !== 0) { + notes.push(`Priority (${state.priority}) is set in the Portal only; \`run submit\` has no --priority flag.`); + } + return assemble(tokens, notes); +} + +export type IterationOp = "eq" | "gte" | "lte"; + +const OP_SYMBOL: Record = { eq: "=", gte: ">=", lte: "<=" }; + +export interface RunListState { + workers?: readonly string[]; + statuses?: readonly string[]; + outcomes?: readonly string[]; + models?: readonly string[]; + os?: readonly string[]; + priorities?: readonly string[]; + agentVersions?: readonly string[]; + profiles?: readonly string[]; + task?: string | null; + criteria?: string | null; + search?: string | null; + /** ISO-8601 datetime (inclusive lower bound). */ + createdAfter?: string | null; + /** ISO-8601 datetime (inclusive upper bound). */ + createdBefore?: string | null; + submissionId?: string | null; + turns?: string | null; + turnsOp?: IterationOp | null; + maxIter?: string | null; + maxIterOp?: IterationOp | null; + sortBy?: string | null; + sortDir?: "asc" | "desc" | null; + includeDeleted?: boolean; + /** Active UI filters the CLI `run list` cannot express, for honest notes. */ + unsupportedFilters?: readonly string[]; +} + +export function buildRunList(state: RunListState): CliCommand { + const tokens = [BINARY, "run", "list"]; + tokens.push(...variadicFlag("-w", state.workers)); + tokens.push(...variadicFlag("--status", state.statuses)); + tokens.push(...variadicFlag("--outcome", state.outcomes)); + tokens.push(...flag("--task", state.task)); + tokens.push(...variadicFlag("--profile", state.profiles)); + tokens.push(...flag("--criteria", state.criteria)); + tokens.push(...variadicFlag("--model", state.models)); + tokens.push(...variadicFlag("--os", state.os)); + tokens.push(...variadicFlag("--priority", state.priorities)); + tokens.push(...variadicFlag("--agent-version", state.agentVersions)); + tokens.push(...flag("--search", state.search?.trim())); + tokens.push(...flag("--created-after", state.createdAfter)); + tokens.push(...flag("--created-before", state.createdBefore)); + tokens.push(...flag("--submission-id", state.submissionId)); + if (state.turns) { + const op = OP_SYMBOL[state.turnsOp ?? "gte"]; + tokens.push(...flag("--turns", `${op}${state.turns}`)); + } + if (state.maxIter) { + const op = OP_SYMBOL[state.maxIterOp ?? "gte"]; + tokens.push(...flag("--max-iterations", `${op}${state.maxIter}`)); + } + tokens.push(...flag("--sort-by", state.sortBy)); + if (state.sortBy) tokens.push(...flag("--sort-dir", state.sortDir)); + tokens.push(...boolFlag("--include-deleted", state.includeDeleted)); + + const notes: string[] = []; + if (state.unsupportedFilters && state.unsupportedFilters.length > 0) { + notes.push( + `Filtered in the Portal only (no \`run list\` flag): ${state.unsupportedFilters.join(", ")}.`, + ); + } + return assemble(tokens, notes); +} + +export function buildRunGet(id: string): CliCommand { + return assemble([BINARY, "run", "get", "-i", shellQuote(id)]); +} + +export type RunAction = "retry" | "cancel" | "delete" | "download"; + +export function buildRunAction(action: RunAction, id: string, opts?: { force?: boolean }): CliCommand { + const tokens = [BINARY, "run", action, "-i", shellQuote(id)]; + if (action === "retry" && opts?.force) tokens.push("-f"); + return assemble(tokens); +} + +/** + * Build a command for a bulk action over a selection of run IDs. + * `cancel` is variadic and `download` has a `download-batch` sibling, so both + * become one command; `delete` and `retry` take a single id, so >1 selection + * becomes a loop. + */ +export function buildRunBulk(action: RunAction, ids: readonly string[], opts?: { force?: boolean }): CliCommand { + const quoted = ids.map(shellQuote); + if (ids.length === 0) { + return assemble([BINARY, "run", action, "-i", "RUN_ID"]); + } + if (action === "cancel") { + return assemble([BINARY, "run", "cancel", "-i", ...quoted]); + } + if (ids.length === 1) { + return buildRunAction(action, ids[0], opts); + } + if (action === "download") { + return assemble([BINARY, "run", "download-batch", "-i", ...quoted]); + } + const force = action === "retry" && opts?.force ? " -f" : ""; + const list = quoted.join(" "); + const command = `for id in ${list}; do ${BINARY} run ${action} -i "$id"${force}; done`; + return { command, display: command, notes: [] }; +} diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index 77ac6b0f..c6811284 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -48,6 +48,8 @@ import type { RunState, LogEvent, ResourceBinding, ResourceRunOutcome } from "@/ import { useShiftModifier } from "@/hooks/useShiftModifier"; import { getRetryButtonState } from "@/components/RetryButton"; import { getRunSkillReferences } from "@/lib/skill-spec"; +import { CliCommand } from "@/components/CliCommand"; +import { buildRunGet } from "@/lib/cli/buildCommand"; /** A compact labeled stat: a micro uppercase label above its value. */ function MetaItem({ label, value, title }: { label: string; value: ReactNode; title?: string }) { @@ -628,6 +630,7 @@ export function RunDetail() { {copied ? : } Copy run ID + {/* Tier 1 — semantic status pills */} diff --git a/apps/portal/src/pages/RunPreviewPanel.tsx b/apps/portal/src/pages/RunPreviewPanel.tsx index 1a6bacee..e2f66dac 100644 --- a/apps/portal/src/pages/RunPreviewPanel.tsx +++ b/apps/portal/src/pages/RunPreviewPanel.tsx @@ -13,6 +13,8 @@ import { StatusBadge, OutcomeBadge } from "@/components/StatusBadge"; import { TaskPromptBadge } from "@/components/TaskPromptBadge"; import { AgentBadge, agentDisplayName, useAgentCatalog } from "@/components/AgentBadge"; import { GATE_METADATA } from "@/lib/gates"; +import { CliCommand } from "@/components/CliCommand"; +import { buildRunGet } from "@/lib/cli/buildCommand"; import { formatDate, formatId, formatDuration, truncate } from "@/lib/utils"; export function RunPreviewPanel() { @@ -79,6 +81,7 @@ export function RunPreviewPanel() { onClose={closePanel} headerActions={
+
} filterRail={ @@ -2192,6 +2261,7 @@ export function RunsList() { > + {groupBy === "none" ? ( diff --git a/apps/portal/src/pages/SubmitRun.tsx b/apps/portal/src/pages/SubmitRun.tsx index 54beb26e..714c8d42 100644 --- a/apps/portal/src/pages/SubmitRun.tsx +++ b/apps/portal/src/pages/SubmitRun.tsx @@ -52,6 +52,8 @@ import { type GateConfig, type GateId, } from "@/lib/gates"; +import { CliCommand } from "@/components/CliCommand"; +import { buildRunSubmit } from "@/lib/cli/buildCommand"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogFooter, @@ -746,6 +748,44 @@ export function SubmitRun() { const submitRunCount = expandedRunCount; const lastVariationDraft = variationDrafts.length > 0 ? variationDrafts[variationDrafts.length - 1] : null; + // Equivalent `scope run submit` command for the "Copy as CLI" affordance. + // Mirrors the payload built in doSubmit() so the CLI submits the same run(s). + const submitCli = useMemo(() => { + const baseProfileSpec = selectedProfileId + ? selectedProfileVersion + ? `${selectedProfileId}@${selectedProfileVersion}` + : selectedProfileId + : null; + return buildRunSubmit({ + task, + criteria: pickedCriteria, + worker, + model, + reasoningEffort, + maxIterations, + mcpServers: selectedMcpServers, + skills: selectedSkills, + extensions: selectedExtensions, + agentVersion: selectedAgentVersion || undefined, + codebase: selectedCodebaseSpec, + resources: selectedResourceSpecs, + agentsMd, + gates: gateConfigs, + baseProfileId: baseProfileSpec, + profileVariations: variationDrafts + .filter((v) => v.profileId.trim().length > 0) + .map((v) => (v.profileVersion ? `${v.profileId}@${v.profileVersion}` : v.profileId)), + occurrences, + priority, + }); + // gateConfigs is rebuilt every render; key it by content instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + task, pickedCriteria, worker, model, reasoningEffort, maxIterations, + selectedMcpServers, selectedSkills, selectedExtensions, selectedAgentVersion, + selectedCodebaseSpec, selectedResourceSpecs, agentsMd, JSON.stringify(gateConfigs), + selectedProfileId, selectedProfileVersion, variationDrafts, occurrences, priority, + ]); const lastVariationName = lastVariationDraft ? profileList.find((p) => p._id === lastVariationDraft.profileId)?.name : null; @@ -1990,6 +2030,7 @@ export function SubmitRun() { )} +