Skip to content

Commit 258e2ac

Browse files
authored
feat(cli): keyring (#367)
* feat: use OS keyring for credential passphrase storage - OtpPassphraseCache: XOR-masks passphrase with random pad, auto-expires - LazyEncryptedCredentialStore: ICredentialStore that starts locked, defers EncryptedKvCredentialStore construction until unlock() - GraphFormatScanner: walks task graph schemas for format annotations, detects credential requirements before prompting - credential CLI command: add/list/get/delete encrypted credentials - keyring.ts: CLI module for OS keyring access with file migration * feat: implement human-in-the-loop interaction components for CLI - Introduced `CliHumanInteractionEnqueue` for managing human prompts in the CLI. - Added `HumanInteractionHost` to integrate human interaction within the Ink UI. - Created `InkHumanConnector` to facilitate communication between tasks and human prompts. - Developed UI components for displaying notifications, elicitations, and data input forms. - Enhanced credential management with improved error handling and user feedback.
1 parent 371d6db commit 258e2ac

27 files changed

+2089
-180
lines changed

bun.lock

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@huggingface/inference": "catalog:",
4444
"@anthropic-ai/sdk": "catalog:",
4545
"@google/generative-ai": "catalog:",
46+
"@napi-rs/keyring": "^1.1.2",
4647
"@inkjs/ui": "^2.0.0",
4748
"chalk": "^5.6.2",
4849
"commander": "^14.0.3",

examples/cli/src/cliHumanBridge.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Steven Roussey <sroussey@gmail.com>
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type { IHumanRequest, IHumanResponse } from "@workglow/tasks";
8+
9+
export type CliHumanInteractionEnqueue = (
10+
request: IHumanRequest,
11+
signal: AbortSignal
12+
) => Promise<IHumanResponse>;
13+
14+
let enqueue: CliHumanInteractionEnqueue | undefined;
15+
16+
/**
17+
* Installed by {@link HumanInteractionHost} while the Ink run UI is mounted.
18+
* {@link InkHumanConnector} delegates here so human/credential-style prompts share the same Ink tree as workflow progress.
19+
*/
20+
export function setCliHumanInteractionEnqueue(fn: CliHumanInteractionEnqueue | undefined): void {
21+
enqueue = fn;
22+
}
23+
24+
export function getCliHumanInteractionEnqueue(): CliHumanInteractionEnqueue | undefined {
25+
return enqueue;
26+
}

examples/cli/src/commands/agent.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,14 @@ export function registerAgentCommand(program: Command): void {
330330
process.exit(0);
331331
}
332332

333+
// Unlock encrypted credential store if the graph needs credentials
334+
const { scanGraphForCredentials } = await import("@workglow/task-graph");
335+
const scanResult = scanGraphForCredentials(graph);
336+
if (scanResult.needsCredentials) {
337+
const { ensureCredentialStoreUnlocked } = await import("../keyring");
338+
await ensureCredentialStoreUnlocked();
339+
}
340+
333341
try {
334342
if (process.stdout.isTTY) {
335343
const { renderWorkflowRun } = await import("../ui/render");
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Steven Roussey <sroussey@gmail.com>
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
CREDENTIAL_PROVIDER_NONE,
9+
CredentialPutInputSchema,
10+
getGlobalCredentialStore,
11+
} from "@workglow/util";
12+
import type { DataPortSchemaObject } from "@workglow/util/schema";
13+
import type { Command } from "commander";
14+
import {
15+
applySchemaDefaults,
16+
generateSchemaHelpText,
17+
parseDynamicFlags,
18+
resolveInput,
19+
validateInput,
20+
} from "../input";
21+
import { ensureCredentialStoreUnlocked } from "../keyring";
22+
23+
const CredentialSchema = CredentialPutInputSchema as unknown as DataPortSchemaObject;
24+
25+
export function registerCredentialCommand(program: Command): void {
26+
const credential = program.command("credential").description("Manage encrypted credentials");
27+
28+
const add = credential
29+
.command("add")
30+
.argument("[key]", "optional credential key; pre-fills Key and focuses Value in the form")
31+
.description("Add or update an encrypted credential")
32+
.allowUnknownOption()
33+
.allowExcessArguments(true)
34+
.helpOption(false)
35+
.option("--input-json <json>", "Input as JSON string")
36+
.option("--input-json-file <path>", "Input from JSON file")
37+
.option("--dry-run", "Validate input without saving")
38+
.option("--help", "Show help")
39+
.action(
40+
async (cliKey: string | undefined, opts: Record<string, string | boolean | undefined>) => {
41+
if (opts.help) {
42+
add.outputHelp();
43+
console.log("\nInput flags (from credential schema):");
44+
console.log(generateSchemaHelpText(CredentialSchema));
45+
process.exit(0);
46+
}
47+
48+
await ensureCredentialStoreUnlocked();
49+
50+
const dynamicFlags = parseDynamicFlags(process.argv, CredentialSchema);
51+
let input = await resolveInput({
52+
inputJson: opts.inputJson as string | undefined,
53+
inputJsonFile: opts.inputJsonFile as string | undefined,
54+
dynamicFlags,
55+
schema: CredentialSchema,
56+
});
57+
58+
input = applySchemaDefaults(input, CredentialSchema);
59+
60+
const trimmedPositional = cliKey !== undefined ? String(cliKey).trim() : "";
61+
let usedPositionalKey = false;
62+
if (trimmedPositional !== "") {
63+
const existing = input.key;
64+
const hasExplicitKey =
65+
existing !== undefined && existing !== null && String(existing).trim() !== "";
66+
if (!hasExplicitKey) {
67+
input = { ...input, key: trimmedPositional };
68+
usedPositionalKey = true;
69+
}
70+
}
71+
72+
if (process.stdin.isTTY) {
73+
const { promptEditableInput } = await import("../input/prompt");
74+
input = await promptEditableInput(input, CredentialSchema, {
75+
initialFocusedFieldKey: usedPositionalKey ? "value" : undefined,
76+
});
77+
}
78+
79+
const validation = validateInput(input, CredentialSchema);
80+
if (!validation.valid) {
81+
console.error("Input validation failed:");
82+
for (const err of validation.errors) {
83+
console.error(` - ${err}`);
84+
}
85+
process.exit(1);
86+
}
87+
88+
const key = String(input.key ?? "").trim();
89+
const value = String(input.value ?? "");
90+
const labelRaw = input.label;
91+
const providerRaw = input.provider;
92+
const label =
93+
typeof labelRaw === "string" && labelRaw.trim() !== "" ? labelRaw.trim() : undefined;
94+
const providerTrim =
95+
typeof providerRaw === "string" && providerRaw.trim() !== ""
96+
? providerRaw.trim()
97+
: undefined;
98+
const provider =
99+
providerTrim !== undefined && providerTrim !== CREDENTIAL_PROVIDER_NONE
100+
? providerTrim
101+
: undefined;
102+
103+
if (!key || !value) {
104+
console.error("Key and value are required and must be non-empty.");
105+
process.exit(1);
106+
}
107+
108+
if (opts.dryRun) {
109+
console.log(JSON.stringify({ key, value: "(redacted)", label, provider }, null, 2));
110+
process.exit(0);
111+
}
112+
113+
const store = getGlobalCredentialStore();
114+
await store.put(key, value, {
115+
provider,
116+
label,
117+
});
118+
console.log(`Credential "${key}" saved.`);
119+
}
120+
);
121+
122+
credential
123+
.command("list")
124+
.description("List all stored credential keys")
125+
.action(async () => {
126+
await ensureCredentialStoreUnlocked();
127+
128+
const store = getGlobalCredentialStore();
129+
const keys = await store.keys();
130+
if (keys.length === 0) {
131+
console.log("No credentials stored.");
132+
return;
133+
}
134+
for (const key of keys) {
135+
console.log(key);
136+
}
137+
});
138+
139+
credential
140+
.command("get")
141+
.argument("<key>", "credential key to retrieve")
142+
.description("Retrieve and display a credential value")
143+
.action(async (key: string) => {
144+
await ensureCredentialStoreUnlocked();
145+
146+
const store = getGlobalCredentialStore();
147+
const value = await store.get(key);
148+
if (value === undefined) {
149+
console.error(`Credential "${key}" not found.`);
150+
process.exit(1);
151+
}
152+
153+
if (process.stdout.isTTY) {
154+
console.warn("Warning: credential value will be displayed in plaintext.");
155+
}
156+
console.log(value);
157+
});
158+
159+
credential
160+
.command("delete")
161+
.argument("<key>", "credential key to delete")
162+
.description("Delete a stored credential")
163+
.action(async (key: string) => {
164+
await ensureCredentialStoreUnlocked();
165+
166+
const store = getGlobalCredentialStore();
167+
const deleted = await store.delete(key);
168+
if (deleted) {
169+
console.log(`Credential "${key}" deleted.`);
170+
} else {
171+
console.error(`Credential "${key}" not found.`);
172+
process.exit(1);
173+
}
174+
});
175+
}

examples/cli/src/commands/workflow.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,14 @@ export function registerWorkflowCommand(program: Command): void {
337337
process.exit(0);
338338
}
339339

340+
// Unlock encrypted credential store if the graph needs credentials
341+
const { scanGraphForCredentials } = await import("@workglow/task-graph");
342+
const scanResult = scanGraphForCredentials(graph);
343+
if (scanResult.needsCredentials) {
344+
const { ensureCredentialStoreUnlocked } = await import("../keyring");
345+
await ensureCredentialStoreUnlocked();
346+
}
347+
340348
try {
341349
const { withCli } = await import("../run-interactive");
342350
const result = await withCli(graph, { suppressResultOutput: true }).run(input, runConfig);

examples/cli/src/input/prompt.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
import type { DataPortSchemaNonBoolean, DataPortSchemaObject } from "@workglow/util/schema";
88
import { getNestedValue } from "../util";
9-
import { evaluateConditionalRequired } from "./schema-conditions";
109
import { deepMerge } from "./resolve-input";
10+
import { evaluateConditionalRequired } from "./schema-conditions";
1111

1212
export interface PromptFieldDescriptor {
1313
readonly key: string;
@@ -273,23 +273,41 @@ function collectAllFields(
273273
}
274274
}
275275

276+
/**
277+
* Builds field descriptors for an Ink form (including model/credential option enrichment).
278+
*/
279+
export async function prepareSchemaFormFields(
280+
input: Record<string, unknown>,
281+
schema: DataPortSchemaObject
282+
): Promise<PromptFieldDescriptor[]> {
283+
let fields = getAllFields(input, schema);
284+
return enrichFieldsWithOptions(fields);
285+
}
286+
287+
export interface PromptEditableInputOptions {
288+
/** Schema field `key` to focus first (e.g. `"value"` when Key was pre-filled from the CLI). */
289+
readonly initialFocusedFieldKey?: string;
290+
}
291+
276292
/**
277293
* Present a full editable form with all schema fields pre-populated from input.
278294
* Returns the edited values merged with input, or exits if cancelled.
279295
*/
280296
export async function promptEditableInput(
281297
input: Record<string, unknown>,
282-
schema: DataPortSchemaObject
298+
schema: DataPortSchemaObject,
299+
options?: PromptEditableInputOptions
283300
): Promise<Record<string, unknown>> {
284301
if (!process.stdin.isTTY) {
285302
return input;
286303
}
287304

288-
let fields = getAllFields(input, schema);
289-
fields = await enrichFieldsWithOptions(fields);
305+
const fields = await prepareSchemaFormFields(input, schema);
290306

291307
const { renderSchemaPrompt } = await import("../ui/render");
292-
const prompted = await renderSchemaPrompt(fields);
308+
const prompted = await renderSchemaPrompt(fields, {
309+
initialFocusedFieldKey: options?.initialFocusedFieldKey,
310+
});
293311
if (prompted === undefined) {
294312
process.exit(0);
295313
}

0 commit comments

Comments
 (0)