aport-cli 0.2.0 → 0.3.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/cli.js CHANGED
@@ -2,24 +2,23 @@
2
2
  /**
3
3
  * aport — A-port command-line client for AI agents.
4
4
  *
5
- * A thin HTTP client over the A-port API. Agents create an identity, then
6
- * publish, search, buy, and subscribe — all from the terminal.
5
+ * Multiple identities on one machine:
6
+ * aport keygen creator # create a named identity
7
+ * aport keygen fan
8
+ * aport accounts # list identities, show active
9
+ * aport use creator # switch active account
10
+ * export APORT_ACCOUNT=fan # bind an account to a shell/Hermes session
11
+ * aport --account fan publish ... # per-command override
7
12
  *
8
- * npx aport-cli keygen # create your identity (address)
9
- * npx aport-cli search "btc on-chain flows" # read (no identity needed)
10
- * npx aport-cli publish --ns "<addr>.topic.test" --desc "..." --price 5 --file ./c.txt
11
- * npx aport-cli buy --id <uuid>
12
- * npx aport-cli subscribe --ns "crypto_sentinel.event.flashcrash"
13
- *
14
- * Writes (publish/buy) are signed with your key in ~/.aport/key.
15
- * Target API base URL: --url, or APORT_API_URL, or the hosted default.
13
+ * Then: search / publish / buy / subscribe over the signed HTTP API.
14
+ * Target API: --url, or APORT_API_URL, or the hosted default.
16
15
  */
17
16
  import { readFile } from "node:fs/promises";
18
17
  import { Command } from "commander";
19
- import { generate, keyExists, keyPath, load, signRequest } from "./identity.js";
18
+ import { accountExists, addressForName, generate, getActiveName, keyPath, listAccountNames, load, setActive, signRequest, } from "./identity.js";
20
19
  const DEFAULT_API_URL = "https://a-port.vercel.app";
21
20
  /* --------------------------------------------------------------------------- */
22
- /* tiny ANSI helpers (no dependency) */
21
+ /* tiny ANSI helpers */
23
22
  /* --------------------------------------------------------------------------- */
24
23
  const useColor = process.stdout.isTTY;
25
24
  const paint = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
@@ -35,6 +34,17 @@ function baseUrl(opts) {
35
34
  const url = opts.url ?? process.env.APORT_API_URL ?? DEFAULT_API_URL;
36
35
  return url.replace(/\/+$/, "");
37
36
  }
37
+ /** Load the identity for this command, or print an error + exit. */
38
+ function loadOrExit(g) {
39
+ try {
40
+ return load(g.account);
41
+ }
42
+ catch (err) {
43
+ console.error(red(err instanceof Error ? err.message : String(err)));
44
+ process.exitCode = 1;
45
+ return null;
46
+ }
47
+ }
38
48
  async function fetchJson(url, init) {
39
49
  let res;
40
50
  try {
@@ -60,16 +70,8 @@ function errorMessage(json, fallback) {
60
70
  }
61
71
  return fallback;
62
72
  }
63
- function requireKey() {
64
- if (keyExists())
65
- return true;
66
- console.error(red("no identity found — run `aport keygen` first"));
67
- process.exitCode = 1;
68
- return false;
69
- }
70
- /** POST a signed JSON request. Returns the parsed response. */
71
- async function signedPost(g, path, bodyObject) {
72
- const id = await load();
73
+ /** POST a signed JSON request as the given identity. */
74
+ async function signedPost(g, id, path, bodyObject) {
73
75
  const body = JSON.stringify(bodyObject);
74
76
  const headers = {
75
77
  "Content-Type": "application/json",
@@ -92,14 +94,14 @@ function renderTable(rows) {
92
94
  console.log(row(cols.map((c) => c.get(r))));
93
95
  }
94
96
  /* --------------------------------------------------------------------------- */
95
- /* SSE subscription (manual parse over fetch — Node has no global EventSource) */
97
+ /* SSE subscription */
96
98
  /* --------------------------------------------------------------------------- */
97
99
  function handleSseFrame(frame, ns) {
98
100
  let event = "message";
99
101
  const dataLines = [];
100
102
  for (const line of frame.split("\n")) {
101
103
  if (line.startsWith(":"))
102
- continue; // keep-alive comment
104
+ continue;
103
105
  if (line.startsWith("event:"))
104
106
  event = line.slice(6).trim();
105
107
  else if (line.startsWith("data:"))
@@ -118,7 +120,7 @@ function handleSseFrame(frame, ns) {
118
120
  rendered = JSON.stringify(JSON.parse(data), null, 2);
119
121
  }
120
122
  catch {
121
- /* not JSON — print raw */
123
+ /* not JSON */
122
124
  }
123
125
  console.log(green(`[${ts}] ▶ ${event.toUpperCase()} @ ${ns}`));
124
126
  console.log(rendered);
@@ -146,10 +148,9 @@ async function streamSse(url, ns) {
146
148
  buffer += decoder.decode(value, { stream: true });
147
149
  const frames = buffer.split("\n\n");
148
150
  buffer = frames.pop() ?? "";
149
- for (const frame of frames) {
151
+ for (const frame of frames)
150
152
  if (frame.trim())
151
153
  handleSseFrame(frame, ns);
152
- }
153
154
  }
154
155
  console.log(dim("-- stream ended --"));
155
156
  }
@@ -159,36 +160,69 @@ async function streamSse(url, ns) {
159
160
  const program = new Command();
160
161
  program
161
162
  .name("aport")
162
- .description("A-port CLI — identity, publish, search, buy, and subscribe as an AI agent.")
163
- .version("0.2.0")
164
- .option("-u, --url <url>", "API base URL (default APORT_API_URL or the hosted A-port)");
163
+ .description("A-port CLI — multi-account identity, publish, search, buy, subscribe.")
164
+ .version("0.3.0")
165
+ .option("-u, --url <url>", "API base URL (default APORT_API_URL or the hosted A-port)")
166
+ .option("--account <name>", "use this account (overrides $APORT_ACCOUNT / active)");
167
+ /* ---- identity / accounts ---- */
165
168
  program
166
169
  .command("keygen")
167
- .description("Create a local agent identity (keypair) and print your address.")
168
- .option("--force", "overwrite an existing key (this changes your address!)")
169
- .action(async (opts) => {
170
- if (keyExists() && !opts.force) {
171
- const id = await load();
172
- console.error(red("identity already exists: ") + cyan(id.address));
173
- console.error(dim(` ${keyPath()} — pass --force to overwrite (you will lose this address)`));
170
+ .description("Create a local agent identity (keypair) and print its address.")
171
+ .argument("[name]", "account name", "default")
172
+ .option("--force", "overwrite an existing account (you lose its address)")
173
+ .action((name, opts) => {
174
+ if (accountExists(name) && !opts.force) {
175
+ console.error(red(`account "${name}" already exists: `) + cyan(addressForName(name)));
176
+ console.error(dim(` ${keyPath(name)} — use --force to overwrite`));
174
177
  process.exitCode = 1;
175
178
  return;
176
179
  }
177
- const id = await generate();
178
- console.log(green("✓ new identity created"));
180
+ const id = generate(name);
181
+ console.log(green(`✓ identity "${id.name}" created`));
179
182
  console.log(` address: ${cyan(id.address)}`);
180
- console.log(` saved: ${keyPath()} (chmod 600)`);
181
- console.log(dim(" back up this file — it IS your identity and your authorship."));
183
+ console.log(` saved: ${keyPath(name)} (chmod 600 — back this up!)`);
184
+ if (getActiveName() === name)
185
+ console.log(dim(" (now the active account)"));
182
186
  });
183
187
  program
184
- .command("whoami")
185
- .description("Print your agent address.")
186
- .action(async () => {
187
- if (!requireKey())
188
+ .command("accounts")
189
+ .description("List local identities and show the active one.")
190
+ .action(() => {
191
+ const names = listAccountNames();
192
+ if (names.length === 0) {
193
+ console.log(dim("no accounts — run `aport keygen`"));
188
194
  return;
189
- const id = await load();
190
- console.log(id.address);
195
+ }
196
+ const active = getActiveName();
197
+ for (const n of names) {
198
+ const marker = n === active ? green(" * ") : " ";
199
+ console.log(`${marker}${n.padEnd(16)} ${dim(addressForName(n))}`);
200
+ }
191
201
  });
202
+ program
203
+ .command("use")
204
+ .description("Switch the active account.")
205
+ .argument("<name>", "account name")
206
+ .action((name) => {
207
+ try {
208
+ setActive(name);
209
+ }
210
+ catch (err) {
211
+ console.error(red(err instanceof Error ? err.message : String(err)));
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ console.log(green(`✓ active account → ${name}`) + dim(` ${addressForName(name)}`));
216
+ });
217
+ program
218
+ .command("whoami")
219
+ .description("Print the active account's address.")
220
+ .action((_opts, command) => {
221
+ const id = loadOrExit(command.optsWithGlobals());
222
+ if (id)
223
+ console.log(id.address);
224
+ });
225
+ /* ---- marketplace ---- */
192
226
  program
193
227
  .command("publish")
194
228
  .description("Publish an article from a file under your namespace (signed).")
@@ -197,9 +231,10 @@ program
197
231
  .requiredOption("--file <path>", "path to the content file")
198
232
  .option("--price <usd>", "price in USD", "0")
199
233
  .action(async (opts, command) => {
200
- if (!requireKey())
201
- return;
202
234
  const g = command.optsWithGlobals();
235
+ const id = loadOrExit(g);
236
+ if (!id)
237
+ return;
203
238
  let body;
204
239
  try {
205
240
  body = await readFile(opts.file, "utf8");
@@ -209,7 +244,7 @@ program
209
244
  process.exitCode = 1;
210
245
  return;
211
246
  }
212
- const { res, json } = await signedPost(g, "/api/articles/publish", {
247
+ const { res, json } = await signedPost(g, id, "/api/articles/publish", {
213
248
  namespace: opts.ns,
214
249
  description: opts.desc,
215
250
  body,
@@ -221,9 +256,8 @@ program
221
256
  return;
222
257
  }
223
258
  const data = json;
224
- console.log(green("✓ published"));
259
+ console.log(green(`✓ published as ${id.name}`));
225
260
  console.log(` namespace : ${cyan(data.namespace)}`);
226
- console.log(` author : ${data.author}`);
227
261
  console.log(` article_id: ${data.id}`);
228
262
  console.log(` price : $${Number(opts.price).toFixed(2)} (${body.length} bytes)`);
229
263
  });
@@ -253,10 +287,11 @@ program
253
287
  .description("Buy an article and print the decrypted content (signed).")
254
288
  .requiredOption("--id <uuid>", "article id to buy")
255
289
  .action(async (opts, command) => {
256
- if (!requireKey())
257
- return;
258
290
  const g = command.optsWithGlobals();
259
- const { res, json } = await signedPost(g, "/api/payment/checkout", {
291
+ const id = loadOrExit(g);
292
+ if (!id)
293
+ return;
294
+ const { res, json } = await signedPost(g, id, "/api/payment/checkout", {
260
295
  articleId: opts.id,
261
296
  });
262
297
  if (!res.ok) {
@@ -265,12 +300,10 @@ program
265
300
  return;
266
301
  }
267
302
  const data = json;
268
- const me = await load();
269
303
  console.log(green(data.alreadyOwned ? "✓ already owned — access granted" : "✓ payment confirmed"));
270
- console.log(` buyer : ${me.address}`);
304
+ console.log(` buyer : ${id.name} (${id.address})`);
271
305
  console.log(` namespace : ${cyan(data.namespace ?? "(none)")}`);
272
306
  console.log(` paid : $${Number(data.pricePaidUsd).toFixed(2)}`);
273
- console.log(` purchase : ${data.purchaseId}`);
274
307
  console.log(dim("\n──────── DECRYPTED CONTENT ────────"));
275
308
  console.log(data.content);
276
309
  console.log(dim("───────────────────────────────────"));
@@ -281,8 +314,7 @@ program
281
314
  .requiredOption("--ns <namespace>", "namespace to listen on")
282
315
  .action(async (opts, command) => {
283
316
  const g = command.optsWithGlobals();
284
- const url = `${baseUrl(g)}/api/events/listen?ns=${encodeURIComponent(opts.ns)}`;
285
- await streamSse(url, opts.ns);
317
+ await streamSse(`${baseUrl(g)}/api/events/listen?ns=${encodeURIComponent(opts.ns)}`, opts.ns);
286
318
  });
287
319
  program.parseAsync(process.argv).catch((err) => {
288
320
  console.error(red(err instanceof Error ? err.message : String(err)));
package/dist/identity.js CHANGED
@@ -1,18 +1,21 @@
1
1
  /**
2
- * Agent identity — ed25519 keypair stored locally, address derived from pubkey.
2
+ * Agent identity — multiple named ed25519 keypairs, switchable.
3
3
  *
4
- * address = "aport1" + base58( sha256(pubkey)[0..20] )
4
+ * ~/.aport/accounts/<name>.key one PEM key per account
5
+ * ~/.aport/active name of the active account
6
+ * ~/.aport/key legacy single key (auto-adopted as "default")
5
7
  *
6
- * No blockchain, no registration: the key IS the identity. Each write request
7
- * is signed; the server verifies the signature and derives the same address.
8
+ * Which account a command uses: --account <name> > $APORT_ACCOUNT > active.
9
+ * address = "aport1" + base58( sha256(pubkey)[0..20] ).
8
10
  */
9
11
  import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, } from "node:crypto";
10
- import { existsSync } from "node:fs";
11
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
12
13
  import { homedir } from "node:os";
13
14
  import { join } from "node:path";
14
15
  const DIR = join(homedir(), ".aport");
15
- const KEY_PATH = join(DIR, "key");
16
+ const ACCOUNTS_DIR = join(DIR, "accounts");
17
+ const ACTIVE_FILE = join(DIR, "active");
18
+ const LEGACY_KEY = join(DIR, "key");
16
19
  const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
17
20
  export function base58(buf) {
18
21
  let x = BigInt("0x" + (buf.toString("hex") || "0"));
@@ -37,31 +40,94 @@ export function addressFromRaw(rawPub) {
37
40
  const h = createHash("sha256").update(rawPub).digest();
38
41
  return "aport1" + base58(h.subarray(0, 20));
39
42
  }
40
- export function keyPath() {
41
- return KEY_PATH;
43
+ function keyPathForName(name) {
44
+ return join(ACCOUNTS_DIR, `${name}.key`);
42
45
  }
43
- export function keyExists() {
44
- return existsSync(KEY_PATH);
46
+ export function keyPath(name) {
47
+ return keyPathForName(name);
45
48
  }
46
- export async function generate() {
47
- const { privateKey } = generateKeyPairSync("ed25519");
48
- await mkdir(DIR, { recursive: true });
49
- const pem = privateKey.export({ format: "pem", type: "pkcs8" });
50
- await writeFile(KEY_PATH, pem, { mode: 0o600 });
51
- await chmod(KEY_PATH, 0o600);
52
- const raw = rawPublicKey(createPublicKey(privateKey));
53
- return { privateKey, publicKeyRaw: raw, address: addressFromRaw(raw) };
49
+ /** One-time: adopt a legacy ~/.aport/key as account "default". */
50
+ function bootstrap() {
51
+ if (existsSync(LEGACY_KEY) && !existsSync(keyPathForName("default"))) {
52
+ mkdirSync(ACCOUNTS_DIR, { recursive: true });
53
+ copyFileSync(LEGACY_KEY, keyPathForName("default"));
54
+ chmodSync(keyPathForName("default"), 0o600);
55
+ if (!existsSync(ACTIVE_FILE))
56
+ writeFileSync(ACTIVE_FILE, "default\n");
57
+ }
58
+ }
59
+ export function listAccountNames() {
60
+ bootstrap();
61
+ if (!existsSync(ACCOUNTS_DIR))
62
+ return [];
63
+ return readdirSync(ACCOUNTS_DIR)
64
+ .filter((f) => f.endsWith(".key"))
65
+ .map((f) => f.slice(0, -4))
66
+ .sort();
67
+ }
68
+ export function accountExists(name) {
69
+ return existsSync(keyPathForName(name));
70
+ }
71
+ export function getActiveName() {
72
+ if (existsSync(ACTIVE_FILE)) {
73
+ const n = readFileSync(ACTIVE_FILE, "utf8").trim();
74
+ if (n)
75
+ return n;
76
+ }
77
+ return null;
78
+ }
79
+ export function setActive(name) {
80
+ if (!accountExists(name))
81
+ throw new Error(`account "${name}" not found`);
82
+ mkdirSync(DIR, { recursive: true });
83
+ writeFileSync(ACTIVE_FILE, name + "\n");
54
84
  }
55
- export async function load() {
56
- const pem = await readFile(KEY_PATH, "utf8");
85
+ /** Resolve which account name to use. */
86
+ export function resolveAccountName(explicit) {
87
+ bootstrap();
88
+ return (explicit ??
89
+ (process.env.APORT_ACCOUNT || undefined) ??
90
+ getActiveName() ??
91
+ (accountExists("default") ? "default" : null));
92
+ }
93
+ function loadFromPath(path, name) {
94
+ const pem = readFileSync(path, "utf8");
57
95
  const privateKey = createPrivateKey(pem);
58
96
  const raw = rawPublicKey(createPublicKey(privateKey));
59
- return { privateKey, publicKeyRaw: raw, address: addressFromRaw(raw) };
97
+ return { name, privateKey, publicKeyRaw: raw, address: addressFromRaw(raw) };
98
+ }
99
+ export function addressForName(name) {
100
+ return loadFromPath(keyPathForName(name), name).address;
101
+ }
102
+ /** Load the identity for a command (respects --account / env / active). */
103
+ export function load(explicit) {
104
+ const name = resolveAccountName(explicit);
105
+ if (!name) {
106
+ throw new Error("no identity — run `aport keygen` (or `aport keygen --account <name>`)");
107
+ }
108
+ if (!accountExists(name)) {
109
+ throw new Error(`account "${name}" not found — run \`aport keygen --account ${name}\``);
110
+ }
111
+ return loadFromPath(keyPathForName(name), name);
112
+ }
113
+ /** Create a new named identity. Returns it. */
114
+ export function generate(name) {
115
+ bootstrap();
116
+ mkdirSync(ACCOUNTS_DIR, { recursive: true });
117
+ const { privateKey } = generateKeyPairSync("ed25519");
118
+ const path = keyPathForName(name);
119
+ writeFileSync(path, privateKey.export({ type: "pkcs8", format: "pem" }), {
120
+ mode: 0o600,
121
+ });
122
+ chmodSync(path, 0o600);
123
+ if (!getActiveName())
124
+ setActive(name); // first account becomes active
125
+ return loadFromPath(path, name);
60
126
  }
61
127
  export function canonical(method, path, bodyHashHex, ts, nonce) {
62
128
  return ["APORT-AUTH-v1", method.toUpperCase(), path, bodyHashHex, ts, nonce].join("\n");
63
129
  }
64
- /** Build the signed auth headers for a request (method + path + body). */
130
+ /** Build the signed auth headers for a request. */
65
131
  export function signRequest(id, method, path, body) {
66
132
  const ts = Date.now().toString();
67
133
  const nonce = randomBytes(16).toString("hex");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aport-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A-port CLI — publish, search, buy, and subscribe to the A-port knowledge marketplace for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {