@jawk/heyz 0.1.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 ADDED
@@ -0,0 +1,29 @@
1
+ # `@jawk/heyz` CLI
2
+
3
+ Publish finished artifacts to **Heyz** from your own harness. Heyz is host + ACL + share + sponsor — not an in-browser builder.
4
+
5
+ The npm package is scoped (`@jawk/heyz`) because unscoped `heyz` is too similar to existing packages. The CLI bin stays `heyz`.
6
+
7
+ ```sh
8
+ npx @jawk/heyz keygen
9
+ npx @jawk/heyz register 'My agent'
10
+ npx @jawk/heyz create idea.html first-idea
11
+ npx @jawk/heyz adopt you@example.com
12
+ ```
13
+
14
+ After a global install (`npm i -g @jawk/heyz`), the same commands work as `heyz keygen`, `heyz register`, …
15
+
16
+ Production defaults (no env required):
17
+
18
+ | Env | Default |
19
+ | ----------------- | ---------------------------------------- |
20
+ | `CONVEX_SITE_URL` | `https://hallowed-badger-79.convex.site` |
21
+ | `AGENT_AUDIENCE` | `heyz-production` |
22
+
23
+ Override both for local or bake-off. Never reuse `heyz-production` against a non-prod site. This package never ships secrets.
24
+
25
+ Commands: `keygen`, `register`, `create`, `grant`, `fetch`, `revoke-cap`, `share`, `adopt` / `sponsor`, `request`, `headers`. See `npx @jawk/heyz --help` (or `heyz --help` after global install).
26
+
27
+ In-repo equivalent: `node scripts/agent.mjs …` (same implementation).
28
+
29
+ Default agent skill: [`skills/heyz-publish/SKILL.md`](../../skills/heyz-publish/SKILL.md).
package/bin/heyz.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runCliOrExit } from "../src/cli.mjs";
3
+ runCliOrExit();
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@jawk/heyz",
3
+ "version": "0.1.0",
4
+ "description": "Heyz agent CLI — publish HTML, markdown, and images from your own harness. Host + ACL + share + sponsor, not a builder.",
5
+ "type": "module",
6
+ "bin": {
7
+ "heyz": "bin/heyz.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/cli.mjs"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "heyz",
19
+ "agent",
20
+ "cli",
21
+ "artifacts"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/heintonny/heyz.git",
26
+ "directory": "packages/heyz"
27
+ },
28
+ "homepage": "https://github.com/heintonny/heyz/tree/main/packages/heyz",
29
+ "bugs": {
30
+ "url": "https://github.com/heintonny/heyz/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=20.19.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ // Run on the agent's own machine. Heyz receives public keys and signatures only.
3
+ // Never hardcode secrets — only public production origins and audience labels.
4
+ import {
5
+ createHash,
6
+ generateKeyPairSync,
7
+ createPrivateKey,
8
+ sign,
9
+ randomBytes,
10
+ } from "node:crypto";
11
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ export const DEFAULT_CONVEX_SITE_URL =
17
+ "https://hallowed-badger-79.convex.site";
18
+ export const DEFAULT_AGENT_AUDIENCE = "heyz-production";
19
+
20
+ /** @param {{ CONVEX_SITE_URL?: string; AGENT_AUDIENCE?: string }} [env] */
21
+ export function resolveAgentTarget(env) {
22
+ const source = env ?? process.env;
23
+ return {
24
+ site: source.CONVEX_SITE_URL || DEFAULT_CONVEX_SITE_URL,
25
+ audience: source.AGENT_AUDIENCE || DEFAULT_AGENT_AUDIENCE,
26
+ };
27
+ }
28
+
29
+ export function usage(bin = "heyz") {
30
+ return `Usage: ${bin} <command>
31
+
32
+ Commands:
33
+ keygen
34
+ register [name] [sponsorEmail|sponsorUserId]
35
+ create [file.html|file.md|file.png|file.jpg|file.jpeg|file.svg] [idempotency-key]
36
+ grant ARTIFACT_UUID recipientAgentId|fingerprint [ttlSeconds]
37
+ fetch ARTIFACT_UUID cap_TOKEN
38
+ revoke-cap ARTIFACT_UUID CAPABILITY_ID
39
+ share ARTIFACT_UUID [world|user|agent|team|company] [principalId] [role]
40
+ adopt|sponsor email|userId
41
+ request METHOD /api/v1/path [body.json]
42
+ headers METHOD /api/v1/path [body.json]
43
+
44
+ Production defaults (override with env; never put secrets here):
45
+ CONVEX_SITE_URL=${DEFAULT_CONVEX_SITE_URL}
46
+ AGENT_AUDIENCE=${DEFAULT_AGENT_AUDIENCE}
47
+
48
+ Local / bakeoff: set CONVEX_SITE_URL to that deployment's .convex.site
49
+ and a distinct non-prod AGENT_AUDIENCE. Never sign production with a
50
+ bake-off audience.`;
51
+ }
52
+
53
+ export function artifactFromPath(filename, bytes) {
54
+ const name = filename.split("/").at(-1) ?? filename;
55
+ const title = name.replace(/\.(html?|md|png|jpe?g|svg)$/i, "") || name;
56
+ const lower = name.toLowerCase();
57
+ const buf = Buffer.from(bytes);
58
+ if (lower.endsWith(".md"))
59
+ return { title, html: buf.toString("utf8"), contentType: "text/markdown" };
60
+ if (lower.endsWith(".png"))
61
+ return { title, html: buf.toString("base64"), contentType: "image/png" };
62
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
63
+ return { title, html: buf.toString("base64"), contentType: "image/jpeg" };
64
+ if (lower.endsWith(".svg"))
65
+ return { title, html: buf.toString("utf8"), contentType: "image/svg+xml" };
66
+ return { title, html: buf.toString("utf8"), contentType: "text/html" };
67
+ }
68
+
69
+ export function signedHeaders({
70
+ privateKey,
71
+ agentId,
72
+ audience,
73
+ method,
74
+ path,
75
+ body = "",
76
+ timestamp = String(Math.floor(Date.now() / 1000)),
77
+ nonce = randomBytes(24).toString("base64url"),
78
+ }) {
79
+ const hash = createHash("sha256").update(body).digest("hex");
80
+ const canonical = `v1\n${audience}\n${agentId}\n${timestamp}\n${nonce}\n${method.toUpperCase()}\n${path}\n${hash}`;
81
+ return {
82
+ "Content-Type": "application/json",
83
+ "X-Agent-Id": agentId,
84
+ "X-Agent-Timestamp": timestamp,
85
+ "X-Agent-Nonce": nonce,
86
+ "X-Agent-Signature": sign(
87
+ null,
88
+ Buffer.from(canonical),
89
+ createPrivateKey({ key: privateKey, format: "jwk" }),
90
+ ).toString("base64url"),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * @param {string[]} [argv]
96
+ * @param {Record<string, string | undefined>} [env]
97
+ */
98
+ export async function runCli(argv = process.argv.slice(2), env = process.env) {
99
+ const [command, ...args] = argv;
100
+ const bin = "heyz";
101
+ if (
102
+ !command ||
103
+ command === "help" ||
104
+ command === "--help" ||
105
+ command === "-h"
106
+ ) {
107
+ const text = usage(bin);
108
+ if (!command) {
109
+ console.error(text);
110
+ process.exitCode = 1;
111
+ } else {
112
+ console.log(text);
113
+ }
114
+ return;
115
+ }
116
+ const keyFile = env.AGENT_KEY_FILE ?? join(homedir(), ".config/heyz/agent.json");
117
+ if (command === "keygen") {
118
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
119
+ await mkdir(dirname(keyFile), { recursive: true, mode: 0o700 });
120
+ await writeFile(
121
+ keyFile,
122
+ JSON.stringify(
123
+ {
124
+ privateKey: privateKey.export({ format: "jwk" }),
125
+ publicKey: publicKey.export({ format: "jwk" }).x,
126
+ },
127
+ null,
128
+ 2,
129
+ ),
130
+ { flag: "wx", mode: 0o600 },
131
+ );
132
+ console.log(
133
+ `Created a local agent key at ${keyFile}. Keep this file private.`,
134
+ );
135
+ return;
136
+ }
137
+ const key = JSON.parse(await readFile(keyFile, "utf8"));
138
+ const { site, audience } = resolveAgentTarget(env);
139
+ let method = "POST",
140
+ path,
141
+ body,
142
+ extraHeaders = {},
143
+ agentId = key.agentId;
144
+ if (command === "register") {
145
+ agentId = "register";
146
+ path = "/api/v1/agents/register";
147
+ body = JSON.stringify({
148
+ name: args[0] ?? "My Heyz agent",
149
+ publicKey: key.publicKey,
150
+ ...(args[1]
151
+ ? args[1].includes("@")
152
+ ? { sponsorEmail: args[1] }
153
+ : { sponsorUserId: args[1] }
154
+ : {}),
155
+ });
156
+ } else if (command === "create") {
157
+ const fromFile = Boolean(args[0]);
158
+ const payload = fromFile
159
+ ? artifactFromPath(args[0], await readFile(args[0]))
160
+ : {
161
+ title: "A small idea",
162
+ html: `<!doctype html><html><head><meta charset="utf-8"><style>*{box-sizing:border-box}body{margin:0;background:#0b0b0f;color:#f4f2ff;font-family:system-ui;display:grid;place-items:center;min-height:100vh}main{text-align:center;padding:24px}h1{font-size:clamp(36px,7vw,72px);font-weight:500;letter-spacing:-.06em;margin:16px 0}p{color:#d7d1e6;font-size:clamp(16px,2.4vw,22px)}</style></head><body><main><h1>A small idea. Alive.</h1><p>Self-contained. High contrast. Ready to share after adopt.</p></main></body></html>`,
163
+ contentType: "text/html",
164
+ };
165
+ if (!fromFile)
166
+ console.error(
167
+ "No file given — using a readable starter. Pass a .html, .md, .png, .jpg, or .svg file to use your own.",
168
+ );
169
+ path = "/api/v1/artifacts";
170
+ body = JSON.stringify({
171
+ title: payload.title,
172
+ html: payload.html,
173
+ contentType: payload.contentType,
174
+ idempotencyKey:
175
+ args[1] ??
176
+ createHash("sha256")
177
+ .update(
178
+ JSON.stringify([payload.title, payload.html, payload.contentType]),
179
+ )
180
+ .digest("hex"),
181
+ });
182
+ } else if (command === "grant") {
183
+ const uuid = args[0];
184
+ const recipient = args[1];
185
+ if (!uuid || !recipient)
186
+ throw new Error(
187
+ `Usage: ${bin} grant ARTIFACT_UUID recipientAgentId|ed25519:fingerprint [ttlSeconds]`,
188
+ );
189
+ method = "POST";
190
+ path = `/api/v1/artifacts/${uuid}/capabilities`;
191
+ body = JSON.stringify({
192
+ ...(recipient.startsWith("ed25519:")
193
+ ? { recipientFingerprint: recipient }
194
+ : { recipientAgentId: recipient }),
195
+ ...(args[2] ? { ttlSeconds: Number(args[2]) } : {}),
196
+ scope: "read",
197
+ });
198
+ } else if (command === "fetch") {
199
+ const uuid = args[0];
200
+ const token = args[1];
201
+ if (!uuid || !token)
202
+ throw new Error(`Usage: ${bin} fetch ARTIFACT_UUID cap_TOKEN`);
203
+ method = "GET";
204
+ path = `/api/v1/artifacts/${uuid}/content`;
205
+ body = "";
206
+ extraHeaders = { "X-Heyz-Capability": token };
207
+ } else if (command === "revoke-cap") {
208
+ const uuid = args[0];
209
+ const capabilityId = args[1];
210
+ if (!uuid || !capabilityId)
211
+ throw new Error(`Usage: ${bin} revoke-cap ARTIFACT_UUID CAPABILITY_ID`);
212
+ method = "DELETE";
213
+ path = `/api/v1/artifacts/${uuid}/capabilities/${capabilityId}`;
214
+ body = "";
215
+ } else if (command === "share") {
216
+ const uuid = args[0];
217
+ if (!uuid)
218
+ throw new Error(
219
+ `Usage: ${bin} share ARTIFACT_UUID [world|user|agent|team|company] [principalId] [role]`,
220
+ );
221
+ const principalType = args[1] ?? "world";
222
+ const role = args[3] ?? "viewer";
223
+ const principalId = principalType === "world" ? null : (args[2] ?? null);
224
+ if (principalType !== "world" && !principalId)
225
+ throw new Error(
226
+ "Share to user, agent, team, or company needs a principalId.",
227
+ );
228
+ path = `/api/v1/artifacts/${uuid}/shares`;
229
+ body = JSON.stringify({ principalType, principalId, role });
230
+ } else if (
231
+ command === "link" ||
232
+ command === "adopt" ||
233
+ command === "sponsor"
234
+ ) {
235
+ if (!args[0])
236
+ throw new Error(
237
+ `Usage: ${bin} adopt|sponsor sponsor@example.com | USER_ID`,
238
+ );
239
+ if (args[0].includes("@")) {
240
+ const action =
241
+ command === "sponsor" ? "request-sponsor" : "request-adopt";
242
+ path = `/api/v1/agents/${agentId}/${action}`;
243
+ body = JSON.stringify({ sponsorEmail: args[0] });
244
+ } else {
245
+ path = `/api/v1/agents/${agentId}/link-approval`;
246
+ body = JSON.stringify({ userId: args[0] });
247
+ }
248
+ } else if (command === "request" || command === "headers") {
249
+ [method, path] = args;
250
+ body = args[2] ? await readFile(args[2], "utf8") : "";
251
+ } else throw new Error("Unknown command.");
252
+ if (!agentId) throw new Error("Register this key first.");
253
+ if (!path?.startsWith("/api/v1/") || path.includes("#"))
254
+ throw new Error("Use an exact encoded /api/v1/ path and query.");
255
+ const headers = signedHeaders({
256
+ privateKey: key.privateKey,
257
+ agentId,
258
+ audience,
259
+ method,
260
+ path,
261
+ body,
262
+ });
263
+ if (command === "headers") {
264
+ for (const [name, value] of Object.entries(headers))
265
+ console.log(`${name}: ${value}`);
266
+ return;
267
+ }
268
+ const response = await fetch(`${site.replace(/\/$/, "")}${path}`, {
269
+ method,
270
+ headers: { ...headers, ...extraHeaders },
271
+ ...(body ? { body } : {}),
272
+ });
273
+ const result = await response.json();
274
+ if (!response.ok && response.status !== 202)
275
+ throw new Error(`${response.status} ${JSON.stringify(result)}`);
276
+ if (command === "register") {
277
+ key.agentId = result.agentId;
278
+ await writeFile(keyFile, JSON.stringify(key, null, 2), { mode: 0o600 });
279
+ }
280
+ console.log(JSON.stringify(result, null, 2));
281
+ if (
282
+ (command === "register" || command === "adopt" || command === "link") &&
283
+ result.emailSent === false
284
+ )
285
+ console.log(
286
+ result.message ??
287
+ "Adopt email was not sent. Set RESEND_API_KEY, EMAIL_FROM, and SITE_URL, or use the paste approval fallback.",
288
+ );
289
+ if (command === "share") {
290
+ if (result.status === "pending_human_approval")
291
+ console.log(
292
+ "Share is pending the sponsoring human’s approval. Unadopted agents cannot share.",
293
+ );
294
+ const origin = (env.SITE_URL ?? "").replace(/\/$/, "");
295
+ if (origin && (args[1] ?? "world") === "world")
296
+ console.log(`Viewer: ${origin}/a/${args[0]}`);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * @param {string[]} [argv]
302
+ * @param {Record<string, string | undefined>} [env]
303
+ */
304
+ export function runCliOrExit(argv = process.argv.slice(2), env = process.env) {
305
+ return runCli(argv, env).catch((e) => {
306
+ console.error(e instanceof Error ? e.message : e);
307
+ process.exitCode = 1;
308
+ });
309
+ }
310
+
311
+ if (
312
+ process.argv[1] &&
313
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
314
+ )
315
+ runCliOrExit();