@heyamiko/amiko-cli 0.14.0-beta.1 → 0.14.0-beta.10

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/dist/index.js CHANGED
@@ -27031,12 +27031,19 @@ async function confirmDestructive(args) {
27031
27031
  process.exit(2);
27032
27032
  }
27033
27033
  async function requireApproval(args) {
27034
+ if (args.cost === undefined === (args.free === undefined)) {
27035
+ throw new Error("requireApproval: set exactly one of `cost` or `free`.");
27036
+ }
27034
27037
  if (args.yes)
27035
27038
  return;
27036
27039
  if (process.stdin.isTTY) {
27037
27040
  console.log("");
27038
27041
  console.log(`About to run: ${args.summary}`);
27039
- console.log(`Cost: ${args.cost}`);
27042
+ if (args.cost !== undefined) {
27043
+ console.log(`Cost: ${args.cost}`);
27044
+ } else {
27045
+ console.log(`Free — but this is a real outward action other people will see (${args.free}).`);
27046
+ }
27040
27047
  const ok = await promptYesNo("Proceed? [y/N] ");
27041
27048
  if (!ok) {
27042
27049
  console.log(dim("Cancelled."));
@@ -27046,10 +27053,17 @@ async function requireApproval(args) {
27046
27053
  }
27047
27054
  console.error(error(`Refusing to run without --yes (non-interactive shell detected).`));
27048
27055
  console.error("");
27049
- console.error(`This command will cost ${args.cost}.`);
27050
- console.error(`Action: ${args.summary}`);
27051
- console.error("");
27052
- console.error(dim(`Quote the cost to the user, get their explicit approval, then re-run with --yes:`));
27056
+ if (args.cost !== undefined) {
27057
+ console.error(`This command will cost ${args.cost}.`);
27058
+ console.error(`Action: ${args.summary}`);
27059
+ console.error("");
27060
+ console.error(dim(`Quote the cost to the user, get their explicit approval, then re-run with --yes:`));
27061
+ } else {
27062
+ console.error(`This action is FREE — do not quote a price or say "cost: 0". It is outward-facing: ${args.free}.`);
27063
+ console.error(`Action: ${args.summary}`);
27064
+ console.error("");
27065
+ console.error(dim(`Ask the owner to confirm the action itself (what will be sent and who sees it), then re-run with --yes:`));
27066
+ }
27053
27067
  console.error(dim(` ${args.commandExample} --yes`));
27054
27068
  process.exit(2);
27055
27069
  }
@@ -28459,7 +28473,7 @@ function computeImageUsd(model, quality, size3, n) {
28459
28473
  return Number((pricing[q][tier] * clamped * IMAGE_MARKUP).toFixed(6));
28460
28474
  }
28461
28475
  function registerImageCommand(program2) {
28462
- program2.command("image <prompt>").alias("img").description("Generate an image — pays in AMIKO at OpenAI pass-through pricing × 1.30 (cost varies by model/quality/size)").option("--size <size>", "Image size: 1024x1024, 1024x1536, 1536x1024", "1024x1024").option("--quality <q>", "Quality: low, medium, high, auto", "high").option("--model <model>", `Model: ${SUPPORTED_MODELS.join(", ")}`, DEFAULT_IMAGE_MODEL).option("--n <count>", "Number of images (1–4)", "1").option("--background <bg>", "Background: transparent, opaque, auto").option("--format <fmt>", "Output: png, webp, jpeg", "png").option("--wallet <address>", "Wallet to pay from (defaults to your Solana wallet — run `amiko wallets create --chain solana` if you don't have one)").option("--token <token>", "Payment token (AMIKO, USDC, USDT)", "AMIKO").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28476
+ program2.command("image <prompt>").alias("img").description("Generate an image — pay in AMIKO, USDC, or USDT (cost varies by model/quality/size; exact amount quoted before charging)").option("--size <size>", "Image size: 1024x1024, 1024x1536, 1536x1024", "1024x1024").option("--quality <q>", "Quality: low, medium, high, auto", "high").option("--model <model>", `Model: ${SUPPORTED_MODELS.join(", ")}`, DEFAULT_IMAGE_MODEL).option("--n <count>", "Number of images (1–4)", "1").option("--background <bg>", "Background: transparent, opaque, auto").option("--format <fmt>", "Output: png, webp, jpeg", "png").option("--wallet <address>", "Wallet to pay from (defaults to your Solana wallet — run `amiko wallets create --chain solana` if you don't have one)").option("--token <token>", "Payment token (AMIKO, USDC, USDT)", "AMIKO").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28463
28477
  if (!SUPPORTED_MODELS.includes(opts.model)) {
28464
28478
  console.error(error(`Unknown model "${opts.model}". Supported: ${SUPPORTED_MODELS.join(", ")}.`));
28465
28479
  process.exit(1);
@@ -28633,6 +28647,15 @@ var ETA = {
28633
28647
  function statusOf(err) {
28634
28648
  return err?.status;
28635
28649
  }
28650
+ function collectOption(value, previous) {
28651
+ return [...previous ?? [], value];
28652
+ }
28653
+ function assertMaxUrls(resourceName, urls, max) {
28654
+ if (urls.length > max) {
28655
+ console.error(error(`At most ${max} ${resourceName} allowed (got ${urls.length}).`));
28656
+ process.exit(1);
28657
+ }
28658
+ }
28636
28659
  function printCompleted(final, title) {
28637
28660
  console.log(heading(`${title}
28638
28661
  `));
@@ -28644,7 +28667,9 @@ function printCompleted(final, title) {
28644
28667
  console.log(label("Type", final.assetMime));
28645
28668
  console.log("");
28646
28669
  if (final.charged) {
28647
- console.log(success(`Charged ${final.costAmiko ?? "?"} AMIKO`) + (final.chargeTx ? dim(` tx: ${final.chargeTx.slice(0, 16)}…`) : ""));
28670
+ const amt = final.chargeAmount ?? final.costAmiko ?? "?";
28671
+ const tok = final.chargeToken ?? "AMIKO";
28672
+ console.log(success(`Charged ${amt} ${tok}`) + (final.chargeTx ? dim(` tx: ${final.chargeTx.slice(0, 16)}…`) : ""));
28648
28673
  } else {
28649
28674
  console.log(dim("Not charged (settlement pending or waived)."));
28650
28675
  }
@@ -28713,9 +28738,12 @@ async function submitCreateJob(opts) {
28713
28738
  console.log(label("Status", "PENDING — generating in the background"));
28714
28739
  console.log(label("Check result", `amiko create status ${jobId}`));
28715
28740
  console.log(dim(`Usually ready in ${ETA[opts.mode]} — charged only on success. (Also: amiko create media)`));
28741
+ if (opts.mode === "VIDEO") {
28742
+ console.log(dim("Async video: HTTP 202 / PENDING means the job was accepted — not a failure. Poll status after ~2–9 min."));
28743
+ }
28716
28744
  }
28717
28745
  function registerCreateCommand(create2) {
28718
- create2.command("image <prompt>").description("Generate an image (Create Studio)").option("--model <model>", "Image model", "gpt-image-2").option("--aspect <ratio>", "Aspect ratio: 1:1, 16:9, 9:16, 4:3, 3:4", "1:1").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28746
+ create2.command("image <prompt>").description("Generate an image (Create Studio)").option("--model <model>", "Image model: nano-banana-2 (default), nano-banana[-pro|-lite] (Google), gpt-image-2/-1.5/-1/-1-mini (OpenAI), grok-imagine-image-quality (xAI), image-01 (MiniMax)", "nano-banana-2").option("--aspect <ratio>", "Aspect ratio: 1:1, 16:9, 9:16, 4:3, 3:4", "1:1").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28719
28747
  await submitCreateJob({
28720
28748
  mode: "IMAGE",
28721
28749
  summary: `Create image: ${prompt.slice(0, 80)}${prompt.length > 80 ? "…" : ""}`,
@@ -28731,12 +28759,19 @@ function registerCreateCommand(create2) {
28731
28759
  }
28732
28760
  });
28733
28761
  });
28734
- create2.command("video <prompt>").description("Generate a video (Create Studio)").option("--model <model>", "Video model", "MiniMax-Hailuo-2.3-Fast").option("--resolution <res>", "512P, 720P, 768P, 1080P", "768P").option("--seconds <n>", "Clip length: 6 or 10", "6").option("--aspect <ratio>", "Aspect ratio (provider-dependent)").option("--first-frame <url>", "Image-to-video: first frame URL/data URI").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28762
+ create2.command("video <prompt>").description("Generate a video (Create Studio)").option("--model <model>", "Video model: MiniMax-Hailuo-2.3-Fast (default; I2V — needs --first-frame), MiniMax-Hailuo-02 / MiniMax-Hailuo-2.3 (T2V+I2V), grok-imagine-video (xAI), dreamina-seedance-* (BytePlus). ONLY these — kling and other ids are not supported", "MiniMax-Hailuo-2.3-Fast").option("--resolution <res>", "512P, 720P, 768P, 1080P", "768P").option("--seconds <n>", "Clip length: 6 or 10", "6").option("--aspect <ratio>", "Aspect ratio (provider-dependent)").option("--first-frame <url>", "Image-to-video: first frame URL/data URI").option("--last-frame <url>", "First-and-last-frame video: ending frame URL/data URI").option("--reference-image <url>", "Seedance multimodal: reference image URL (repeatable, max 9)", collectOption, []).option("--reference-video <url>", "Seedance multimodal: reference video URL (repeatable, max 3)", collectOption, []).option("--reference-audio <url>", "Seedance multimodal: reference audio URL (repeatable, max 3)", collectOption, []).option("--subject-reference <url>", "MiniMax S2V: subject/character reference image URL").option("--generate-audio", "Seedance: generate audio in the output video").option("--no-generate-audio", "Seedance: output silent video").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28735
28763
  const seconds = Number(opts.seconds);
28736
28764
  if (seconds !== 6 && seconds !== 10) {
28737
28765
  console.error(error("--seconds must be 6 or 10."));
28738
28766
  process.exit(1);
28739
28767
  }
28768
+ const referenceImageUrls = opts.referenceImage ?? [];
28769
+ const referenceVideoUrls = opts.referenceVideo ?? [];
28770
+ const referenceAudioUrls = opts.referenceAudio ?? [];
28771
+ assertMaxUrls("--reference-image", referenceImageUrls, 9);
28772
+ assertMaxUrls("--reference-video", referenceVideoUrls, 3);
28773
+ assertMaxUrls("--reference-audio", referenceAudioUrls, 3);
28774
+ const generateAudio = opts.generateAudio;
28740
28775
  await submitCreateJob({
28741
28776
  mode: "VIDEO",
28742
28777
  summary: `Create video: ${prompt.slice(0, 80)}${prompt.length > 80 ? "…" : ""}`,
@@ -28756,6 +28791,19 @@ function registerCreateCommand(create2) {
28756
28791
  seconds,
28757
28792
  ...opts.aspect ? { aspectRatio: opts.aspect } : {},
28758
28793
  ...opts.firstFrame ? { firstFrameImage: opts.firstFrame } : {},
28794
+ ...opts.lastFrame ? { lastFrameImage: opts.lastFrame } : {},
28795
+ ...referenceImageUrls.length ? { referenceImageUrls } : {},
28796
+ ...referenceVideoUrls.length ? { referenceVideoUrls } : {},
28797
+ ...referenceAudioUrls.length ? { referenceAudioUrls } : {},
28798
+ ...opts.subjectReference ? {
28799
+ subjectReference: [
28800
+ {
28801
+ type: "character",
28802
+ image: [opts.subjectReference]
28803
+ }
28804
+ ]
28805
+ } : {},
28806
+ ...generateAudio !== undefined ? { generateAudio } : {},
28759
28807
  ...opts.token ? { preferredToken: opts.token } : {}
28760
28808
  }
28761
28809
  });
@@ -28776,7 +28824,7 @@ function registerCreateCommand(create2) {
28776
28824
  }
28777
28825
  });
28778
28826
  });
28779
- create2.command("music [prompt]").description("Generate music (Create Studio)").option("--model <model>", "music_v1, music-2.6, music-cover", "music-2.6").option("--lyrics <lyrics>", "Lyrics (optional)").option("--duration <ms>", "Length in ms").option("--instrumental", "Instrumental (no vocals)").option("--lyrics-optimizer", "Auto-write lyrics from the prompt (music_v1 only; on by default when no --lyrics/--instrumental)").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28827
+ create2.command("music [prompt]").description("Generate music (Create Studio)").option("--model <model>", "music_v1, music-2.6[-free], music-cover[-free]", "music-2.6").option("--lyrics <lyrics>", "Lyrics (optional)").option("--duration <ms>", "Length in ms").option("--instrumental", "Instrumental (no vocals)").option("--lyrics-optimizer", "Auto-write lyrics from the prompt (music_v1 only; on by default when no --lyrics/--instrumental)").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28780
28828
  if (!prompt && !opts.lyrics) {
28781
28829
  console.error(error("Provide a prompt or --lyrics."));
28782
28830
  process.exit(1);
@@ -28900,41 +28948,42 @@ import { basename, extname } from "node:path";
28900
28948
  var MAX_BYTES = 100 * 1024 * 1024;
28901
28949
  var FETCH_TIMEOUT_MS = 30000;
28902
28950
  async function resolveFileInput(src, opts = {}) {
28951
+ const maxBytes = opts.maxBytes ?? MAX_BYTES;
28903
28952
  if (src === "-" || opts.stdin) {
28904
- const buffer2 = await readStdin();
28953
+ const buffer2 = await readStdin(maxBytes);
28905
28954
  const name2 = opts.hintName ?? "stdin";
28906
28955
  const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
28907
28956
  return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
28908
28957
  }
28909
28958
  if (/^https?:\/\//i.test(src)) {
28910
- const buffer2 = await fetchRemote(src);
28959
+ const buffer2 = await fetchRemote(src, maxBytes);
28911
28960
  const urlPath = new URL(src).pathname;
28912
28961
  const name2 = opts.hintName ?? (basename(urlPath) || "download");
28913
28962
  const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
28914
28963
  return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
28915
28964
  }
28916
28965
  const buffer = await readFile(src);
28917
- if (buffer.length > MAX_BYTES) {
28918
- throw new Error(`File too large: ${buffer.length} bytes exceeds ${MAX_BYTES} byte cap.`);
28966
+ if (buffer.length > maxBytes) {
28967
+ throw new Error(`File too large: ${buffer.length} bytes exceeds ${maxBytes} byte cap.`);
28919
28968
  }
28920
28969
  const name = opts.hintName ?? basename(src);
28921
28970
  const mime = opts.hintMime ?? detectMime(buffer, name);
28922
28971
  return { buffer, name, mime, size: buffer.length };
28923
28972
  }
28924
- async function readStdin() {
28973
+ async function readStdin(maxBytes) {
28925
28974
  const chunks = [];
28926
28975
  let total = 0;
28927
28976
  for await (const chunk of process.stdin) {
28928
28977
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
28929
28978
  total += buf.length;
28930
- if (total > MAX_BYTES) {
28931
- throw new Error(`stdin exceeded ${MAX_BYTES} byte cap.`);
28979
+ if (total > maxBytes) {
28980
+ throw new Error(`stdin exceeded ${maxBytes} byte cap.`);
28932
28981
  }
28933
28982
  chunks.push(buf);
28934
28983
  }
28935
28984
  return Buffer.concat(chunks);
28936
28985
  }
28937
- async function fetchRemote(url2) {
28986
+ async function fetchRemote(url2, maxBytes) {
28938
28987
  const controller = new AbortController;
28939
28988
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
28940
28989
  try {
@@ -28943,12 +28992,12 @@ async function fetchRemote(url2) {
28943
28992
  throw new Error(`Fetch ${url2} failed: ${res.status} ${res.statusText}`);
28944
28993
  }
28945
28994
  const len = Number(res.headers.get("content-length") || 0);
28946
- if (len > MAX_BYTES) {
28947
- throw new Error(`Remote file too large: ${len} bytes exceeds ${MAX_BYTES} byte cap.`);
28995
+ if (len > maxBytes) {
28996
+ throw new Error(`Remote file too large: ${len} bytes exceeds ${maxBytes} byte cap.`);
28948
28997
  }
28949
28998
  const buf = Buffer.from(await res.arrayBuffer());
28950
- if (buf.length > MAX_BYTES) {
28951
- throw new Error(`Remote file too large: ${buf.length} bytes exceeds ${MAX_BYTES} byte cap.`);
28999
+ if (buf.length > maxBytes) {
29000
+ throw new Error(`Remote file too large: ${buf.length} bytes exceeds ${maxBytes} byte cap.`);
28952
29001
  }
28953
29002
  return buf;
28954
29003
  } finally {
@@ -28979,6 +29028,13 @@ var EXT_MIME = {
28979
29028
  ".mp3": "audio/mpeg",
28980
29029
  ".m4a": "audio/mp4",
28981
29030
  ".ogg": "audio/ogg",
29031
+ ".aac": "audio/aac",
29032
+ ".flac": "audio/flac",
29033
+ ".mp4": "video/mp4",
29034
+ ".m4v": "video/x-m4v",
29035
+ ".webm": "video/webm",
29036
+ ".mov": "video/quicktime",
29037
+ ".mkv": "video/x-matroska",
28982
29038
  ".zip": "application/zip"
28983
29039
  };
28984
29040
  function detectMime(buffer, name) {
@@ -28991,12 +29047,15 @@ function detectMime(buffer, name) {
28991
29047
  return EXT_MIME[ext] ?? "application/octet-stream";
28992
29048
  }
28993
29049
 
28994
- // src/commands/chat.ts
29050
+ // src/lib/conversations.ts
28995
29051
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28996
29052
  var CUID_RE = /^c[a-z0-9]{20,}$/i;
28997
29053
  function looksLikeConversationId(t) {
28998
29054
  return UUID_RE.test(t) || CUID_RE.test(t);
28999
29055
  }
29056
+ function normalizeUserId(id) {
29057
+ return id.replace(/^did:privy:/, "");
29058
+ }
29000
29059
  function authOrExit() {
29001
29060
  try {
29002
29061
  return requireAuth();
@@ -29008,9 +29067,531 @@ function authOrExit() {
29008
29067
  function convLabel(conv, selfUserId) {
29009
29068
  if (conv.title)
29010
29069
  return conv.title;
29011
- const others = (conv.participants ?? []).filter((p) => !(p.participant_type === "user" && p.participant_id === selfUserId)).map((p) => p.user?.name ?? p.twin?.name ?? p.participant_id.slice(0, 8)).filter((n) => !!n);
29070
+ const self = selfUserId ? normalizeUserId(selfUserId) : undefined;
29071
+ const others = (conv.participants ?? []).filter((p) => !(p.participant_type === "user" && normalizeUserId(p.participant_id) === self)).map((p) => p.user?.name ?? p.twin?.name ?? p.participant_id.slice(0, 8)).filter((n) => !!n);
29012
29072
  return others.length ? others.join(", ") : "(conversation)";
29013
29073
  }
29074
+ function candidateList(users) {
29075
+ return users.map((u) => `${u.name ?? "?"} (@${u.handle ?? u.id.slice(0, 8)})`).join(", ");
29076
+ }
29077
+ async function resolveMembers(auth, tokens) {
29078
+ const resolved = [];
29079
+ const failures = [];
29080
+ const seen = new Set;
29081
+ let friendsCache = null;
29082
+ const getFriends = async () => {
29083
+ if (friendsCache)
29084
+ return friendsCache;
29085
+ const data = await amikoWebFetch(auth, "/api/friends", {
29086
+ query: { type: "user" },
29087
+ timeoutMs: 15000
29088
+ });
29089
+ friendsCache = (data.friends ?? []).map((f) => f.friend).filter((f) => !!f && f.type === "user");
29090
+ return friendsCache;
29091
+ };
29092
+ const push = (m) => {
29093
+ if (seen.has(m.userId))
29094
+ return;
29095
+ seen.add(m.userId);
29096
+ resolved.push(m);
29097
+ };
29098
+ for (const raw of tokens) {
29099
+ const token = raw.trim();
29100
+ if (!token)
29101
+ continue;
29102
+ const asId = normalizeUserId(token);
29103
+ if (looksLikeConversationId(asId)) {
29104
+ push({ token, userId: asId, source: "id" });
29105
+ continue;
29106
+ }
29107
+ if (token.startsWith("@")) {
29108
+ const handle = token.slice(1);
29109
+ try {
29110
+ const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(handle)}`, {
29111
+ timeoutMs: 15000
29112
+ });
29113
+ if (!prof.user?.id)
29114
+ throw new Error(`No user @${handle}.`);
29115
+ push({
29116
+ token,
29117
+ userId: prof.user.id,
29118
+ name: prof.user.name ?? undefined,
29119
+ handle: prof.user.handle ?? handle,
29120
+ source: "handle"
29121
+ });
29122
+ } catch (e5) {
29123
+ failures.push({
29124
+ token,
29125
+ reason: e5 instanceof Error ? e5.message : `No user @${handle}.`
29126
+ });
29127
+ }
29128
+ continue;
29129
+ }
29130
+ try {
29131
+ const friends = await getFriends();
29132
+ const lower = token.toLowerCase();
29133
+ const exact = friends.filter((f) => f.name?.toLowerCase() === lower || f.handle?.toLowerCase() === lower);
29134
+ const matches = exact.length ? exact : friends.filter((f) => f.name?.toLowerCase().includes(lower) || f.handle?.toLowerCase().includes(lower));
29135
+ if (matches.length === 1) {
29136
+ const f = matches[0];
29137
+ push({
29138
+ token,
29139
+ userId: f.id,
29140
+ name: f.name,
29141
+ handle: f.handle,
29142
+ source: "friend"
29143
+ });
29144
+ continue;
29145
+ }
29146
+ if (matches.length > 1) {
29147
+ failures.push({
29148
+ token,
29149
+ reason: `ambiguous — matches: ${candidateList(matches)}. Use an exact @handle or a user id.`
29150
+ });
29151
+ continue;
29152
+ }
29153
+ const res = await amikoWebFetch(auth, "/api/search", {
29154
+ query: { q: token, type: "people", limit: 8 },
29155
+ timeoutMs: 15000
29156
+ });
29157
+ const users = res.users ?? [];
29158
+ if (users.length === 1) {
29159
+ const u = users[0];
29160
+ push({
29161
+ token,
29162
+ userId: u.id,
29163
+ name: u.name,
29164
+ handle: u.handle,
29165
+ source: "search"
29166
+ });
29167
+ } else if (users.length > 1) {
29168
+ failures.push({
29169
+ token,
29170
+ reason: `ambiguous — matches: ${candidateList(users)}. Use an exact @handle or a user id.`
29171
+ });
29172
+ } else {
29173
+ failures.push({
29174
+ token,
29175
+ reason: `no friend or user matches. Use an exact @handle, or an id from \`amiko friends list --json\`.`
29176
+ });
29177
+ }
29178
+ } catch (e5) {
29179
+ failures.push({
29180
+ token,
29181
+ reason: e5 instanceof Error ? e5.message : "lookup failed"
29182
+ });
29183
+ }
29184
+ }
29185
+ return { resolved, failures };
29186
+ }
29187
+ async function fetchConversationById(auth, id) {
29188
+ const data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(id)}`, { timeoutMs: 20000 });
29189
+ if (!data.conversation) {
29190
+ throw new Error(`Conversation ${id} not found or you are not a participant.`);
29191
+ }
29192
+ return data.conversation;
29193
+ }
29194
+ async function listGroups(auth, limit = 100) {
29195
+ const data = await amikoWebFetch(auth, "/api/conversations", { query: { limit }, timeoutMs: 20000 });
29196
+ return (data.conversations ?? []).filter((c) => c.conversation_type === "group");
29197
+ }
29198
+ async function findGroupIdByExactTitle(auth, title) {
29199
+ const lower = title.trim().toLowerCase();
29200
+ if (!lower)
29201
+ return null;
29202
+ const groups = await listGroups(auth);
29203
+ const matches = groups.filter((g) => g.title?.toLowerCase() === lower);
29204
+ return matches.length === 1 ? matches[0].id : null;
29205
+ }
29206
+ async function resolveGroup(auth, target) {
29207
+ const t = target.trim();
29208
+ if (!t) {
29209
+ throw new Error("No group given. Run `amiko chat group list` and pass a group id or title.");
29210
+ }
29211
+ if (looksLikeConversationId(t)) {
29212
+ const conv = await fetchConversationById(auth, t);
29213
+ if (conv.conversation_type !== "group") {
29214
+ throw new Error(`Conversation ${t} is not a group chat (type: ${conv.conversation_type}).`);
29215
+ }
29216
+ return conv;
29217
+ }
29218
+ const groups = await listGroups(auth);
29219
+ const lower = t.toLowerCase();
29220
+ const exact = groups.filter((g) => g.title?.toLowerCase() === lower);
29221
+ const matches = exact.length ? exact : groups.filter((g) => g.title?.toLowerCase().includes(lower));
29222
+ if (matches.length === 0) {
29223
+ throw new Error(`No group matching "${t}". Run \`amiko chat group list\` and use the group's id or exact title.`);
29224
+ }
29225
+ if (matches.length > 1) {
29226
+ const names = matches.map((g) => `"${g.title}" (${g.id})`).join(", ");
29227
+ throw new Error(`"${t}" is ambiguous — matches: ${names}. Use the group's id.`);
29228
+ }
29229
+ return fetchConversationById(auth, matches[0].id);
29230
+ }
29231
+
29232
+ // src/commands/chat-group.ts
29233
+ var collect = (v, prev) => [...prev, v];
29234
+ function statusOf2(e5) {
29235
+ return e5 && typeof e5 === "object" && "status" in e5 ? e5.status : undefined;
29236
+ }
29237
+ var ADMIN_TIP = "Only group admins can do this — check roles with `amiko chat group info`.";
29238
+ function memberLabel(m) {
29239
+ const name = m.name ?? m.token;
29240
+ return m.handle ? `${name} (@${m.handle})` : name;
29241
+ }
29242
+ function memberFlags(tokens) {
29243
+ return tokens.map((t) => `--member ${JSON.stringify(t)}`).join(" ");
29244
+ }
29245
+ function exitWithError(e5, fallback2) {
29246
+ console.error(error(e5 instanceof Error ? e5.message : fallback2));
29247
+ process.exit(1);
29248
+ }
29249
+ async function resolveMembersOrExit(auth, tokens) {
29250
+ if (tokens.length === 0) {
29251
+ console.error(error("At least one --member is required."));
29252
+ process.exit(1);
29253
+ }
29254
+ let resolution;
29255
+ try {
29256
+ resolution = await resolveMembers(auth, tokens);
29257
+ } catch (e5) {
29258
+ exitWithError(e5, "Could not resolve members");
29259
+ }
29260
+ const { resolved, failures } = resolution;
29261
+ if (failures.length > 0) {
29262
+ console.error(error("Could not resolve every member — nothing was changed."));
29263
+ for (const m of resolved) {
29264
+ console.error(dim(` resolved: ${memberLabel(m)} → ${m.userId}`));
29265
+ }
29266
+ for (const f of failures) {
29267
+ console.error(error(` ${JSON.stringify(f.token)}: ${f.reason}`));
29268
+ }
29269
+ process.exit(1);
29270
+ }
29271
+ return resolved;
29272
+ }
29273
+ async function resolveGroupOrExit(auth, target) {
29274
+ try {
29275
+ return await resolveGroup(auth, target);
29276
+ } catch (e5) {
29277
+ exitWithError(e5, "Could not resolve group");
29278
+ }
29279
+ }
29280
+ function activeParticipantOf(conv, userId) {
29281
+ return (conv.participants ?? []).find((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === userId && !p.left_at);
29282
+ }
29283
+ function participantName(p) {
29284
+ return p.user?.name ?? p.twin?.name ?? p.user?.handle ?? p.participant_id.slice(0, 8);
29285
+ }
29286
+ function reportMemberResults(conversationId, results, raw) {
29287
+ if (raw) {
29288
+ console.log(JSON.stringify({
29289
+ conversation_id: conversationId,
29290
+ results: results.map((r) => ({
29291
+ user_id: r.member.userId,
29292
+ member: memberLabel(r.member),
29293
+ outcome: r.outcome,
29294
+ ok: r.ok
29295
+ }))
29296
+ }, null, 2));
29297
+ return;
29298
+ }
29299
+ for (const r of results) {
29300
+ const line = `${memberLabel(r.member)} — ${r.outcome}`;
29301
+ console.log(r.ok ? success(line) : error(line));
29302
+ }
29303
+ }
29304
+ function registerChatGroupCommand(group) {
29305
+ group.command("create <name>").description("Create a group chat with the given name and members").option("--member <who>", "Member to add (name, @handle, or user id); repeat per member", collect, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (name, opts) => {
29306
+ const auth = authOrExit();
29307
+ const members = await resolveMembersOrExit(auth, opts.member);
29308
+ await requireApproval({
29309
+ free: "creates a group chat that real people are added to",
29310
+ summary: `Create group "${name}" with ${members.map(memberLabel).join(", ")}`,
29311
+ yes: opts.yes,
29312
+ commandExample: `amiko chat group create ${JSON.stringify(name)} ${memberFlags(opts.member)}`
29313
+ });
29314
+ let data;
29315
+ try {
29316
+ data = await amikoWebFetch(auth, "/api/conversations", {
29317
+ method: "POST",
29318
+ body: {
29319
+ participant_ids: members.map((m) => m.userId),
29320
+ participant_types: members.map(() => "user"),
29321
+ conversation_type: "group",
29322
+ title: name
29323
+ },
29324
+ timeoutMs: 20000
29325
+ });
29326
+ } catch (e5) {
29327
+ exitWithError(e5, "Failed to create group");
29328
+ }
29329
+ if (opts.raw) {
29330
+ console.log(JSON.stringify(data, null, 2));
29331
+ return;
29332
+ }
29333
+ if (!data.conversation?.id) {
29334
+ console.error(error("Group creation returned no conversation id."));
29335
+ process.exit(1);
29336
+ }
29337
+ console.log(success(`Group created: ${name}`));
29338
+ console.log(label("ID", data.conversation.id));
29339
+ console.log(label("Members", `${members.map(memberLabel).join(", ")} + you (admin)`));
29340
+ if (data.reused_existing) {
29341
+ console.log(dim("Note: an existing matching group was reused."));
29342
+ }
29343
+ });
29344
+ group.command("list").description("List your group chats").option("--limit <n>", "Max conversations to scan", "100").option("--raw", "Output raw JSON").action(async (opts) => {
29345
+ const auth = authOrExit();
29346
+ let groups;
29347
+ try {
29348
+ const data = await amikoWebFetch(auth, "/api/conversations", { query: { limit: opts.limit }, timeoutMs: 20000 });
29349
+ groups = (data.conversations ?? []).filter((c) => c.conversation_type === "group");
29350
+ } catch (e5) {
29351
+ exitWithError(e5, "Failed to list groups");
29352
+ }
29353
+ if (opts.raw) {
29354
+ console.log(JSON.stringify({ groups }, null, 2));
29355
+ return;
29356
+ }
29357
+ if (groups.length === 0) {
29358
+ console.log(dim("No group chats."));
29359
+ return;
29360
+ }
29361
+ console.log(heading(`Groups (${groups.length})
29362
+ `));
29363
+ const rows = [
29364
+ [dim("TITLE"), dim("ID"), dim("MEMBERS"), dim("UNREAD"), dim("LAST")],
29365
+ ...groups.map((g) => [
29366
+ g.title ?? "(untitled)",
29367
+ g.id,
29368
+ String(g.participants?.length ?? "-"),
29369
+ g.unread_count ? String(g.unread_count) : "-",
29370
+ g.last_message?.content?.slice(0, 40) ?? "-"
29371
+ ])
29372
+ ];
29373
+ console.log(table(rows));
29374
+ });
29375
+ group.command("info <group>").description("Show a group's members and roles (group id or title)").option("--raw", "Output raw JSON").action(async (target, opts) => {
29376
+ const auth = authOrExit();
29377
+ const conv = await resolveGroupOrExit(auth, target);
29378
+ if (opts.raw) {
29379
+ console.log(JSON.stringify({ conversation: conv }, null, 2));
29380
+ return;
29381
+ }
29382
+ const selfId = auth.userId ? normalizeUserId(auth.userId) : undefined;
29383
+ const active = (conv.participants ?? []).filter((p) => !p.left_at);
29384
+ console.log(heading(conv.title ?? "(untitled group)"));
29385
+ console.log(label("ID", conv.id));
29386
+ console.log(label("Members", String(active.length)));
29387
+ console.log("");
29388
+ const rows = [
29389
+ [dim("NAME"), dim("HANDLE"), dim("USER ID"), dim("TYPE"), dim("ROLE")],
29390
+ ...active.map((p) => {
29391
+ const isSelf = p.participant_type === "user" && normalizeUserId(p.participant_id) === selfId;
29392
+ return [
29393
+ participantName(p) + (isSelf ? " (you)" : ""),
29394
+ p.user?.handle ? `@${p.user.handle}` : "-",
29395
+ p.participant_id,
29396
+ p.participant_type,
29397
+ p.role ?? "member"
29398
+ ];
29399
+ })
29400
+ ];
29401
+ console.log(table(rows));
29402
+ });
29403
+ group.command("invite <group>").description("Show a group's invite/join link + QR image URL (admins only)").option("--raw", "Output raw JSON").action(async (target, opts) => {
29404
+ const auth = authOrExit();
29405
+ const conv = await resolveGroupOrExit(auth, target);
29406
+ let data;
29407
+ try {
29408
+ data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(conv.id)}/invite`, { timeoutMs: 20000 });
29409
+ } catch (e5) {
29410
+ if (statusOf2(e5) === 403) {
29411
+ console.error(error(`Can't view the invite link. ${ADMIN_TIP}`));
29412
+ process.exit(1);
29413
+ }
29414
+ exitWithError(e5, "Failed to load invite link");
29415
+ }
29416
+ if (!data.url || !data.qrUrl) {
29417
+ exitWithError(null, "The invite link response was missing its link or QR URL — nothing to share.");
29418
+ }
29419
+ if (opts.raw) {
29420
+ console.log(JSON.stringify(data, null, 2));
29421
+ return;
29422
+ }
29423
+ console.log(heading(conv.title ?? "(untitled group)"));
29424
+ console.log(label("Invite link", data.url));
29425
+ console.log(label("QR image", data.qrUrl));
29426
+ console.log(dim("Share with group admins only — anyone who opens it can join."));
29427
+ });
29428
+ group.command("rename <group> <newTitle>").description("Rename a group (admins only)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, newTitle, opts) => {
29429
+ const auth = authOrExit();
29430
+ const conv = await resolveGroupOrExit(auth, target);
29431
+ await confirmDestructive({
29432
+ action: `Rename group "${conv.title ?? conv.id}" to "${newTitle}"`,
29433
+ yes: opts.yes,
29434
+ commandExample: `amiko chat group rename ${JSON.stringify(target)} ${JSON.stringify(newTitle)}`
29435
+ });
29436
+ let data;
29437
+ try {
29438
+ data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(conv.id)}`, { method: "PATCH", body: { title: newTitle }, timeoutMs: 20000 });
29439
+ } catch (e5) {
29440
+ if (statusOf2(e5) === 403) {
29441
+ console.error(error(`Rename failed. ${ADMIN_TIP}`));
29442
+ process.exit(1);
29443
+ }
29444
+ exitWithError(e5, "Rename failed");
29445
+ }
29446
+ if (opts.raw) {
29447
+ console.log(JSON.stringify(data, null, 2));
29448
+ return;
29449
+ }
29450
+ console.log(success(`Group renamed to: ${newTitle}`) + dim(` (${conv.id})`));
29451
+ });
29452
+ group.command("add <group>").description("Add members to a group (admins only)").option("--member <who>", "Member to add (name, @handle, or user id); repeat per member", collect, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
29453
+ const auth = authOrExit();
29454
+ const members = await resolveMembersOrExit(auth, opts.member);
29455
+ const conv = await resolveGroupOrExit(auth, target);
29456
+ await requireApproval({
29457
+ free: "adds real people to the group",
29458
+ summary: `Add ${members.map(memberLabel).join(", ")} to group "${conv.title ?? conv.id}"`,
29459
+ yes: opts.yes,
29460
+ commandExample: `amiko chat group add ${JSON.stringify(target)} ${memberFlags(opts.member)}`
29461
+ });
29462
+ const results = [];
29463
+ let hardFailure = false;
29464
+ for (const m of members) {
29465
+ if (activeParticipantOf(conv, m.userId)) {
29466
+ results.push({ member: m, outcome: "already a member", ok: true });
29467
+ continue;
29468
+ }
29469
+ try {
29470
+ await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(conv.id)}/participants`, {
29471
+ method: "POST",
29472
+ body: { participant_id: m.userId, participant_type: "user" },
29473
+ timeoutMs: 20000
29474
+ });
29475
+ results.push({ member: m, outcome: "added", ok: true });
29476
+ } catch (e5) {
29477
+ const status = statusOf2(e5);
29478
+ if (status === 409) {
29479
+ results.push({ member: m, outcome: "already a member", ok: true });
29480
+ } else if (status === 403) {
29481
+ if (results.length)
29482
+ reportMemberResults(conv.id, results, opts.raw);
29483
+ console.error(error(`Adding members failed. ${ADMIN_TIP}`));
29484
+ process.exit(1);
29485
+ } else if (status === 404) {
29486
+ results.push({ member: m, outcome: "user not found", ok: false });
29487
+ hardFailure = true;
29488
+ } else {
29489
+ results.push({
29490
+ member: m,
29491
+ outcome: e5 instanceof Error ? e5.message : "failed",
29492
+ ok: false
29493
+ });
29494
+ hardFailure = true;
29495
+ }
29496
+ }
29497
+ }
29498
+ reportMemberResults(conv.id, results, opts.raw);
29499
+ if (hardFailure)
29500
+ process.exit(1);
29501
+ });
29502
+ group.command("remove <group>").description("Remove members from a group (admins only; anyone can remove themselves)").option("--member <who>", "Member to remove (name, @handle, or user id); repeat per member", collect, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
29503
+ const auth = authOrExit();
29504
+ const members = await resolveMembersOrExit(auth, opts.member);
29505
+ const conv = await resolveGroupOrExit(auth, target);
29506
+ await confirmDestructive({
29507
+ action: `Remove ${members.map(memberLabel).join(", ")} from group "${conv.title ?? conv.id}"`,
29508
+ yes: opts.yes,
29509
+ commandExample: `amiko chat group remove ${JSON.stringify(target)} ${memberFlags(opts.member)}`
29510
+ });
29511
+ const results = [];
29512
+ let hardFailure = false;
29513
+ for (const m of members) {
29514
+ const row = activeParticipantOf(conv, m.userId);
29515
+ if (!row?.id) {
29516
+ results.push({ member: m, outcome: "not a member", ok: true });
29517
+ continue;
29518
+ }
29519
+ try {
29520
+ await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(conv.id)}/participants/${encodeURIComponent(row.id)}`, { method: "DELETE", timeoutMs: 20000 });
29521
+ results.push({ member: m, outcome: "removed", ok: true });
29522
+ } catch (e5) {
29523
+ if (statusOf2(e5) === 403) {
29524
+ if (results.length)
29525
+ reportMemberResults(conv.id, results, opts.raw);
29526
+ console.error(error(`Removing members failed. ${ADMIN_TIP}`));
29527
+ process.exit(1);
29528
+ }
29529
+ results.push({
29530
+ member: m,
29531
+ outcome: e5 instanceof Error ? e5.message : "failed",
29532
+ ok: false
29533
+ });
29534
+ hardFailure = true;
29535
+ }
29536
+ }
29537
+ reportMemberResults(conv.id, results, opts.raw);
29538
+ if (hardFailure)
29539
+ process.exit(1);
29540
+ });
29541
+ group.command("leave <group>").alias("delete").description("Leave a group (hides it for you only — other members keep the conversation)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
29542
+ const auth = authOrExit();
29543
+ const conv = await resolveGroupOrExit(auth, target);
29544
+ await confirmDestructive({
29545
+ action: `Leave group "${conv.title ?? conv.id}" — this removes/hides the conversation for YOU only; other members keep it and its history.`,
29546
+ yes: opts.yes,
29547
+ commandExample: `amiko chat group leave ${JSON.stringify(target)}`
29548
+ });
29549
+ let data;
29550
+ try {
29551
+ data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(conv.id)}`, { method: "DELETE", timeoutMs: 20000 });
29552
+ } catch (e5) {
29553
+ exitWithError(e5, "Leave failed");
29554
+ }
29555
+ if (opts.raw) {
29556
+ console.log(JSON.stringify(data, null, 2));
29557
+ return;
29558
+ }
29559
+ console.log(success(`Left group "${conv.title ?? conv.id}"`) + dim(" (hidden for you; other members keep it)"));
29560
+ });
29561
+ group.command("promote <group>").description("Promote a member to group admin (admins only)").requiredOption("--member <who>", "Member to promote (name, @handle, or user id)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
29562
+ const auth = authOrExit();
29563
+ const [member] = await resolveMembersOrExit(auth, [opts.member]);
29564
+ const conv = await resolveGroupOrExit(auth, target);
29565
+ const row = activeParticipantOf(conv, member.userId);
29566
+ if (!row?.id) {
29567
+ console.error(error(`${memberLabel(member)} is not a member of "${conv.title ?? conv.id}".`));
29568
+ process.exit(1);
29569
+ }
29570
+ await confirmDestructive({
29571
+ action: `Promote ${memberLabel(member)} to admin in "${conv.title ?? conv.id}"`,
29572
+ detail: "There is no demote — this cannot be undone from the CLI.",
29573
+ yes: opts.yes,
29574
+ commandExample: `amiko chat group promote ${JSON.stringify(target)} --member ${JSON.stringify(opts.member)}`
29575
+ });
29576
+ let data;
29577
+ try {
29578
+ data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(conv.id)}/participants/${encodeURIComponent(row.id)}`, { method: "PATCH", body: { role: "admin" }, timeoutMs: 20000 });
29579
+ } catch (e5) {
29580
+ if (statusOf2(e5) === 403) {
29581
+ console.error(error(`Promote failed. ${ADMIN_TIP}`));
29582
+ process.exit(1);
29583
+ }
29584
+ exitWithError(e5, "Promote failed");
29585
+ }
29586
+ if (opts.raw) {
29587
+ console.log(JSON.stringify(data, null, 2));
29588
+ return;
29589
+ }
29590
+ console.log(success(`${memberLabel(member)} is now an admin.`));
29591
+ });
29592
+ }
29593
+
29594
+ // src/commands/chat.ts
29014
29595
  async function findOrCreateDm(auth, userId) {
29015
29596
  const data = await amikoWebFetch(auth, "/api/conversations", {
29016
29597
  method: "POST",
@@ -29026,30 +29607,91 @@ async function findOrCreateDm(auth, userId) {
29026
29607
  throw new Error("Could not open a conversation with that user.");
29027
29608
  return id;
29028
29609
  }
29610
+ async function fetchUserById(auth, id) {
29611
+ try {
29612
+ const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(id)}`, { timeoutMs: 15000 });
29613
+ return prof.user?.id ? { ...prof.user, id: prof.user.id } : null;
29614
+ } catch (e5) {
29615
+ if (e5.status === 404)
29616
+ return null;
29617
+ throw e5;
29618
+ }
29619
+ }
29620
+ async function findDmByUserId(auth, userId) {
29621
+ const want = normalizeUserId(userId);
29622
+ const limit = 100;
29623
+ for (let offset = 0;offset < 1e4; offset += limit) {
29624
+ const data = await amikoWebFetch(auth, "/api/conversations", {
29625
+ query: { limit, offset },
29626
+ timeoutMs: 20000
29627
+ });
29628
+ const page = data.conversations ?? [];
29629
+ const dm = page.find((c) => c.conversation_type === "direct" && (c.participants ?? []).some((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === want));
29630
+ if (dm)
29631
+ return dm.id;
29632
+ if (page.length < limit || data.pagination?.has_more === false)
29633
+ return null;
29634
+ }
29635
+ return null;
29636
+ }
29637
+ function personLabel(u) {
29638
+ const name = u.name ?? u.id;
29639
+ return u.handle ? `${name} (@${u.handle})` : name;
29640
+ }
29029
29641
  async function resolveConversation(auth, target) {
29030
29642
  const t = target.trim();
29031
- if (looksLikeConversationId(t))
29032
- return t;
29643
+ if (!t) {
29644
+ throw new Error("No target given. Pass a conversation id, a user id, an @handle, or a name.");
29645
+ }
29646
+ const person = (u) => ({
29647
+ kind: "user",
29648
+ userId: u.id,
29649
+ label: `DM with ${personLabel(u)}`
29650
+ });
29033
29651
  if (t.startsWith("@")) {
29034
29652
  const handle = t.slice(1);
29035
- const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(handle)}`, { timeoutMs: 15000 });
29653
+ const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(handle)}`, {
29654
+ timeoutMs: 15000
29655
+ });
29036
29656
  if (!prof.user?.id)
29037
29657
  throw new Error(`No user @${handle}.`);
29038
- return findOrCreateDm(auth, prof.user.id);
29658
+ return person({ ...prof.user, id: prof.user.id });
29039
29659
  }
29040
- const res = await amikoWebFetch(auth, "/api/search", {
29041
- query: { q: t, type: "people", limit: 8 },
29042
- timeoutMs: 15000
29043
- });
29044
- const users = res.users ?? [];
29045
- if (users.length === 0) {
29046
- throw new Error(`No user matching "${t}". Use an exact @handle, or a conversation id from \`amiko chat list\`.`);
29660
+ const idish = normalizeUserId(t);
29661
+ if (looksLikeConversationId(idish)) {
29662
+ if (idish === t) {
29663
+ try {
29664
+ const conv = await fetchConversationById(auth, t);
29665
+ return {
29666
+ kind: "conversation",
29667
+ id: conv.id,
29668
+ label: conv.conversation_type === "direct" ? `DM with ${convLabel(conv, auth.userId)}` : `group "${convLabel(conv, auth.userId)}"`
29669
+ };
29670
+ } catch (e5) {
29671
+ const status = e5.status;
29672
+ if (status === 403) {
29673
+ throw new Error(`Conversation ${t} exists but you are not a participant.`);
29674
+ }
29675
+ if (status !== 404)
29676
+ throw e5;
29677
+ }
29678
+ }
29679
+ const user = await fetchUserById(auth, idish);
29680
+ if (!user) {
29681
+ throw new Error(`"${t}" is neither a conversation nor a user id — check \`amiko chat list\` or \`amiko friends list --json\`.`);
29682
+ }
29683
+ return person(user);
29047
29684
  }
29048
- if (users.length > 1) {
29049
- const names = users.map((u) => `${u.name ?? "?"} (@${u.handle ?? u.id.slice(0, 8)})`).join(", ");
29050
- throw new Error(`"${t}" is ambiguous — matches: ${names}. Use an exact @handle or a conversation id.`);
29685
+ const groupId = await findGroupIdByExactTitle(auth, t);
29686
+ if (groupId)
29687
+ return { kind: "conversation", id: groupId, label: `group "${t}"` };
29688
+ const { resolved, failures } = await resolveMembers(auth, [t]);
29689
+ if (failures.length || resolved.length !== 1) {
29690
+ const reason = failures[0]?.reason ?? "could not be resolved";
29691
+ throw new Error(`"${t}" ${reason}`);
29051
29692
  }
29052
- return findOrCreateDm(auth, users[0].id);
29693
+ const p = resolved[0];
29694
+ return person({ id: p.userId, name: p.name, handle: p.handle });
29053
29695
  }
29054
29696
  var HTTPS_RE = /^https:\/\//i;
29055
29697
  var CHAT_IMAGE_MIME = new Set([
@@ -29136,11 +29778,20 @@ function registerChatCommand(chat) {
29136
29778
  console.log(dim(` ${unread.trim()}`));
29137
29779
  }
29138
29780
  });
29139
- chat.command("read <target>").description("Read recent messages in a conversation (conversation id, @handle, or name)").option("--limit <n>", "Max messages", "20").option("--raw", "Output raw JSON").action(async (target, opts) => {
29781
+ chat.command("read <target>").description("Read recent messages in a conversation (conversation id, user id, @handle, or name)").option("--limit <n>", "Max messages", "20").option("--raw", "Output raw JSON").action(async (target, opts) => {
29140
29782
  const auth = authOrExit();
29141
29783
  let convId;
29142
29784
  try {
29143
- convId = await resolveConversation(auth, target);
29785
+ const resolved = await resolveConversation(auth, target);
29786
+ if (resolved.kind === "user") {
29787
+ const existing = await findDmByUserId(auth, resolved.userId);
29788
+ if (!existing) {
29789
+ throw new Error(`No ${resolved.label} yet — start one with \`amiko chat send\`.`);
29790
+ }
29791
+ convId = existing;
29792
+ } else {
29793
+ convId = resolved.id;
29794
+ }
29144
29795
  } catch (e5) {
29145
29796
  console.error(error(e5 instanceof Error ? e5.message : "Could not resolve target"));
29146
29797
  process.exit(1);
@@ -29171,26 +29822,33 @@ function registerChatCommand(chat) {
29171
29822
  console.log(dim(` ${when}`));
29172
29823
  }
29173
29824
  });
29174
- 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) => {
29825
+ chat.command("send <target> <message>").description("Send a message as yourself (the owner) to a conversation or person (conversation id, user id, @handle, or name — DMs are opened automatically)").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) => {
29175
29826
  const auth = authOrExit();
29176
29827
  if (opts.image && opts.audio) {
29177
29828
  console.error(error("Attach either --image or --audio, not both (one media block per message)."));
29178
29829
  process.exit(1);
29179
29830
  }
29180
- let convId;
29831
+ let resolved;
29181
29832
  try {
29182
- convId = await resolveConversation(auth, target);
29833
+ resolved = await resolveConversation(auth, target);
29183
29834
  } catch (e5) {
29184
29835
  console.error(error(e5 instanceof Error ? e5.message : "Could not resolve target"));
29185
29836
  process.exit(1);
29186
29837
  }
29187
29838
  const media = opts.image ? `image ${opts.image}` : opts.audio ? `audio ${opts.audio}` : "";
29188
29839
  await requireApproval({
29189
- cost: "no charge — sends a message as you (the owner)",
29190
- summary: `Send to ${target}${media ? ` [+${media}]` : ""}: ${message.slice(0, 80)}${message.length > 80 ? "…" : ""}`,
29840
+ free: "sends a message as you (the owner) — the recipients see it",
29841
+ summary: `Send to ${resolved.label}${media ? ` [+${media}]` : ""}: ${message.slice(0, 80)}${message.length > 80 ? "…" : ""}`,
29191
29842
  yes: opts.yes,
29192
29843
  commandExample: `amiko chat send ${JSON.stringify(target)} ${JSON.stringify(message)}`
29193
29844
  });
29845
+ let convId;
29846
+ try {
29847
+ convId = resolved.kind === "user" ? await findOrCreateDm(auth, resolved.userId) : resolved.id;
29848
+ } catch (e5) {
29849
+ console.error(error(e5 instanceof Error ? e5.message : "Could not open the conversation"));
29850
+ process.exit(1);
29851
+ }
29194
29852
  const messagesPath = `/api/conversations/${encodeURIComponent(convId)}/messages`;
29195
29853
  const post = (body) => amikoWebFetch(auth, messagesPath, {
29196
29854
  method: "POST",
@@ -29227,6 +29885,8 @@ function registerChatCommand(chat) {
29227
29885
  const what = opts.image ? "Message + image sent" : opts.audio ? "Message + voice note sent" : "Message sent";
29228
29886
  console.log(success(what) + dim(` (conversation ${convId.slice(0, 8)}…)`));
29229
29887
  });
29888
+ const group = chat.command("group").description("Group chats — create, list, info, rename, add/remove members, leave");
29889
+ registerChatGroupCommand(group);
29230
29890
  }
29231
29891
 
29232
29892
  // src/commands/card.ts
@@ -29444,7 +30104,7 @@ function registerCardCommand(card) {
29444
30104
  // src/commands/amazon.ts
29445
30105
  var AMAZON_SEARCH_USD = 0.01;
29446
30106
  function registerAmazonCommand(program2) {
29447
- const cmd = program2.command("amazon").description("Search and buy products on Amazon — paid with AMIKO");
30107
+ const cmd = program2.command("amazon").description("Search and buy products on Amazon — pay in AMIKO, USDC, or USDT");
29448
30108
  cmd.command("search <query>").description("Search Amazon products — $0.01, paid in AMIKO, USDC, or USDT").option("--limit <n>", "Number of results", "5").option("--wallet <address>", "Wallet to pay from (defaults to your Solana wallet — run `amiko wallets create --chain solana` if you don't have one)").option("--token <token>", "Payment token (AMIKO, USDC, USDT)", "AMIKO").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (query, opts) => {
29449
30109
  let payToken;
29450
30110
  try {
@@ -29661,7 +30321,7 @@ async function fetchWithTimeout2(url2, init = {}, timeoutMs) {
29661
30321
  }
29662
30322
  }
29663
30323
  function registerServicesCommand(program2) {
29664
- const svc = program2.command("service").alias("svc").description("Call any MPP service — pays with AMIKO tokens");
30324
+ const svc = program2.command("service").alias("svc").description("Call any MPP service — pay in AMIKO, USDC, or USDT");
29665
30325
  svc.command("tts <voiceId> <text>").description("Text-to-speech — 1 AMIKO").option("--raw", "Output raw response").option("--wallet <address>", "Wallet to pay from (defaults to your Solana wallet — run `amiko wallets create --chain solana` if none)").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (voiceId, text, opts) => {
29666
30326
  await requireApproval({
29667
30327
  cost: "1 AMIKO",
@@ -29997,6 +30657,18 @@ function registerTwinCommand(program2) {
29997
30657
  // src/commands/drive.ts
29998
30658
  import { writeFile as writeFile2 } from "node:fs/promises";
29999
30659
  import { basename as basename2, resolve as resolvePath } from "node:path";
30660
+ var DIRECT_UPLOAD_MAX_BYTES = 4 * 1024 * 1024;
30661
+ var MAX_DRIVE_UPLOAD_BYTES = 200 * 1024 * 1024;
30662
+ var PRESIGNED_PUT_TIMEOUT_MS = 10 * 60 * 1000;
30663
+ function shareBase(auth) {
30664
+ return (auth.platformUrl ?? "https://platform.heyamiko.com").replace(/\/$/, "");
30665
+ }
30666
+ function fileShareUrl(auth, slug) {
30667
+ return `${shareBase(auth)}/d/${slug}`;
30668
+ }
30669
+ function folderShareUrl(auth, slug) {
30670
+ return `${shareBase(auth)}/d/f/${slug}`;
30671
+ }
30000
30672
  function fmtBytes(n) {
30001
30673
  if (!n || n <= 0)
30002
30674
  return "-";
@@ -30009,7 +30681,7 @@ function fmtBytes(n) {
30009
30681
  }
30010
30682
  return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
30011
30683
  }
30012
- function statusOf2(doc2) {
30684
+ function statusOf3(doc2) {
30013
30685
  const meta3 = doc2.metadata?.status;
30014
30686
  if (meta3)
30015
30687
  return meta3;
@@ -30040,6 +30712,7 @@ function renderDocTable(docs) {
30040
30712
  dim("TYPE"),
30041
30713
  dim("FOLDER"),
30042
30714
  dim("STATUS"),
30715
+ dim("PUBLIC"),
30043
30716
  dim("CREATED")
30044
30717
  ],
30045
30718
  ...docs.map((doc2) => [
@@ -30047,7 +30720,8 @@ function renderDocTable(docs) {
30047
30720
  doc2.title || doc2.filename,
30048
30721
  doc2.file_type || "-",
30049
30722
  doc2.folder_id || dim("root"),
30050
- statusOf2(doc2),
30723
+ statusOf3(doc2),
30724
+ doc2.is_public ? "yes" : dim("-"),
30051
30725
  doc2.created_at?.slice(0, 10) || "-"
30052
30726
  ])
30053
30727
  ];
@@ -30120,26 +30794,56 @@ Search hits filename, title, description, and parsed RAG content (once processed
30120
30794
  try {
30121
30795
  const file2 = await resolveFileInput(source, {
30122
30796
  stdin: opts.stdin,
30123
- hintName: opts.name
30797
+ hintName: opts.name,
30798
+ maxBytes: MAX_DRIVE_UPLOAD_BYTES
30124
30799
  });
30125
30800
  if (spinner)
30126
- spinner.text = `Uploading ${file2.name}...`;
30127
- const form = new FormData;
30128
- form.set("file", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
30129
- const upload = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/upload`, {
30130
- method: "POST",
30131
- multipart: form
30132
- });
30133
- if (!upload.url || !upload.filename || !upload.fileType) {
30134
- throw new Error(`Upload response missing required fields: ${JSON.stringify(upload)}`);
30801
+ spinner.text = `Uploading ${file2.name} (${fmtBytes(file2.size)})...`;
30802
+ let uploadedUrl;
30803
+ let uploadedName;
30804
+ let uploadedType;
30805
+ if (file2.size > DIRECT_UPLOAD_MAX_BYTES) {
30806
+ const pres = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/presigned-url`, {
30807
+ method: "POST",
30808
+ body: { filename: file2.name }
30809
+ });
30810
+ if (!pres.uploadUrl || !pres.publicUrl) {
30811
+ throw new Error(`Presigned-URL response missing fields: ${JSON.stringify(pres)}`);
30812
+ }
30813
+ const putRes = await fetch(pres.uploadUrl, {
30814
+ method: "PUT",
30815
+ headers: { "Content-Type": file2.mime },
30816
+ body: new Uint8Array(file2.buffer),
30817
+ signal: AbortSignal.timeout(PRESIGNED_PUT_TIMEOUT_MS)
30818
+ });
30819
+ if (!putRes.ok) {
30820
+ const detail = await putRes.text().catch(() => "");
30821
+ throw new Error(`Direct storage upload failed: ${putRes.status} ${putRes.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ""}`);
30822
+ }
30823
+ uploadedUrl = pres.publicUrl;
30824
+ uploadedName = file2.name;
30825
+ uploadedType = file2.mime;
30826
+ } else {
30827
+ const form = new FormData;
30828
+ form.set("file", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
30829
+ const upload = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/upload`, {
30830
+ method: "POST",
30831
+ multipart: form
30832
+ });
30833
+ if (!upload.url || !upload.filename || !upload.fileType) {
30834
+ throw new Error(`Upload response missing required fields: ${JSON.stringify(upload)}`);
30835
+ }
30836
+ uploadedUrl = upload.url;
30837
+ uploadedName = upload.filename;
30838
+ uploadedType = upload.fileType;
30135
30839
  }
30136
30840
  if (spinner)
30137
- spinner.text = `Registering ${upload.filename}...`;
30138
- const title = opts.title || upload.filename.replace(/\.[^.]+$/, "");
30841
+ spinner.text = `Registering ${uploadedName}...`;
30842
+ const title = opts.title || uploadedName.replace(/\.[^.]+$/, "");
30139
30843
  const body = {
30140
- filename: upload.filename,
30141
- fileUrl: upload.url,
30142
- fileType: upload.fileType,
30844
+ filename: uploadedName,
30845
+ fileUrl: uploadedUrl,
30846
+ fileType: uploadedType,
30143
30847
  title,
30144
30848
  doc_type: opts.docType ?? "other"
30145
30849
  };
@@ -30155,10 +30859,8 @@ Search hits filename, title, description, and parsed RAG content (once processed
30155
30859
  console.log(label("Title", d.title || "-"));
30156
30860
  if (d.folder_id)
30157
30861
  console.log(label("Folder", d.folder_id));
30158
- if (upload.fileSize)
30159
- console.log(label("Size", fmtBytes(upload.fileSize)));
30160
- if (upload.fileType)
30161
- console.log(label("Type", upload.fileType));
30862
+ console.log(label("Size", fmtBytes(file2.size)));
30863
+ console.log(label("Type", uploadedType));
30162
30864
  if (d.job_id) {
30163
30865
  console.log(label("RAG job", d.job_id));
30164
30866
  console.log(dim("Processing runs in the background. Use `amiko drive status <id>` to check."));
@@ -30236,9 +30938,15 @@ Search hits filename, title, description, and parsed RAG content (once processed
30236
30938
  console.log(label("Folder", d.doc.folder_id));
30237
30939
  if (d.doc.description)
30238
30940
  console.log(label("Description", d.doc.description));
30239
- console.log(label("Status", statusOf2(d.doc)));
30941
+ console.log(label("Status", statusOf3(d.doc)));
30240
30942
  if (d.doc.metadata?.error)
30241
30943
  console.log(label("Error", d.doc.metadata.error));
30944
+ if (d.doc.is_public && d.doc.public_slug) {
30945
+ console.log(label("Public", "yes"));
30946
+ console.log(label("Share URL", fileShareUrl(auth, d.doc.public_slug)));
30947
+ } else {
30948
+ console.log(label("Public", "no"));
30949
+ }
30242
30950
  console.log(label("Signed URL", d.signed_url));
30243
30951
  if (opts.content && d.doc.content) {
30244
30952
  console.log(heading(`
@@ -30300,6 +31008,40 @@ Parsed content available (${d.doc.content.length} chars). Pass --content to prin
30300
31008
  process.exit(1);
30301
31009
  }
30302
31010
  });
31011
+ program2.command("share <docId>").description("Make a file public and print its share link").option("--twin <idOrName>", "Target twin id or name").option("--yes", "Skip confirmation").option("--json", "Output as JSON").action(async (docId, opts) => {
31012
+ const { twinId, auth } = twinFromFlag(opts);
31013
+ await confirmDestructive({
31014
+ action: `Make file ${docId} PUBLIC`,
31015
+ detail: "Anyone with the link can view and download it — no Amiko login required. The link stays stable across unshare/re-share.",
31016
+ yes: opts.yes,
31017
+ commandExample: `amiko drive share ${docId}`
31018
+ });
31019
+ try {
31020
+ const result = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/${docId}`, { method: "PATCH", body: { isPublic: true } });
31021
+ renderOutput(result, (r) => {
31022
+ console.log(success(`${r.doc.title || r.doc.filename} is now public`));
31023
+ if (r.doc.public_slug)
31024
+ console.log(label("Share URL", fileShareUrl(auth, r.doc.public_slug)));
31025
+ console.log(dim("Revoke anytime: amiko drive unshare " + docId));
31026
+ }, { json: opts.json });
31027
+ } catch (e5) {
31028
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
31029
+ process.exit(1);
31030
+ }
31031
+ });
31032
+ program2.command("unshare <docId>").description("Revoke a file's public share link").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (docId, opts) => {
31033
+ const { twinId, auth } = twinFromFlag(opts);
31034
+ try {
31035
+ const result = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/${docId}`, { method: "PATCH", body: { isPublic: false } });
31036
+ renderOutput(result, (r) => {
31037
+ console.log(success(`${r.doc.title || r.doc.filename} is private again`));
31038
+ console.log(dim("The link no longer works. Re-sharing later restores the SAME link. Note: the file stays reachable if a parent folder is still shared."));
31039
+ }, { json: opts.json });
31040
+ } catch (e5) {
31041
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
31042
+ process.exit(1);
31043
+ }
31044
+ });
30303
31045
  program2.command("presign <name>").description("Get a presigned upload URL (browser-direct uploads; no upload performed)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (name, opts) => {
30304
31046
  const { twinId, auth } = twinFromFlag(opts);
30305
31047
  try {
@@ -30341,11 +31083,12 @@ Parsed content available (${d.doc.content.length} chars). Pass --content to prin
30341
31083
  }
30342
31084
  console.log(heading(`Folders (${d.folders.length})`));
30343
31085
  const rows = [
30344
- [dim("ID"), dim("NAME"), dim("PARENT"), dim("CREATED")],
31086
+ [dim("ID"), dim("NAME"), dim("PARENT"), dim("PUBLIC"), dim("CREATED")],
30345
31087
  ...d.folders.map((f) => [
30346
31088
  f.id,
30347
31089
  f.name,
30348
31090
  f.parent_id || dim("root"),
31091
+ f.is_public ? "yes" : dim("-"),
30349
31092
  f.created_at?.slice(0, 10) || "-"
30350
31093
  ])
30351
31094
  ];
@@ -30394,6 +31137,40 @@ Parsed content available (${d.doc.content.length} chars). Pass --content to prin
30394
31137
  process.exit(1);
30395
31138
  }
30396
31139
  });
31140
+ folder.command("share <folderId>").description("Make a folder public (shares its ENTIRE subtree) and print the link").option("--twin <idOrName>", "Target twin id or name").option("--yes", "Skip confirmation").option("--json", "Output as JSON").action(async (folderId, opts) => {
31141
+ const { twinId, auth } = twinFromFlag(opts);
31142
+ await confirmDestructive({
31143
+ action: `Make folder ${folderId} PUBLIC`,
31144
+ detail: "Anyone with the link can browse and download EVERY file and subfolder inside it — no Amiko login required. Files added later are exposed too.",
31145
+ yes: opts.yes,
31146
+ commandExample: `amiko drive folder share ${folderId}`
31147
+ });
31148
+ try {
31149
+ const result = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/folders/${folderId}`, { method: "PATCH", body: { isPublic: true } });
31150
+ renderOutput(result, (r) => {
31151
+ console.log(success(`Folder "${r.folder.name}" is now public`));
31152
+ if (r.folder.public_slug)
31153
+ console.log(label("Share URL", folderShareUrl(auth, r.folder.public_slug)));
31154
+ console.log(dim("Revoke anytime: amiko drive folder unshare " + folderId));
31155
+ }, { json: opts.json });
31156
+ } catch (e5) {
31157
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
31158
+ process.exit(1);
31159
+ }
31160
+ });
31161
+ folder.command("unshare <folderId>").description("Revoke a folder's public share link (instantly unshares the subtree)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (folderId, opts) => {
31162
+ const { twinId, auth } = twinFromFlag(opts);
31163
+ try {
31164
+ const result = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/folders/${folderId}`, { method: "PATCH", body: { isPublic: false } });
31165
+ renderOutput(result, (r) => {
31166
+ console.log(success(`Folder "${r.folder.name}" is private again`));
31167
+ console.log(dim("The link no longer works and the whole subtree is unshared (unless an ancestor folder or individual file is still public). Re-sharing restores the SAME link."));
31168
+ }, { json: opts.json });
31169
+ } catch (e5) {
31170
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
31171
+ process.exit(1);
31172
+ }
31173
+ });
30397
31174
  folder.command("delete <folderId>").description("Delete a folder (use --force to delete non-empty folders + their contents)").option("--twin <idOrName>", "Target twin id or name").option("--force", "Recursively delete subfolders and files in the subtree").option("--yes", "Skip confirmation").option("--json", "Output as JSON").action(async (folderId, opts) => {
30398
31175
  const { twinId, auth } = twinFromFlag(opts);
30399
31176
  await confirmDestructive({
@@ -32227,10 +33004,10 @@ var didOnboard = await runOnboarding(process.argv);
32227
33004
  if (didOnboard)
32228
33005
  process.exit(0);
32229
33006
  var program2 = new Command;
32230
- program2.name("amiko").description("Amiko CLI — swap tokens, manage credits, bridge cross-chain, and call marketplace services").version(PKG_VERSION);
33007
+ program2.name("amiko").description("Amiko CLI — Create Studio media generation, chats, drive, credits & wallets, social graph, and paid MPP marketplace services").version(PKG_VERSION);
32231
33008
  var markets = program2.command("markets").description("MPP marketplace — discover and call paid AMIKO services");
32232
33009
  var create2 = program2.command("create").description("Create Studio — generate images, video, speech, music, and SFX (charged on success)");
32233
- var chat = program2.command("chat").description("Your conversations (DMs + group chats) as the owner — list, read, send");
33010
+ var chat = program2.command("chat").description("Your conversations (DMs + group chats) as the owner — list, read, send, manage groups");
32234
33011
  var card = program2.command("card").description("Twin Cards (work/play/love) — generate, view, download, mint");
32235
33012
  var wallets = program2.command("wallets").description("Manage twin wallets — list, swap, bridge");
32236
33013
  var twin = program2.command("twin").description("Update twin identity (name, description, visibility)");