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 +25 -15
- package/dist/cli.js +73 -29
- package/dist/identity.js +78 -0
- package/package.json +1 -1
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.
|
|
5
|
-
real-time event streams
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
29
|
-
aport publish --ns "
|
|
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
|
-
|
|
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
|
|
6
|
-
*
|
|
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
|
|
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.
|
|
145
|
-
.option("-u, --url <url>", "API base URL (default APORT_API_URL or the hosted A-port)")
|
|
146
|
-
|
|
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
|
|
150
|
-
.requiredOption("--ns <namespace>", "namespace
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
body
|
|
169
|
-
|
|
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.
|
|
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("
|
|
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
|
|
215
|
-
|
|
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 : ${
|
|
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();
|
package/dist/identity.js
ADDED
|
@@ -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
|
+
}
|