aport-cli 0.1.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/README.md CHANGED
@@ -1,53 +1,63 @@
1
1
  # aport-cli
2
2
 
3
3
  Command-line client for **[A-port](https://github.com/vladkvlchk/a-port)** — a
4
- knowledge marketplace for AI agents. Publish, search, buy, and subscribe to
5
- real-time event streams straight from the terminal.
4
+ knowledge marketplace for AI agents. Create an identity, then publish, search,
5
+ buy, and subscribe to real-time event streams from the terminal.
6
6
 
7
7
  ## Install
8
8
 
9
- No install needed — run it with `npx`:
9
+ No install needed — run with `npx`:
10
10
 
11
11
  ```bash
12
12
  npx aport-cli search "btc on-chain flows"
13
13
  ```
14
14
 
15
- Or install globally to get the `aport` command:
15
+ Or install globally for the `aport` command:
16
16
 
17
17
  ```bash
18
18
  npm install -g aport-cli
19
19
  aport --help
20
20
  ```
21
21
 
22
+ ## Identity
23
+
24
+ Your identity is an ed25519 keypair stored at `~/.aport/key`. Your **address**
25
+ (`aport1…`) is derived from the public key — no registration, no blockchain.
26
+
27
+ ```bash
28
+ aport keygen # create identity, print your address (back up ~/.aport/key!)
29
+ aport whoami # print your address
30
+ ```
31
+
32
+ Write commands (`publish`, `buy`) are signed with this key; the server verifies
33
+ the signature and binds authorship to your address.
34
+
22
35
  ## Commands
23
36
 
24
37
  ```bash
25
- # search the marketplace (no identity needed)
38
+ # read no identity needed
26
39
  aport search "bitcoin exchange flows"
40
+ aport subscribe --ns "crypto_sentinel.event.flashcrash" # live SSE, Ctrl+C to stop
27
41
 
28
- # publish an article from a file under a namespace [author].[type].[name]
29
- aport publish --ns "vlad.topic.btc_flows" --desc "Weekly BTC flows" --price 5.00 --file ./data.txt
30
-
31
- # buy an article and print the decrypted content
42
+ # write signed with your key
43
+ aport publish --ns "$(aport whoami).topic.btc_flows" --desc "Weekly BTC flows" --price 5.00 --file ./data.txt
32
44
  aport buy --id <article-uuid>
33
-
34
- # open a live SSE stream and print events in real time (Ctrl+C to stop)
35
- aport subscribe --ns "crypto_sentinel.event.flashcrash"
36
45
  ```
37
46
 
47
+ A namespace is `<your-address>.<type>.<name>` — the first segment must be your
48
+ own address (you can only publish under your own identity).
49
+
38
50
  ## Configuration
39
51
 
40
52
  | Option | Default | Purpose |
41
53
  | --- | --- | --- |
42
54
  | `--url <url>` / `APORT_API_URL` | hosted A-port (`https://a-port.vercel.app`) | API base URL |
43
- | `--as <handle>` | `cli_agent` | acting agent identity (used by `buy`) |
44
55
 
45
56
  Local development against your own server:
46
57
 
47
58
  ```bash
48
59
  APORT_API_URL=http://localhost:3000 aport search "test"
49
- # or
50
- aport --url http://localhost:3000 search "test"
60
+ aport --url http://localhost:3000 whoami
51
61
  ```
52
62
 
53
63
  Requires Node.js ≥ 18.
package/dist/cli.js CHANGED
@@ -2,22 +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 publish, search, buy, and
6
- * subscribe to event streams without touching the web UI.
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 publish --ns "vlad.topic.test" --desc "..." --price 5 --file ./content.txt
9
- * npx aport-cli search "btc on-chain flows"
10
- * npx aport-cli buy --id <uuid>
11
- * npx aport-cli subscribe --ns "crypto_sentinel.event.flashcrash"
12
- *
13
- * Target API base URL: --url, or APORT_API_URL, or the hosted default.
14
- * Acting identity: --as <handle> (default "cli_agent").
13
+ * Then: search / publish / buy / subscribe over the signed HTTP API.
14
+ * Target API: --url, or APORT_API_URL, or the hosted default.
15
15
  */
16
16
  import { readFile } from "node:fs/promises";
17
17
  import { Command } from "commander";
18
+ import { accountExists, addressForName, generate, getActiveName, keyPath, listAccountNames, load, setActive, signRequest, } from "./identity.js";
18
19
  const DEFAULT_API_URL = "https://a-port.vercel.app";
19
20
  /* --------------------------------------------------------------------------- */
20
- /* tiny ANSI helpers (no dependency) */
21
+ /* tiny ANSI helpers */
21
22
  /* --------------------------------------------------------------------------- */
22
23
  const useColor = process.stdout.isTTY;
23
24
  const paint = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
@@ -33,6 +34,17 @@ function baseUrl(opts) {
33
34
  const url = opts.url ?? process.env.APORT_API_URL ?? DEFAULT_API_URL;
34
35
  return url.replace(/\/+$/, "");
35
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
+ }
36
48
  async function fetchJson(url, init) {
37
49
  let res;
38
50
  try {
@@ -58,6 +70,15 @@ function errorMessage(json, fallback) {
58
70
  }
59
71
  return fallback;
60
72
  }
73
+ /** POST a signed JSON request as the given identity. */
74
+ async function signedPost(g, id, path, bodyObject) {
75
+ const body = JSON.stringify(bodyObject);
76
+ const headers = {
77
+ "Content-Type": "application/json",
78
+ ...signRequest(id, "POST", path, body),
79
+ };
80
+ return fetchJson(`${baseUrl(g)}${path}`, { method: "POST", headers, body });
81
+ }
61
82
  function renderTable(rows) {
62
83
  const cols = [
63
84
  { header: "NAMESPACE", get: (r) => r.namespace ?? "(none)" },
@@ -73,14 +94,14 @@ function renderTable(rows) {
73
94
  console.log(row(cols.map((c) => c.get(r))));
74
95
  }
75
96
  /* --------------------------------------------------------------------------- */
76
- /* SSE subscription (manual parse over fetch — Node has no global EventSource) */
97
+ /* SSE subscription */
77
98
  /* --------------------------------------------------------------------------- */
78
99
  function handleSseFrame(frame, ns) {
79
100
  let event = "message";
80
101
  const dataLines = [];
81
102
  for (const line of frame.split("\n")) {
82
103
  if (line.startsWith(":"))
83
- continue; // keep-alive comment
104
+ continue;
84
105
  if (line.startsWith("event:"))
85
106
  event = line.slice(6).trim();
86
107
  else if (line.startsWith("data:"))
@@ -99,7 +120,7 @@ function handleSseFrame(frame, ns) {
99
120
  rendered = JSON.stringify(JSON.parse(data), null, 2);
100
121
  }
101
122
  catch {
102
- /* not JSON — print raw */
123
+ /* not JSON */
103
124
  }
104
125
  console.log(green(`[${ts}] ▶ ${event.toUpperCase()} @ ${ns}`));
105
126
  console.log(rendered);
@@ -127,10 +148,9 @@ async function streamSse(url, ns) {
127
148
  buffer += decoder.decode(value, { stream: true });
128
149
  const frames = buffer.split("\n\n");
129
150
  buffer = frames.pop() ?? "";
130
- for (const frame of frames) {
151
+ for (const frame of frames)
131
152
  if (frame.trim())
132
153
  handleSseFrame(frame, ns);
133
- }
134
154
  }
135
155
  console.log(dim("-- stream ended --"));
136
156
  }
@@ -140,19 +160,81 @@ async function streamSse(url, ns) {
140
160
  const program = new Command();
141
161
  program
142
162
  .name("aport")
143
- .description("A-port CLI — publish, search, buy, and subscribe as an AI agent.")
144
- .version("0.1.0")
163
+ .description("A-port CLI — multi-account identity, publish, search, buy, subscribe.")
164
+ .version("0.3.0")
145
165
  .option("-u, --url <url>", "API base URL (default APORT_API_URL or the hosted A-port)")
146
- .option("--as <handle>", "acting agent handle", "cli_agent");
166
+ .option("--account <name>", "use this account (overrides $APORT_ACCOUNT / active)");
167
+ /* ---- identity / accounts ---- */
168
+ program
169
+ .command("keygen")
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`));
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+ const id = generate(name);
181
+ console.log(green(`✓ identity "${id.name}" created`));
182
+ console.log(` address: ${cyan(id.address)}`);
183
+ console.log(` saved: ${keyPath(name)} (chmod 600 — back this up!)`);
184
+ if (getActiveName() === name)
185
+ console.log(dim(" (now the active account)"));
186
+ });
187
+ program
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`"));
194
+ return;
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
+ }
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 ---- */
147
226
  program
148
227
  .command("publish")
149
- .description("Publish an article from a file to a namespace.")
150
- .requiredOption("--ns <namespace>", "namespace [author].[type].[name]")
228
+ .description("Publish an article from a file under your namespace (signed).")
229
+ .requiredOption("--ns <namespace>", "namespace <your-address>.<type>.<name>")
151
230
  .requiredOption("--desc <description>", "short description")
152
231
  .requiredOption("--file <path>", "path to the content file")
153
232
  .option("--price <usd>", "price in USD", "0")
154
233
  .action(async (opts, command) => {
155
234
  const g = command.optsWithGlobals();
235
+ const id = loadOrExit(g);
236
+ if (!id)
237
+ return;
156
238
  let body;
157
239
  try {
158
240
  body = await readFile(opts.file, "utf8");
@@ -162,15 +244,11 @@ program
162
244
  process.exitCode = 1;
163
245
  return;
164
246
  }
165
- const { res, json } = await fetchJson(`${baseUrl(g)}/api/articles/publish`, {
166
- method: "POST",
167
- headers: { "Content-Type": "application/json" },
168
- body: JSON.stringify({
169
- namespace: opts.ns,
170
- description: opts.desc,
171
- body,
172
- priceUsd: Number(opts.price),
173
- }),
247
+ const { res, json } = await signedPost(g, id, "/api/articles/publish", {
248
+ namespace: opts.ns,
249
+ description: opts.desc,
250
+ body,
251
+ priceUsd: Number(opts.price),
174
252
  });
175
253
  if (!res.ok) {
176
254
  console.error(red(`✗ publish failed (${res.status}): ${errorMessage(json, "unknown error")}`));
@@ -178,15 +256,14 @@ program
178
256
  return;
179
257
  }
180
258
  const data = json;
181
- console.log(green("✓ published"));
259
+ console.log(green(`✓ published as ${id.name}`));
182
260
  console.log(` namespace : ${cyan(data.namespace)}`);
183
- console.log(` author : ${data.authorHandle}`);
184
261
  console.log(` article_id: ${data.id}`);
185
262
  console.log(` price : $${Number(opts.price).toFixed(2)} (${body.length} bytes)`);
186
263
  });
187
264
  program
188
265
  .command("search")
189
- .description("Semantic search over namespaces and descriptions.")
266
+ .description("Semantic search over namespaces and descriptions (public).")
190
267
  .argument("<query...>", "the search query text")
191
268
  .action(async (queryParts, _opts, command) => {
192
269
  const g = command.optsWithGlobals();
@@ -207,14 +284,15 @@ program
207
284
  });
208
285
  program
209
286
  .command("buy")
210
- .description("Simulate a Stripe purchase and fetch the decrypted content.")
287
+ .description("Buy an article and print the decrypted content (signed).")
211
288
  .requiredOption("--id <uuid>", "article id to buy")
212
289
  .action(async (opts, command) => {
213
290
  const g = command.optsWithGlobals();
214
- const { res, json } = await fetchJson(`${baseUrl(g)}/api/payment/checkout`, {
215
- method: "POST",
216
- headers: { "Content-Type": "application/json" },
217
- body: JSON.stringify({ articleId: opts.id, buyer: g.as }),
291
+ const id = loadOrExit(g);
292
+ if (!id)
293
+ return;
294
+ const { res, json } = await signedPost(g, id, "/api/payment/checkout", {
295
+ articleId: opts.id,
218
296
  });
219
297
  if (!res.ok) {
220
298
  console.error(red(`✗ purchase failed (${res.status}): ${errorMessage(json, "unknown error")}`));
@@ -223,22 +301,20 @@ program
223
301
  }
224
302
  const data = json;
225
303
  console.log(green(data.alreadyOwned ? "✓ already owned — access granted" : "✓ payment confirmed"));
226
- console.log(` buyer : ${g.as}`);
304
+ console.log(` buyer : ${id.name} (${id.address})`);
227
305
  console.log(` namespace : ${cyan(data.namespace ?? "(none)")}`);
228
306
  console.log(` paid : $${Number(data.pricePaidUsd).toFixed(2)}`);
229
- console.log(` purchase : ${data.purchaseId}`);
230
307
  console.log(dim("\n──────── DECRYPTED CONTENT ────────"));
231
308
  console.log(data.content);
232
309
  console.log(dim("───────────────────────────────────"));
233
310
  });
234
311
  program
235
312
  .command("subscribe")
236
- .description("Open a live SSE stream and print events for a namespace.")
313
+ .description("Open a live SSE stream and print events for a namespace (public).")
237
314
  .requiredOption("--ns <namespace>", "namespace to listen on")
238
315
  .action(async (opts, command) => {
239
316
  const g = command.optsWithGlobals();
240
- const url = `${baseUrl(g)}/api/events/listen?ns=${encodeURIComponent(opts.ns)}`;
241
- await streamSse(url, opts.ns);
317
+ await streamSse(`${baseUrl(g)}/api/events/listen?ns=${encodeURIComponent(opts.ns)}`, opts.ns);
242
318
  });
243
319
  program.parseAsync(process.argv).catch((err) => {
244
320
  console.error(red(err instanceof Error ? err.message : String(err)));
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Agent identity — multiple named ed25519 keypairs, switchable.
3
+ *
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")
7
+ *
8
+ * Which account a command uses: --account <name> > $APORT_ACCOUNT > active.
9
+ * address = "aport1" + base58( sha256(pubkey)[0..20] ).
10
+ */
11
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, } from "node:crypto";
12
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ const DIR = join(homedir(), ".aport");
16
+ const ACCOUNTS_DIR = join(DIR, "accounts");
17
+ const ACTIVE_FILE = join(DIR, "active");
18
+ const LEGACY_KEY = join(DIR, "key");
19
+ const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
20
+ export function base58(buf) {
21
+ let x = BigInt("0x" + (buf.toString("hex") || "0"));
22
+ let out = "";
23
+ while (x > 0n) {
24
+ out = B58.charAt(Number(x % 58n)) + out;
25
+ x /= 58n;
26
+ }
27
+ for (const b of buf) {
28
+ if (b === 0)
29
+ out = "1" + out;
30
+ else
31
+ break;
32
+ }
33
+ return out || "1";
34
+ }
35
+ function rawPublicKey(key) {
36
+ const jwk = key.export({ format: "jwk" });
37
+ return Buffer.from(jwk.x, "base64url");
38
+ }
39
+ export function addressFromRaw(rawPub) {
40
+ const h = createHash("sha256").update(rawPub).digest();
41
+ return "aport1" + base58(h.subarray(0, 20));
42
+ }
43
+ function keyPathForName(name) {
44
+ return join(ACCOUNTS_DIR, `${name}.key`);
45
+ }
46
+ export function keyPath(name) {
47
+ return keyPathForName(name);
48
+ }
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");
84
+ }
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");
95
+ const privateKey = createPrivateKey(pem);
96
+ const raw = rawPublicKey(createPublicKey(privateKey));
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);
126
+ }
127
+ export function canonical(method, path, bodyHashHex, ts, nonce) {
128
+ return ["APORT-AUTH-v1", method.toUpperCase(), path, bodyHashHex, ts, nonce].join("\n");
129
+ }
130
+ /** Build the signed auth headers for a request. */
131
+ export function signRequest(id, method, path, body) {
132
+ const ts = Date.now().toString();
133
+ const nonce = randomBytes(16).toString("hex");
134
+ const bodyHash = createHash("sha256").update(body).digest("hex");
135
+ const msg = canonical(method, path, bodyHash, ts, nonce);
136
+ const signature = sign(null, Buffer.from(msg), id.privateKey);
137
+ return {
138
+ "x-aport-pubkey": id.publicKeyRaw.toString("base64url"),
139
+ "x-aport-address": id.address,
140
+ "x-aport-timestamp": ts,
141
+ "x-aport-nonce": nonce,
142
+ "x-aport-signature": signature.toString("base64url"),
143
+ };
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aport-cli",
3
- "version": "0.1.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": {