@ricsam/r5dctl 0.0.20 → 0.0.22

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.
package/README.md CHANGED
@@ -49,6 +49,9 @@ r5dctl delete project <namespace/name|id>
49
49
 
50
50
  r5dctl -p <project> get branches
51
51
  r5dctl -p <project> describe branch <branch>
52
+ r5dctl -p <project> -b <branch> get envs
53
+ r5dctl -p <project> -b <branch> set envs --from-env-file ~/.env --only-missing
54
+ r5dctl -p <project> -b <branch> set envs -e backend/API_KEY=value --description "API credential"
52
55
  r5dctl -p <project> get sessions --branch <branch>
53
56
  r5dctl -p <project> create session -b <branch> --name "Spec session"
54
57
  r5dctl describe session <session-id>
@@ -66,6 +69,8 @@ r5dctl -s <session-id> apply-required-envs -e backend/KEY=value -e frontend/KEY=
66
69
 
67
70
  ```
68
71
 
72
+ `get envs` reports names and whether values are set, but never prints the values. Prefer `--from-env-file` for bulk imports so secret values do not appear in shell arguments or history. Dotenv imports target backend envs by default; use `--target frontend` when needed. Empty dotenv values are skipped unless `--include-empty` is passed, and `--only-missing` preserves values that are already set.
73
+
69
74
  Conversation output is human-readable by default and excludes both the system prompt and tool definitions. `--raw` keeps the transcript layout but renders structured message and tool parts as JSON, `--tools` includes the provider-shaped tool definitions, and `--system` includes system prompt messages. The global `--json` flag remains separate and prints the complete API response envelope.
70
75
 
71
76
  When available, conversation rendering uses the newest complete persisted model-request snapshot on the active conversation branch, so its mode, tier, model, thinking mode, tools, and messages reflect the request that actually ran. Legacy or empty sessions without snapshots fall back to reconstruction from the latest conversation context.
package/dist/cjs/cli.cjs CHANGED
@@ -36,9 +36,12 @@ __export(cli_exports, {
36
36
  parseEnvFlags: () => parseEnvFlags,
37
37
  parseGlobalArgs: () => parseGlobalArgs,
38
38
  parsePromptArgs: () => parsePromptArgs,
39
+ parseSetEnvArgs: () => parseSetEnvArgs,
40
+ readDotenvFile: () => readDotenvFile,
39
41
  renderConversationResponse: () => renderConversationResponse,
40
42
  resolveCommandExecution: () => resolveCommandExecution,
41
- runR5dctlCli: () => runR5dctlCli
43
+ runR5dctlCli: () => runR5dctlCli,
44
+ summarizeEnvData: () => summarizeEnvData
42
45
  });
43
46
  module.exports = __toCommonJS(cli_exports);
44
47
  var import_node_fs = __toESM(require("node:fs"), 1);
@@ -46,6 +49,7 @@ var import_node_os = __toESM(require("node:os"), 1);
46
49
  var import_node_path = __toESM(require("node:path"), 1);
47
50
  var import_node_child_process = require("node:child_process");
48
51
  var import_promises = require("node:timers/promises");
52
+ var import_dotenv = require("dotenv");
49
53
  var import_r5d_api = require("@ricsam/r5d-api");
50
54
  const CHAT_MODES = /* @__PURE__ */ new Set([
51
55
  "ask",
@@ -103,6 +107,21 @@ const SHARED_HELP_ENTRIES = [
103
107
  { section: "projects", usage: "delete project <namespace/name|id>", description: "Delete a project." },
104
108
  { section: "branches", usage: "-p <project> get branches", description: "List branches for a project." },
105
109
  { section: "branches", usage: "-p <project> describe branch <branch>", description: "Show branch URLs and sessions." },
110
+ {
111
+ section: "envs",
112
+ usage: "-p <project> -b <branch> get envs",
113
+ description: "List declared envs and whether each has a value, without revealing values."
114
+ },
115
+ {
116
+ section: "envs",
117
+ usage: "-p <project> -b <branch> set envs --from-env-file <path> [--target <backend|frontend>] [--only-missing]",
118
+ description: "Securely import branch envs from a dotenv file without putting values in command arguments."
119
+ },
120
+ {
121
+ section: "envs",
122
+ usage: "-p <project> -b <branch> set envs -e backend/KEY=value [--description <text>] [--only-missing]",
123
+ description: "Set one or more branch envs directly."
124
+ },
106
125
  {
107
126
  section: "sessions",
108
127
  usage: "-p <project> get sessions [--branch <branch>]",
@@ -151,11 +170,12 @@ const SHARED_HELP_ENTRIES = [
151
170
  { section: "merges", usage: "-p <project> continue-merge <target-branch>", description: "Commit a resolved merge." },
152
171
  { section: "merges", usage: "-p <project> abort-merge <target-branch>", description: "Abort an in-progress merge." }
153
172
  ];
154
- const HELP_SECTION_ORDER = ["auth", "projects", "branches", "sessions", "agents", "merges"];
173
+ const HELP_SECTION_ORDER = ["auth", "projects", "branches", "envs", "sessions", "agents", "merges"];
155
174
  const HELP_SECTION_TITLES = {
156
175
  auth: "Auth",
157
176
  projects: "Projects",
158
177
  branches: "Branches",
178
+ envs: "Environment variables",
159
179
  sessions: "Sessions",
160
180
  agents: "Agents",
161
181
  merges: "Merges"
@@ -501,10 +521,117 @@ function validateEnvAssignment(value) {
501
521
  }
502
522
  const target = assignment.slice(0, slashIndex);
503
523
  const key = assignment.slice(slashIndex + 1);
504
- if (target !== "backend" && target !== "frontend" || key.length === 0) {
524
+ if (target !== "backend" && target !== "frontend" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
505
525
  throw new Error(`Invalid env assignment '${value}'. Expected backend/KEY=value or frontend/KEY=value`);
506
526
  }
507
527
  }
528
+ function parseEnvAssignment(value) {
529
+ validateEnvAssignment(value);
530
+ const equalsIndex = value.indexOf("=");
531
+ const slashIndex = value.indexOf("/");
532
+ return {
533
+ target: value.slice(0, slashIndex),
534
+ name: value.slice(slashIndex + 1, equalsIndex),
535
+ value: value.slice(equalsIndex + 1)
536
+ };
537
+ }
538
+ function parseSetEnvArgs(args) {
539
+ const parsed = {
540
+ assignments: [],
541
+ target: "backend",
542
+ onlyMissing: false,
543
+ includeEmpty: false
544
+ };
545
+ for (let index = 0; index < args.length; index += 1) {
546
+ const arg = args[index];
547
+ if (!arg) continue;
548
+ if (arg === "-e" || arg === "--env") {
549
+ const value = requireValue(args[index + 1], `Missing value for ${arg}`);
550
+ validateEnvAssignment(value);
551
+ parsed.assignments.push(value);
552
+ index += 1;
553
+ continue;
554
+ }
555
+ const inlineEnv = parseLongOptionWithEquals(arg, "--env");
556
+ if (inlineEnv !== void 0) {
557
+ validateEnvAssignment(inlineEnv);
558
+ parsed.assignments.push(inlineEnv);
559
+ continue;
560
+ }
561
+ if (arg === "--from-env-file") {
562
+ parsed.fromEnvFile = requireValue(args[index + 1], "Missing value for --from-env-file");
563
+ index += 1;
564
+ continue;
565
+ }
566
+ const inlineFile = parseLongOptionWithEquals(arg, "--from-env-file");
567
+ if (inlineFile !== void 0) {
568
+ parsed.fromEnvFile = requireValue(inlineFile, "Missing value for --from-env-file");
569
+ continue;
570
+ }
571
+ if (arg === "--target") {
572
+ const target = requireValue(args[index + 1], "Missing value for --target");
573
+ if (target !== "backend" && target !== "frontend") {
574
+ throw new Error("Invalid --target. Expected backend or frontend");
575
+ }
576
+ parsed.target = target;
577
+ index += 1;
578
+ continue;
579
+ }
580
+ const inlineTarget = parseLongOptionWithEquals(arg, "--target");
581
+ if (inlineTarget !== void 0) {
582
+ if (inlineTarget !== "backend" && inlineTarget !== "frontend") {
583
+ throw new Error("Invalid --target. Expected backend or frontend");
584
+ }
585
+ parsed.target = inlineTarget;
586
+ continue;
587
+ }
588
+ if (arg === "--description") {
589
+ parsed.description = requireValue(args[index + 1], "Missing value for --description");
590
+ index += 1;
591
+ continue;
592
+ }
593
+ const inlineDescription = parseLongOptionWithEquals(arg, "--description");
594
+ if (inlineDescription !== void 0) {
595
+ parsed.description = requireValue(inlineDescription, "Missing value for --description");
596
+ continue;
597
+ }
598
+ if (arg === "--optional") {
599
+ if (parsed.optional === false) throw new Error("Use only one of --optional or --required");
600
+ parsed.optional = true;
601
+ continue;
602
+ }
603
+ if (arg === "--required") {
604
+ if (parsed.optional === true) throw new Error("Use only one of --optional or --required");
605
+ parsed.optional = false;
606
+ continue;
607
+ }
608
+ if (arg === "--only-missing") {
609
+ parsed.onlyMissing = true;
610
+ continue;
611
+ }
612
+ if (arg === "--include-empty") {
613
+ parsed.includeEmpty = true;
614
+ continue;
615
+ }
616
+ throw new Error(arg.startsWith("-") ? `Unknown set envs flag: ${arg}` : `Unexpected set envs value: ${arg}`);
617
+ }
618
+ if (parsed.assignments.length > 0 && parsed.fromEnvFile) {
619
+ throw new Error("Use either -e/--env or --from-env-file, not both");
620
+ }
621
+ if (parsed.assignments.length === 0 && !parsed.fromEnvFile) {
622
+ throw new Error("Provide at least one -e/--env assignment or --from-env-file <path>");
623
+ }
624
+ return parsed;
625
+ }
626
+ function resolveLocalPath(filePath) {
627
+ if (filePath === "~") return import_node_os.default.homedir();
628
+ if (filePath.startsWith("~/")) return import_node_path.default.join(import_node_os.default.homedir(), filePath.slice(2));
629
+ return import_node_path.default.resolve(filePath);
630
+ }
631
+ function readDotenvFile(filePath) {
632
+ const resolvedPath = resolveLocalPath(filePath);
633
+ return (0, import_dotenv.parse)(import_node_fs.default.readFileSync(resolvedPath));
634
+ }
508
635
  function parseEnvFlags(args) {
509
636
  const envs = [];
510
637
  for (let i = 0; i < args.length; i += 1) {
@@ -739,6 +866,108 @@ function renderSessionList(sessions) {
739
866
  function renderSessionDescription(session) {
740
867
  return [`Session: ${session.id}`, `Project: ${session.projectPath}`, `Branch: ${session.branchName}`].join("\n") + "\n";
741
868
  }
869
+ function summarizeEnvData(data) {
870
+ const summary = {};
871
+ for (const target of ["backend", "frontend"]) {
872
+ const entries = data[target];
873
+ if (!entries) continue;
874
+ summary[target] = Object.fromEntries(
875
+ Object.entries(entries).sort(([left], [right]) => left.localeCompare(right)).map(([name, env]) => [
876
+ name,
877
+ {
878
+ optional: env.optional ?? false,
879
+ description: env.description,
880
+ hasValue: env.value !== null
881
+ }
882
+ ])
883
+ );
884
+ }
885
+ return summary;
886
+ }
887
+ function renderEnvSummary(data) {
888
+ const lines = [];
889
+ for (const target of ["backend", "frontend"]) {
890
+ const entries = Object.entries(data[target] ?? {});
891
+ if (entries.length === 0) continue;
892
+ if (lines.length > 0) lines.push("");
893
+ lines.push(`${target}:`);
894
+ for (const [name, env] of entries) {
895
+ lines.push(` ${name} ${env.hasValue ? "set" : "missing"} ${env.optional ? "optional" : "required"} ${env.description}`);
896
+ }
897
+ }
898
+ return lines.length > 0 ? `${lines.join("\n")}
899
+ ` : "No environment variables declared.\n";
900
+ }
901
+ function renderEnvSetResult(result) {
902
+ const lines = [
903
+ `Updated ${result.updated.length} environment variable${result.updated.length === 1 ? "" : "s"} for ${result.project}/${result.branch}.`
904
+ ];
905
+ if (result.skippedExisting.length > 0) lines.push(`Skipped ${result.skippedExisting.length} existing value(s).`);
906
+ if (result.skippedEmpty.length > 0) lines.push(`Skipped ${result.skippedEmpty.length} empty value(s).`);
907
+ if (result.updated.length > 0) {
908
+ lines.push(...result.updated.map(({ target, name }) => ` ${target}/${name}`));
909
+ }
910
+ return `${lines.join("\n")}
911
+ `;
912
+ }
913
+ async function setBranchEnvs(client, projectRef, branchName, args) {
914
+ const options = parseSetEnvArgs(args);
915
+ const project = await client.projects.describe(projectRef);
916
+ const current = await client.projects.env.get(project.id, branchName);
917
+ const assignments = options.fromEnvFile ? Object.entries(readDotenvFile(options.fromEnvFile)).map(([name, value]) => {
918
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
919
+ throw new Error(`Invalid environment variable name in dotenv file: ${name}`);
920
+ }
921
+ return { target: options.target, name, value };
922
+ }) : options.assignments.map(parseEnvAssignment);
923
+ const uniqueAssignments = /* @__PURE__ */ new Map();
924
+ for (const assignment of assignments) uniqueAssignments.set(`${assignment.target}/${assignment.name}`, assignment);
925
+ const update = {};
926
+ const result = {
927
+ success: true,
928
+ project: project.path,
929
+ branch: branchName,
930
+ updated: [],
931
+ skippedExisting: [],
932
+ skippedEmpty: []
933
+ };
934
+ const missingDescriptions = [];
935
+ const importedDescription = options.fromEnvFile ? `Imported from ${import_node_path.default.basename(options.fromEnvFile)}.` : void 0;
936
+ for (const assignment of uniqueAssignments.values()) {
937
+ const identity = { target: assignment.target, name: assignment.name };
938
+ if (!options.includeEmpty && assignment.value.length === 0) {
939
+ result.skippedEmpty.push(identity);
940
+ continue;
941
+ }
942
+ const existing = current[assignment.target]?.[assignment.name];
943
+ if (options.onlyMissing && existing?.value !== null && existing?.value !== void 0) {
944
+ result.skippedExisting.push(identity);
945
+ continue;
946
+ }
947
+ const description = options.description ?? existing?.description ?? importedDescription;
948
+ if (!description) {
949
+ missingDescriptions.push(identity);
950
+ continue;
951
+ }
952
+ const targetUpdate = update[assignment.target] ??= {};
953
+ const optional = options.optional ?? existing?.optional;
954
+ targetUpdate[assignment.name] = {
955
+ description,
956
+ value: assignment.value,
957
+ ...optional === void 0 ? {} : { optional }
958
+ };
959
+ result.updated.push(identity);
960
+ }
961
+ if (missingDescriptions.length > 0) {
962
+ throw new Error(
963
+ `--description is required when declaring new envs with -e: ${missingDescriptions.map(({ target, name }) => `${target}/${name}`).join(", ")}`
964
+ );
965
+ }
966
+ if (result.updated.length > 0) {
967
+ await client.projects.env.update(project.id, branchName, update);
968
+ }
969
+ return result;
970
+ }
742
971
  function renderAgentStart(agent) {
743
972
  return [
744
973
  `Agent: ${agent.sessionId}`,
@@ -940,6 +1169,24 @@ Sessions: ${result.sessions.length}
940
1169
  `);
941
1170
  return;
942
1171
  }
1172
+ if (first === "get" && second === "envs") {
1173
+ const projectRef = requireValue(args[2], "Missing project reference");
1174
+ const branchName = requireValue(args[3], "Missing branch name");
1175
+ const project = await client.projects.describe(projectRef);
1176
+ const envs = summarizeEnvData(await client.projects.env.get(project.id, branchName));
1177
+ write({ project: project.path, branch: branchName, envs }, renderEnvSummary(envs));
1178
+ return;
1179
+ }
1180
+ if (first === "set" && second === "envs") {
1181
+ const result = await setBranchEnvs(
1182
+ client,
1183
+ requireValue(args[2], "Missing project reference"),
1184
+ requireValue(args[3], "Missing branch name"),
1185
+ args.slice(4)
1186
+ );
1187
+ write(result, renderEnvSetResult(result));
1188
+ return;
1189
+ }
943
1190
  if (first === "projects" && second === "sessions" && third === "list" || first === "get" && second === "sessions") {
944
1191
  const offset = first === "get" ? 2 : 3;
945
1192
  const projectRef = requireValue(args[offset], "Missing project reference");
@@ -1177,8 +1424,36 @@ function resolveCommandExecution(options, rest) {
1177
1424
  pluginArgs
1178
1425
  };
1179
1426
  }
1427
+ if (target === "envs") {
1428
+ if (!options.project) {
1429
+ throw new Error("--project/-p is required for `get envs`");
1430
+ }
1431
+ if (!options.branch) {
1432
+ throw new Error("--branch/-b is required for `get envs`");
1433
+ }
1434
+ return {
1435
+ kind: "plugin",
1436
+ pluginArgs: ["get", "envs", options.project, options.branch]
1437
+ };
1438
+ }
1180
1439
  throw new Error("Unknown get command");
1181
1440
  }
1441
+ if (command === "set") {
1442
+ const target = commandArgs[0];
1443
+ if (target !== "envs") {
1444
+ throw new Error("Unknown set command");
1445
+ }
1446
+ if (!options.project) {
1447
+ throw new Error("--project/-p is required for `set envs`");
1448
+ }
1449
+ if (!options.branch) {
1450
+ throw new Error("--branch/-b is required for `set envs`");
1451
+ }
1452
+ return {
1453
+ kind: "plugin",
1454
+ pluginArgs: ["set", "envs", options.project, options.branch, ...commandArgs.slice(1)]
1455
+ };
1456
+ }
1182
1457
  if (command === "describe") {
1183
1458
  const target = commandArgs[0];
1184
1459
  if (target === "project") {
@@ -1487,7 +1762,10 @@ async function main(argv = process.argv.slice(2)) {
1487
1762
  parseEnvFlags,
1488
1763
  parseGlobalArgs,
1489
1764
  parsePromptArgs,
1765
+ parseSetEnvArgs,
1766
+ readDotenvFile,
1490
1767
  renderConversationResponse,
1491
1768
  resolveCommandExecution,
1492
- runR5dctlCli
1769
+ runR5dctlCli,
1770
+ summarizeEnvData
1493
1771
  });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "type": "commonjs"
5
5
  }
package/dist/mjs/cli.mjs CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { setTimeout as sleep } from "node:timers/promises";
6
+ import { parse as parseDotenv } from "dotenv";
6
7
  import {
7
8
  R5dctlApiError,
8
9
  R5dctlClient
@@ -63,6 +64,21 @@ const SHARED_HELP_ENTRIES = [
63
64
  { section: "projects", usage: "delete project <namespace/name|id>", description: "Delete a project." },
64
65
  { section: "branches", usage: "-p <project> get branches", description: "List branches for a project." },
65
66
  { section: "branches", usage: "-p <project> describe branch <branch>", description: "Show branch URLs and sessions." },
67
+ {
68
+ section: "envs",
69
+ usage: "-p <project> -b <branch> get envs",
70
+ description: "List declared envs and whether each has a value, without revealing values."
71
+ },
72
+ {
73
+ section: "envs",
74
+ usage: "-p <project> -b <branch> set envs --from-env-file <path> [--target <backend|frontend>] [--only-missing]",
75
+ description: "Securely import branch envs from a dotenv file without putting values in command arguments."
76
+ },
77
+ {
78
+ section: "envs",
79
+ usage: "-p <project> -b <branch> set envs -e backend/KEY=value [--description <text>] [--only-missing]",
80
+ description: "Set one or more branch envs directly."
81
+ },
66
82
  {
67
83
  section: "sessions",
68
84
  usage: "-p <project> get sessions [--branch <branch>]",
@@ -111,11 +127,12 @@ const SHARED_HELP_ENTRIES = [
111
127
  { section: "merges", usage: "-p <project> continue-merge <target-branch>", description: "Commit a resolved merge." },
112
128
  { section: "merges", usage: "-p <project> abort-merge <target-branch>", description: "Abort an in-progress merge." }
113
129
  ];
114
- const HELP_SECTION_ORDER = ["auth", "projects", "branches", "sessions", "agents", "merges"];
130
+ const HELP_SECTION_ORDER = ["auth", "projects", "branches", "envs", "sessions", "agents", "merges"];
115
131
  const HELP_SECTION_TITLES = {
116
132
  auth: "Auth",
117
133
  projects: "Projects",
118
134
  branches: "Branches",
135
+ envs: "Environment variables",
119
136
  sessions: "Sessions",
120
137
  agents: "Agents",
121
138
  merges: "Merges"
@@ -461,10 +478,117 @@ function validateEnvAssignment(value) {
461
478
  }
462
479
  const target = assignment.slice(0, slashIndex);
463
480
  const key = assignment.slice(slashIndex + 1);
464
- if (target !== "backend" && target !== "frontend" || key.length === 0) {
481
+ if (target !== "backend" && target !== "frontend" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
465
482
  throw new Error(`Invalid env assignment '${value}'. Expected backend/KEY=value or frontend/KEY=value`);
466
483
  }
467
484
  }
485
+ function parseEnvAssignment(value) {
486
+ validateEnvAssignment(value);
487
+ const equalsIndex = value.indexOf("=");
488
+ const slashIndex = value.indexOf("/");
489
+ return {
490
+ target: value.slice(0, slashIndex),
491
+ name: value.slice(slashIndex + 1, equalsIndex),
492
+ value: value.slice(equalsIndex + 1)
493
+ };
494
+ }
495
+ function parseSetEnvArgs(args) {
496
+ const parsed = {
497
+ assignments: [],
498
+ target: "backend",
499
+ onlyMissing: false,
500
+ includeEmpty: false
501
+ };
502
+ for (let index = 0; index < args.length; index += 1) {
503
+ const arg = args[index];
504
+ if (!arg) continue;
505
+ if (arg === "-e" || arg === "--env") {
506
+ const value = requireValue(args[index + 1], `Missing value for ${arg}`);
507
+ validateEnvAssignment(value);
508
+ parsed.assignments.push(value);
509
+ index += 1;
510
+ continue;
511
+ }
512
+ const inlineEnv = parseLongOptionWithEquals(arg, "--env");
513
+ if (inlineEnv !== void 0) {
514
+ validateEnvAssignment(inlineEnv);
515
+ parsed.assignments.push(inlineEnv);
516
+ continue;
517
+ }
518
+ if (arg === "--from-env-file") {
519
+ parsed.fromEnvFile = requireValue(args[index + 1], "Missing value for --from-env-file");
520
+ index += 1;
521
+ continue;
522
+ }
523
+ const inlineFile = parseLongOptionWithEquals(arg, "--from-env-file");
524
+ if (inlineFile !== void 0) {
525
+ parsed.fromEnvFile = requireValue(inlineFile, "Missing value for --from-env-file");
526
+ continue;
527
+ }
528
+ if (arg === "--target") {
529
+ const target = requireValue(args[index + 1], "Missing value for --target");
530
+ if (target !== "backend" && target !== "frontend") {
531
+ throw new Error("Invalid --target. Expected backend or frontend");
532
+ }
533
+ parsed.target = target;
534
+ index += 1;
535
+ continue;
536
+ }
537
+ const inlineTarget = parseLongOptionWithEquals(arg, "--target");
538
+ if (inlineTarget !== void 0) {
539
+ if (inlineTarget !== "backend" && inlineTarget !== "frontend") {
540
+ throw new Error("Invalid --target. Expected backend or frontend");
541
+ }
542
+ parsed.target = inlineTarget;
543
+ continue;
544
+ }
545
+ if (arg === "--description") {
546
+ parsed.description = requireValue(args[index + 1], "Missing value for --description");
547
+ index += 1;
548
+ continue;
549
+ }
550
+ const inlineDescription = parseLongOptionWithEquals(arg, "--description");
551
+ if (inlineDescription !== void 0) {
552
+ parsed.description = requireValue(inlineDescription, "Missing value for --description");
553
+ continue;
554
+ }
555
+ if (arg === "--optional") {
556
+ if (parsed.optional === false) throw new Error("Use only one of --optional or --required");
557
+ parsed.optional = true;
558
+ continue;
559
+ }
560
+ if (arg === "--required") {
561
+ if (parsed.optional === true) throw new Error("Use only one of --optional or --required");
562
+ parsed.optional = false;
563
+ continue;
564
+ }
565
+ if (arg === "--only-missing") {
566
+ parsed.onlyMissing = true;
567
+ continue;
568
+ }
569
+ if (arg === "--include-empty") {
570
+ parsed.includeEmpty = true;
571
+ continue;
572
+ }
573
+ throw new Error(arg.startsWith("-") ? `Unknown set envs flag: ${arg}` : `Unexpected set envs value: ${arg}`);
574
+ }
575
+ if (parsed.assignments.length > 0 && parsed.fromEnvFile) {
576
+ throw new Error("Use either -e/--env or --from-env-file, not both");
577
+ }
578
+ if (parsed.assignments.length === 0 && !parsed.fromEnvFile) {
579
+ throw new Error("Provide at least one -e/--env assignment or --from-env-file <path>");
580
+ }
581
+ return parsed;
582
+ }
583
+ function resolveLocalPath(filePath) {
584
+ if (filePath === "~") return os.homedir();
585
+ if (filePath.startsWith("~/")) return path.join(os.homedir(), filePath.slice(2));
586
+ return path.resolve(filePath);
587
+ }
588
+ function readDotenvFile(filePath) {
589
+ const resolvedPath = resolveLocalPath(filePath);
590
+ return parseDotenv(fs.readFileSync(resolvedPath));
591
+ }
468
592
  function parseEnvFlags(args) {
469
593
  const envs = [];
470
594
  for (let i = 0; i < args.length; i += 1) {
@@ -699,6 +823,108 @@ function renderSessionList(sessions) {
699
823
  function renderSessionDescription(session) {
700
824
  return [`Session: ${session.id}`, `Project: ${session.projectPath}`, `Branch: ${session.branchName}`].join("\n") + "\n";
701
825
  }
826
+ function summarizeEnvData(data) {
827
+ const summary = {};
828
+ for (const target of ["backend", "frontend"]) {
829
+ const entries = data[target];
830
+ if (!entries) continue;
831
+ summary[target] = Object.fromEntries(
832
+ Object.entries(entries).sort(([left], [right]) => left.localeCompare(right)).map(([name, env]) => [
833
+ name,
834
+ {
835
+ optional: env.optional ?? false,
836
+ description: env.description,
837
+ hasValue: env.value !== null
838
+ }
839
+ ])
840
+ );
841
+ }
842
+ return summary;
843
+ }
844
+ function renderEnvSummary(data) {
845
+ const lines = [];
846
+ for (const target of ["backend", "frontend"]) {
847
+ const entries = Object.entries(data[target] ?? {});
848
+ if (entries.length === 0) continue;
849
+ if (lines.length > 0) lines.push("");
850
+ lines.push(`${target}:`);
851
+ for (const [name, env] of entries) {
852
+ lines.push(` ${name} ${env.hasValue ? "set" : "missing"} ${env.optional ? "optional" : "required"} ${env.description}`);
853
+ }
854
+ }
855
+ return lines.length > 0 ? `${lines.join("\n")}
856
+ ` : "No environment variables declared.\n";
857
+ }
858
+ function renderEnvSetResult(result) {
859
+ const lines = [
860
+ `Updated ${result.updated.length} environment variable${result.updated.length === 1 ? "" : "s"} for ${result.project}/${result.branch}.`
861
+ ];
862
+ if (result.skippedExisting.length > 0) lines.push(`Skipped ${result.skippedExisting.length} existing value(s).`);
863
+ if (result.skippedEmpty.length > 0) lines.push(`Skipped ${result.skippedEmpty.length} empty value(s).`);
864
+ if (result.updated.length > 0) {
865
+ lines.push(...result.updated.map(({ target, name }) => ` ${target}/${name}`));
866
+ }
867
+ return `${lines.join("\n")}
868
+ `;
869
+ }
870
+ async function setBranchEnvs(client, projectRef, branchName, args) {
871
+ const options = parseSetEnvArgs(args);
872
+ const project = await client.projects.describe(projectRef);
873
+ const current = await client.projects.env.get(project.id, branchName);
874
+ const assignments = options.fromEnvFile ? Object.entries(readDotenvFile(options.fromEnvFile)).map(([name, value]) => {
875
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
876
+ throw new Error(`Invalid environment variable name in dotenv file: ${name}`);
877
+ }
878
+ return { target: options.target, name, value };
879
+ }) : options.assignments.map(parseEnvAssignment);
880
+ const uniqueAssignments = /* @__PURE__ */ new Map();
881
+ for (const assignment of assignments) uniqueAssignments.set(`${assignment.target}/${assignment.name}`, assignment);
882
+ const update = {};
883
+ const result = {
884
+ success: true,
885
+ project: project.path,
886
+ branch: branchName,
887
+ updated: [],
888
+ skippedExisting: [],
889
+ skippedEmpty: []
890
+ };
891
+ const missingDescriptions = [];
892
+ const importedDescription = options.fromEnvFile ? `Imported from ${path.basename(options.fromEnvFile)}.` : void 0;
893
+ for (const assignment of uniqueAssignments.values()) {
894
+ const identity = { target: assignment.target, name: assignment.name };
895
+ if (!options.includeEmpty && assignment.value.length === 0) {
896
+ result.skippedEmpty.push(identity);
897
+ continue;
898
+ }
899
+ const existing = current[assignment.target]?.[assignment.name];
900
+ if (options.onlyMissing && existing?.value !== null && existing?.value !== void 0) {
901
+ result.skippedExisting.push(identity);
902
+ continue;
903
+ }
904
+ const description = options.description ?? existing?.description ?? importedDescription;
905
+ if (!description) {
906
+ missingDescriptions.push(identity);
907
+ continue;
908
+ }
909
+ const targetUpdate = update[assignment.target] ??= {};
910
+ const optional = options.optional ?? existing?.optional;
911
+ targetUpdate[assignment.name] = {
912
+ description,
913
+ value: assignment.value,
914
+ ...optional === void 0 ? {} : { optional }
915
+ };
916
+ result.updated.push(identity);
917
+ }
918
+ if (missingDescriptions.length > 0) {
919
+ throw new Error(
920
+ `--description is required when declaring new envs with -e: ${missingDescriptions.map(({ target, name }) => `${target}/${name}`).join(", ")}`
921
+ );
922
+ }
923
+ if (result.updated.length > 0) {
924
+ await client.projects.env.update(project.id, branchName, update);
925
+ }
926
+ return result;
927
+ }
702
928
  function renderAgentStart(agent) {
703
929
  return [
704
930
  `Agent: ${agent.sessionId}`,
@@ -900,6 +1126,24 @@ Sessions: ${result.sessions.length}
900
1126
  `);
901
1127
  return;
902
1128
  }
1129
+ if (first === "get" && second === "envs") {
1130
+ const projectRef = requireValue(args[2], "Missing project reference");
1131
+ const branchName = requireValue(args[3], "Missing branch name");
1132
+ const project = await client.projects.describe(projectRef);
1133
+ const envs = summarizeEnvData(await client.projects.env.get(project.id, branchName));
1134
+ write({ project: project.path, branch: branchName, envs }, renderEnvSummary(envs));
1135
+ return;
1136
+ }
1137
+ if (first === "set" && second === "envs") {
1138
+ const result = await setBranchEnvs(
1139
+ client,
1140
+ requireValue(args[2], "Missing project reference"),
1141
+ requireValue(args[3], "Missing branch name"),
1142
+ args.slice(4)
1143
+ );
1144
+ write(result, renderEnvSetResult(result));
1145
+ return;
1146
+ }
903
1147
  if (first === "projects" && second === "sessions" && third === "list" || first === "get" && second === "sessions") {
904
1148
  const offset = first === "get" ? 2 : 3;
905
1149
  const projectRef = requireValue(args[offset], "Missing project reference");
@@ -1137,8 +1381,36 @@ function resolveCommandExecution(options, rest) {
1137
1381
  pluginArgs
1138
1382
  };
1139
1383
  }
1384
+ if (target === "envs") {
1385
+ if (!options.project) {
1386
+ throw new Error("--project/-p is required for `get envs`");
1387
+ }
1388
+ if (!options.branch) {
1389
+ throw new Error("--branch/-b is required for `get envs`");
1390
+ }
1391
+ return {
1392
+ kind: "plugin",
1393
+ pluginArgs: ["get", "envs", options.project, options.branch]
1394
+ };
1395
+ }
1140
1396
  throw new Error("Unknown get command");
1141
1397
  }
1398
+ if (command === "set") {
1399
+ const target = commandArgs[0];
1400
+ if (target !== "envs") {
1401
+ throw new Error("Unknown set command");
1402
+ }
1403
+ if (!options.project) {
1404
+ throw new Error("--project/-p is required for `set envs`");
1405
+ }
1406
+ if (!options.branch) {
1407
+ throw new Error("--branch/-b is required for `set envs`");
1408
+ }
1409
+ return {
1410
+ kind: "plugin",
1411
+ pluginArgs: ["set", "envs", options.project, options.branch, ...commandArgs.slice(1)]
1412
+ };
1413
+ }
1142
1414
  if (command === "describe") {
1143
1415
  const target = commandArgs[0];
1144
1416
  if (target === "project") {
@@ -1446,7 +1718,10 @@ export {
1446
1718
  parseEnvFlags,
1447
1719
  parseGlobalArgs,
1448
1720
  parsePromptArgs,
1721
+ parseSetEnvArgs,
1722
+ readDotenvFile,
1449
1723
  renderConversationResponse,
1450
1724
  resolveCommandExecution,
1451
- runR5dctlCli
1725
+ runR5dctlCli,
1726
+ summarizeEnvData
1452
1727
  };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "type": "module"
5
5
  }
@@ -1,4 +1,4 @@
1
- import { type R5dctlConversationRenderOptions, type R5dctlConversationResponse, type ChatMode, type ModelTier } from "@ricsam/r5d-api";
1
+ import { type R5dctlConversationRenderOptions, type R5dctlConversationResponse, type R5dctlEnvData, type R5dctlEnvTarget, type ChatMode, type ModelTier } from "@ricsam/r5d-api";
2
2
  export type GlobalOptions = {
3
3
  baseUrl?: string;
4
4
  json: boolean;
@@ -38,12 +38,29 @@ export declare function parseGlobalArgs(argv: string[]): {
38
38
  rest: string[];
39
39
  };
40
40
  export declare function parseAnswerFlags(args: string[]): string[];
41
+ export type SetEnvOptions = {
42
+ assignments: string[];
43
+ fromEnvFile?: string;
44
+ target: R5dctlEnvTarget;
45
+ description?: string;
46
+ optional?: boolean;
47
+ onlyMissing: boolean;
48
+ includeEmpty: boolean;
49
+ };
50
+ export declare function parseSetEnvArgs(args: string[]): SetEnvOptions;
51
+ export declare function readDotenvFile(filePath: string): Record<string, string>;
41
52
  export declare function parseEnvFlags(args: string[]): string[];
42
53
  export declare function parsePromptArgs(args: string[]): {
43
54
  mode: ChatMode;
44
55
  model: ModelTier;
45
56
  message: string;
46
57
  };
58
+ export type EnvSummaryData = Partial<Record<R5dctlEnvTarget, Record<string, {
59
+ optional: boolean;
60
+ description: string;
61
+ hasValue: boolean;
62
+ }>>>;
63
+ export declare function summarizeEnvData(data: R5dctlEnvData): EnvSummaryData;
47
64
  export declare function parseConversationRenderArgs(args: string[]): ConversationRenderOptions;
48
65
  export declare function renderConversationResponse(read: R5dctlConversationResponse): string;
49
66
  export declare function resolveCommandExecution(options: GlobalOptions, rest: string[]): CommandExecutionPlan;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/cli.cjs",
6
6
  "module": "./dist/mjs/cli.mjs",
@@ -26,7 +26,8 @@
26
26
  "r5dctl": "dist/cjs/main.cjs"
27
27
  },
28
28
  "dependencies": {
29
- "@ricsam/r5d-api": "^0.0.20"
29
+ "@ricsam/r5d-api": "^0.0.22",
30
+ "dotenv": "^17"
30
31
  },
31
32
  "files": [
32
33
  "dist",