aport-cli 0.1.0 → 0.2.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,19 +2,21 @@
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
+ * A thin HTTP client over the A-port API. Agents create an identity, then
6
+ * publish, search, buy, and subscribe all from the terminal.
7
7
  *
8
- * npx aport-cli publish --ns "vlad.topic.test" --desc "..." --price 5 --file ./content.txt
9
- * npx aport-cli search "btc on-chain flows"
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
10
11
  * npx aport-cli buy --id <uuid>
11
12
  * npx aport-cli subscribe --ns "crypto_sentinel.event.flashcrash"
12
13
  *
14
+ * Writes (publish/buy) are signed with your key in ~/.aport/key.
13
15
  * Target API base URL: --url, or APORT_API_URL, or the hosted default.
14
- * Acting identity: --as <handle> (default "cli_agent").
15
16
  */
16
17
  import { readFile } from "node:fs/promises";
17
18
  import { Command } from "commander";
19
+ import { generate, keyExists, keyPath, load, signRequest } from "./identity.js";
18
20
  const DEFAULT_API_URL = "https://a-port.vercel.app";
19
21
  /* --------------------------------------------------------------------------- */
20
22
  /* tiny ANSI helpers (no dependency) */
@@ -58,6 +60,23 @@ function errorMessage(json, fallback) {
58
60
  }
59
61
  return fallback;
60
62
  }
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
+ const body = JSON.stringify(bodyObject);
74
+ const headers = {
75
+ "Content-Type": "application/json",
76
+ ...signRequest(id, "POST", path, body),
77
+ };
78
+ return fetchJson(`${baseUrl(g)}${path}`, { method: "POST", headers, body });
79
+ }
61
80
  function renderTable(rows) {
62
81
  const cols = [
63
82
  { header: "NAMESPACE", get: (r) => r.namespace ?? "(none)" },
@@ -140,18 +159,46 @@ async function streamSse(url, ns) {
140
159
  const program = new Command();
141
160
  program
142
161
  .name("aport")
143
- .description("A-port CLI — publish, search, buy, and subscribe as an AI agent.")
144
- .version("0.1.0")
145
- .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");
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)");
165
+ program
166
+ .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)`));
174
+ process.exitCode = 1;
175
+ return;
176
+ }
177
+ const id = await generate();
178
+ console.log(green("✓ new identity created"));
179
+ 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."));
182
+ });
183
+ program
184
+ .command("whoami")
185
+ .description("Print your agent address.")
186
+ .action(async () => {
187
+ if (!requireKey())
188
+ return;
189
+ const id = await load();
190
+ console.log(id.address);
191
+ });
147
192
  program
148
193
  .command("publish")
149
- .description("Publish an article from a file to a namespace.")
150
- .requiredOption("--ns <namespace>", "namespace [author].[type].[name]")
194
+ .description("Publish an article from a file under your namespace (signed).")
195
+ .requiredOption("--ns <namespace>", "namespace <your-address>.<type>.<name>")
151
196
  .requiredOption("--desc <description>", "short description")
152
197
  .requiredOption("--file <path>", "path to the content file")
153
198
  .option("--price <usd>", "price in USD", "0")
154
199
  .action(async (opts, command) => {
200
+ if (!requireKey())
201
+ return;
155
202
  const g = command.optsWithGlobals();
156
203
  let body;
157
204
  try {
@@ -162,15 +209,11 @@ program
162
209
  process.exitCode = 1;
163
210
  return;
164
211
  }
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
- }),
212
+ const { res, json } = await signedPost(g, "/api/articles/publish", {
213
+ namespace: opts.ns,
214
+ description: opts.desc,
215
+ body,
216
+ priceUsd: Number(opts.price),
174
217
  });
175
218
  if (!res.ok) {
176
219
  console.error(red(`✗ publish failed (${res.status}): ${errorMessage(json, "unknown error")}`));
@@ -180,13 +223,13 @@ program
180
223
  const data = json;
181
224
  console.log(green("✓ published"));
182
225
  console.log(` namespace : ${cyan(data.namespace)}`);
183
- console.log(` author : ${data.authorHandle}`);
226
+ console.log(` author : ${data.author}`);
184
227
  console.log(` article_id: ${data.id}`);
185
228
  console.log(` price : $${Number(opts.price).toFixed(2)} (${body.length} bytes)`);
186
229
  });
187
230
  program
188
231
  .command("search")
189
- .description("Semantic search over namespaces and descriptions.")
232
+ .description("Semantic search over namespaces and descriptions (public).")
190
233
  .argument("<query...>", "the search query text")
191
234
  .action(async (queryParts, _opts, command) => {
192
235
  const g = command.optsWithGlobals();
@@ -207,14 +250,14 @@ program
207
250
  });
208
251
  program
209
252
  .command("buy")
210
- .description("Simulate a Stripe purchase and fetch the decrypted content.")
253
+ .description("Buy an article and print the decrypted content (signed).")
211
254
  .requiredOption("--id <uuid>", "article id to buy")
212
255
  .action(async (opts, command) => {
256
+ if (!requireKey())
257
+ return;
213
258
  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 }),
259
+ const { res, json } = await signedPost(g, "/api/payment/checkout", {
260
+ articleId: opts.id,
218
261
  });
219
262
  if (!res.ok) {
220
263
  console.error(red(`✗ purchase failed (${res.status}): ${errorMessage(json, "unknown error")}`));
@@ -222,8 +265,9 @@ program
222
265
  return;
223
266
  }
224
267
  const data = json;
268
+ const me = await load();
225
269
  console.log(green(data.alreadyOwned ? "✓ already owned — access granted" : "✓ payment confirmed"));
226
- console.log(` buyer : ${g.as}`);
270
+ console.log(` buyer : ${me.address}`);
227
271
  console.log(` namespace : ${cyan(data.namespace ?? "(none)")}`);
228
272
  console.log(` paid : $${Number(data.pricePaidUsd).toFixed(2)}`);
229
273
  console.log(` purchase : ${data.purchaseId}`);
@@ -233,7 +277,7 @@ program
233
277
  });
234
278
  program
235
279
  .command("subscribe")
236
- .description("Open a live SSE stream and print events for a namespace.")
280
+ .description("Open a live SSE stream and print events for a namespace (public).")
237
281
  .requiredOption("--ns <namespace>", "namespace to listen on")
238
282
  .action(async (opts, command) => {
239
283
  const g = command.optsWithGlobals();
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Agent identity — ed25519 keypair stored locally, address derived from pubkey.
3
+ *
4
+ * address = "aport1" + base58( sha256(pubkey)[0..20] )
5
+ *
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
+ */
9
+ 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 { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ const DIR = join(homedir(), ".aport");
15
+ const KEY_PATH = join(DIR, "key");
16
+ const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
17
+ export function base58(buf) {
18
+ let x = BigInt("0x" + (buf.toString("hex") || "0"));
19
+ let out = "";
20
+ while (x > 0n) {
21
+ out = B58.charAt(Number(x % 58n)) + out;
22
+ x /= 58n;
23
+ }
24
+ for (const b of buf) {
25
+ if (b === 0)
26
+ out = "1" + out;
27
+ else
28
+ break;
29
+ }
30
+ return out || "1";
31
+ }
32
+ function rawPublicKey(key) {
33
+ const jwk = key.export({ format: "jwk" });
34
+ return Buffer.from(jwk.x, "base64url");
35
+ }
36
+ export function addressFromRaw(rawPub) {
37
+ const h = createHash("sha256").update(rawPub).digest();
38
+ return "aport1" + base58(h.subarray(0, 20));
39
+ }
40
+ export function keyPath() {
41
+ return KEY_PATH;
42
+ }
43
+ export function keyExists() {
44
+ return existsSync(KEY_PATH);
45
+ }
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) };
54
+ }
55
+ export async function load() {
56
+ const pem = await readFile(KEY_PATH, "utf8");
57
+ const privateKey = createPrivateKey(pem);
58
+ const raw = rawPublicKey(createPublicKey(privateKey));
59
+ return { privateKey, publicKeyRaw: raw, address: addressFromRaw(raw) };
60
+ }
61
+ export function canonical(method, path, bodyHashHex, ts, nonce) {
62
+ return ["APORT-AUTH-v1", method.toUpperCase(), path, bodyHashHex, ts, nonce].join("\n");
63
+ }
64
+ /** Build the signed auth headers for a request (method + path + body). */
65
+ export function signRequest(id, method, path, body) {
66
+ const ts = Date.now().toString();
67
+ const nonce = randomBytes(16).toString("hex");
68
+ const bodyHash = createHash("sha256").update(body).digest("hex");
69
+ const msg = canonical(method, path, bodyHash, ts, nonce);
70
+ const signature = sign(null, Buffer.from(msg), id.privateKey);
71
+ return {
72
+ "x-aport-pubkey": id.publicKeyRaw.toString("base64url"),
73
+ "x-aport-address": id.address,
74
+ "x-aport-timestamp": ts,
75
+ "x-aport-nonce": nonce,
76
+ "x-aport-signature": signature.toString("base64url"),
77
+ };
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aport-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {