@vellumai/cli 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,3 @@
1
- import { spawn } from "child_process";
2
1
  import { basename } from "path";
3
2
  import {
4
3
  useCallback,
@@ -10,8 +9,6 @@ import {
10
9
  } from "react";
11
10
  import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
12
11
 
13
- import { removeAssistantEntry } from "../lib/assistant-config";
14
-
15
12
  import { SPECIES_CONFIG, type Species } from "../lib/constants";
16
13
  import { checkHealth } from "../lib/health-check";
17
14
  import { appendHistory, loadHistory } from "../lib/input-history";
@@ -43,9 +40,27 @@ export const SLASH_COMMANDS = [
43
40
  "/help",
44
41
  "/q",
45
42
  "/quit",
46
- "/retire",
47
43
  ];
48
44
 
45
+ const HELP_COMMANDS = [
46
+ {
47
+ command: "/btw <question>",
48
+ description: "Ask a side question while the assistant is working",
49
+ },
50
+ {
51
+ command: "/quit, /exit, /q",
52
+ description: "Disconnect and exit",
53
+ },
54
+ {
55
+ command: "/clear",
56
+ description: "Clear the screen",
57
+ },
58
+ {
59
+ command: "/help, ?",
60
+ description: "Show this help",
61
+ },
62
+ ] as const;
63
+
49
64
  const SEND_TIMEOUT_MS = 5000;
50
65
 
51
66
  // ── Layout constants ──────────────────────────────────────
@@ -100,12 +115,11 @@ const MIN_FEED_ROWS = 3;
100
115
  // Feed item height estimation
101
116
  const TOOL_CALL_CHROME_LINES = 2; // header (┌) + footer (└)
102
117
  const MESSAGE_SPACING = 1;
103
- const HELP_DISPLAY_HEIGHT = 7;
118
+ const HELP_DISPLAY_HEIGHT = HELP_COMMANDS.length + 1;
104
119
 
105
120
  interface ListMessagesResponse {
106
121
  messages: RuntimeMessage[];
107
122
  nextCursor?: string;
108
- interfaces?: string[];
109
123
  }
110
124
 
111
125
  interface SendMessageResponse {
@@ -742,26 +756,12 @@ function HelpDisplay(): ReactElement {
742
756
  return (
743
757
  <Box flexDirection="column">
744
758
  <Text bold>Commands:</Text>
745
- <Text>
746
- {" /btw <question> "}
747
- <Text dimColor>Ask a side question while the assistant is working</Text>
748
- </Text>
749
- <Text>
750
- {" /retire "}
751
- <Text dimColor>Retire the remote instance and exit</Text>
752
- </Text>
753
- <Text>
754
- {" /quit, /exit, /q "}
755
- <Text dimColor>Disconnect and exit</Text>
756
- </Text>
757
- <Text>
758
- {" /clear "}
759
- <Text dimColor>Clear the screen</Text>
760
- </Text>
761
- <Text>
762
- {" /help, ? "}
763
- <Text dimColor>Show this help</Text>
764
- </Text>
759
+ {HELP_COMMANDS.map((entry) => (
760
+ <Text key={entry.command}>
761
+ {` ${entry.command.padEnd(17)} `}
762
+ <Text dimColor>{entry.description}</Text>
763
+ </Text>
764
+ ))}
765
765
  </Box>
766
766
  );
767
767
  }
@@ -1391,8 +1391,6 @@ interface ChatAppProps {
1391
1391
  /** Pre-built auth headers (e.g. { Authorization: "Bearer ..." } for local,
1392
1392
  * { "X-Session-Token": "...", "Vellum-Organization-Id": "..." } for platform). */
1393
1393
  auth?: Record<string, string>;
1394
- project?: string;
1395
- zone?: string;
1396
1394
  onExit: () => void;
1397
1395
  handleRef: (handle: ChatAppHandle) => void;
1398
1396
  }
@@ -1403,8 +1401,6 @@ function ChatApp({
1403
1401
  assistantName,
1404
1402
  species,
1405
1403
  auth,
1406
- project,
1407
- zone,
1408
1404
  onExit,
1409
1405
  handleRef,
1410
1406
  }: ChatAppProps): ReactElement {
@@ -1954,86 +1950,6 @@ function ChatApp({
1954
1950
  return;
1955
1951
  }
1956
1952
 
1957
- if (trimmed === "/retire") {
1958
- if (!project || !zone) {
1959
- h.showError(
1960
- "No instance info available. Connect to a hatched instance first.",
1961
- );
1962
- return;
1963
- }
1964
-
1965
- const confirmIndex = await h.showSelection(`Retire ${assistantId}?`, [
1966
- "Yes, retire",
1967
- "Cancel",
1968
- ]);
1969
- if (confirmIndex !== 0) {
1970
- h.addStatus("Cancelled.");
1971
- return;
1972
- }
1973
-
1974
- h.showSpinner(`Retiring instance ${assistantId}...`);
1975
-
1976
- try {
1977
- const labelChild = spawn(
1978
- "gcloud",
1979
- [
1980
- "compute",
1981
- "instances",
1982
- "add-labels",
1983
- assistantId,
1984
- `--project=${project}`,
1985
- `--zone=${zone}`,
1986
- "--labels=retired-by=vel",
1987
- ],
1988
- { stdio: "pipe" },
1989
- );
1990
- await new Promise<void>((resolve) => {
1991
- labelChild.on("close", () => resolve());
1992
- labelChild.on("error", () => resolve());
1993
- });
1994
- } catch {
1995
- // Best-effort labeling before deletion
1996
- }
1997
-
1998
- const child = spawn(
1999
- "gcloud",
2000
- [
2001
- "compute",
2002
- "instances",
2003
- "delete",
2004
- assistantId,
2005
- `--project=${project}`,
2006
- `--zone=${zone}`,
2007
- "--quiet",
2008
- ],
2009
- { stdio: "pipe" },
2010
- );
2011
-
2012
- child.on("close", (code) => {
2013
- handleRef_.current?.hideSpinner();
2014
- if (code === 0) {
2015
- removeAssistantEntry(assistantId);
2016
- handleRef_.current?.addStatus(
2017
- `Removed ${assistantId} from lockfile.json`,
2018
- );
2019
- } else {
2020
- handleRef_.current?.showError(
2021
- `Failed to delete instance (exit code ${code})`,
2022
- );
2023
- }
2024
- cleanup();
2025
- process.exit(code === 0 ? 0 : 1);
2026
- });
2027
-
2028
- child.on("error", (err) => {
2029
- handleRef_.current?.hideSpinner();
2030
- handleRef_.current?.showError(
2031
- `Failed to retire instance: ${err.message}`,
2032
- );
2033
- });
2034
- return;
2035
- }
2036
-
2037
1953
  // If a connection attempt is already in progress, don't silently drop input
2038
1954
  if (connectingRef.current) {
2039
1955
  h.addStatus(
@@ -2218,7 +2134,7 @@ function ChatApp({
2218
2134
  // racing with SSE events that may arrive during the sendMessage await.
2219
2135
  h.showSpinner("Working...");
2220
2136
  },
2221
- [runtimeUrl, assistantId, auth, project, zone, cleanup, ensureConnected],
2137
+ [runtimeUrl, assistantId, auth, cleanup, ensureConnected],
2222
2138
  );
2223
2139
 
2224
2140
  const handleSubmit = useCallback(
@@ -2530,8 +2446,6 @@ export function renderChatApp(
2530
2446
  onExit: () => void,
2531
2447
  options?: {
2532
2448
  auth?: Record<string, string>;
2533
- project?: string;
2534
- zone?: string;
2535
2449
  assistantName?: string;
2536
2450
  },
2537
2451
  ): ChatAppInstance {
@@ -2544,8 +2458,6 @@ export function renderChatApp(
2544
2458
  assistantName={options?.assistantName}
2545
2459
  species={species}
2546
2460
  auth={options?.auth}
2547
- project={options?.project}
2548
- zone={options?.zone}
2549
2461
  onExit={onExit}
2550
2462
  handleRef={(h) => {
2551
2463
  chatHandle = h;
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { message } from "./commands/message";
14
14
  import { ps } from "./commands/ps";
15
15
  import { recover } from "./commands/recover";
16
16
  import { restore } from "./commands/restore";
17
+ import { roadmap } from "./commands/roadmap";
17
18
  import { retire } from "./commands/retire";
18
19
  import { rollback } from "./commands/rollback";
19
20
  import { setup } from "./commands/setup";
@@ -45,6 +46,7 @@ const commands = {
45
46
  recover,
46
47
  restore,
47
48
  retire,
49
+ roadmap,
48
50
  rollback,
49
51
  setup,
50
52
  sleep,
@@ -83,6 +85,7 @@ function printHelp(): void {
83
85
  " restore Restore data (and optionally version) from a .vbundle backup",
84
86
  );
85
87
  console.log(" retire Delete an assistant instance");
88
+ console.log(" roadmap Manage roadmap items");
86
89
  console.log(" rollback Roll back an assistant to a previous version");
87
90
  console.log(" setup Configure API keys interactively");
88
91
  console.log(" sleep Stop the assistant process");
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createServer, type Server } from "net";
3
+
4
+ import { findOpenPort } from "../port-allocator.js";
5
+
6
+ const HOST = "127.0.0.1";
7
+
8
+ async function bindBlocker(port: number, host: string = HOST): Promise<Server> {
9
+ return new Promise((resolve, reject) => {
10
+ const server = createServer();
11
+ server.once("error", reject);
12
+ server.once("listening", () => resolve(server));
13
+ server.listen(port, host);
14
+ });
15
+ }
16
+
17
+ async function closeServer(server: Server): Promise<void> {
18
+ return new Promise((resolve, reject) => {
19
+ server.close((err) => (err ? reject(err) : resolve()));
20
+ });
21
+ }
22
+
23
+ async function getEphemeralPort(): Promise<number> {
24
+ const server = await new Promise<Server>((resolve, reject) => {
25
+ const s = createServer();
26
+ s.once("error", reject);
27
+ s.once("listening", () => resolve(s));
28
+ s.listen(0, HOST);
29
+ });
30
+ const addr = server.address();
31
+ if (!addr || typeof addr === "string" || addr.port == null) {
32
+ throw new Error("Could not obtain ephemeral port");
33
+ }
34
+ const port = addr.port;
35
+ await closeServer(server);
36
+ return port;
37
+ }
38
+
39
+ describe("findOpenPort", () => {
40
+ test("returns the preferred port when it is free", async () => {
41
+ const port = await getEphemeralPort();
42
+ const result = await findOpenPort(port, { host: HOST });
43
+ expect(result).toBe(port);
44
+ });
45
+
46
+ test("walks past an in-use port and returns the next free one", async () => {
47
+ const blocked = await getEphemeralPort();
48
+ const blocker = await bindBlocker(blocked);
49
+ try {
50
+ const result = await findOpenPort(blocked, { host: HOST });
51
+ expect(result).toBeGreaterThan(blocked);
52
+ expect(result).toBeLessThanOrEqual(blocked + 50);
53
+ } finally {
54
+ await closeServer(blocker);
55
+ }
56
+ });
57
+
58
+ test("walks past two consecutive in-use ports", async () => {
59
+ const first = await getEphemeralPort();
60
+ const blockerA = await bindBlocker(first);
61
+ let blockerB: Server | null = null;
62
+ try {
63
+ // Best-effort grab of the next consecutive port; if the kernel
64
+ // handed it to someone else just before we got here, that's still a
65
+ // valid "two consecutive blockers" scenario for the walk.
66
+ try {
67
+ blockerB = await bindBlocker(first + 1);
68
+ } catch {
69
+ blockerB = null;
70
+ }
71
+ const result = await findOpenPort(first, { host: HOST });
72
+ expect(result).toBeGreaterThan(first + (blockerB ? 1 : 0));
73
+ } finally {
74
+ await closeServer(blockerA);
75
+ if (blockerB) await closeServer(blockerB);
76
+ }
77
+ });
78
+
79
+ test("throws when the entire requested window is in use", async () => {
80
+ const blocked = await getEphemeralPort();
81
+ const blocker = await bindBlocker(blocked);
82
+ try {
83
+ await expect(
84
+ findOpenPort(blocked, { host: HOST, maxAttempts: 1 }),
85
+ ).rejects.toThrow(/no open port/i);
86
+ } finally {
87
+ await closeServer(blocker);
88
+ }
89
+ });
90
+
91
+ test("rejects non-integer or out-of-range preferred port", async () => {
92
+ await expect(findOpenPort(0, { host: HOST })).rejects.toThrow(
93
+ /not a valid TCP port/i,
94
+ );
95
+ await expect(findOpenPort(65536, { host: HOST })).rejects.toThrow(
96
+ /not a valid TCP port/i,
97
+ );
98
+ await expect(findOpenPort(1.5, { host: HOST })).rejects.toThrow(
99
+ /not a valid TCP port/i,
100
+ );
101
+ });
102
+
103
+ test("rejects non-positive maxAttempts", async () => {
104
+ await expect(
105
+ findOpenPort(20100, { host: HOST, maxAttempts: 0 }),
106
+ ).rejects.toThrow(/maxAttempts/i);
107
+ });
108
+
109
+ test("does not leak the probe port — port is rebindable after resolution", async () => {
110
+ const port = await getEphemeralPort();
111
+ const found = await findOpenPort(port, { host: HOST });
112
+ expect(found).toBe(port);
113
+ // If the probe leaked a listener on `port`, this would throw EADDRINUSE.
114
+ const reuse = await bindBlocker(found);
115
+ await closeServer(reuse);
116
+ });
117
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { buildExecErrorMessage, exec, execOutput } from "../step-runner";
4
+
5
+ describe("buildExecErrorMessage", () => {
6
+ it("omits the argv from the header so secrets in args can't leak", () => {
7
+ // Realistic shape — docker hatch invocations pass `-e <NAME>=<val>`
8
+ // flags inline. If we ever regress and put argv in the header, this
9
+ // assertion catches it immediately.
10
+ const msg = buildExecErrorMessage("docker", 125, "stderr text", "");
11
+ expect(msg).not.toContain("ANTHROPIC_API_KEY");
12
+ expect(msg).not.toContain("OPENAI_API_KEY");
13
+ expect(msg.startsWith("docker exited with code 125")).toBe(true);
14
+ });
15
+
16
+ it("appends stderr below the header when present", () => {
17
+ const msg = buildExecErrorMessage("docker", 125, " bind failed\n", "");
18
+ expect(msg).toBe("docker exited with code 125\nbind failed");
19
+ });
20
+
21
+ it("appends stdout when stderr is empty", () => {
22
+ const msg = buildExecErrorMessage("docker", 1, "", "stdout-only\n");
23
+ expect(msg).toBe("docker exited with code 1\nstdout-only");
24
+ });
25
+
26
+ it("appends both streams joined by newline when both present", () => {
27
+ const msg = buildExecErrorMessage("docker", 1, "stderr-line", "stdout-line");
28
+ expect(msg).toBe("docker exited with code 1\nstderr-line\nstdout-line");
29
+ });
30
+
31
+ it("collapses an empty output to just the header", () => {
32
+ const msg = buildExecErrorMessage("docker", 1, " ", "\n");
33
+ expect(msg).toBe("docker exited with code 1");
34
+ });
35
+
36
+ it("handles a null exit code (signal-terminated child)", () => {
37
+ const msg = buildExecErrorMessage("docker", null, "killed", "");
38
+ expect(msg).toBe("docker exited with an unknown code\nkilled");
39
+ });
40
+ });
41
+
42
+ describe("exec — secret leak regression", () => {
43
+ it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
44
+ // The classic hatch failure shape: docker invoked with several
45
+ // -e flags, exiting non-zero. Without the fix, args.join(" ")
46
+ // would put `-e ANTHROPIC_API_KEY=sk-ant-…` into err.message.
47
+ const fakeSecret = "sk-ant-this-should-never-appear-in-logs";
48
+ try {
49
+ await exec("sh", [
50
+ "-c",
51
+ `echo "bind for 0.0.0.0:20100 failed: port is already allocated" 1>&2 && exit 125`,
52
+ "-e",
53
+ `ANTHROPIC_API_KEY=${fakeSecret}`,
54
+ ]);
55
+ throw new Error("exec should have rejected");
56
+ } catch (err) {
57
+ const message = err instanceof Error ? err.message : String(err);
58
+ expect(message).not.toContain(fakeSecret);
59
+ expect(message).not.toContain("ANTHROPIC_API_KEY");
60
+ expect(message).toContain("sh exited with code 125");
61
+ expect(message).toContain("port is already allocated");
62
+ }
63
+ });
64
+ });
65
+
66
+ describe("execOutput — secret leak regression", () => {
67
+ it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
68
+ const fakeSecret = "sk-openai-leak-canary";
69
+ try {
70
+ await execOutput("sh", [
71
+ "-c",
72
+ `echo "no such container" 1>&2 && exit 1`,
73
+ "-e",
74
+ `OPENAI_API_KEY=${fakeSecret}`,
75
+ ]);
76
+ throw new Error("execOutput should have rejected");
77
+ } catch (err) {
78
+ const message = err instanceof Error ? err.message : String(err);
79
+ expect(message).not.toContain(fakeSecret);
80
+ expect(message).not.toContain("OPENAI_API_KEY");
81
+ expect(message).toContain("sh exited with code 1");
82
+ expect(message).toContain("no such container");
83
+ }
84
+ });
85
+ });
@@ -0,0 +1,40 @@
1
+ import { LLM_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
2
+
3
+ export interface ApiKeyCheckResult {
4
+ hasKey: boolean;
5
+ }
6
+
7
+ /**
8
+ * Returns true when a key value is a real credential rather than a placeholder.
9
+ *
10
+ * .env.example ships values like `sk-ant-...`, `sk-...`, and `...` to show
11
+ * where credentials go. Any value containing `...` or that is empty is treated
12
+ * as a placeholder that the user has not replaced yet.
13
+ */
14
+ function isPlaceholder(value: string | undefined): boolean {
15
+ if (!value || value.trim() === "") return true;
16
+ if (value.includes("...")) return true;
17
+ return false;
18
+ }
19
+
20
+ /**
21
+ * Check whether at least one LLM provider API key is configured in the
22
+ * current process environment.
23
+ *
24
+ * The CLI's job is to spawn the daemon and pass configuration via environment
25
+ * variables — it does not read from the .vellum/ directory (see AGENTS.md).
26
+ * Checking process.env is sufficient: the daemon forwards whatever is set
27
+ * in the environment, so exporting a key before running `vellum hatch` is
28
+ * the correct way to supply it.
29
+ *
30
+ * Uses the canonical LLM provider env-var catalog so the list stays in sync
31
+ * automatically as new providers are added.
32
+ */
33
+ export function checkProviderApiKey(): ApiKeyCheckResult {
34
+ for (const envVar of Object.values(LLM_PROVIDER_ENV_VAR_NAMES)) {
35
+ if (!isPlaceholder(process.env[envVar])) {
36
+ return { hasKey: true };
37
+ }
38
+ }
39
+ return { hasKey: false };
40
+ }
@@ -109,6 +109,11 @@ export interface AssistantEntry {
109
109
  [key: string]: unknown;
110
110
  }
111
111
 
112
+ export type AssistantLookupResult =
113
+ | { status: "found"; entry: AssistantEntry }
114
+ | { status: "not_found" }
115
+ | { status: "ambiguous"; matches: AssistantEntry[] };
116
+
112
117
  interface LockfileData {
113
118
  assistants?: Record<string, unknown>[];
114
119
  activeAssistant?: string;
@@ -309,8 +314,70 @@ export function loadLatestAssistant(): AssistantEntry | null {
309
314
  }
310
315
 
311
316
  export function findAssistantByName(name: string): AssistantEntry | null {
317
+ return readAssistants().find((entry) => entry.assistantId === name) ?? null;
318
+ }
319
+
320
+ export function getAssistantDisplayName(entry: AssistantEntry): string {
321
+ const primary = entry.name?.trim();
322
+ if (primary) return primary;
323
+
324
+ const legacy = entry.assistantName?.trim();
325
+ if (legacy) return legacy;
326
+
327
+ return entry.assistantId;
328
+ }
329
+
330
+ export function formatAssistantReference(entry: AssistantEntry): string {
331
+ const displayName = getAssistantDisplayName(entry);
332
+ return displayName === entry.assistantId
333
+ ? entry.assistantId
334
+ : `${displayName} (${entry.assistantId})`;
335
+ }
336
+
337
+ function getAssistantDisplayNameCandidates(entry: AssistantEntry): string[] {
338
+ return Array.from(
339
+ new Set(
340
+ [entry.name?.trim(), entry.assistantName?.trim()].filter(
341
+ (value): value is string => typeof value === "string" && value !== "",
342
+ ),
343
+ ),
344
+ );
345
+ }
346
+
347
+ export function lookupAssistantByIdentifier(
348
+ identifier: string,
349
+ ): AssistantLookupResult {
312
350
  const entries = readAssistants();
313
- return entries.find((e) => e.assistantId === name) ?? null;
351
+ const exactId = entries.find((entry) => entry.assistantId === identifier);
352
+ if (exactId) {
353
+ return { status: "found", entry: exactId };
354
+ }
355
+
356
+ const displayMatches = entries.filter((entry) =>
357
+ getAssistantDisplayNameCandidates(entry).includes(identifier),
358
+ );
359
+ if (displayMatches.length === 1) {
360
+ return { status: "found", entry: displayMatches[0] };
361
+ }
362
+ if (displayMatches.length > 1) {
363
+ return { status: "ambiguous", matches: displayMatches };
364
+ }
365
+
366
+ return { status: "not_found" };
367
+ }
368
+
369
+ export function formatAssistantLookupError(
370
+ identifier: string,
371
+ result: AssistantLookupResult = lookupAssistantByIdentifier(identifier),
372
+ ): string {
373
+ if (result.status === "ambiguous") {
374
+ const matches = result.matches
375
+ .map((entry) => formatAssistantReference(entry))
376
+ .join(", ");
377
+ return `Multiple assistants match '${identifier}': ${matches}. Use the assistant ID to disambiguate.`;
378
+ }
379
+
380
+ return `No assistant found with name or ID '${identifier}'.`;
314
381
  }
315
382
 
316
383
  export function removeAssistantEntry(assistantId: string): void {
@@ -446,13 +513,25 @@ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
446
513
  * 3. Sole lockfile entry (any cloud)
447
514
  */
448
515
  export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
449
- const entry = resolveAssistant(nameArg);
450
- if (entry) return entry;
451
-
452
516
  if (nameArg) {
453
- console.error(`No assistant found with name '${nameArg}'.`);
517
+ const result = lookupAssistantByIdentifier(nameArg);
518
+ if (result.status === "found") return result.entry;
519
+ console.error(formatAssistantLookupError(nameArg, result));
454
520
  } else {
521
+ const active = getActiveAssistant();
522
+ if (active) {
523
+ const result = lookupAssistantByIdentifier(active);
524
+ if (result.status === "found") return result.entry;
525
+ if (result.status === "ambiguous") {
526
+ console.error(formatAssistantLookupError(active, result));
527
+ process.exit(1);
528
+ }
529
+ // Active assistant no longer exists in lockfile — fall through.
530
+ }
531
+
455
532
  const all = readAssistants();
533
+ if (all.length === 1) return all[0];
534
+
456
535
  if (all.length === 0) {
457
536
  console.error("No assistant found. Run 'vellum hatch' first.");
458
537
  } else {
@@ -0,0 +1,21 @@
1
+ export function parseAssistantTargetArg(
2
+ args: string[],
3
+ flagsWithValues: readonly string[] = [],
4
+ ): string | undefined {
5
+ const flagsWithValuesSet = new Set(flagsWithValues);
6
+ const parts: string[] = [];
7
+
8
+ for (let i = 0; i < args.length; i++) {
9
+ const arg = args[i];
10
+ if (flagsWithValuesSet.has(arg)) {
11
+ i++;
12
+ continue;
13
+ }
14
+ if (arg.startsWith("-")) {
15
+ continue;
16
+ }
17
+ parts.push(arg);
18
+ }
19
+
20
+ return parts.length > 0 ? parts.join(" ") : undefined;
21
+ }