humanish 0.94.0 → 0.95.0

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/dist/program.js CHANGED
@@ -10,6 +10,8 @@ import { Command, Option } from "commander";
10
10
  import { startCodexAppServerUi } from "./codex-app-server-ui.js";
11
11
  import { loadEnvFile } from "./env-file.js";
12
12
  import { discoverProviderKeys, listUserKeys, resolveKeyName, setUserKey, unsetUserKey, userKeyStorePath } from "./key-resolution.js";
13
+ import { COMMS_PROVIDERS, readCommsSetup, saveCommsConnection } from "./comms-connections.js";
14
+ import { promptSecret } from "./secret-prompt.js";
13
15
  import { redactText } from "./redaction.js";
14
16
  import { draftFeedback, listFeedback, renderIssueMarkdown, renderIssueUrl, verifyFeedback } from "./feedback.js";
15
17
  import { runInit } from "./init.js";
@@ -461,6 +463,7 @@ function registerDoctorCommand(parent, io) {
461
463
  });
462
464
  }
463
465
  const defaultTuiRuntime = {
466
+ promptSecret,
464
467
  stdin: process.stdin,
465
468
  stdout: process.stdout,
466
469
  env: process.env,
@@ -560,10 +563,14 @@ function registerTuiCommand(parent, io) {
560
563
  .description("Open the interactive terminal surface for browsing labs and runs (humans only).")
561
564
  .summary("Human terminal for labs and runs; refuses detected agent sessions and non-TTY input/output. Agents: humanish lab list --json, humanish lab inspect <lab> --json, humanish runs --json.")
562
565
  .option("--cwd <path>", "Target project directory.", ".")
566
+ .option("--env-file <path>", "Load a local env file for this terminal session and its runs without printing values.")
563
567
  .option("--force", "Open it anyway in a session that looks like an agent's.")
564
568
  .option("--json", JSON_OPTION_DESCRIPTION)
565
569
  .action(async (options, command) => {
566
570
  const { stdin, stdout } = tuiRuntime;
571
+ // Production uses process.env, including SDKs used by existing cleanup actions. Tests inject
572
+ // an isolated host context. Values stay behind the capability closures, never in view data.
573
+ const sessionEnv = tuiRuntime.env;
567
574
  // An agent runner, even with a real terminal. `codex exec` allocates a PTY for the commands
568
575
  // it runs, so the TTY check below passes and the surface used to open: a study watched an
569
576
  // agent navigate the labs list and start a run it did not mean to start
@@ -623,43 +630,89 @@ function registerTuiCommand(parent, io) {
623
630
  });
624
631
  return;
625
632
  }
633
+ const discoveredKeys = new Set();
634
+ if (!await applyEnvFileOption({ command, cwd: options.cwd, envFile: options.envFile, io, env: sessionEnv,
635
+ onDiscovered: names => names.forEach(name => discoveredKeys.add(name)) }))
636
+ return;
637
+ // Probe stored credentials afresh. Discovery fills must not become permanent env overrides
638
+ // when a person replaces a stored key during this terminal session.
639
+ const connectionEnv = () => {
640
+ const env = { ...sessionEnv };
641
+ for (const name of discoveredKeys)
642
+ delete env[name];
643
+ return env;
644
+ };
626
645
  const observerSession = createTuiObserverSession(resolve(options.cwd));
627
- let exitCode;
646
+ let exitCode = 0;
647
+ let connectionNotice;
628
648
  try {
629
- exitCode = await loaded.startTui({
630
- cwd: resolve(options.cwd),
631
- version: { cli: CLI_VERSION },
632
- capabilities: {
633
- // One cache for the life of the surface: it refreshes on a cadence, and re-walking every
634
- // run tree each tick is the cost this index exists to avoid.
635
- readRunIndex: (target, readOptions) => readRunIndex(target, { ...readOptions, cache: runIndexCache }),
636
- listLabs: listLabManifests,
637
- startRun: launchRun,
638
- readLaunchLog: readLaunchLogTail,
639
- readRunDetail,
640
- readLabSummary,
641
- readProjectState,
642
- openObserver: (target, observerPath) => observerSession.open(target, observerPath),
643
- reclaimRun: (target, runId) => reclaimRunSandboxes(target, runId),
644
- stopRun,
645
- initProject: async (target) => {
646
- const result = await runInit({ cwd: target, yes: true });
647
- return result.ok
648
- ? {
649
- schema: TUI_ACTION_SCHEMA,
650
- ok: true,
651
- message: `set up humanish here — ${result.changes.filter((change) => change.action !== "skip").length} files written`
652
- }
653
- : {
654
- schema: TUI_ACTION_SCHEMA,
655
- ok: false,
656
- message: result.error?.message ?? "humanish init could not set this directory up"
657
- };
658
- }
659
- },
660
- stdin,
661
- stdout
662
- });
649
+ for (;;) {
650
+ const outcome = await loaded.startTui({
651
+ ...(connectionNotice === undefined ? {} : { initialScreen: "connections", connectionNotice }),
652
+ cwd: resolve(options.cwd),
653
+ version: { cli: CLI_VERSION },
654
+ capabilities: {
655
+ comms: {
656
+ read: () => readCommsSetup(resolve(options.cwd), connectionEnv()),
657
+ save: () => saveCommsConnection(resolve(options.cwd))
658
+ },
659
+ // One cache for the life of the surface: it refreshes on a cadence, and re-walking every
660
+ // run tree each tick is the cost this index exists to avoid.
661
+ readRunIndex: (target, readOptions) => readRunIndex(target, { ...readOptions, cache: runIndexCache }),
662
+ listLabs: listLabManifests,
663
+ startRun: launchOptions => launchRun({ ...launchOptions, env: sessionEnv }),
664
+ readLaunchLog: readLaunchLogTail,
665
+ readRunDetail,
666
+ readLabSummary: (target, lab, readOptions) => readLabSummary(target, lab, { ...readOptions, env: sessionEnv }),
667
+ readProjectState,
668
+ openObserver: (target, observerPath) => observerSession.open(target, observerPath),
669
+ reclaimRun: (target, runId) => reclaimRunSandboxes(target, runId),
670
+ stopRun,
671
+ initProject: async (target) => {
672
+ const result = await runInit({ cwd: target, yes: true });
673
+ return result.ok
674
+ ? {
675
+ schema: TUI_ACTION_SCHEMA,
676
+ ok: true,
677
+ message: `set up humanish here — ${result.changes.filter((change) => change.action !== "skip").length} files written`
678
+ }
679
+ : {
680
+ schema: TUI_ACTION_SCHEMA,
681
+ ok: false,
682
+ message: result.error?.message ?? "humanish init could not set this directory up"
683
+ };
684
+ }
685
+ },
686
+ stdin,
687
+ stdout
688
+ });
689
+ if (typeof outcome === "number") {
690
+ exitCode = outcome;
691
+ break;
692
+ }
693
+ if (outcome.action !== "agentmail-key") {
694
+ exitCode = 1;
695
+ break;
696
+ }
697
+ // startTui has unmounted: only the host reads the credential, then remounts the view.
698
+ const value = await tuiRuntime.promptSecret("AgentMail API key", stdin, stdout);
699
+ if (value === null) {
700
+ connectionNotice = "Key entry cancelled. Nothing was changed.";
701
+ continue;
702
+ }
703
+ try {
704
+ setUserKey("AGENTMAIL_API_KEY", value, sessionEnv);
705
+ // Refresh only a value filled implicitly by discovery; explicit env/file wins.
706
+ if (discoveredKeys.has("AGENTMAIL_API_KEY"))
707
+ delete sessionEnv.AGENTMAIL_API_KEY;
708
+ const saved = await saveCommsConnection(resolve(options.cwd));
709
+ connectionNotice = saved.ok ? "Key stored. Project connection saved."
710
+ : `Key stored for your user. ${saved.message}`;
711
+ }
712
+ catch {
713
+ connectionNotice = "Could not store the key. Use a single non-empty line and check key-store permissions.";
714
+ }
715
+ }
663
716
  }
664
717
  finally {
665
718
  await observerSession.close();
@@ -681,7 +734,7 @@ function formatKeysHuman(result) {
681
734
  return `${lines.join("\n")}\n`;
682
735
  }
683
736
  /** Read one secret line: from a piped stdin when --stdin, else a hidden TTY prompt. */
684
- async function readSecretValue(useStdin, promptLabel, io) {
737
+ async function readSecretValue(useStdin, promptLabel) {
685
738
  if (useStdin || !process.stdin.isTTY) {
686
739
  const chunks = [];
687
740
  for await (const chunk of process.stdin)
@@ -689,38 +742,7 @@ async function readSecretValue(useStdin, promptLabel, io) {
689
742
  const text = Buffer.concat(chunks).toString("utf8").trim();
690
743
  return text.length > 0 ? text : null;
691
744
  }
692
- io.writeErr(`${promptLabel} (input hidden): `);
693
- return await new Promise((resolve) => {
694
- const stdin = process.stdin;
695
- let value = "";
696
- stdin.setRawMode?.(true);
697
- stdin.resume();
698
- stdin.setEncoding("utf8");
699
- const onData = (key) => {
700
- if (key === "\u0003") {
701
- cleanup();
702
- resolve(null);
703
- return;
704
- }
705
- if (key === "\r" || key === "\n") {
706
- cleanup();
707
- io.writeErr("\n");
708
- resolve(value.trim().length > 0 ? value.trim() : null);
709
- return;
710
- }
711
- if (key === "\u007f" || key === "\b") {
712
- value = value.slice(0, -1);
713
- return;
714
- }
715
- value += key;
716
- };
717
- const cleanup = () => {
718
- stdin.off("data", onData);
719
- stdin.setRawMode?.(false);
720
- stdin.pause();
721
- };
722
- stdin.on("data", onData);
723
- });
745
+ return promptSecret(promptLabel, process.stdin, process.stderr);
724
746
  }
725
747
  function registerKeysCommand(parent, io) {
726
748
  const keys = parent
@@ -729,7 +751,7 @@ function registerKeysCommand(parent, io) {
729
751
  .summary("Manage the user-level provider key store.");
730
752
  keys
731
753
  .command("set")
732
- .argument("<vendor-or-name>", "A vendor alias (openai, e2b, anthropic, github) or a raw ENV_NAME.")
754
+ .argument("<vendor-or-name>", "A vendor alias (openai, e2b, anthropic, github, agentmail) or a raw ENV_NAME.")
733
755
  .description("Store one provider key in the user store (0600), prompted with hidden input.")
734
756
  .option("--stdin", "Read the value from stdin instead of prompting (for agents/pipes).")
735
757
  .option("--json", JSON_OPTION_DESCRIPTION)
@@ -739,13 +761,13 @@ function registerKeysCommand(parent, io) {
739
761
  if (name === null) {
740
762
  const result = {
741
763
  schema: KEYS_RESULT_SCHEMA, ok: false, action: "set", store: storePath, names: [],
742
- message: `Not a vendor alias or valid env name: ${vendorOrName}. Vendors: openai, e2b, anthropic, github.`
764
+ message: `Not a vendor alias or valid env name: ${vendorOrName}. Vendors: openai, e2b, anthropic, github, agentmail.`
743
765
  };
744
766
  writeResult(command, io, result, formatKeysHuman);
745
767
  io.setExitCode(2);
746
768
  return;
747
769
  }
748
- const value = await readSecretValue(options.stdin === true, `Value for ${name}`, io);
770
+ const value = await readSecretValue(options.stdin === true, `Value for ${name}`);
749
771
  if (value === null) {
750
772
  const result = {
751
773
  schema: KEYS_RESULT_SCHEMA, ok: false, action: "set", store: storePath, names: [name],
@@ -1140,8 +1162,42 @@ function registerRunsCommand(parent, io) {
1140
1162
  function registerCommsCommands(parent, io) {
1141
1163
  const comms = parent
1142
1164
  .command("comms")
1143
- .description("Off-app comms surfaces (email/SMS the app under test sends).")
1165
+ .description("Email capture and communication connection setup.")
1144
1166
  .summary("Off-app comms surfaces.");
1167
+ comms.command("providers")
1168
+ .description("List installed communication provider capabilities. No network requests.")
1169
+ .option("--json", JSON_OPTION_DESCRIPTION)
1170
+ .action((_options, command) => {
1171
+ const result = { schema: "humanish.comms-providers.v1", ok: true, providers: COMMS_PROVIDERS };
1172
+ writeResult(command, io, result, () => COMMS_PROVIDERS.map(provider => `${provider.label}: ${provider.limitation}\nKey: ${provider.keyEnv}\nSetup: ${provider.setupUrl}\n`).join("\n"));
1173
+ });
1174
+ const connections = comms.command("connections").description("Manage project-local non-secret connection profiles. Setup does not enable email receiving.");
1175
+ connections.command("list")
1176
+ .description("Show saved connections and local credential status; does not authenticate with a provider.")
1177
+ .option("--cwd <path>", "Target project directory.", ".")
1178
+ .option("--env-file <path>", "Load credentials for local status without printing values.")
1179
+ .option("--json", JSON_OPTION_DESCRIPTION)
1180
+ .action(async (options, command) => {
1181
+ if (!await applyEnvFileOption({ command, cwd: options.cwd, envFile: options.envFile, io }))
1182
+ return;
1183
+ const result = await readCommsSetup(resolve(options.cwd), process.env);
1184
+ writeResult(command, io, result, value => `${value.message}\nAgentMail key: ${value.credential.present ? "present" : "missing"}\n${value.connections.map(connection => `${connection.name}: ${connection.provider} (${connection.apiKeyEnv})\n`).join("")}`);
1185
+ io.setExitCode(result.ok ? 0 : 2);
1186
+ });
1187
+ connections.command("add")
1188
+ .argument("[name]", "Project connection name.", "agentmail")
1189
+ .description("Save an AgentMail connection profile. Does not write a key, alter a lab or contact the provider.")
1190
+ .option("--cwd <path>", "Target project directory.", ".")
1191
+ .option("--provider <id>", "Installed provider id.", "agentmail")
1192
+ .option("--api-key-env <name>", "Environment variable NAME, never its value.", "AGENTMAIL_API_KEY")
1193
+ .option("--json", JSON_OPTION_DESCRIPTION)
1194
+ .action(async (name, options, command) => {
1195
+ const result = { schema: "humanish.comms-connection-result.v1", ...(options.provider === "agentmail"
1196
+ ? await saveCommsConnection(resolve(options.cwd), name, options.apiKeyEnv)
1197
+ : { ok: false, message: "Only AgentMail connection setup is currently available." }) };
1198
+ writeResult(command, io, result, value => `${value.message}\n`);
1199
+ io.setExitCode(result.ok ? 0 : 2);
1200
+ });
1145
1201
  comms
1146
1202
  .command("catch")
1147
1203
  .description("Run the email catch on THIS host so humanish can study an app it does not provision (#328). Your app posts its email sends here; the persona opens /inbox; humanish drains GET /deliveries and writes digest-only evidence. Point your lab's comms.email.external.catchBaseUrl at this server.")
@@ -3232,24 +3288,29 @@ async function renderAndMaybeFollowObserver(args) {
3232
3288
  }
3233
3289
  }
3234
3290
  async function applyEnvFileOption(args) {
3291
+ const env = args.env ?? process.env;
3235
3292
  if (args.envFile) {
3236
- const result = await loadEnvFile(args.cwd, args.envFile);
3293
+ const stagedEnv = { ...env };
3294
+ const result = await loadEnvFile(args.cwd, args.envFile, stagedEnv);
3237
3295
  if (!result.ok) {
3238
3296
  writeResult(args.command, args.io, result, formatEnvFileHuman);
3239
3297
  args.io.setExitCode(2);
3240
3298
  return false;
3241
3299
  }
3300
+ for (const name of result.loaded)
3301
+ env[name] = stagedEnv[name];
3242
3302
  }
3243
3303
  // Provider-key discovery (#436): fill still-missing keys from the documented project
3244
3304
  // overlay, the owning vendors' native stores, and the humanish user store — fill-only
3245
3305
  // (an explicit --env-file or process env always wins), each fill announced by name and
3246
3306
  // source on stderr, never by value. HUMANISH_STRICT_KEYS=1 restores env-only behavior.
3247
3307
  try {
3248
- await keyDiscoveryFn({
3308
+ const discovered = await keyDiscoveryFn({
3249
3309
  cwd: args.cwd,
3250
- env: process.env,
3310
+ env,
3251
3311
  announce: (line) => args.io.writeErr(`${line}\n`)
3252
3312
  });
3313
+ args.onDiscovered?.(discovered.map(fill => fill.name));
3253
3314
  }
3254
3315
  catch {
3255
3316
  // Discovery must never break a command; a rung that fails to read is a miss, not an error.