@heyamiko/amiko-cli 0.12.0-beta.4 → 0.12.0-beta.6

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,4 +1,4 @@
1
- # @heyamiko/amiko-cli (v0.12.0-beta.4)
1
+ # @heyamiko/amiko-cli (v0.12.0-beta.6)
2
2
 
3
3
  Manage wallets, credits, swaps, MPP marketplace services, and your Amiko twin (identity, documents, voice, avatar, friends, feed) from the terminal. Works for both human users and AI agents running on OpenClaw.
4
4
 
@@ -221,12 +221,35 @@ amiko chat read @sophie # recent messages with a user (
221
221
  amiko chat read <conversationId> --limit 40 # by conversation id (from `chat list`) — works for groups too
222
222
  amiko chat send @sophie "on my way!" --yes # send as the owner
223
223
  amiko chat send <groupConversationId> "hi all" --yes # send to a group chat
224
+ amiko chat send @sophie "look 👀" --image ./cat.png --yes # attach a local image
225
+ amiko chat send @sophie "made this 🎵" --audio <generated-url> --yes # attach audio (e.g. from `amiko create`)
224
226
  ```
225
227
 
226
228
  - **`<target>`** for `read`/`send` is a **conversation id** (from `chat list` — use this for groups), an **`@handle`**, or a **name** (resolved via user search; if ambiguous, the CLI lists candidates instead of guessing).
227
229
  - A DM target that doesn't exist yet is **created automatically** (find-or-create).
230
+ - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≤10MB or an Amiko URL) and `--audio <pathOrUrl>` (local ≤25MB or an Amiko URL → sent as a voice note). One media block per message (image *or* audio). **Video isn't supported in chat yet.** A local file is uploaded; an Amiko URL (e.g. a generated asset from `amiko create`) attaches directly with no re-upload. (A local *audio* file is sent as a voice note, with your text as a separate message.)
228
231
  - `chat send` messages a real person, so it's **gated on `--yes`** in non-interactive shells. Delivery is real-time. `--raw` prints raw JSON on any subcommand.
229
232
 
233
+ ## Twin Cards
234
+
235
+ Generate, view, download, and mint your **Twin Card** (template `twins_take`, flavors **work / play / love**).
236
+
237
+ ```bash
238
+ amiko card templates # available flavors (DB-managed)
239
+ amiko card create --flavor work # generate → prints slug + share link
240
+ amiko card create --flavor love --reroll 1 # a fresh take
241
+ amiko card list # your latest card
242
+ amiko card show <slug> # stored card data (JSON)
243
+ amiko card image <slug> --flavor work --out card.png # download the PNG
244
+ amiko card share <slug> # print https://platform.heyamiko.com/s/<slug>
245
+ amiko card mint <slug> --flavor work --yes # mint as an NFT (gated on --yes)
246
+ amiko card mint <slug> --status # check an existing mint
247
+ ```
248
+
249
+ - Flavor defaults to `love`; flavors are DB-managed — `card templates` lists the valid ones.
250
+ - `create` errors are user-fixable and exit with **code 2** (not a crash): `profile_not_ready`, `guardrails_failed`, `rate_limited` (reroll cooldown).
251
+ - `card mint` creates an on-chain NFT → **gated on `--yes`**. All subcommands support `--json`.
252
+
230
253
  ## Amazon
231
254
 
232
255
  ```bash
package/dist/index.js CHANGED
@@ -28924,6 +28924,103 @@ More available — raise --limit to see more.`));
28924
28924
  });
28925
28925
  }
28926
28926
 
28927
+ // src/lib/file-input.ts
28928
+ import { readFile } from "node:fs/promises";
28929
+ import { basename, extname } from "node:path";
28930
+ var MAX_BYTES = 100 * 1024 * 1024;
28931
+ var FETCH_TIMEOUT_MS = 30000;
28932
+ async function resolveFileInput(src, opts = {}) {
28933
+ if (src === "-" || opts.stdin) {
28934
+ const buffer2 = await readStdin();
28935
+ const name2 = opts.hintName ?? "stdin";
28936
+ const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
28937
+ return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
28938
+ }
28939
+ if (/^https?:\/\//i.test(src)) {
28940
+ const buffer2 = await fetchRemote(src);
28941
+ const urlPath = new URL(src).pathname;
28942
+ const name2 = opts.hintName ?? (basename(urlPath) || "download");
28943
+ const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
28944
+ return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
28945
+ }
28946
+ const buffer = await readFile(src);
28947
+ if (buffer.length > MAX_BYTES) {
28948
+ throw new Error(`File too large: ${buffer.length} bytes exceeds ${MAX_BYTES} byte cap.`);
28949
+ }
28950
+ const name = opts.hintName ?? basename(src);
28951
+ const mime = opts.hintMime ?? detectMime(buffer, name);
28952
+ return { buffer, name, mime, size: buffer.length };
28953
+ }
28954
+ async function readStdin() {
28955
+ const chunks = [];
28956
+ let total = 0;
28957
+ for await (const chunk of process.stdin) {
28958
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
28959
+ total += buf.length;
28960
+ if (total > MAX_BYTES) {
28961
+ throw new Error(`stdin exceeded ${MAX_BYTES} byte cap.`);
28962
+ }
28963
+ chunks.push(buf);
28964
+ }
28965
+ return Buffer.concat(chunks);
28966
+ }
28967
+ async function fetchRemote(url2) {
28968
+ const controller = new AbortController;
28969
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
28970
+ try {
28971
+ const res = await fetch(url2, { signal: controller.signal });
28972
+ if (!res.ok) {
28973
+ throw new Error(`Fetch ${url2} failed: ${res.status} ${res.statusText}`);
28974
+ }
28975
+ const len = Number(res.headers.get("content-length") || 0);
28976
+ if (len > MAX_BYTES) {
28977
+ throw new Error(`Remote file too large: ${len} bytes exceeds ${MAX_BYTES} byte cap.`);
28978
+ }
28979
+ const buf = Buffer.from(await res.arrayBuffer());
28980
+ if (buf.length > MAX_BYTES) {
28981
+ throw new Error(`Remote file too large: ${buf.length} bytes exceeds ${MAX_BYTES} byte cap.`);
28982
+ }
28983
+ return buf;
28984
+ } finally {
28985
+ clearTimeout(timer);
28986
+ }
28987
+ }
28988
+ var MAGIC = [
28989
+ [Buffer.from([37, 80, 68, 70]), "application/pdf"],
28990
+ [Buffer.from([137, 80, 78, 71]), "image/png"],
28991
+ [Buffer.from([255, 216, 255]), "image/jpeg"],
28992
+ [Buffer.from([71, 73, 70, 56]), "image/gif"],
28993
+ [Buffer.from([82, 73, 70, 70]), "audio/wav"],
28994
+ [Buffer.from([73, 68, 51]), "audio/mpeg"],
28995
+ [Buffer.from([255, 251]), "audio/mpeg"],
28996
+ [Buffer.from([80, 75, 3, 4]), "application/zip"]
28997
+ ];
28998
+ var EXT_MIME = {
28999
+ ".pdf": "application/pdf",
29000
+ ".png": "image/png",
29001
+ ".jpg": "image/jpeg",
29002
+ ".jpeg": "image/jpeg",
29003
+ ".gif": "image/gif",
29004
+ ".webp": "image/webp",
29005
+ ".txt": "text/plain",
29006
+ ".md": "text/markdown",
29007
+ ".json": "application/json",
29008
+ ".wav": "audio/wav",
29009
+ ".mp3": "audio/mpeg",
29010
+ ".m4a": "audio/mp4",
29011
+ ".ogg": "audio/ogg",
29012
+ ".zip": "application/zip"
29013
+ };
29014
+ function detectMime(buffer, name) {
29015
+ for (const [sig, mime] of MAGIC) {
29016
+ if (buffer.length >= sig.length && buffer.subarray(0, sig.length).equals(sig)) {
29017
+ return mime;
29018
+ }
29019
+ }
29020
+ const ext = extname(name).toLowerCase();
29021
+ return EXT_MIME[ext] ?? "application/octet-stream";
29022
+ }
29023
+
28927
29024
  // src/commands/chat.ts
28928
29025
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28929
29026
  function authOrExit() {
@@ -28980,6 +29077,52 @@ async function resolveConversation(auth, target) {
28980
29077
  }
28981
29078
  return findOrCreateDm(auth, users[0].id);
28982
29079
  }
29080
+ var HTTPS_RE = /^https:\/\//i;
29081
+ var CHAT_IMAGE_MIME = new Set([
29082
+ "image/jpeg",
29083
+ "image/jpg",
29084
+ "image/png",
29085
+ "image/webp",
29086
+ "image/gif"
29087
+ ]);
29088
+ var MAX_CHAT_IMAGE = 10 * 1024 * 1024;
29089
+ var MAX_CHAT_AUDIO = 25 * 1024 * 1024;
29090
+ function urlName(url2) {
29091
+ try {
29092
+ const p = new URL(url2).pathname.split("/").pop();
29093
+ return p && p.length ? decodeURIComponent(p) : "attachment";
29094
+ } catch {
29095
+ return "attachment";
29096
+ }
29097
+ }
29098
+ async function uploadChatImage(auth, convId, path2) {
29099
+ const f = await resolveFileInput(path2);
29100
+ if (!CHAT_IMAGE_MIME.has(f.mime)) {
29101
+ throw new Error(`"${path2}" is not a supported image (detected ${f.mime}). Allowed: JPEG, PNG, WebP, GIF.`);
29102
+ }
29103
+ if (f.size > MAX_CHAT_IMAGE) {
29104
+ throw new Error(`"${path2}" exceeds the 10MB chat image limit.`);
29105
+ }
29106
+ const form = new FormData;
29107
+ form.set("file", new Blob([new Uint8Array(f.buffer)], { type: f.mime }), f.name);
29108
+ const data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(convId)}/images/upload`, { method: "POST", multipart: form, timeoutMs: 60000 });
29109
+ if (!data.image?.url)
29110
+ throw new Error("Image upload returned no URL.");
29111
+ return { url: data.image.url, name: data.image.name ?? f.name };
29112
+ }
29113
+ async function uploadChatVoice(auth, convId, path2) {
29114
+ const f = await resolveFileInput(path2);
29115
+ if (!f.mime.startsWith("audio/")) {
29116
+ throw new Error(`"${path2}" is not an audio file (detected ${f.mime}).`);
29117
+ }
29118
+ if (f.size > MAX_CHAT_AUDIO) {
29119
+ throw new Error(`"${path2}" exceeds the 25MB chat audio limit.`);
29120
+ }
29121
+ const form = new FormData;
29122
+ form.set("audio", new Blob([new Uint8Array(f.buffer)], { type: f.mime }), f.name);
29123
+ form.set("duration", "0");
29124
+ await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(convId)}/audio/upload`, { method: "POST", multipart: form, timeoutMs: 120000 });
29125
+ }
28983
29126
  function registerChatCommand(chat) {
28984
29127
  chat.command("list").description("List your conversations (DMs and group chats)").option("--limit <n>", "Max conversations", "30").option("--archived", "Include archived conversations").option("--raw", "Output raw JSON").action(async (opts) => {
28985
29128
  const auth = authOrExit();
@@ -29054,8 +29197,12 @@ function registerChatCommand(chat) {
29054
29197
  console.log(dim(` ${when}`));
29055
29198
  }
29056
29199
  });
29057
- chat.command("send <target> <message>").description("Send a message as yourself (the owner) to a conversation (id, @handle, or name)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, message, opts) => {
29200
+ chat.command("send <target> <message>").description("Send a message as yourself (the owner) to a conversation (id, @handle, or name)").option("--image <pathOrUrl>", "Attach an image: local file (jpg/png/webp/gif, ≤10MB) or an Amiko URL (e.g. from `amiko create`)").option("--audio <pathOrUrl>", "Attach audio as a voice note: local file (≤25MB) or an Amiko URL").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, message, opts) => {
29058
29201
  const auth = authOrExit();
29202
+ if (opts.image && opts.audio) {
29203
+ console.error(error("Attach either --image or --audio, not both (one media block per message)."));
29204
+ process.exit(1);
29205
+ }
29059
29206
  let convId;
29060
29207
  try {
29061
29208
  convId = await resolveConversation(auth, target);
@@ -29063,15 +29210,38 @@ function registerChatCommand(chat) {
29063
29210
  console.error(error(e5 instanceof Error ? e5.message : "Could not resolve target"));
29064
29211
  process.exit(1);
29065
29212
  }
29213
+ const media = opts.image ? `image ${opts.image}` : opts.audio ? `audio ${opts.audio}` : "";
29066
29214
  await requireApproval({
29067
29215
  cost: "no charge — sends a message as you (the owner)",
29068
- summary: `Send to ${target}: ${message.slice(0, 80)}${message.length > 80 ? "…" : ""}`,
29216
+ summary: `Send to ${target}${media ? ` [+${media}]` : ""}: ${message.slice(0, 80)}${message.length > 80 ? "…" : ""}`,
29069
29217
  yes: opts.yes,
29070
29218
  commandExample: `amiko chat send ${JSON.stringify(target)} ${JSON.stringify(message)}`
29071
29219
  });
29220
+ const messagesPath = `/api/conversations/${encodeURIComponent(convId)}/messages`;
29221
+ const post = (body) => amikoWebFetch(auth, messagesPath, {
29222
+ method: "POST",
29223
+ body,
29224
+ timeoutMs: 20000
29225
+ });
29072
29226
  let data;
29073
29227
  try {
29074
- data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(convId)}/messages`, { method: "POST", body: { content: message }, timeoutMs: 20000 });
29228
+ if (opts.image) {
29229
+ const img = HTTPS_RE.test(opts.image) ? { url: opts.image, name: urlName(opts.image) } : await uploadChatImage(auth, convId, opts.image);
29230
+ data = await post({ content: message, metadata: { images: [img] } });
29231
+ } else if (opts.audio) {
29232
+ if (HTTPS_RE.test(opts.audio)) {
29233
+ data = await post({
29234
+ content: message,
29235
+ message_type: "voice",
29236
+ metadata: { audioUrl: opts.audio }
29237
+ });
29238
+ } else {
29239
+ data = await post({ content: message });
29240
+ await uploadChatVoice(auth, convId, opts.audio);
29241
+ }
29242
+ } else {
29243
+ data = await post({ content: message });
29244
+ }
29075
29245
  } catch (e5) {
29076
29246
  console.error(error(e5 instanceof Error ? e5.message : "Send failed"));
29077
29247
  process.exit(1);
@@ -29080,7 +29250,220 @@ function registerChatCommand(chat) {
29080
29250
  console.log(JSON.stringify(data, null, 2));
29081
29251
  return;
29082
29252
  }
29083
- console.log(success("Message sent") + dim(` (conversation ${convId.slice(0, 8)}…)`));
29253
+ const what = opts.image ? "Message + image sent" : opts.audio ? "Message + voice note sent" : "Message sent";
29254
+ console.log(success(what) + dim(` (conversation ${convId.slice(0, 8)}…)`));
29255
+ });
29256
+ }
29257
+
29258
+ // src/commands/card.ts
29259
+ import { writeFile } from "node:fs/promises";
29260
+ var TEMPLATE_ID = "twins_take";
29261
+ var DEFAULT_FLAVOR = "love";
29262
+ function authOrExit2() {
29263
+ try {
29264
+ return requireAuth();
29265
+ } catch (e5) {
29266
+ console.error(error(e5 instanceof Error ? e5.message : "auth failed"));
29267
+ process.exit(1);
29268
+ }
29269
+ }
29270
+ function platformBase(auth) {
29271
+ return (auth.platformUrl ?? "https://platform.heyamiko.com").replace(/\/$/, "");
29272
+ }
29273
+ function cardErrorExit(e5) {
29274
+ const err = e5;
29275
+ let body = {};
29276
+ try {
29277
+ body = JSON.parse(err?.body ?? "{}");
29278
+ } catch {}
29279
+ const code = body.error ?? "";
29280
+ const map2 = {
29281
+ profile_not_ready: {
29282
+ msg: "Your Amiko profile isn't complete enough to generate a card yet — flesh out your profile first.",
29283
+ exit: 2
29284
+ },
29285
+ guardrails_failed: {
29286
+ msg: "Content guardrails didn't pass — try again, or a different --flavor.",
29287
+ exit: 2
29288
+ },
29289
+ rate_limited: {
29290
+ msg: `Card generation is rate-limited right now — try again later${body.retry_after_seconds ? ` (~${body.retry_after_seconds}s)` : ""}.`,
29291
+ exit: 2
29292
+ },
29293
+ unknown_flavor: {
29294
+ msg: "Unknown flavor. Run `amiko card templates` to see the valid flavors.",
29295
+ exit: 1
29296
+ },
29297
+ unknown_template: { msg: "Only the twins_take card can be generated.", exit: 1 },
29298
+ template_not_generatable: {
29299
+ msg: "Only the twins_take card can be generated.",
29300
+ exit: 1
29301
+ }
29302
+ };
29303
+ const hit = map2[code];
29304
+ if (hit) {
29305
+ console.error(error(hit.msg));
29306
+ process.exit(hit.exit);
29307
+ }
29308
+ console.error(error(e5 instanceof Error ? e5.message : "Card request failed"));
29309
+ process.exit(1);
29310
+ }
29311
+ function registerCardCommand(card) {
29312
+ card.command("templates").description("List the available card flavors (work / play / love)").option("--json", "Output raw JSON").action(async (opts) => {
29313
+ const auth = authOrExit2();
29314
+ let data;
29315
+ try {
29316
+ data = await amikoWebFetch(auth, "/api/twin-cards/templates", {
29317
+ timeoutMs: 15000
29318
+ });
29319
+ } catch (e5) {
29320
+ cardErrorExit(e5);
29321
+ }
29322
+ if (opts.json) {
29323
+ console.log(JSON.stringify(data, null, 2));
29324
+ return;
29325
+ }
29326
+ const flavors = data.templates ?? [];
29327
+ if (flavors.length === 0) {
29328
+ console.log(dim("No flavors available."));
29329
+ return;
29330
+ }
29331
+ console.log(heading(`Card flavors (${flavors.length})
29332
+ `));
29333
+ for (const f of flavors)
29334
+ console.log(label(f.key, f.name ?? ""));
29335
+ });
29336
+ card.command("list").description("Show your most recently generated card").option("--json", "Output raw JSON").action(async (opts) => {
29337
+ const auth = authOrExit2();
29338
+ let data;
29339
+ try {
29340
+ data = await amikoWebFetch(auth, "/api/twin-cards/me", {
29341
+ timeoutMs: 15000
29342
+ });
29343
+ } catch (e5) {
29344
+ cardErrorExit(e5);
29345
+ }
29346
+ if (opts.json) {
29347
+ console.log(JSON.stringify(data, null, 2));
29348
+ return;
29349
+ }
29350
+ if (!data.slug) {
29351
+ console.log(dim("No card yet — generate one with `amiko card create`."));
29352
+ return;
29353
+ }
29354
+ console.log(heading(`Your latest card
29355
+ `));
29356
+ console.log(label("Slug", data.slug));
29357
+ console.log(label("Flavors", Object.keys(data.cards ?? {}).join(", ") || "-"));
29358
+ if (data.generated_at)
29359
+ console.log(label("Generated", data.generated_at));
29360
+ console.log(label("Share", `${platformBase(auth)}/s/${data.slug}`));
29361
+ });
29362
+ card.command("create").description("Generate (or reroll) your Twin Card").option("--flavor <flavor>", "Flavor: work, play, love (DB-managed; see `card templates`)", DEFAULT_FLAVOR).option("--reroll <n>", "Reroll number (≥0) for a fresh take", "0").option("--json", "Output raw JSON").action(async (opts) => {
29363
+ const auth = authOrExit2();
29364
+ let data;
29365
+ try {
29366
+ data = await amikoWebFetch(auth, "/api/share/generate-card", {
29367
+ method: "POST",
29368
+ body: {
29369
+ template_id: TEMPLATE_ID,
29370
+ flavor: opts.flavor,
29371
+ reroll_number: Math.max(0, Math.floor(Number(opts.reroll) || 0))
29372
+ },
29373
+ timeoutMs: 120000
29374
+ });
29375
+ } catch (e5) {
29376
+ cardErrorExit(e5);
29377
+ }
29378
+ if (opts.json) {
29379
+ console.log(JSON.stringify(data, null, 2));
29380
+ return;
29381
+ }
29382
+ console.log(heading(`${opts.flavor} card ready
29383
+ `));
29384
+ if (data.slug) {
29385
+ console.log(label("Slug", data.slug));
29386
+ console.log(label("Share", `${platformBase(auth)}/s/${data.slug}`));
29387
+ console.log(dim(`Image: amiko card image ${data.slug} --flavor ${opts.flavor}`));
29388
+ }
29389
+ });
29390
+ card.command("show <slug>").description("Read a card's stored data").option("--json", "Output raw JSON").action(async (slug, opts) => {
29391
+ const auth = authOrExit2();
29392
+ let data;
29393
+ try {
29394
+ data = await amikoWebFetch(auth, `/api/share/${encodeURIComponent(slug)}`, { timeoutMs: 15000 });
29395
+ } catch (e5) {
29396
+ cardErrorExit(e5);
29397
+ }
29398
+ console.log(JSON.stringify(data, null, 2));
29399
+ });
29400
+ card.command("image <slug>").description("Download the card image (PNG)").option("--flavor <flavor>", "Flavor: work, play, love", DEFAULT_FLAVOR).option("--out <path>", "Output file (default: card-<slug>-<flavor>.png)").action(async (slug, opts) => {
29401
+ const auth = authOrExit2();
29402
+ const url2 = `${platformBase(auth)}/api/share/${encodeURIComponent(slug)}/image?flavor=${encodeURIComponent(opts.flavor)}`;
29403
+ let buf;
29404
+ try {
29405
+ const res = await fetch(url2, {
29406
+ headers: { Authorization: `Bearer ${auth.twinToken ?? ""}` },
29407
+ signal: AbortSignal.timeout(60000)
29408
+ });
29409
+ if (!res.ok) {
29410
+ console.error(error(`Failed to fetch card image: ${res.status} ${res.statusText}`));
29411
+ process.exit(1);
29412
+ }
29413
+ buf = Buffer.from(await res.arrayBuffer());
29414
+ } catch (e5) {
29415
+ console.error(error(e5 instanceof Error ? e5.message : "Image download failed"));
29416
+ process.exit(1);
29417
+ }
29418
+ const out = opts.out ?? `card-${slug}-${opts.flavor}.png`;
29419
+ await writeFile(out, buf);
29420
+ console.log(success(`Saved ${buf.length.toLocaleString()} bytes → ${out}`));
29421
+ });
29422
+ card.command("mint <slug>").description("Mint a card as an NFT (or --status to check an existing mint)").option("--flavor <flavor>", "Flavor: work, play, love", DEFAULT_FLAVOR).option("--name <name>", "NFT name (optional)").option("--description <desc>", "NFT description (optional)").option("--status", "Check mint status instead of minting").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--json", "Output raw JSON").action(async (slug, opts) => {
29423
+ const auth = authOrExit2();
29424
+ if (opts.status) {
29425
+ let data2;
29426
+ try {
29427
+ data2 = await amikoWebFetch(auth, `/api/share/twin-cards/mint?slug=${encodeURIComponent(slug)}`, { timeoutMs: 15000 });
29428
+ } catch (e5) {
29429
+ cardErrorExit(e5);
29430
+ }
29431
+ console.log(JSON.stringify(data2, null, 2));
29432
+ return;
29433
+ }
29434
+ await requireApproval({
29435
+ cost: "mints an on-chain NFT (cNFT) from your twin card",
29436
+ summary: `Mint ${opts.flavor} card ${slug} as an NFT`,
29437
+ yes: opts.yes,
29438
+ commandExample: `amiko card mint ${slug} --flavor ${opts.flavor}`
29439
+ });
29440
+ let data;
29441
+ try {
29442
+ data = await amikoWebFetch(auth, "/api/share/twin-cards/mint", {
29443
+ method: "POST",
29444
+ body: {
29445
+ slug,
29446
+ flavor: opts.flavor,
29447
+ ...opts.name ? { name: opts.name } : {},
29448
+ ...opts.description ? { description: opts.description } : {}
29449
+ },
29450
+ timeoutMs: 120000
29451
+ });
29452
+ } catch (e5) {
29453
+ cardErrorExit(e5);
29454
+ }
29455
+ if (opts.json) {
29456
+ console.log(JSON.stringify(data, null, 2));
29457
+ return;
29458
+ }
29459
+ console.log(success(`Mint ${data.status ?? "submitted"}`));
29460
+ if (data.mint_address)
29461
+ console.log(label("Mint", data.mint_address));
29462
+ console.log(dim(`Check status: amiko card mint ${slug} --status`));
29463
+ });
29464
+ card.command("share <slug>").description("Print the public share link for a card").action(async (slug) => {
29465
+ const auth = authOrExit2();
29466
+ console.log(`${platformBase(auth)}/s/${slug}`);
29084
29467
  });
29085
29468
  }
29086
29469
 
@@ -29632,107 +30015,8 @@ function registerTwinCommand(program2) {
29632
30015
  }
29633
30016
 
29634
30017
  // src/commands/drive.ts
29635
- import { writeFile } from "node:fs/promises";
30018
+ import { writeFile as writeFile2 } from "node:fs/promises";
29636
30019
  import { basename as basename2, resolve as resolvePath } from "node:path";
29637
-
29638
- // src/lib/file-input.ts
29639
- import { readFile } from "node:fs/promises";
29640
- import { basename, extname } from "node:path";
29641
- var MAX_BYTES = 100 * 1024 * 1024;
29642
- var FETCH_TIMEOUT_MS = 30000;
29643
- async function resolveFileInput(src, opts = {}) {
29644
- if (src === "-" || opts.stdin) {
29645
- const buffer2 = await readStdin();
29646
- const name2 = opts.hintName ?? "stdin";
29647
- const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
29648
- return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
29649
- }
29650
- if (/^https?:\/\//i.test(src)) {
29651
- const buffer2 = await fetchRemote(src);
29652
- const urlPath = new URL(src).pathname;
29653
- const name2 = opts.hintName ?? (basename(urlPath) || "download");
29654
- const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
29655
- return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
29656
- }
29657
- const buffer = await readFile(src);
29658
- if (buffer.length > MAX_BYTES) {
29659
- throw new Error(`File too large: ${buffer.length} bytes exceeds ${MAX_BYTES} byte cap.`);
29660
- }
29661
- const name = opts.hintName ?? basename(src);
29662
- const mime = opts.hintMime ?? detectMime(buffer, name);
29663
- return { buffer, name, mime, size: buffer.length };
29664
- }
29665
- async function readStdin() {
29666
- const chunks = [];
29667
- let total = 0;
29668
- for await (const chunk of process.stdin) {
29669
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29670
- total += buf.length;
29671
- if (total > MAX_BYTES) {
29672
- throw new Error(`stdin exceeded ${MAX_BYTES} byte cap.`);
29673
- }
29674
- chunks.push(buf);
29675
- }
29676
- return Buffer.concat(chunks);
29677
- }
29678
- async function fetchRemote(url2) {
29679
- const controller = new AbortController;
29680
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
29681
- try {
29682
- const res = await fetch(url2, { signal: controller.signal });
29683
- if (!res.ok) {
29684
- throw new Error(`Fetch ${url2} failed: ${res.status} ${res.statusText}`);
29685
- }
29686
- const len = Number(res.headers.get("content-length") || 0);
29687
- if (len > MAX_BYTES) {
29688
- throw new Error(`Remote file too large: ${len} bytes exceeds ${MAX_BYTES} byte cap.`);
29689
- }
29690
- const buf = Buffer.from(await res.arrayBuffer());
29691
- if (buf.length > MAX_BYTES) {
29692
- throw new Error(`Remote file too large: ${buf.length} bytes exceeds ${MAX_BYTES} byte cap.`);
29693
- }
29694
- return buf;
29695
- } finally {
29696
- clearTimeout(timer);
29697
- }
29698
- }
29699
- var MAGIC = [
29700
- [Buffer.from([37, 80, 68, 70]), "application/pdf"],
29701
- [Buffer.from([137, 80, 78, 71]), "image/png"],
29702
- [Buffer.from([255, 216, 255]), "image/jpeg"],
29703
- [Buffer.from([71, 73, 70, 56]), "image/gif"],
29704
- [Buffer.from([82, 73, 70, 70]), "audio/wav"],
29705
- [Buffer.from([73, 68, 51]), "audio/mpeg"],
29706
- [Buffer.from([255, 251]), "audio/mpeg"],
29707
- [Buffer.from([80, 75, 3, 4]), "application/zip"]
29708
- ];
29709
- var EXT_MIME = {
29710
- ".pdf": "application/pdf",
29711
- ".png": "image/png",
29712
- ".jpg": "image/jpeg",
29713
- ".jpeg": "image/jpeg",
29714
- ".gif": "image/gif",
29715
- ".webp": "image/webp",
29716
- ".txt": "text/plain",
29717
- ".md": "text/markdown",
29718
- ".json": "application/json",
29719
- ".wav": "audio/wav",
29720
- ".mp3": "audio/mpeg",
29721
- ".m4a": "audio/mp4",
29722
- ".ogg": "audio/ogg",
29723
- ".zip": "application/zip"
29724
- };
29725
- function detectMime(buffer, name) {
29726
- for (const [sig, mime] of MAGIC) {
29727
- if (buffer.length >= sig.length && buffer.subarray(0, sig.length).equals(sig)) {
29728
- return mime;
29729
- }
29730
- }
29731
- const ext = extname(name).toLowerCase();
29732
- return EXT_MIME[ext] ?? "application/octet-stream";
29733
- }
29734
-
29735
- // src/commands/drive.ts
29736
30020
  function fmtBytes(n) {
29737
30021
  if (!n || n <= 0)
29738
30022
  return "-";
@@ -29944,7 +30228,7 @@ Search hits filename, title, description, and parsed RAG content (once processed
29944
30228
  return;
29945
30229
  }
29946
30230
  const outPath = resolvePath(opts.out ?? basename2(meta3.doc.filename));
29947
- await writeFile(outPath, buf);
30231
+ await writeFile2(outPath, buf);
29948
30232
  spinner?.stop();
29949
30233
  console.log(success(`Saved ${meta3.doc.filename} → ${outPath}`));
29950
30234
  console.log(label("Size", fmtBytes(buf.length)));
@@ -31967,6 +32251,7 @@ program2.name("amiko").description("Amiko CLI — swap tokens, manage credits, b
31967
32251
  var markets = program2.command("markets").description("MPP marketplace — discover and call paid AMIKO services");
31968
32252
  var create2 = program2.command("create").description("Create Studio — generate images, video, speech, music, and SFX (charged on success)");
31969
32253
  var chat = program2.command("chat").description("Your conversations (DMs + group chats) as the owner — list, read, send");
32254
+ var card = program2.command("card").description("Twin Cards (work/play/love) — generate, view, download, mint");
31970
32255
  var wallets = program2.command("wallets").description("Manage twin wallets — list, swap, bridge");
31971
32256
  var twin = program2.command("twin").description("Update twin identity (name, description, visibility)");
31972
32257
  var drive = program2.command("drive").alias("docs").description("Manage twin drive — upload/download/search files, organise into folders");
@@ -31982,6 +32267,7 @@ registerSearchCommand(markets);
31982
32267
  registerImageCommand(markets);
31983
32268
  registerCreateCommand(create2);
31984
32269
  registerChatCommand(chat);
32270
+ registerCardCommand(card);
31985
32271
  registerAmazonCommand(markets);
31986
32272
  registerWalletsCreateCommand(wallets);
31987
32273
  registerWalletsListCommand(wallets);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyamiko/amiko-cli",
3
- "version": "0.12.0-beta.4",
3
+ "version": "0.12.0-beta.6",
4
4
  "description": "Amiko CLI — swap tokens, manage credits, bridge cross-chain, and call marketplace agents",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/SKILL.md CHANGED
@@ -41,13 +41,13 @@ The CLI is installed globally and is pre-authenticated when you're inside your w
41
41
 
42
42
  ## Command groups
43
43
 
44
- Discover everything via `amiko --help`. Groups: `markets` (paid MPP services), `create` (Create Studio — async media generation, charged on success), `chat` (owner's DMs + group chats), `wallets`, `credits`, `twin`, `drive` (files / folders / RAG; `docs` is an alias), `voice`, `avatar`, `friends`, `users`, `post`, `review`, `feed`, `notifications`, `memory`, plus the top-level `accounts`, `info`, `config`, `update`. All twin-scoped commands accept `--twin <id>`. Most commands support `--json`.
44
+ Discover everything via `amiko --help`. Groups: `markets` (paid MPP services), `create` (Create Studio — async media generation, charged on success), `chat` (owner's DMs + group chats), `card` (Twin Cards — work/play/love), `wallets`, `credits`, `twin`, `drive` (files / folders / RAG; `docs` is an alias), `voice`, `avatar`, `friends`, `users`, `post`, `review`, `feed`, `notifications`, `memory`, plus the top-level `accounts`, `info`, `config`, `update`. All twin-scoped commands accept `--twin <id>`. Most commands support `--json`.
45
45
 
46
46
  > **Two chat surfaces — pick by identity.** `amiko chat` is the **owner's** DMs and group chats, acting **as the owner** (list / read / send). The openhermit gateway's `session_list` / `session_send` are the **agent's own** sessions, acting **as the agent**. "Send a message to Sophie for me" → `amiko chat send`; "have the twin reply as itself" → gateway. (The old `amiko conversation` namespace was removed in 0.10.1-beta.4; `amiko chat` is its owner-identity replacement.)
47
47
 
48
48
  ## Critical Rules
49
49
 
50
- 1. **Every paid / value-moving command is hard-gated on `--yes` in a non-interactive shell.** The CLI refuses to run `markets *`, `create *`, `chat send`, `credits topup`, `wallets swap send`, `wallets bridge send`, `wallets transfer` (and destructive ops like `twin update --public`, `drive delete`, `friends remove`, `friends reports request`, `avatar update`, `voice reset`, `review reject`) unless `--yes` is passed. The refusal prints the cost and the re-run command. **Quote the cost, get explicit approval, THEN append `--yes`.**
50
+ 1. **Every paid / value-moving command is hard-gated on `--yes` in a non-interactive shell.** The CLI refuses to run `markets *`, `create *`, `chat send`, `card mint`, `credits topup`, `wallets swap send`, `wallets bridge send`, `wallets transfer` (and destructive ops like `twin update --public`, `drive delete`, `friends remove`, `friends reports request`, `avatar update`, `voice reset`, `review reject`) unless `--yes` is passed. The refusal prints the cost and the re-run command. **Quote the cost, get explicit approval, THEN append `--yes`.**
51
51
  2. **After every paid command, report the remaining balance.** The CLI prints a `Balance: N credits` line — include that figure in your reply.
52
52
  3. **Never retry a failed command.** Report and stop. Every paid call costs tokens even on failure.
53
53
  4. **Auth is automatic.** Never suggest `amiko login` / `amiko connect`. Run from the agent's workspace folder; if anything looks off, `amiko accounts` shows the resolved `userId` / `twinId`.
@@ -103,8 +103,18 @@ All paid markets commands auto-select the twin's active Solana wallet (see `--wa
103
103
  - `amiko chat list` — all conversations, **DMs and group chats** (id, type, peer/title, last message, unread).
104
104
  - `amiko chat read <target>` — recent messages. `<target>` = a conversation id (from `list`), an `@handle`, or a name.
105
105
  - `amiko chat send <target> "message"` — send **as the owner**. Delivered in real time. `--yes` required in non-interactive shells (it's an outward message to a real person). For **group chats**, use the conversation id from `list`; for a **DM**, an `@handle`/name resolves + finds-or-creates the DM.
106
+ - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≤10MB or an Amiko URL — e.g. a generated image) and `--audio <pathOrUrl>` (local ≤25MB or an Amiko URL → sent as a voice note). One media block per message (image XOR audio). **Video is not supported** on chat yet. To share a generated asset, pass its URL from `amiko create status` (no re-upload). A local audio file is sent as a voice note with the text as a separate message.
106
107
  - Name resolution goes through user search; if ambiguous it lists candidates — don't blind-send. Server enforces who you're allowed to message (friend/participant rules); surface its error, don't retry.
107
108
 
109
+ ## Card — behavior notes
110
+
111
+ `amiko card` = the owner's **Twin Card** (shareable card, template `twins_take`, flavors **work / play / love** — DB-managed, discover with `card templates`).
112
+
113
+ - `card create --flavor <work|play|love> [--reroll <n>]` — generate/reroll; returns a slug + share link (`/s/<slug>`). Flavor defaults to `love`. Errors are user-fixable and exit **non-zero code 2** (don't treat as a crash): `profile_not_ready` (complete the profile first), `guardrails_failed` (retry / other flavor), `rate_limited` (reroll cooldown — try later).
114
+ - `card list` — your latest card; `card show <slug>` — its data; `card image <slug> --flavor <f> [--out <path>]` — download the PNG; `card share <slug>` — print the public link.
115
+ - `card mint <slug> --flavor <f>` — mint as an on-chain NFT → **`--yes` gated**. `card mint <slug> --status` checks an existing mint.
116
+ - All `--json`. Auth is the injected twin token (same as `post`/`create`); no login.
117
+
108
118
  ## Drive (files & folders)
109
119
 
110
120
  The drive is **shared between the user and the agent** — anything either side uploads is visible to both. Uploads kick off an async RAG-parsing job (~seconds to minutes); list/search/rename/move/delete all work regardless of parsing status. `drive search` and `drive list --search` hit filename, title, description, and the parsed content body (once parsing completes). Prefer reading parsed content via the agent workspace sync over polling `drive status` from long-running tasks. `amiko docs` is kept as an alias for backward compat.