Skip to content
Closed
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
83 changes: 51 additions & 32 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { createRequire } from "node:module";
import { stdin as input, stdout as output } from "node:process";
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { readFile, unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { basename, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as prompts from "@clack/prompts";
import { getShellConfig } from "@earendil-works/pi-coding-agent";
Expand Down Expand Up @@ -75,9 +75,10 @@ async function main(argv: string[]): Promise<void> {
runConfigCommand(args);
return;
case "agents":
if (!loadConfig().subagents) {
const config = loadConfig();
if (!config.subagents && !config.workflows) {
throw new Error(
"Subagents are disabled. Set DEVSPACE_SUBAGENTS=1 to enable the experimental feature.",
"Subagents and Dynamic Workflows are disabled. Set DEVSPACE_SUBAGENTS=1 or DEVSPACE_WORKFLOWS=1 to enable agent tooling.",
);
}
await runAgentsCommand(args);
Comment on lines +78 to 84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Scope the agents case declaration with braces.

Biome reports noSwitchDeclarations for config. Wrap this case body in braces so its declaration cannot be visible to other switch clauses.

Proposed fix
-    case "agents":
+    case "agents": {
       const config = loadConfig();
       if (!config.subagents && !config.workflows) {
         throw new Error(
           "Subagents and Dynamic Workflows are disabled. Set DEVSPACE_SUBAGENTS=1 or DEVSPACE_WORKFLOWS=1 to enable agent tooling.",
         );
       }
       await runAgentsCommand(args);
       return;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const config = loadConfig();
if (!config.subagents && !config.workflows) {
throw new Error(
"Subagents are disabled. Set DEVSPACE_SUBAGENTS=1 to enable the experimental feature.",
"Subagents and Dynamic Workflows are disabled. Set DEVSPACE_SUBAGENTS=1 or DEVSPACE_WORKFLOWS=1 to enable agent tooling.",
);
}
await runAgentsCommand(args);
case "agents": {
const config = loadConfig();
if (!config.subagents && !config.workflows) {
throw new Error(
"Subagents and Dynamic Workflows are disabled. Set DEVSPACE_SUBAGENTS=1 or DEVSPACE_WORKFLOWS=1 to enable agent tooling.",
);
}
await runAgentsCommand(args);
return;
}
🧰 Tools
🪛 Biome (2.5.6)

[error] 78-78: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

(lint/correctness/noSwitchDeclarations)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli.ts` around lines 78 - 84, Wrap the `agents` switch case body
containing the `config` declaration and `runAgentsCommand(args)` in braces,
ensuring `config` is scoped only to that case while preserving the existing
validation and command execution.

Source: Linters/SAST tools

Expand Down Expand Up @@ -495,27 +496,31 @@ async function runAgentsShow(args: string[]): Promise<void> {

const config = loadConfig();
const store = createLocalAgentStore(config);
let record = store.get(id);
if (!record) throw new Error(`Unknown subagent id: ${id}`);
assertAgentInScope(record, resolveCurrentWorkspaceScope(config));

const deadline = Date.now() + 15_000;
while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) {
await sleep(500);
record = store.get(id) ?? record;
}
try {
let record = store.get(id);
if (!record) throw new Error(`Unknown subagent id: ${id}`);
assertAgentInScope(record, resolveCurrentWorkspaceScope(config));

const deadline = Date.now() + 15_000;
while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) {
await sleep(500);
record = store.get(id) ?? record;
}

console.log(formatAgentLine(record));
if (record.latestResponse) {
console.log(record.latestResponse);
return;
}
if (record.error) {
console.log(record.error);
return;
}
if (record.status === "starting" || record.status === "running") {
console.log(`No final response yet. Call \`devspace agents show ${record.id}\` again later.`);
console.log(formatAgentLine(record));
if (record.latestResponse) {
console.log(record.latestResponse);
return;
}
if (record.error) {
console.log(record.error);
return;
}
if (record.status === "starting" || record.status === "running") {
console.log(`No final response yet. Call \`devspace agents show ${record.id}\` again later.`);
}
} finally {
store.close();
}
}

Expand All @@ -527,11 +532,11 @@ async function runAgentsWorker(args: string[]): Promise<void> {

const config = loadConfig();
const store = createLocalAgentStore(config);
const record = store.get(id);
if (!record) throw new Error(`Unknown subagent id: ${id}`);

store.update(record.id, { status: "running", error: undefined });
try {
const record = store.get(id);
if (!record) throw new Error(`Unknown subagent id: ${id}`);

store.update(record.id, { status: "running", error: undefined });
const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot);
Comment on lines +536 to 540

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Enforce workspace scope before starting the worker.

runAgentsWorker can load and execute any stored agent ID. A direct agents __worker invocation can therefore mutate and run an agent record from another workspace. Call assertAgentInScope(record, resolveCurrentWorkspaceScope(config)) before changing its status.

As per coding guidelines: treat every operation as workspace-scoped and use workspaceId as the opaque handle returned by open_workspace.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli.ts` around lines 536 - 540, Update runAgentsWorker after retrieving
the record and before store.update to call assertAgentInScope with the record
and resolveCurrentWorkspaceScope(config). Preserve workspace scoping for all
operations, using the workspaceId returned by open_workspace as the opaque scope
handle.

Source: Coding guidelines

const prompt = await readFile(promptFile, "utf8");
const target = resolveLocalAgentExecution({
Expand All @@ -557,10 +562,18 @@ async function runAgentsWorker(args: string[]): Promise<void> {
error: undefined,
});
} catch (error) {
store.update(record.id, {
status: "error",
error: error instanceof Error ? error.message : String(error),
});
const record = store.get(id);
if (record) {
store.update(record.id, {
status: "error",
error: error instanceof Error ? error.message : String(error),
});
}
} finally {
Comment on lines +570 to +572

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Prompt directories remain after cleanup

The worker unlinks prompt.txt but leaves the unique directory created by mkdtempSync, so repeated agent runs permanently accumulate empty devspace-agent-prompt-* directories in the system temporary directory.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

if (isGeneratedPromptFile(promptFile)) {
await unlink(promptFile).catch(() => undefined);
}
store.close();
Comment on lines +573 to +576

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Remove the generated prompt directory after cleanup.

writeAgentPromptFile creates a unique temporary directory. This block removes only prompt.txt, so every worker leaves an empty devspace-agent-prompt-* directory behind. Remove the empty generated directory after unlinking the prompt file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli.ts` around lines 573 - 576, Update the cleanup block after
isGeneratedPromptFile to remove the temporary directory created by
writeAgentPromptFile after unlinking promptFile. Preserve the existing
best-effort cleanup behavior and ensure the generated directory is removed only
after the prompt file cleanup completes.

}
}

Expand Down Expand Up @@ -588,6 +601,12 @@ function writeAgentPromptFile(prompt: string): string {
return filePath;
}

function isGeneratedPromptFile(filePath: string): boolean {
const resolvedPath = resolve(filePath);
const prefix = `${resolve(tmpdir())}${process.platform === "win32" ? "\\" : "/"}devspace-agent-prompt-`;
return resolvedPath.startsWith(prefix) && basename(resolvedPath) === "prompt.txt";
}

function resolveCurrentWorkspaceRoot(config: ReturnType<typeof loadConfig>): string {
return resolveCliWorkspaceScope(config.allowedRoots).workspaceRoot;
}
Expand Down
5 changes: 5 additions & 0 deletions src/local-agent-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ assert.throws(
/Missing value for --effort/,
);

assert.throws(
() => parseLocalAgentRunArgs(["codex", "--unknown", "hello"]),
/Unknown option: --unknown/,
);

{
const target = resolveLocalAgentTarget("reviewer", profiles);
assert.equal(target?.kind, "profile");
Expand Down
3 changes: 3 additions & 0 deletions src/local-agent-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs
effort = value;
continue;
}
if (part?.startsWith("--")) {
throw new Error(`Unknown option: ${part}\n${USAGE}`);
}
Comment on lines +67 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject option-like tokens before consuming option values.

The new check runs after known options parse their values. Therefore, parseLocalAgentRunArgs(["codex", "--model", "--unknown", "hello"]) stores "--unknown" as model, and --effort=--unknown stores it as effort.

If model and effort values cannot start with --, reject such values in every model, effort, and thinking value branch. Add regression tests for separated and = forms.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/local-agent-targets.ts` around lines 67 - 69, Update the option-value
parsing branches in the local-agent argument parser so model, effort, and
thinking values beginning with “--” are rejected before assignment, for both
separated and equals forms. Preserve valid value handling, and add regression
tests covering each affected form, including parseLocalAgentRunArgs with
separated model and effort arguments.

promptParts.push(part ?? "");
}

Expand Down
5 changes: 0 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import { formatPathForPrompt } from "./skills.js";
import { createWorkspaceStore } from "./workspace-store.js";
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
import { buildLocalAgentCatalog } from "./local-agent-catalog.js";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Workflow MCP tools are unregistered

When Dynamic Workflows are enabled, createMcpServer no longer calls the sole registerWorkflowTools registration function, so MCP calls to workflow execution, status, cancellation, and UI tools fail as unknown tools.

import { registerWorkflowTools } from "./workflow-tools.js";
import { startWorkflowReaper } from "./workflow-lifecycle.js";
import { createWorkflowStore } from "./workflow-store.js";
import { loadActiveWorkflowSummaries } from "./workflow-ui.js";
Expand Down Expand Up @@ -1628,10 +1627,6 @@ function createMcpServer(
registerCodexProcessTools(server, config, workspaces, processSessions);
}

if (config.workflows) {
registerWorkflowTools(server, config, workspaces);
}

return server;
}

Expand Down
95 changes: 83 additions & 12 deletions src/workflow-cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveCliWorkspaceScope } from "./cli-workspace.js";
import type { ServerConfig } from "./config.js";
import { parseWorkflowArgFlagsResult } from "./workflow-files.js";
import {
Expand Down Expand Up @@ -27,6 +28,7 @@ import {
spawnWorkflowWorker,
spawnWorkflowWorkerFromCli,
} from "./workflow-worker.js";
import { isPathInsideRoot } from "./roots.js";

export { runWorkflowWorker, spawnWorkflowWorker, spawnWorkflowWorkerFromCli };

Expand Down Expand Up @@ -104,6 +106,8 @@ export function printWorkflowHelp(): void {

async function runWorkflowRun(args: string[], config: ServerConfig): Promise<void> {
const { flags } = splitFlags(args);
assertKnownFlags(flags, ["follow", "script-path", "file", "name", "resume", "arg"],
"Usage: devspace workflow run [--file|--script-path <path> | --name <name>] [--resume <runId>] [--arg key=value]... [--follow]");
const follow = flags.has("follow");
const file = flagValue(flags, "script-path") ?? flagValue(flags, "file");
const name = flagValue(flags, "name");
Expand All @@ -126,10 +130,15 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise<voi
});
}

const source = buildCliLaunchSource({ file, name, resumeFrom });
const scope = resolveCliWorkspaceScope(config.allowedRoots);
const source = buildCliLaunchSource({
file: file ? resolveWorkflowFilePath(file, scope.workspaceRoot) : undefined,
name,
resumeFrom,
});
const store = createWorkflowStore(config);
try {
const workspaceRoot = resolve(process.env.DEVSPACE_WORKSPACE_ROOT || process.cwd());
const workspaceRoot = scope.workspaceRoot;
let argsValue = Object.keys(workflowArgs).length ? workflowArgs : undefined;

// Resume without explicit --arg reuses prior args inside launch; if CLI
Expand All @@ -144,7 +153,7 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise<voi
store,
config,
workspaceRoot,
workspaceId: process.env.DEVSPACE_WORKSPACE_ID,
workspaceId: scope.workspaceId,
source,
args: argsValue,
cliEntry: fileURLToPath(import.meta.url.replace(/workflow-cli\.(ts|js)$/, "cli.$1")),
Expand Down Expand Up @@ -183,8 +192,10 @@ function buildCliLaunchSource(input: {
}

async function runWorkflowStatus(args: string[], config: ServerConfig): Promise<void> {
const follow = args.includes("--follow");
const runId = args.find((a) => !a.startsWith("-"));
const { flags, positionals } = splitFlags(args);
assertKnownFlags(flags, ["follow"], "Usage: devspace workflow status <runId> [--follow]");
const follow = flags.has("follow");
const runId = positionals[0];
if (!runId) {
throw new InvalidWorkflowInputError({
code: "invalid_argument",
Expand All @@ -199,6 +210,7 @@ async function runWorkflowStatus(args: string[], config: ServerConfig): Promise<
if (runResult.isErr()) throw runResult.error;
const run = runResult.value;
if (!run) throw new WorkflowNotFoundError(runId);
assertWorkflowInScope(run, resolveCliWorkspaceScope(config.allowedRoots));
console.log(formatRunLine(run));
console.log(formatCallSummary(store.listAgentCalls(runId)));
if (follow) {
Expand All @@ -213,7 +225,9 @@ async function runWorkflowStatus(args: string[], config: ServerConfig): Promise<
}

async function runWorkflowCancel(args: string[], config: ServerConfig): Promise<void> {
const runId = args[0];
const { flags, positionals } = splitFlags(args);
assertKnownFlags(flags, [], "Usage: devspace workflow cancel <runId>");
const runId = positionals[0];
if (!runId) {
throw new InvalidWorkflowInputError({
code: "invalid_argument",
Expand All @@ -223,6 +237,9 @@ async function runWorkflowCancel(args: string[], config: ServerConfig): Promise<
const store = createWorkflowStore(config);
try {
reapStaleWorkflows(store);
const run = store.getRun(runId);
if (!run) throw new WorkflowNotFoundError(runId);
assertWorkflowInScope(run, resolveCliWorkspaceScope(config.allowedRoots));
console.log(formatRunLine(await cancelWorkflowRun(store, runId)));
} finally {
store.close();
Expand All @@ -233,7 +250,8 @@ async function runWorkflowList(config: ServerConfig): Promise<void> {
const store = createWorkflowStore(config);
try {
reapStaleWorkflows(store);
const runs = store.listRuns(50);
const scope = resolveCliWorkspaceScope(config.allowedRoots);
const runs = store.listRunsForWorkspace(scope.workspaceRoot, { limit: 50 });
if (runs.length === 0) {
console.log("No workflow runs.");
return;
Expand All @@ -245,7 +263,9 @@ async function runWorkflowList(config: ServerConfig): Promise<void> {
}

async function runWorkflowCalls(args: string[], config: ServerConfig): Promise<void> {
const runId = args[0];
const { flags, positionals } = splitFlags(args);
assertKnownFlags(flags, [], "Usage: devspace workflow calls <runId>");
const runId = positionals[0];
if (!runId) {
throw new InvalidWorkflowInputError({
code: "invalid_argument",
Expand All @@ -254,7 +274,9 @@ async function runWorkflowCalls(args: string[], config: ServerConfig): Promise<v
}
const store = createWorkflowStore(config);
try {
if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId);
const run = store.getRun(runId);
if (!run) throw new WorkflowNotFoundError(runId);
assertWorkflowInScope(run, resolveCliWorkspaceScope(config.allowedRoots));
const calls = store.listAgentCalls(runId);
if (calls.length === 0) {
console.log("No workflow agent calls.");
Expand All @@ -267,8 +289,10 @@ async function runWorkflowCalls(args: string[], config: ServerConfig): Promise<v
}

async function runWorkflowCall(args: string[], config: ServerConfig): Promise<void> {
const runId = args[0];
const callIndex = Number(args[1]);
const { flags, positionals } = splitFlags(args);
assertKnownFlags(flags, [], "Usage: devspace workflow call <runId> <callIndex>");
const runId = positionals[0];
const callIndex = Number(positionals[1]);
if (!runId || !Number.isInteger(callIndex) || callIndex < 0) {
throw new InvalidWorkflowInputError({
code: "invalid_argument",
Expand All @@ -277,7 +301,9 @@ async function runWorkflowCall(args: string[], config: ServerConfig): Promise<vo
}
const store = createWorkflowStore(config);
try {
if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId);
const run = store.getRun(runId);
if (!run) throw new WorkflowNotFoundError(runId);
assertWorkflowInScope(run, resolveCliWorkspaceScope(config.allowedRoots));
const call = store.getAgentCall(runId, callIndex);
if (!call) {
throw new InvalidWorkflowInputError({
Expand All @@ -294,6 +320,7 @@ async function runWorkflowCall(args: string[], config: ServerConfig): Promise<vo
async function followRun(store: WorkflowStore, runId: string): Promise<void> {
let sinceSeq = 0;
for (;;) {
reapStaleWorkflows(store);
const page = store.drainEvents(runId, sinceSeq, WORKFLOW_LIMITS.eventDrainDefault);
for (const event of page.events) printEvent(event);
sinceSeq = page.nextSeq;
Expand Down Expand Up @@ -440,6 +467,50 @@ function splitFlags(args: string[]): {
return { flags, positionals };
}

function assertKnownFlags(
flags: Map<string, string | true>,
allowed: string[],
usage: string,
): void {
const allowedSet = new Set(allowed);
const unknown = [...flags.keys()].filter((flag) => !allowedSet.has(flag));
if (unknown.length > 0) {
throw new InvalidWorkflowInputError({
code: "invalid_argument",
message: `${usage}\nUnknown option: --${unknown[0]}`,
});
}
}

function resolveWorkflowFilePath(path: string, workspaceRoot: string): string {
const resolvedPath = resolve(workspaceRoot, path);
if (!isPathInsideRoot(resolvedPath, workspaceRoot)) {
throw new InvalidWorkflowInputError({
code: "invalid_path",
message: `Workflow file must be inside the workspace: ${workspaceRoot}`,
});
}
return resolvedPath;
Comment on lines +485 to +493

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Resolve symlinks before accepting a workflow file.

isPathInsideRoot only checks lexical paths. A symlink inside the workspace can point outside the workspace and still pass this check. Canonicalize both paths and enforce containment after canonicalization before launch.

Based on learnings: enforce lexical and canonical containment for workflow script paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/workflow-cli.ts` around lines 485 - 493, Update resolveWorkflowFilePath
to canonicalize both the resolved workflow path and workspaceRoot before the
containment check, while retaining the existing lexical isPathInsideRoot
validation. Enforce canonical containment before returning the path or launching
the workflow, and throw InvalidWorkflowInputError with code "invalid_path" when
either check fails.

Source: Learnings

}

function assertWorkflowInScope(
run: Pick<WorkflowRunRecord, "workspaceRoot" | "workspaceId">,
scope: { workspaceRoot: string; workspaceId?: string },
): void {
if (resolve(run.workspaceRoot) !== resolve(scope.workspaceRoot)) {
throw new InvalidWorkflowInputError({
code: "invalid_argument",
message: `Workflow run belongs to a different workspace: ${scope.workspaceRoot}`,
});
}
if (scope.workspaceId && run.workspaceId && run.workspaceId !== scope.workspaceId) {
throw new InvalidWorkflowInputError({
code: "invalid_argument",
message: `Workflow run belongs to a different workspaceId: ${scope.workspaceId}`,
});
}
}
Comment on lines +496 to +512

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Apply workspaceId consistently to workflow ownership.

When the active scope has a workspaceId, Line 506 permits a run with no workspaceId. Line 254 also lists every run for the root without an ID filter. This exposes legacy or other-scope records within the same root.

  • src/workflow-cli.ts#L496-L512: reject a run unless its workspaceId exactly matches the active workspaceId when one is present.
  • src/workflow-cli.ts#L253-L254: extend the store query to filter by workspaceId before applying the result limit. Define an explicit migration or compatibility path for ID-less records.

As per coding guidelines: treat every operation as workspace-scoped and use workspaceId as the opaque handle returned by open_workspace.

📍 Affects 1 file
  • src/workflow-cli.ts#L496-L512 (this comment)
  • src/workflow-cli.ts#L253-L254
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/workflow-cli.ts` around lines 496 - 512, Update src/workflow-cli.ts lines
496-512 in assertWorkflowInScope to require an exact workspaceId match whenever
the active scope provides one, rejecting runs with missing or different IDs.
Update src/workflow-cli.ts lines 253-254 to filter the store query by
workspaceId before applying the result limit, and define an explicit migration
or compatibility path for ID-less records; all workflow operations must remain
scoped by the opaque ID returned from open_workspace.

Source: Coding guidelines


function flagValue(flags: Map<string, string | true>, key: string): string | undefined {
const value = flags.get(key);
return typeof value === "string" ? value : undefined;
Expand Down
Loading