@aixyz/cli 0.11.0 → 0.13.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.
@@ -1,9 +1,9 @@
1
1
  import type { BunPlugin } from "bun";
2
2
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs";
3
- import { resolve, relative, basename, join } from "path";
3
+ import { resolve, relative, join } from "path";
4
4
  import { getAixyzConfig } from "@aixyz/config";
5
5
 
6
- export function AixyzServerPlugin(entrypoint: string, mode: "vercel" | "standalone"): BunPlugin {
6
+ export function AixyzServerPlugin(entrypoint: string, mode: "vercel" | "standalone" | "executable"): BunPlugin {
7
7
  return {
8
8
  name: "aixyz-entrypoint",
9
9
  setup(build) {
@@ -17,7 +17,7 @@ export function AixyzServerPlugin(entrypoint: string, mode: "vercel" | "standalo
17
17
  const transformed = source.replace(/export\s+default\s+(\w+)\s*;/, "export default $1.express;");
18
18
  return { contents: transformed, loader: "ts" };
19
19
  } else {
20
- // For standalone, keep the server export but add startup code
20
+ // For standalone and executable, keep the server export but add startup code
21
21
  // TODO(@fuxingloh): use Bun.serve later.
22
22
  const transformed = source.replace(
23
23
  /export\s+default\s+(\w+)\s*;/,
@@ -55,8 +55,40 @@ export function getEntrypointMayGenerate(cwd: string, mode: "dev" | "build"): st
55
55
  class AixyzGlob {
56
56
  constructor(readonly config = getAixyzConfig()) {}
57
57
 
58
- includes(file: string): boolean {
59
- const included = this.config.build.includes.some((pattern) => new Bun.Glob(pattern).match(file));
58
+ hasRootAgent(appDir: string): { file: string } | undefined {
59
+ const file = readdirSync(appDir).find((f) => /^agent\.(js|ts)$/.test(f) && this.includesAgent(f));
60
+ return file ? { file } : undefined;
61
+ }
62
+
63
+ getAgents(agentsDir: string): { name: string; identifier: string }[] {
64
+ if (!existsSync(agentsDir)) return [];
65
+ return readdirSync(agentsDir)
66
+ .filter((file) => this.includesAgent(`agents/${file}`))
67
+ .map((file) => {
68
+ const name = file.replace(/\.(js|ts)$/, "");
69
+ return { name, identifier: toIdentifier(name) };
70
+ });
71
+ }
72
+
73
+ getTools(toolsDir: string): { name: string; identifier: string }[] {
74
+ if (!existsSync(toolsDir)) return [];
75
+ return readdirSync(toolsDir)
76
+ .filter((file) => this.includesTool(`tools/${file}`))
77
+ .map((file) => {
78
+ const name = file.replace(/\.(js|ts)$/, "");
79
+ return { name, identifier: toIdentifier(name) };
80
+ });
81
+ }
82
+
83
+ private includesAgent(file: string): boolean {
84
+ const included = this.config.build.agents.some((pattern) => new Bun.Glob(pattern).match(file));
85
+ if (!included) return false;
86
+ const excluded = this.config.build.excludes.some((pattern) => new Bun.Glob(pattern).match(file));
87
+ return !excluded;
88
+ }
89
+
90
+ private includesTool(file: string): boolean {
91
+ const included = this.config.build.tools.some((pattern) => new Bun.Glob(pattern).match(file));
60
92
  if (!included) return false;
61
93
  const excluded = this.config.build.excludes.some((pattern) => new Bun.Glob(pattern).match(file));
62
94
  return !excluded;
@@ -64,9 +96,9 @@ class AixyzGlob {
64
96
  }
65
97
 
66
98
  /**
67
- * Generate server.ts content by scanning the app directory for agent.ts and tools/.
99
+ * Generate server.ts content by scanning the app directory for agent.ts, agents/, and tools/.
68
100
  *
69
- * @param appDir - The app directory containing agent.ts and tools/
101
+ * @param appDir - The app directory containing agent.ts, agents/, and tools/
70
102
  * @param entrypointDir - Directory where the generated file will live (for computing relative imports).
71
103
  */
72
104
  function generateServer(appDir: string, entrypointDir: string): string {
@@ -86,24 +118,25 @@ function generateServer(appDir: string, entrypointDir: string): string {
86
118
  imports.push('import { facilitator } from "aixyz/accepts";');
87
119
  }
88
120
 
89
- const hasAgent = existsSync(resolve(appDir, "agent.ts")) && glob.includes("agent.ts");
90
- if (hasAgent) {
121
+ const rootAgent = glob.hasRootAgent(appDir);
122
+ if (rootAgent) {
91
123
  imports.push('import { useA2A } from "aixyz/server/adapters/a2a";');
92
124
  imports.push(`import * as agent from "${importPrefix}/agent";`);
93
125
  }
94
126
 
95
- const toolsDir = resolve(appDir, "tools");
96
- const tools: { name: string; identifier: string }[] = [];
97
- if (existsSync(toolsDir)) {
98
- for (const file of readdirSync(toolsDir)) {
99
- if (glob.includes(`tools/${file}`)) {
100
- const name = basename(file, ".ts");
101
- const identifier = toIdentifier(name);
102
- tools.push({ name, identifier });
103
- }
104
- }
127
+ const agentsDir = resolve(appDir, "agents");
128
+ const subAgents = glob.getAgents(agentsDir);
129
+
130
+ if (!rootAgent && subAgents.length > 0) {
131
+ imports.push('import { useA2A } from "aixyz/server/adapters/a2a";');
132
+ }
133
+ for (const subAgent of subAgents) {
134
+ imports.push(`import * as ${subAgent.identifier} from "${importPrefix}/agents/${subAgent.name}";`);
105
135
  }
106
136
 
137
+ const toolsDir = resolve(appDir, "tools");
138
+ const tools = glob.getTools(toolsDir);
139
+
107
140
  if (tools.length > 0) {
108
141
  imports.push('import { AixyzMCP } from "aixyz/server/adapters/mcp";');
109
142
  for (const tool of tools) {
@@ -115,10 +148,13 @@ function generateServer(appDir: string, entrypointDir: string): string {
115
148
  body.push("await server.initialize();");
116
149
  body.push("server.unstable_withIndexPage();");
117
150
 
118
- if (hasAgent) {
151
+ if (rootAgent) {
119
152
  body.push("useA2A(server, agent);");
120
153
  }
121
154
 
155
+ for (const subAgent of subAgents) {
156
+ body.push(`useA2A(server, ${subAgent.identifier}, "${subAgent.name}");`);
157
+ }
122
158
  if (tools.length > 0) {
123
159
  body.push("const mcp = new AixyzMCP(server);");
124
160
  for (const tool of tools) {
@@ -132,8 +168,11 @@ function generateServer(appDir: string, entrypointDir: string): string {
132
168
  if (hasErc8004) {
133
169
  imports.push('import { useERC8004 } from "aixyz/server/adapters/erc-8004";');
134
170
  imports.push(`import * as erc8004 from "${importPrefix}/erc-8004";`);
171
+ const a2aPaths: string[] = [];
172
+ if (rootAgent) a2aPaths.push("/.well-known/agent-card.json");
173
+ for (const subAgent of subAgents) a2aPaths.push(`/${subAgent.name}/.well-known/agent-card.json`);
135
174
  body.push(
136
- `useERC8004(server, { default: erc8004.default, options: { mcp: ${tools.length > 0}, a2a: ${hasAgent} } });`,
175
+ `useERC8004(server, { default: erc8004.default, options: { mcp: ${tools.length > 0}, a2a: ${JSON.stringify(a2aPaths)} } });`,
137
176
  );
138
177
  }
139
178
 
package/build/index.ts CHANGED
@@ -14,7 +14,7 @@ interface BuildOptions {
14
14
 
15
15
  export const buildCommand = new Command("build")
16
16
  .description("Build the aixyz agent")
17
- .option("--output <type>", "Output format: 'standalone' or 'vercel'")
17
+ .option("--output <type>", "Output format: 'standalone', 'vercel', or 'executable'")
18
18
  .addHelpText(
19
19
  "after",
20
20
  `
@@ -28,6 +28,10 @@ Details:
28
28
  Generates Vercel Build Output API v3 structure at .vercel/output/
29
29
  (Automatically detected when deploying to Vercel)
30
30
 
31
+ With --output executable:
32
+ Compiles into a self-contained binary at ./.aixyz/output/server
33
+ (No Bun runtime required to run the output)
34
+
31
35
  The build process:
32
36
  1. Loads aixyz.config.ts from the current directory
33
37
  2. Detects entrypoint (app/server.ts or auto-generates from app/agent.ts + app/tools/)
@@ -42,6 +46,7 @@ Examples:
42
46
  $ aixyz build # Build standalone (default)
43
47
  $ aixyz build --output standalone # Build standalone explicitly
44
48
  $ aixyz build --output vercel # Build for Vercel deployment
49
+ $ aixyz build --output executable # Build self-contained binary
45
50
  $ VERCEL=1 aixyz build # Auto-detected Vercel build`,
46
51
  )
47
52
  .action(action);
@@ -59,7 +64,10 @@ async function action(options: BuildOptions = {}): Promise<void> {
59
64
 
60
65
  if (target === "vercel") {
61
66
  console.log(chalk.cyan("▶") + " Building for " + chalk.bold("Vercel") + "...");
62
- await buildVercel(entrypoint);
67
+ await buildVercel(entrypoint, config);
68
+ } else if (target === "executable") {
69
+ console.log(chalk.cyan("▶") + " Building " + chalk.bold("Executable") + "...");
70
+ await buildExecutable(entrypoint);
63
71
  } else {
64
72
  console.log(chalk.cyan("▶") + " Building for " + chalk.bold("Standalone") + "...");
65
73
  await buildBun(entrypoint);
@@ -125,7 +133,63 @@ async function buildBun(entrypoint: string): Promise<void> {
125
133
  console.log("To run: bun .aixyz/output/server.js");
126
134
  }
127
135
 
128
- async function buildVercel(entrypoint: string): Promise<void> {
136
+ async function buildExecutable(entrypoint: string): Promise<void> {
137
+ const cwd = process.cwd();
138
+
139
+ const outputDir = resolve(cwd, ".aixyz/output");
140
+ rmSync(outputDir, { recursive: true, force: true });
141
+ mkdirSync(outputDir, { recursive: true });
142
+
143
+ const outfile = resolve(outputDir, "server");
144
+
145
+ // Build as a self-contained compiled binary using Bun's compile feature
146
+ const result = await Bun.build({
147
+ entrypoints: [entrypoint],
148
+ outdir: outputDir,
149
+ target: "bun",
150
+ sourcemap: "linked",
151
+ compile: { outfile },
152
+ define: {
153
+ "process.env.NODE_ENV": JSON.stringify("production"),
154
+ "process.env.AIXYZ_ENV": JSON.stringify("production"),
155
+ },
156
+ plugins: [AixyzConfigPlugin(), AixyzServerPlugin(entrypoint, "executable")],
157
+ });
158
+
159
+ if (!result.success) {
160
+ console.error("Build failed:");
161
+ for (const log of result.logs) {
162
+ console.error(log);
163
+ }
164
+ process.exit(1);
165
+ }
166
+
167
+ // Copy static assets (public/ → .aixyz/output/public/)
168
+ const publicDir = resolve(cwd, "public");
169
+ if (existsSync(publicDir)) {
170
+ const destPublicDir = resolve(outputDir, "public");
171
+ cpSync(publicDir, destPublicDir, { recursive: true });
172
+ console.log("Copied public/ →", destPublicDir);
173
+ }
174
+
175
+ const iconFile = findIconFile(resolve(cwd, "app"));
176
+ if (iconFile) {
177
+ await copyAgentIcon(iconFile, resolve(outputDir, "icon.png"));
178
+ await generateFavicon(iconFile, resolve(outputDir, "public/favicon.ico"));
179
+ }
180
+
181
+ // Log summary
182
+ console.log("");
183
+ console.log("Build complete! Output:");
184
+ console.log(" .aixyz/output/server");
185
+ if (existsSync(publicDir) || iconFile) {
186
+ console.log(" .aixyz/output/public/ and assets");
187
+ }
188
+ console.log("");
189
+ console.log("To run: ./.aixyz/output/server");
190
+ }
191
+
192
+ async function buildVercel(entrypoint: string, config: ReturnType<typeof getAixyzConfig>): Promise<void> {
129
193
  const cwd = process.cwd();
130
194
 
131
195
  const outputDir = resolve(cwd, ".vercel/output");
@@ -165,6 +229,7 @@ async function buildVercel(entrypoint: string): Promise<void> {
165
229
  handler: "server.js",
166
230
  runtime: "bun1.x",
167
231
  launcherType: "Bun",
232
+ maxDuration: config.vercel.maxDuration,
168
233
  shouldAddHelpers: true,
169
234
  shouldAddSourcemapSupport: true,
170
235
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aixyz/cli",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Payment-native SDK for AI Agent",
5
5
  "keywords": [
6
6
  "ai",
@@ -27,8 +27,8 @@
27
27
  "bin.ts"
28
28
  ],
29
29
  "dependencies": {
30
- "@aixyz/config": "0.11.0",
31
- "@aixyz/erc-8004": "0.11.0",
30
+ "@aixyz/config": "0.13.0",
31
+ "@aixyz/erc-8004": "0.13.0",
32
32
  "@inquirer/prompts": "^8.3.0",
33
33
  "@next/env": "^16.1.6",
34
34
  "boxen": "^8.0.1",
package/register/index.ts CHANGED
@@ -22,6 +22,10 @@ erc8004Command
22
22
  .option("--browser", "Use browser extension wallet (any extension)")
23
23
  .option("--broadcast", "Sign and broadcast the transaction (default: dry-run)")
24
24
  .option("--out-dir <path>", "Write deployment result as JSON to the given directory")
25
+ .option(
26
+ "--supported-trust <values>",
27
+ 'Comma-separated supported trust mechanisms (e.g., "reputation,tee-attestation"). If omitted and app/erc-8004.ts does not exist, you will be prompted interactively.',
28
+ )
25
29
  .addHelpText(
26
30
  "after",
27
31
  `
@@ -64,6 +68,13 @@ Option Details:
64
68
  --out-dir <path>
65
69
  Directory to write the deployment result as a JSON file.
66
70
 
71
+ --supported-trust <values>
72
+ Comma-separated list of trust mechanisms to declare in app/erc-8004.ts
73
+ when it does not yet exist. Valid values: reputation, crypto-economic,
74
+ tee-attestation, social, governance.
75
+ Example: --supported-trust "reputation,tee-attestation"
76
+ If omitted, you will be prompted to select interactively.
77
+
67
78
  Behavior:
68
79
  If app/erc-8004.ts does not exist, you will be prompted to create it
69
80
  (selecting supported trust mechanisms). After a successful on-chain
@@ -80,6 +91,9 @@ Examples:
80
91
  $ aixyz erc-8004 register --url "https://my-agent.example.com" --chain-id 84532 --keystore ~/.foundry/keystores/default --broadcast
81
92
  $ aixyz erc-8004 register --url "https://my-agent.example.com" --chain-id 84532 --browser --broadcast
82
93
 
94
+ # Non-interactive (CI-friendly)
95
+ $ aixyz erc-8004 register --url "https://my-agent.example.com" --chain-id 84532 --supported-trust "reputation,tee-attestation" --keystore ~/.foundry/keystores/default --broadcast
96
+
83
97
  # BYO: register on any custom EVM chain
84
98
  $ aixyz erc-8004 register --url "https://my-agent.example.com" --chain-id 999999 --rpc-url https://my-rpc.example.com --registry 0xABCD... --broadcast`,
85
99
  )
@@ -89,6 +103,7 @@ erc8004Command
89
103
  .command("update")
90
104
  .description("Update the metadata URI of a registered agent")
91
105
  .option("--url <url>", "New agent deployment URL (e.g., https://my-agent.example.com)")
106
+ .option("--agent-id <id>", "Agent ID to update (selects which registration from app/erc-8004.ts)", parseInt)
92
107
  .option("--rpc-url <url>", "Custom RPC URL (uses default if not provided)")
93
108
  .option("--registry <address>", "Contract address of the IdentityRegistry (required for localhost)")
94
109
  .option("--keystore <path>", "Path to Ethereum keystore (V3) JSON file for local signing")
@@ -104,6 +119,11 @@ Option Details:
104
119
  The URI will be derived as <url>/_aixyz/erc-8004.json.
105
120
  If omitted, you will be prompted to enter the URL interactively.
106
121
 
122
+ --agent-id <id>
123
+ Agent ID (numeric) to select which registration from app/erc-8004.ts
124
+ to update. Required in non-interactive (non-TTY) mode when multiple
125
+ registrations exist.
126
+
107
127
  --rpc-url <url>
108
128
  Custom RPC endpoint URL. Overrides the default RPC for the selected
109
129
  chain. Cannot be used with --browser.
@@ -141,6 +161,9 @@ Examples:
141
161
 
142
162
  # Sign and broadcast
143
163
  $ aixyz erc-8004 update --url "https://new-domain.example.com" --keystore ~/.foundry/keystores/default --broadcast
144
- $ aixyz erc-8004 update --url "https://new-domain.example.com" --browser --broadcast`,
164
+ $ aixyz erc-8004 update --url "https://new-domain.example.com" --browser --broadcast
165
+
166
+ # Non-interactive (CI-friendly)
167
+ $ aixyz erc-8004 update --url "https://new-domain.example.com" --agent-id 42 --keystore ~/.foundry/keystores/default --broadcast`,
145
168
  )
146
169
  .action(update);
@@ -12,7 +12,14 @@ import {
12
12
  } from "./utils/chain";
13
13
  import { writeResultJson } from "./utils/result";
14
14
  import { label, truncateUri, broadcastAndConfirm, logSignResult } from "./utils/transaction";
15
- import { promptAgentUrl, promptSupportedTrust, promptRegistryAddress, deriveAgentUri } from "./utils/prompt";
15
+ import {
16
+ promptAgentUrl,
17
+ promptSupportedTrust,
18
+ promptRegistryAddress,
19
+ deriveAgentUri,
20
+ isTTY,
21
+ parseSupportedTrust,
22
+ } from "./utils/prompt";
16
23
  import { hasErc8004File, createErc8004File, writeRegistrationEntry } from "./utils/erc8004-file";
17
24
  import { confirm } from "@inquirer/prompts";
18
25
  import chalk from "chalk";
@@ -22,6 +29,7 @@ import type { BaseOptions } from "./index";
22
29
  export interface RegisterOptions extends BaseOptions {
23
30
  url?: string;
24
31
  chainId?: number;
32
+ supportedTrust?: string;
25
33
  }
26
34
 
27
35
  export async function register(options: RegisterOptions): Promise<void> {
@@ -29,7 +37,9 @@ export async function register(options: RegisterOptions): Promise<void> {
29
37
  if (!hasErc8004File()) {
30
38
  console.log(chalk.yellow("No app/erc-8004.ts found. Let's create one."));
31
39
  console.log("");
32
- const supportedTrust = await promptSupportedTrust();
40
+ const supportedTrust = options.supportedTrust
41
+ ? parseSupportedTrust(options.supportedTrust)
42
+ : await promptSupportedTrust();
33
43
  createErc8004File(supportedTrust);
34
44
  console.log(chalk.green("Created app/erc-8004.ts"));
35
45
  console.log("");
@@ -39,12 +49,14 @@ export async function register(options: RegisterOptions): Promise<void> {
39
49
  const agentUrl = options.url ?? (await promptAgentUrl());
40
50
  const resolvedUri = deriveAgentUri(agentUrl);
41
51
 
42
- const yes = await confirm({
43
- message: `Will register URI as: ${chalk.cyan(resolvedUri)} — confirm?`,
44
- default: true,
45
- });
46
- if (!yes) {
47
- throw new Error("Aborted.");
52
+ if (isTTY()) {
53
+ const yes = await confirm({
54
+ message: `Will register URI as: ${chalk.cyan(resolvedUri)} — confirm?`,
55
+ default: true,
56
+ });
57
+ if (!yes) {
58
+ throw new Error("Aborted.");
59
+ }
48
60
  }
49
61
 
50
62
  // Step 3: Select chain
@@ -1,6 +1,15 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { CHAIN_ID, getIdentityRegistryAddress } from "@aixyz/erc-8004";
3
- import { deriveAgentUri } from "./utils/prompt";
3
+ import {
4
+ deriveAgentUri,
5
+ isTTY,
6
+ parseSupportedTrust,
7
+ promptAgentUrl,
8
+ promptRegistryAddress,
9
+ promptSupportedTrust,
10
+ withTTY,
11
+ } from "./utils/prompt";
12
+ import { selectChain } from "./utils/chain";
4
13
 
5
14
  describe("update command chain configuration", () => {
6
15
  test("sepolia chain ID is correct", () => {
@@ -45,3 +54,112 @@ describe("deriveAgentUri", () => {
45
54
  );
46
55
  });
47
56
  });
57
+
58
+ describe("parseSupportedTrust", () => {
59
+ test("parses a single value", () => {
60
+ expect(parseSupportedTrust("reputation")).toStrictEqual(["reputation"]);
61
+ });
62
+
63
+ test("parses multiple comma-separated values", () => {
64
+ expect(parseSupportedTrust("reputation,tee-attestation")).toStrictEqual(["reputation", "tee-attestation"]);
65
+ });
66
+
67
+ test("trims whitespace around values", () => {
68
+ expect(parseSupportedTrust("reputation, tee-attestation , social")).toStrictEqual([
69
+ "reputation",
70
+ "tee-attestation",
71
+ "social",
72
+ ]);
73
+ });
74
+
75
+ test("throws for invalid trust mechanism", () => {
76
+ expect(() => parseSupportedTrust("reputation,invalid")).toThrow("Invalid trust mechanism(s): invalid");
77
+ });
78
+
79
+ test("throws for empty string", () => {
80
+ expect(() => parseSupportedTrust("")).toThrow("--supported-trust requires at least one value");
81
+ });
82
+
83
+ test("accepts all valid mechanisms", () => {
84
+ expect(parseSupportedTrust("reputation,crypto-economic,tee-attestation,social,governance")).toStrictEqual([
85
+ "reputation",
86
+ "crypto-economic",
87
+ "tee-attestation",
88
+ "social",
89
+ "governance",
90
+ ]);
91
+ });
92
+ });
93
+
94
+ describe("isTTY", () => {
95
+ test("returns a boolean", () => {
96
+ expect(typeof isTTY()).toBe("boolean");
97
+ });
98
+ });
99
+
100
+ describe("withTTY", () => {
101
+ async function withNoTTYOverride<T>(fn: () => Promise<T>): Promise<T> {
102
+ const originalIsTTY = process.stdin.isTTY;
103
+ (process.stdin as NodeJS.ReadStream & { isTTY: boolean | undefined }).isTTY = undefined;
104
+ try {
105
+ return await fn();
106
+ } finally {
107
+ (process.stdin as NodeJS.ReadStream & { isTTY: boolean | undefined }).isTTY = originalIsTTY;
108
+ }
109
+ }
110
+
111
+ test("throws the provided message when no TTY", async () => {
112
+ await withNoTTYOverride(async () => {
113
+ await expect(withTTY(() => Promise.resolve("ok"), "use --flag instead")).rejects.toThrow("use --flag instead");
114
+ });
115
+ });
116
+
117
+ test("returns the fn result when TTY is available", async () => {
118
+ const originalIsTTY = process.stdin.isTTY;
119
+ (process.stdin as NodeJS.ReadStream & { isTTY: boolean | undefined }).isTTY = true;
120
+ try {
121
+ const result = await withTTY(() => Promise.resolve("hello"), "should not throw");
122
+ expect(result).toBe("hello");
123
+ } finally {
124
+ (process.stdin as NodeJS.ReadStream & { isTTY: boolean | undefined }).isTTY = originalIsTTY;
125
+ }
126
+ });
127
+ });
128
+
129
+ describe("TTY-gated prompts throw in non-interactive environments", () => {
130
+ // Tests run in a piped (non-TTY) environment, so these error paths are exercised directly.
131
+
132
+ async function withNoTTY<T>(fn: () => Promise<T>): Promise<T> {
133
+ const originalIsTTY = process.stdin.isTTY;
134
+ (process.stdin as NodeJS.ReadStream & { isTTY: boolean | undefined }).isTTY = undefined;
135
+ try {
136
+ return await fn();
137
+ } finally {
138
+ (process.stdin as NodeJS.ReadStream & { isTTY: boolean | undefined }).isTTY = originalIsTTY;
139
+ }
140
+ }
141
+
142
+ test("promptAgentUrl throws when stdin is not a TTY", async () => {
143
+ await withNoTTY(async () => {
144
+ await expect(promptAgentUrl()).rejects.toThrow("No TTY detected. Provide --url");
145
+ });
146
+ });
147
+
148
+ test("promptSupportedTrust throws when stdin is not a TTY", async () => {
149
+ await withNoTTY(async () => {
150
+ await expect(promptSupportedTrust()).rejects.toThrow("No TTY detected");
151
+ });
152
+ });
153
+
154
+ test("promptRegistryAddress throws when stdin is not a TTY", async () => {
155
+ await withNoTTY(async () => {
156
+ await expect(promptRegistryAddress()).rejects.toThrow("No TTY detected. Provide --registry");
157
+ });
158
+ });
159
+
160
+ test("selectChain throws when stdin is not a TTY", async () => {
161
+ await withNoTTY(async () => {
162
+ await expect(selectChain()).rejects.toThrow("No TTY detected. Provide --chain-id");
163
+ });
164
+ });
165
+ });
@@ -5,7 +5,7 @@ import { signTransaction } from "./wallet/sign";
5
5
  import { resolveChainConfigById, validateBrowserRpcConflict, getExplorerUrl, CHAINS } from "./utils/chain";
6
6
  import { writeResultJson } from "./utils/result";
7
7
  import { label, truncateUri, broadcastAndConfirm, logSignResult } from "./utils/transaction";
8
- import { promptAgentUrl, promptSelectRegistration, deriveAgentUri } from "./utils/prompt";
8
+ import { promptAgentUrl, promptSelectRegistration, deriveAgentUri, isTTY } from "./utils/prompt";
9
9
  import { readRegistrations } from "./utils/erc8004-file";
10
10
  import { confirm } from "@inquirer/prompts";
11
11
  import chalk from "chalk";
@@ -14,6 +14,7 @@ import type { BaseOptions } from "./index";
14
14
 
15
15
  export interface UpdateOptions extends BaseOptions {
16
16
  url?: string;
17
+ agentId?: number;
17
18
  }
18
19
 
19
20
  export async function update(options: UpdateOptions): Promise<void> {
@@ -25,7 +26,18 @@ export async function update(options: UpdateOptions): Promise<void> {
25
26
  }
26
27
 
27
28
  // Step 2: Select which registration to update
28
- const selected = await promptSelectRegistration(registrations);
29
+ let selected: (typeof registrations)[number];
30
+ if (options.agentId !== undefined) {
31
+ const match = registrations.find((r) => r.agentId === options.agentId);
32
+ if (!match) {
33
+ throw new Error(
34
+ `No registration found with agentId ${options.agentId}. Available: ${registrations.map((r) => r.agentId).join(", ")}`,
35
+ );
36
+ }
37
+ selected = match;
38
+ } else {
39
+ selected = await promptSelectRegistration(registrations);
40
+ }
29
41
 
30
42
  // Step 3: Derive chain info from agentRegistry (eip155:<chainId>:<address>)
31
43
  const parts = selected.agentRegistry.split(":");
@@ -42,12 +54,14 @@ export async function update(options: UpdateOptions): Promise<void> {
42
54
  const agentUrl = options.url ?? (await promptAgentUrl());
43
55
  const resolvedUri = deriveAgentUri(agentUrl);
44
56
 
45
- const yes = await confirm({
46
- message: `Will update URI to: ${chalk.cyan(resolvedUri)} — confirm?`,
47
- default: true,
48
- });
49
- if (!yes) {
50
- throw new Error("Aborted.");
57
+ if (isTTY()) {
58
+ const yes = await confirm({
59
+ message: `Will update URI to: ${chalk.cyan(resolvedUri)} — confirm?`,
60
+ default: true,
61
+ });
62
+ if (!yes) {
63
+ throw new Error("Aborted.");
64
+ }
51
65
  }
52
66
 
53
67
  // Step 5: Encode transaction
@@ -33,6 +33,7 @@ import {
33
33
  } from "viem/chains";
34
34
  import { CHAIN_ID, getIdentityRegistryAddress } from "@aixyz/erc-8004";
35
35
  import { input, select } from "@inquirer/prompts";
36
+ import { withTTY } from "./prompt";
36
37
 
37
38
  export interface ChainConfig {
38
39
  chain: Chain;
@@ -156,29 +157,31 @@ export function resolveChainConfigById(chainId: number, rpcUrl?: string): ChainC
156
157
  // Prompt user to select a chain interactively, returning the numeric chain ID.
157
158
  // Chains are sorted by popularity. "Other" allows entering any custom chain ID.
158
159
  export async function selectChain(): Promise<number> {
159
- const chainById = new Map(Object.values(CHAINS).map((c) => [c.chainId, c]));
160
- const nameById = new Map(Object.entries(CHAINS).map(([name, c]) => [c.chainId, name]));
160
+ return withTTY(async () => {
161
+ const chainById = new Map(Object.values(CHAINS).map((c) => [c.chainId, c]));
162
+ const nameById = new Map(Object.entries(CHAINS).map(([name, c]) => [c.chainId, name]));
161
163
 
162
- const choices = CHAIN_SELECTION_ORDER.filter((id) => chainById.has(id)).map((id) => ({
163
- name: `${nameById.get(id) ?? `chain-${id}`} (${id})`,
164
- value: id,
165
- }));
166
- choices.push({ name: "Other (enter chain ID)", value: OTHER_CHAIN_ID });
164
+ const choices = CHAIN_SELECTION_ORDER.filter((id) => chainById.has(id)).map((id) => ({
165
+ name: `${nameById.get(id) ?? `chain-${id}`} (${id})`,
166
+ value: id,
167
+ }));
168
+ choices.push({ name: "Other (enter chain ID)", value: OTHER_CHAIN_ID });
167
169
 
168
- const selected = await select({ message: "Select target chain:", choices });
170
+ const selected = await select({ message: "Select target chain:", choices });
169
171
 
170
- if (selected === OTHER_CHAIN_ID) {
171
- const raw = await input({
172
- message: "Enter chain ID:",
173
- validate: (v) => {
174
- const n = parseInt(v, 10);
175
- return Number.isInteger(n) && n > 0 ? true : "Must be a positive integer";
176
- },
177
- });
178
- return parseInt(raw, 10);
179
- }
172
+ if (selected === OTHER_CHAIN_ID) {
173
+ const raw = await input({
174
+ message: "Enter chain ID:",
175
+ validate: (v) => {
176
+ const n = parseInt(v, 10);
177
+ return Number.isInteger(n) && n > 0 ? true : "Must be a positive integer";
178
+ },
179
+ });
180
+ return parseInt(raw, 10);
181
+ }
180
182
 
181
- return selected;
183
+ return selected;
184
+ }, "No TTY detected. Provide --chain-id to specify the target chain.");
182
185
  }
183
186
 
184
187
  // Returns the registry address if known, or null if no default exists for the chain (requires interactive prompt).
@@ -2,40 +2,81 @@ import { checkbox, confirm, input, select } from "@inquirer/prompts";
2
2
  import { isAddress } from "viem";
3
3
  import type { RegistrationEntry } from "@aixyz/erc-8004/schemas/registration";
4
4
 
5
+ export function isTTY(): boolean {
6
+ return Boolean(process.stdin.isTTY);
7
+ }
8
+
9
+ export async function withTTY<T>(fn: () => Promise<T>, nonTtyMessage: string): Promise<T> {
10
+ if (!isTTY()) {
11
+ throw new Error(nonTtyMessage);
12
+ }
13
+ return fn();
14
+ }
15
+
5
16
  export async function promptAgentUrl(): Promise<string> {
6
- return input({
7
- message: "Agent deployment URL (e.g., https://my-agent.example.com):",
8
- validate: (value) => {
9
- try {
10
- const url = new URL(value);
11
- if (url.protocol !== "https:" && url.protocol !== "http:") {
12
- return "URL must start with https:// or http://";
13
- }
14
- return true;
15
- } catch {
16
- return "Must be a valid URL (e.g., https://my-agent.example.com)";
17
- }
18
- },
19
- });
17
+ return withTTY(
18
+ () =>
19
+ input({
20
+ message: "Agent deployment URL (e.g., https://my-agent.example.com):",
21
+ validate: (value) => {
22
+ try {
23
+ const url = new URL(value);
24
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
25
+ return "URL must start with https:// or http://";
26
+ }
27
+ return true;
28
+ } catch {
29
+ return "Must be a valid URL (e.g., https://my-agent.example.com)";
30
+ }
31
+ },
32
+ }),
33
+ "No TTY detected. Provide --url to specify the agent deployment URL.",
34
+ );
20
35
  }
21
36
 
22
37
  export async function promptSupportedTrust(): Promise<string[]> {
23
- return checkbox({
24
- message: "Select supported trust mechanisms:",
25
- choices: [
26
- { name: "reputation", value: "reputation", checked: true },
27
- { name: "crypto-economic", value: "crypto-economic" },
28
- { name: "tee-attestation", value: "tee-attestation" },
29
- { name: "social", value: "social" },
30
- { name: "governance", value: "governance" },
31
- ],
32
- required: true,
33
- });
38
+ return withTTY(
39
+ () =>
40
+ checkbox({
41
+ message: "Select supported trust mechanisms:",
42
+ choices: [
43
+ { name: "reputation", value: "reputation", checked: true },
44
+ { name: "crypto-economic", value: "crypto-economic" },
45
+ { name: "tee-attestation", value: "tee-attestation" },
46
+ { name: "social", value: "social" },
47
+ { name: "governance", value: "governance" },
48
+ ],
49
+ required: true,
50
+ }),
51
+ `No TTY detected. Provide --supported-trust to specify trust mechanisms (e.g., --supported-trust "reputation,tee-attestation").`,
52
+ );
53
+ }
54
+
55
+ const VALID_TRUST_MECHANISMS = ["reputation", "crypto-economic", "tee-attestation", "social", "governance"] as const;
56
+
57
+ export function parseSupportedTrust(value: string): string[] {
58
+ const items = value
59
+ .split(",")
60
+ .map((s) => s.trim())
61
+ .filter(Boolean);
62
+ const invalid = items.filter((i) => !(VALID_TRUST_MECHANISMS as readonly string[]).includes(i));
63
+ if (invalid.length > 0) {
64
+ throw new Error(
65
+ `Invalid trust mechanism(s): ${invalid.join(", ")}. Valid values: ${VALID_TRUST_MECHANISMS.join(", ")}`,
66
+ );
67
+ }
68
+ if (items.length === 0) {
69
+ throw new Error("--supported-trust requires at least one value.");
70
+ }
71
+ return items;
34
72
  }
35
73
 
36
74
  export async function promptSelectRegistration(registrations: RegistrationEntry[]): Promise<RegistrationEntry> {
37
75
  if (registrations.length === 1) {
38
76
  const reg = registrations[0]!;
77
+ if (!isTTY()) {
78
+ return reg;
79
+ }
39
80
  const yes = await confirm({
40
81
  message: `Update this registration? (agentId: ${reg.agentId}, registry: ${reg.agentRegistry})`,
41
82
  default: true,
@@ -46,21 +87,27 @@ export async function promptSelectRegistration(registrations: RegistrationEntry[
46
87
  return reg;
47
88
  }
48
89
 
49
- return select({
50
- message: "Select registration to update:",
51
- choices: registrations.map((reg) => ({
52
- name: `agentId: ${reg.agentId} ${reg.agentRegistry}`,
53
- value: reg,
54
- })),
55
- });
90
+ return withTTY(
91
+ () =>
92
+ select({
93
+ message: "Select registration to update:",
94
+ choices: registrations.map((reg) => ({
95
+ name: `agentId: ${reg.agentId} — ${reg.agentRegistry}`,
96
+ value: reg,
97
+ })),
98
+ }),
99
+ "No TTY detected. Multiple registrations found in app/erc-8004.ts; cannot select one non-interactively.",
100
+ );
56
101
  }
57
102
 
58
103
  export async function promptRegistryAddress(): Promise<`0x${string}`> {
59
- const value = await input({
60
- message: "IdentityRegistry contract address (no default for this chain):",
61
- validate: (v) => (isAddress(v) ? true : "Must be a valid Ethereum address (0x…)"),
62
- });
63
- return value as `0x${string}`;
104
+ return withTTY(async () => {
105
+ const value = await input({
106
+ message: "IdentityRegistry contract address (no default for this chain):",
107
+ validate: (v) => (isAddress(v) ? true : "Must be a valid Ethereum address (0x…)"),
108
+ });
109
+ return value as `0x${string}`;
110
+ }, "No TTY detected. Provide --registry to specify the IdentityRegistry contract address.");
64
111
  }
65
112
 
66
113
  export function deriveAgentUri(url: string): string {
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import type { Chain, WalletClient } from "viem";
3
3
  import { select, input, password } from "@inquirer/prompts";
4
+ import { withTTY } from "../utils/prompt";
4
5
  import { createPrivateKeyWallet } from "./privatekey";
5
6
  import { createKeystoreWallet } from "./keystore";
6
7
 
@@ -33,36 +34,38 @@ export async function selectWalletMethod(options: WalletOptions): Promise<Wallet
33
34
  }
34
35
 
35
36
  // Interactive: prompt user to choose
36
- const method = await select({
37
- message: "Select signing method:",
38
- choices: [
39
- { name: "Keystore file", value: "keystore" },
40
- { name: "Browser wallet (any EIP-6963 compatible wallets)", value: "browser" },
41
- { name: "Private key (not recommended)", value: "privatekey" },
42
- ],
43
- });
37
+ return withTTY(async () => {
38
+ const method = await select({
39
+ message: "Select signing method:",
40
+ choices: [
41
+ { name: "Keystore file", value: "keystore" },
42
+ { name: "Browser wallet (any EIP-6963 compatible wallets)", value: "browser" },
43
+ { name: "Private key (not recommended)", value: "privatekey" },
44
+ ],
45
+ });
44
46
 
45
- switch (method) {
46
- case "keystore": {
47
- const keystorePath = await input({
48
- message: "Enter keystore path:",
49
- default: `${homedir()}/.foundry/keystores/default`,
50
- });
51
- return { type: "keystore", path: keystorePath };
47
+ switch (method) {
48
+ case "keystore": {
49
+ const keystorePath = await input({
50
+ message: "Enter keystore path:",
51
+ default: `${homedir()}/.foundry/keystores/default`,
52
+ });
53
+ return { type: "keystore", path: keystorePath };
54
+ }
55
+ case "browser":
56
+ return { type: "browser" };
57
+ case "privatekey": {
58
+ const key = await password({
59
+ message: "Enter private key:",
60
+ mask: "*",
61
+ });
62
+ console.warn("Warning: Using raw private key is not recommended for production");
63
+ return { type: "privatekey", resolveKey: () => Promise.resolve(key) };
64
+ }
65
+ default:
66
+ throw new Error("No wallet method selected");
52
67
  }
53
- case "browser":
54
- return { type: "browser" };
55
- case "privatekey": {
56
- const key = await password({
57
- message: "Enter private key:",
58
- mask: "*",
59
- });
60
- console.warn("Warning: Using raw private key is not recommended for production");
61
- return { type: "privatekey", resolveKey: () => Promise.resolve(key) };
62
- }
63
- default:
64
- throw new Error("No wallet method selected");
65
- }
68
+ }, "No TTY detected. Provide --keystore, --browser, or PRIVATE_KEY environment variable to specify a signing method.");
66
69
  }
67
70
 
68
71
  export async function createWalletFromMethod(