@heyamiko/amiko-cli 0.14.0-beta.9 → 0.14.2

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.
Files changed (4) hide show
  1. package/README.md +162 -18
  2. package/dist/index.js +2093 -225
  3. package/package.json +1 -1
  4. package/skills/SKILL.md +126 -16
package/dist/index.js CHANGED
@@ -25861,27 +25861,50 @@ function readFromFile(path) {
25861
25861
  twinId: asString(parsed.amikoTwinId) ?? asString(parsed.AMIKO_TWIN_ID),
25862
25862
  userId: asString(parsed.amikoUserId) ?? asString(parsed.AMIKO_USER_ID),
25863
25863
  platformUrl: asString(parsed.amikoPlatformUrl) ?? asString(parsed.AMIKO_PLATFORM_URL),
25864
- apiBase: asString(parsed.amikoApiBase) ?? asString(parsed.AMIKO_API_BASE) ?? asString(parsed.apiBase)
25864
+ apiBase: asString(parsed.amikoApiBase) ?? asString(parsed.AMIKO_API_BASE) ?? asString(parsed.apiBase),
25865
+ billingApiKey: asString(parsed.amikoApiKey) ?? asString(parsed.AMIKO_API_KEY) ?? asString(parsed.billingApiKey),
25866
+ billingUrl: asString(parsed.amikoBillingUrl) ?? asString(parsed.AMIKO_BILLING_URL) ?? asString(parsed.billingUrl)
25865
25867
  };
25866
25868
  } catch {
25867
25869
  return null;
25868
25870
  }
25869
25871
  }
25872
+ function readRoutingIdsFromEnv() {
25873
+ return {
25874
+ agentId: asString(process.env.OPENHERMIT_AGENT_ID),
25875
+ sessionId: asString(process.env.OPENHERMIT_SESSION_ID)
25876
+ };
25877
+ }
25870
25878
  function readFromEnv() {
25871
25879
  return {
25872
25880
  twinToken: asString(process.env.AMIKO_TWIN_TOKEN) ?? asString(process.env.AMIKO_USER_TOKEN),
25873
25881
  twinId: asString(process.env.AMIKO_TWIN_ID),
25874
25882
  userId: asString(process.env.AMIKO_USER_ID),
25875
25883
  platformUrl: asString(process.env.AMIKO_PLATFORM_URL),
25876
- apiBase: asString(process.env.AMIKO_API_BASE)
25884
+ apiBase: asString(process.env.AMIKO_API_BASE),
25885
+ billingApiKey: asString(process.env.AMIKO_API_KEY),
25886
+ billingUrl: asString(process.env.AMIKO_BILLING_URL),
25887
+ ...readRoutingIdsFromEnv()
25888
+ };
25889
+ }
25890
+ function resolveFileAuth(path) {
25891
+ const fromFile = readFromFile(path);
25892
+ if (!fromFile)
25893
+ return null;
25894
+ const fromEnv = readFromEnv();
25895
+ return {
25896
+ ...fromFile,
25897
+ ...readRoutingIdsFromEnv(),
25898
+ billingApiKey: fromEnv.billingApiKey ?? fromFile.billingApiKey,
25899
+ billingUrl: fromEnv.billingUrl ?? fromFile.billingUrl
25877
25900
  };
25878
25901
  }
25879
25902
  function resolveAuth() {
25880
25903
  const filePath = findAmikoJsonPath();
25881
25904
  if (filePath) {
25882
- const fromFile = readFromFile(filePath);
25883
- if (fromFile)
25884
- return fromFile;
25905
+ const merged = resolveFileAuth(filePath);
25906
+ if (merged)
25907
+ return merged;
25885
25908
  }
25886
25909
  return readFromEnv();
25887
25910
  }
@@ -27611,7 +27634,31 @@ function registerBridgeCommand(program2) {
27611
27634
  import readline2 from "node:readline";
27612
27635
 
27613
27636
  // src/lib/balance.ts
27614
- async function fetchCreditsBalance(auth) {
27637
+ var DEFAULT_BILLING_URL = "https://billing.heyamiko.com";
27638
+ function billingBase(auth) {
27639
+ const url = auth.billingUrl || DEFAULT_BILLING_URL;
27640
+ return url.replace(/\/$/, "");
27641
+ }
27642
+ async function fetchCreditsBalanceViaAmk(auth) {
27643
+ const apiKey = auth.billingApiKey;
27644
+ if (!apiKey || !apiKey.startsWith("amk_"))
27645
+ return null;
27646
+ try {
27647
+ const res = await fetch(`${billingBase(auth)}/api/credits`, {
27648
+ method: "GET",
27649
+ headers: { Authorization: `Bearer ${apiKey}` },
27650
+ signal: AbortSignal.timeout(15000)
27651
+ });
27652
+ if (!res.ok)
27653
+ return null;
27654
+ const data = await res.json();
27655
+ const raw = data.displayBalance;
27656
+ return typeof raw === "number" && isFinite(raw) ? Math.round(raw) : null;
27657
+ } catch {
27658
+ return null;
27659
+ }
27660
+ }
27661
+ async function fetchCreditsBalanceViaPlatformProxy(auth) {
27615
27662
  if (!auth.twinToken)
27616
27663
  return null;
27617
27664
  try {
@@ -27622,6 +27669,12 @@ async function fetchCreditsBalance(auth) {
27622
27669
  return null;
27623
27670
  }
27624
27671
  }
27672
+ async function fetchCreditsBalance(auth) {
27673
+ const viaAmk = await fetchCreditsBalanceViaAmk(auth);
27674
+ if (viaAmk != null)
27675
+ return viaAmk;
27676
+ return fetchCreditsBalanceViaPlatformProxy(auth);
27677
+ }
27625
27678
  async function printBalanceRemaining(auth) {
27626
27679
  const balance = await fetchCreditsBalance(auth);
27627
27680
  if (balance == null) {
@@ -28637,6 +28690,7 @@ function registerImageCommand(program2) {
28637
28690
  }
28638
28691
 
28639
28692
  // src/commands/create.ts
28693
+ import { randomUUID } from "node:crypto";
28640
28694
  var ETA = {
28641
28695
  IMAGE: "~15–60s",
28642
28696
  VIDEO: "~2–9 min",
@@ -28647,6 +28701,13 @@ var ETA = {
28647
28701
  function statusOf(err) {
28648
28702
  return err?.status;
28649
28703
  }
28704
+ function autoApproveLimit() {
28705
+ const raw = process.env.AMIKO_AUTO_APPROVE_LIMIT;
28706
+ if (typeof raw !== "string" || raw.trim() === "")
28707
+ return;
28708
+ const limit = Number(raw);
28709
+ return Number.isFinite(limit) ? limit : undefined;
28710
+ }
28650
28711
  function collectOption(value, previous) {
28651
28712
  return [...previous ?? [], value];
28652
28713
  }
@@ -28656,7 +28717,7 @@ function assertMaxUrls(resourceName, urls, max) {
28656
28717
  process.exit(1);
28657
28718
  }
28658
28719
  }
28659
- function printCompleted(final, title) {
28720
+ function printCompleted(final, title, deliveredToThisSession = false) {
28660
28721
  console.log(heading(`${title}
28661
28722
  `));
28662
28723
  if (final.prompt)
@@ -28665,6 +28726,9 @@ function printCompleted(final, title) {
28665
28726
  console.log(label("URL", final.assetUrl));
28666
28727
  if (final.assetMime)
28667
28728
  console.log(label("Type", final.assetMime));
28729
+ if (deliveredToThisSession) {
28730
+ console.log(success("Already delivered to the user in this chat automatically. Do NOT send this file again and do NOT paste the URL — you would duplicate it."));
28731
+ }
28668
28732
  console.log("");
28669
28733
  if (final.charged) {
28670
28734
  const amt = final.chargeAmount ?? final.costAmiko ?? "?";
@@ -28673,6 +28737,9 @@ function printCompleted(final, title) {
28673
28737
  } else {
28674
28738
  console.log(dim("Not charged (settlement pending or waived)."));
28675
28739
  }
28740
+ if (final.driveDocId) {
28741
+ console.log(label("Saved to drive", final.driveDocId) + dim(" (amiko drive get " + final.driveDocId + ")"));
28742
+ }
28676
28743
  console.log(dim("View in your media space: https://platform.heyamiko.com/media"));
28677
28744
  }
28678
28745
  async function submitCreateJob(opts) {
@@ -28683,7 +28750,17 @@ async function submitCreateJob(opts) {
28683
28750
  console.error(error(e5 instanceof Error ? e5.message : "auth failed"));
28684
28751
  process.exit(1);
28685
28752
  }
28686
- let costLine = "charged from your twin wallet on success (nothing on failure)";
28753
+ const payMethod = (opts.pay ?? "credits").toLowerCase();
28754
+ if (payMethod !== "wallet" && payMethod !== "credits") {
28755
+ console.error(error(`Unknown --pay "${opts.pay}". Allowed: wallet, credits.`));
28756
+ process.exit(1);
28757
+ }
28758
+ const payCredits = payMethod === "credits";
28759
+ if (payCredits && opts.jobBody.preferredToken) {
28760
+ console.error(dim("--token is ignored with --pay credits (credits come from your Amiko account, not a wallet token)."));
28761
+ }
28762
+ let costLine = payCredits ? "reserved from your Amiko account credits, captured on success (released on failure)" : "charged from your twin wallet on success (nothing on failure)";
28763
+ let quotedCostAmiko;
28687
28764
  try {
28688
28765
  const q = await amikoWebFetch(auth, "/api/create/quote", {
28689
28766
  method: "POST",
@@ -28691,13 +28768,32 @@ async function submitCreateJob(opts) {
28691
28768
  timeoutMs: 15000
28692
28769
  });
28693
28770
  if (q.cost_usd != null) {
28694
- costLine = `${q.cost_amiko ?? "?"} AMIKO (≈ $${q.cost_usd.toFixed(4)}) — charged only on success`;
28771
+ costLine = payCredits ? `≈ ${Math.ceil(q.cost_usd * 1e4)} Amiko credits (≈ $${q.cost_usd.toFixed(4)}) — reserved on submit, captured only on success` : `${q.cost_amiko ?? "?"} AMIKO (≈ $${q.cost_usd.toFixed(4)}) — charged only on success`;
28772
+ }
28773
+ if (typeof q.cost_amiko === "string" && q.cost_amiko.trim() !== "") {
28774
+ const n = Number(q.cost_amiko);
28775
+ if (Number.isFinite(n))
28776
+ quotedCostAmiko = n;
28695
28777
  }
28696
28778
  } catch {}
28779
+ const preferredToken = typeof opts.jobBody.preferredToken === "string" ? opts.jobBody.preferredToken.toUpperCase() : undefined;
28780
+ const chargeIsAmiko = !payCredits && (preferredToken === undefined || preferredToken === "AMIKO");
28781
+ const autoApprovable = payCredits || chargeIsAmiko;
28782
+ const effectiveChargeToken = preferredToken ?? "AMIKO";
28783
+ let yes = opts.yes;
28784
+ let autoApproved = false;
28785
+ if (!yes) {
28786
+ const limit = autoApproveLimit();
28787
+ if (limit !== undefined && quotedCostAmiko !== undefined && quotedCostAmiko > 0 && autoApprovable && quotedCostAmiko <= limit) {
28788
+ yes = true;
28789
+ autoApproved = true;
28790
+ console.log(dim(`Auto-approved (cost ${quotedCostAmiko} ≤ limit ${limit} AMIKO${payCredits ? ", charged as account credits" : ""})`));
28791
+ }
28792
+ }
28697
28793
  await requireApproval({
28698
28794
  cost: costLine,
28699
28795
  summary: opts.summary,
28700
- yes: opts.yes,
28796
+ yes,
28701
28797
  commandExample: `amiko create ${opts.mode.toLowerCase()} ...`
28702
28798
  });
28703
28799
  const isTTY = process.stderr.isTTY;
@@ -28707,10 +28803,22 @@ async function submitCreateJob(opts) {
28707
28803
  spinner.stop();
28708
28804
  };
28709
28805
  let jobId;
28806
+ let routedAgentId;
28807
+ let routedSessionId;
28710
28808
  try {
28711
28809
  const submit = await amikoWebFetch(auth, "/api/create/jobs", {
28712
28810
  method: "POST",
28713
- body: { ...opts.jobBody, twinId: auth.twinId },
28811
+ body: {
28812
+ ...opts.jobBody,
28813
+ twinId: auth.twinId,
28814
+ ...autoApproved && !payCredits ? { preferredToken: effectiveChargeToken, strictToken: true } : {},
28815
+ ...payCredits ? {
28816
+ paymentMethod: { kind: "account_credits" },
28817
+ requestId: randomUUID()
28818
+ } : {},
28819
+ ...auth.agentId ? { agentId: auth.agentId } : {},
28820
+ ...auth.sessionId ? { sessionId: auth.sessionId } : {}
28821
+ },
28714
28822
  timeoutMs: 30000
28715
28823
  });
28716
28824
  if (!submit.jobId) {
@@ -28719,35 +28827,58 @@ async function submitCreateJob(opts) {
28719
28827
  process.exit(1);
28720
28828
  }
28721
28829
  jobId = submit.jobId;
28830
+ routedAgentId = submit.agentId;
28831
+ routedSessionId = submit.sessionId;
28722
28832
  } catch (e5) {
28723
28833
  stop();
28724
- console.error(error(e5 instanceof Error ? e5.message : "Submit failed"));
28834
+ const webErr = e5;
28835
+ let message = e5 instanceof Error ? e5.message : "Submit failed";
28836
+ let errorCode;
28837
+ if (typeof webErr.body === "string" && webErr.body) {
28838
+ try {
28839
+ const parsed = JSON.parse(webErr.body);
28840
+ if (parsed.error)
28841
+ message = parsed.error;
28842
+ if (typeof parsed.code === "string")
28843
+ errorCode = parsed.code;
28844
+ } catch {}
28845
+ }
28846
+ console.error(error(message));
28847
+ if (payCredits && (errorCode === "ACCOUNT_CREDITS_DISABLED" || errorCode === "ACCOUNT_NOT_UNIFIED")) {
28848
+ console.error(dim("Account credits are not available for this account yet — retry without --pay credits to charge the twin wallet."));
28849
+ }
28725
28850
  process.exit(1);
28726
28851
  }
28852
+ const sessionRouted = !!(routedAgentId && routedSessionId);
28727
28853
  stop();
28728
28854
  if (opts.raw) {
28729
28855
  console.log(JSON.stringify({
28730
28856
  jobId,
28731
28857
  status: "PENDING",
28732
28858
  check: `amiko create status ${jobId}`,
28733
- eta: ETA[opts.mode]
28859
+ eta: ETA[opts.mode],
28860
+ ...sessionRouted ? { sessionRouted: true, deliveredToSession: routedSessionId } : {}
28734
28861
  }, null, 2));
28735
28862
  return;
28736
28863
  }
28737
28864
  console.log(success(`Submitted — job ${jobId}`));
28738
28865
  console.log(label("Status", "PENDING — generating in the background"));
28739
28866
  console.log(label("Check result", `amiko create status ${jobId}`));
28867
+ if (sessionRouted) {
28868
+ console.log(dim("A generating-placeholder is already showing in the chat and the finished media will be delivered there automatically — do not send the file or URL yourself."));
28869
+ }
28740
28870
  console.log(dim(`Usually ready in ${ETA[opts.mode]} — charged only on success. (Also: amiko create media)`));
28741
28871
  if (opts.mode === "VIDEO") {
28742
28872
  console.log(dim("Async video: HTTP 202 / PENDING means the job was accepted — not a failure. Poll status after ~2–9 min."));
28743
28873
  }
28744
28874
  }
28745
28875
  function registerCreateCommand(create2) {
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) => {
28876
+ 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("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28747
28877
  await submitCreateJob({
28748
28878
  mode: "IMAGE",
28749
28879
  summary: `Create image: ${prompt.slice(0, 80)}${prompt.length > 80 ? "…" : ""}`,
28750
28880
  raw: opts.raw,
28881
+ pay: opts.pay,
28751
28882
  yes: opts.yes,
28752
28883
  quoteBody: { mode: "IMAGE", model: opts.model, size: opts.aspect },
28753
28884
  jobBody: {
@@ -28759,7 +28890,7 @@ function registerCreateCommand(create2) {
28759
28890
  }
28760
28891
  });
28761
28892
  });
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) => {
28893
+ create2.command("video <prompt>").description("Generate a video (Create Studio)").option("--model <model>", "Video model: MiniMax-Hailuo-02 (default for prompt-only; T2V+I2V), MiniMax-Hailuo-2.3-Fast (default with --first-frame; I2V only), MiniMax-Hailuo-2.3 (T2V+I2V), veo-3.1[-fast|-lite]-generate-preview (Google; durations clamp to 4/6/8s), grok-imagine-video (xAI), dreamina-seedance-* (BytePlus). ONLY these — kling and other ids are not supported").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("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28763
28894
  const seconds = Number(opts.seconds);
28764
28895
  if (seconds !== 6 && seconds !== 10) {
28765
28896
  console.error(error("--seconds must be 6 or 10."));
@@ -28772,21 +28903,24 @@ function registerCreateCommand(create2) {
28772
28903
  assertMaxUrls("--reference-video", referenceVideoUrls, 3);
28773
28904
  assertMaxUrls("--reference-audio", referenceAudioUrls, 3);
28774
28905
  const generateAudio = opts.generateAudio;
28906
+ const model = opts.model ?? (opts.firstFrame ? "MiniMax-Hailuo-2.3-Fast" : "MiniMax-Hailuo-02");
28775
28907
  await submitCreateJob({
28776
28908
  mode: "VIDEO",
28777
28909
  summary: `Create video: ${prompt.slice(0, 80)}${prompt.length > 80 ? "…" : ""}`,
28778
28910
  raw: opts.raw,
28911
+ pay: opts.pay,
28779
28912
  yes: opts.yes,
28780
28913
  quoteBody: {
28781
28914
  mode: "VIDEO",
28782
- model: opts.model,
28915
+ model,
28783
28916
  size: opts.resolution,
28784
- seconds
28917
+ seconds,
28918
+ ...referenceVideoUrls.length ? { referenceVideoUrls } : {}
28785
28919
  },
28786
28920
  jobBody: {
28787
28921
  mode: "VIDEO",
28788
28922
  prompt,
28789
- model: opts.model,
28923
+ model,
28790
28924
  resolution: opts.resolution,
28791
28925
  seconds,
28792
28926
  ...opts.aspect ? { aspectRatio: opts.aspect } : {},
@@ -28808,23 +28942,59 @@ function registerCreateCommand(create2) {
28808
28942
  }
28809
28943
  });
28810
28944
  });
28811
- create2.command("tts <text>").description("Generate speech from text (Create Studio)").option("--model <model>", "TTS model id", "eleven_multilingual_v2").option("--voice <voiceId>", "Voice id", "21m00Tcm4TlvDq8ikWAM").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 (text, opts) => {
28945
+ const DEFAULT_EL_TTS_MODEL = "eleven_multilingual_v2";
28946
+ const DEFAULT_EL_TTS_VOICE = "21m00Tcm4TlvDq8ikWAM";
28947
+ const DEFAULT_MM_TTS_MODEL = "speech-2.8-turbo";
28948
+ const DEFAULT_MM_TTS_VOICE = "English_expressive_narrator";
28949
+ const MINIMAX_TTS_MODELS = new Set([
28950
+ "speech-2.8-turbo",
28951
+ "speech-2.8-hd",
28952
+ "speech-2.6-turbo",
28953
+ "speech-2.6-hd"
28954
+ ]);
28955
+ create2.command("tts <text>").description("Generate speech from text (Create Studio)").option("--provider <name>", "TTS provider: minimax | elevenlabs (sets defaults when --model/--voice omitted)").option("--model <model>", `TTS model id (MiniMax: speech-2.8-turbo|hd, speech-2.6-turbo|hd; ElevenLabs: eleven_*)`).option("--voice <voiceId>", "Voice id (MiniMax system e.g. English_expressive_narrator, or a cloned voice_id; ElevenLabs voice hash)").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (text, opts) => {
28956
+ const providerRaw = typeof opts.provider === "string" ? opts.provider.trim().toLowerCase() : "";
28957
+ if (providerRaw && providerRaw !== "minimax" && providerRaw !== "elevenlabs") {
28958
+ console.error(error("--provider must be minimax or elevenlabs"));
28959
+ process.exit(1);
28960
+ }
28961
+ let model = typeof opts.model === "string" && opts.model.trim().length > 0 ? opts.model.trim() : undefined;
28962
+ let voice = typeof opts.voice === "string" && opts.voice.trim().length > 0 ? opts.voice.trim() : undefined;
28963
+ let provider = providerRaw === "minimax" || providerRaw === "elevenlabs" ? providerRaw : model && MINIMAX_TTS_MODELS.has(model) ? "minimax" : model && model.startsWith("eleven") ? "elevenlabs" : "elevenlabs";
28964
+ if (!model) {
28965
+ model = provider === "minimax" ? DEFAULT_MM_TTS_MODEL : DEFAULT_EL_TTS_MODEL;
28966
+ }
28967
+ if (!voice) {
28968
+ voice = provider === "minimax" ? DEFAULT_MM_TTS_VOICE : DEFAULT_EL_TTS_VOICE;
28969
+ }
28970
+ if (MINIMAX_TTS_MODELS.has(model) && providerRaw !== "elevenlabs") {
28971
+ provider = "minimax";
28972
+ }
28973
+ if (provider === "minimax" && !MINIMAX_TTS_MODELS.has(model)) {
28974
+ console.error(error(`--provider minimax requires a MiniMax speech model (${[...MINIMAX_TTS_MODELS].join(", ")})`));
28975
+ process.exit(1);
28976
+ }
28977
+ if (provider === "elevenlabs" && !model.startsWith("eleven")) {
28978
+ console.error(error("--provider elevenlabs requires an eleven_* model"));
28979
+ process.exit(1);
28980
+ }
28812
28981
  await submitCreateJob({
28813
28982
  mode: "TTS",
28814
- summary: `Create speech: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
28983
+ summary: `Create speech (${provider}/${model}): ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
28815
28984
  raw: opts.raw,
28985
+ pay: opts.pay,
28816
28986
  yes: opts.yes,
28817
- quoteBody: { mode: "TTS", model: opts.model, prompt: text },
28987
+ quoteBody: { mode: "TTS", model, prompt: text, voiceId: voice },
28818
28988
  jobBody: {
28819
28989
  mode: "TTS",
28820
28990
  prompt: text,
28821
- model: opts.model,
28822
- voiceId: opts.voice,
28991
+ model,
28992
+ voiceId: voice,
28823
28993
  ...opts.token ? { preferredToken: opts.token } : {}
28824
28994
  }
28825
28995
  });
28826
28996
  });
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) => {
28997
+ 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("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
28828
28998
  if (!prompt && !opts.lyrics) {
28829
28999
  console.error(error("Provide a prompt or --lyrics."));
28830
29000
  process.exit(1);
@@ -28833,6 +29003,7 @@ function registerCreateCommand(create2) {
28833
29003
  mode: "MUSIC",
28834
29004
  summary: `Create music: ${(prompt ?? opts.lyrics ?? "").slice(0, 80)}`,
28835
29005
  raw: opts.raw,
29006
+ pay: opts.pay,
28836
29007
  yes: opts.yes,
28837
29008
  quoteBody: { mode: "MUSIC", model: opts.model },
28838
29009
  jobBody: {
@@ -28847,11 +29018,12 @@ function registerCreateCommand(create2) {
28847
29018
  }
28848
29019
  });
28849
29020
  });
28850
- create2.command("sfx <text>").description("Generate a sound effect (Create Studio)").option("--duration <seconds>", "Clip length in seconds").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 (text, opts) => {
29021
+ create2.command("sfx <text>").description("Generate a sound effect (Create Studio)").option("--duration <seconds>", "Clip length in seconds").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (text, opts) => {
28851
29022
  await submitCreateJob({
28852
29023
  mode: "SFX",
28853
29024
  summary: `Create SFX: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
28854
29025
  raw: opts.raw,
29026
+ pay: opts.pay,
28855
29027
  yes: opts.yes,
28856
29028
  quoteBody: { mode: "SFX" },
28857
29029
  jobBody: {
@@ -28881,8 +29053,9 @@ function registerCreateCommand(create2) {
28881
29053
  }
28882
29054
  process.exit(1);
28883
29055
  }
29056
+ const deliveredToThisSession = !!(p.agentId && p.sessionId && auth.sessionId && p.sessionId === auth.sessionId);
28884
29057
  if (opts.raw) {
28885
- console.log(JSON.stringify(p, null, 2));
29058
+ console.log(JSON.stringify(deliveredToThisSession ? { ...p, sessionRouted: true, deliveredToSession: p.sessionId } : p, null, 2));
28886
29059
  return;
28887
29060
  }
28888
29061
  if (p.status === "FAILED") {
@@ -28894,7 +29067,7 @@ function registerCreateCommand(create2) {
28894
29067
  console.log(dim(`Job id: ${jobId}`));
28895
29068
  return;
28896
29069
  }
28897
- printCompleted(p, "Job complete");
29070
+ printCompleted(p, "Job complete", deliveredToThisSession);
28898
29071
  });
28899
29072
  create2.command("media").description("List your recent Create Studio generations").option("--limit <n>", "How many to show (max 50)", "20").option("--service <type>", "Filter by type: image, video, tts, music, sfx").option("--raw", "Output raw JSON").action(async (opts) => {
28900
29073
  let auth;
@@ -28948,41 +29121,42 @@ import { basename, extname } from "node:path";
28948
29121
  var MAX_BYTES = 100 * 1024 * 1024;
28949
29122
  var FETCH_TIMEOUT_MS = 30000;
28950
29123
  async function resolveFileInput(src, opts = {}) {
29124
+ const maxBytes = opts.maxBytes ?? MAX_BYTES;
28951
29125
  if (src === "-" || opts.stdin) {
28952
- const buffer2 = await readStdin();
29126
+ const buffer2 = await readStdin(maxBytes);
28953
29127
  const name2 = opts.hintName ?? "stdin";
28954
29128
  const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
28955
29129
  return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
28956
29130
  }
28957
29131
  if (/^https?:\/\//i.test(src)) {
28958
- const buffer2 = await fetchRemote(src);
29132
+ const buffer2 = await fetchRemote(src, maxBytes);
28959
29133
  const urlPath = new URL(src).pathname;
28960
29134
  const name2 = opts.hintName ?? (basename(urlPath) || "download");
28961
29135
  const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
28962
29136
  return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
28963
29137
  }
28964
29138
  const buffer = await readFile(src);
28965
- if (buffer.length > MAX_BYTES) {
28966
- throw new Error(`File too large: ${buffer.length} bytes exceeds ${MAX_BYTES} byte cap.`);
29139
+ if (buffer.length > maxBytes) {
29140
+ throw new Error(`File too large: ${buffer.length} bytes exceeds ${maxBytes} byte cap.`);
28967
29141
  }
28968
29142
  const name = opts.hintName ?? basename(src);
28969
29143
  const mime = opts.hintMime ?? detectMime(buffer, name);
28970
29144
  return { buffer, name, mime, size: buffer.length };
28971
29145
  }
28972
- async function readStdin() {
29146
+ async function readStdin(maxBytes) {
28973
29147
  const chunks = [];
28974
29148
  let total = 0;
28975
29149
  for await (const chunk of process.stdin) {
28976
29150
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
28977
29151
  total += buf.length;
28978
- if (total > MAX_BYTES) {
28979
- throw new Error(`stdin exceeded ${MAX_BYTES} byte cap.`);
29152
+ if (total > maxBytes) {
29153
+ throw new Error(`stdin exceeded ${maxBytes} byte cap.`);
28980
29154
  }
28981
29155
  chunks.push(buf);
28982
29156
  }
28983
29157
  return Buffer.concat(chunks);
28984
29158
  }
28985
- async function fetchRemote(url2) {
29159
+ async function fetchRemote(url2, maxBytes) {
28986
29160
  const controller = new AbortController;
28987
29161
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
28988
29162
  try {
@@ -28991,12 +29165,12 @@ async function fetchRemote(url2) {
28991
29165
  throw new Error(`Fetch ${url2} failed: ${res.status} ${res.statusText}`);
28992
29166
  }
28993
29167
  const len = Number(res.headers.get("content-length") || 0);
28994
- if (len > MAX_BYTES) {
28995
- throw new Error(`Remote file too large: ${len} bytes exceeds ${MAX_BYTES} byte cap.`);
29168
+ if (len > maxBytes) {
29169
+ throw new Error(`Remote file too large: ${len} bytes exceeds ${maxBytes} byte cap.`);
28996
29170
  }
28997
29171
  const buf = Buffer.from(await res.arrayBuffer());
28998
- if (buf.length > MAX_BYTES) {
28999
- throw new Error(`Remote file too large: ${buf.length} bytes exceeds ${MAX_BYTES} byte cap.`);
29172
+ if (buf.length > maxBytes) {
29173
+ throw new Error(`Remote file too large: ${buf.length} bytes exceeds ${maxBytes} byte cap.`);
29000
29174
  }
29001
29175
  return buf;
29002
29176
  } finally {
@@ -29027,6 +29201,13 @@ var EXT_MIME = {
29027
29201
  ".mp3": "audio/mpeg",
29028
29202
  ".m4a": "audio/mp4",
29029
29203
  ".ogg": "audio/ogg",
29204
+ ".aac": "audio/aac",
29205
+ ".flac": "audio/flac",
29206
+ ".mp4": "video/mp4",
29207
+ ".m4v": "video/x-m4v",
29208
+ ".webm": "video/webm",
29209
+ ".mov": "video/quicktime",
29210
+ ".mkv": "video/x-matroska",
29030
29211
  ".zip": "application/zip"
29031
29212
  };
29032
29213
  function detectMime(buffer, name) {
@@ -29220,6 +29401,170 @@ async function resolveGroup(auth, target) {
29220
29401
  }
29221
29402
  return fetchConversationById(auth, matches[0].id);
29222
29403
  }
29404
+ async function fetchUserById(auth, id) {
29405
+ try {
29406
+ const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(id)}`, { timeoutMs: 15000 });
29407
+ return prof.user?.id ? { ...prof.user, id: prof.user.id } : null;
29408
+ } catch (e5) {
29409
+ if (e5.status === 404)
29410
+ return null;
29411
+ throw e5;
29412
+ }
29413
+ }
29414
+ async function findDmByUserId(auth, userId) {
29415
+ const want = normalizeUserId(userId);
29416
+ const limit = 100;
29417
+ for (let offset = 0;offset < 1e4; offset += limit) {
29418
+ const data = await amikoWebFetch(auth, "/api/conversations", {
29419
+ query: { limit, offset },
29420
+ timeoutMs: 20000
29421
+ });
29422
+ const page = data.conversations ?? [];
29423
+ const dm = page.find((c) => c.conversation_type === "direct" && (c.participants ?? []).some((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === want));
29424
+ if (dm)
29425
+ return dm.id;
29426
+ if (page.length < limit || data.pagination?.has_more === false)
29427
+ return null;
29428
+ }
29429
+ return null;
29430
+ }
29431
+ function personLabel(u) {
29432
+ const name = u.name ?? u.id;
29433
+ return u.handle ? `${name} (@${u.handle})` : name;
29434
+ }
29435
+ async function resolveConversation(auth, target) {
29436
+ const t = target.trim();
29437
+ if (!t) {
29438
+ throw new Error("No target given. Pass a conversation id, a user id, an @handle, or a name.");
29439
+ }
29440
+ const person = (u) => ({
29441
+ kind: "user",
29442
+ userId: u.id,
29443
+ label: `DM with ${personLabel(u)}`
29444
+ });
29445
+ if (t.startsWith("@")) {
29446
+ const handle = t.slice(1);
29447
+ const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(handle)}`, {
29448
+ timeoutMs: 15000
29449
+ });
29450
+ if (!prof.user?.id)
29451
+ throw new Error(`No user @${handle}.`);
29452
+ return person({ ...prof.user, id: prof.user.id });
29453
+ }
29454
+ const idish = normalizeUserId(t);
29455
+ if (looksLikeConversationId(idish)) {
29456
+ if (idish === t) {
29457
+ try {
29458
+ const conv = await fetchConversationById(auth, t);
29459
+ return {
29460
+ kind: "conversation",
29461
+ id: conv.id,
29462
+ label: conv.conversation_type === "direct" ? `DM with ${convLabel(conv, auth.userId)}` : `group "${convLabel(conv, auth.userId)}"`,
29463
+ conversation: conv
29464
+ };
29465
+ } catch (e5) {
29466
+ const status = e5.status;
29467
+ if (status === 403) {
29468
+ throw new Error(`Conversation ${t} exists but you are not a participant.`);
29469
+ }
29470
+ if (status !== undefined && status !== 404)
29471
+ throw e5;
29472
+ }
29473
+ }
29474
+ const user = await fetchUserById(auth, idish);
29475
+ if (!user) {
29476
+ throw new Error(`"${t}" is neither a conversation nor a user id — check \`amiko chat list\` or \`amiko friends list --json\`.`);
29477
+ }
29478
+ return person(user);
29479
+ }
29480
+ const groupId = await findGroupIdByExactTitle(auth, t);
29481
+ if (groupId)
29482
+ return { kind: "conversation", id: groupId, label: `group "${t}"` };
29483
+ const { resolved, failures } = await resolveMembers(auth, [t]);
29484
+ if (failures.length || resolved.length !== 1) {
29485
+ const reason = failures[0]?.reason ?? "could not be resolved";
29486
+ throw new Error(`"${t}" ${reason}`);
29487
+ }
29488
+ const p = resolved[0];
29489
+ return person({ id: p.userId, name: p.name, handle: p.handle });
29490
+ }
29491
+
29492
+ // src/lib/mentions.ts
29493
+ var MENTION_ALL_TOKEN = "@[all](all:all)";
29494
+ var MENTION_ALL_LITERAL_SRC = String.raw`(^|[^\w@])@all\b`;
29495
+ function hasMentionAllToken(content) {
29496
+ return content.includes(MENTION_ALL_TOKEN);
29497
+ }
29498
+ function hasMentionAllLiteral(content) {
29499
+ return new RegExp(MENTION_ALL_LITERAL_SRC, "i").test(content);
29500
+ }
29501
+ function rewriteMentionAllLiteral(content) {
29502
+ return content.replace(new RegExp(MENTION_ALL_LITERAL_SRC, "gi"), `$1${MENTION_ALL_TOKEN}`);
29503
+ }
29504
+ function applyMentionAll(content) {
29505
+ if (hasMentionAllToken(content))
29506
+ return content;
29507
+ return content ? `${MENTION_ALL_TOKEN} ${content}` : MENTION_ALL_TOKEN;
29508
+ }
29509
+ function mentionMarkupToPlainText(content) {
29510
+ return content.replace(/@\[([^\]]+)\]\(([^:()\s]+):(user|agent|all)\)/g, "@$1");
29511
+ }
29512
+ function canMentionAll(conv, selfUserId) {
29513
+ if (conv.conversation_type !== "group" || !selfUserId)
29514
+ return false;
29515
+ const me = normalizeUserId(selfUserId);
29516
+ const self = (conv.participants ?? []).find((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === me && !p.left_at);
29517
+ if (!self)
29518
+ return false;
29519
+ return self.role === "admin" || self.role === "owner" || conv.allow_everyone_mention_all === true;
29520
+ }
29521
+
29522
+ // src/lib/reactions.ts
29523
+ var EMOJI_MAX = 32;
29524
+ function validateEmoji(raw) {
29525
+ const emoji3 = (raw ?? "").trim();
29526
+ if (!emoji3)
29527
+ throw new Error("No emoji given. Pass the emoji to react with, e.g. ❤️.");
29528
+ if (emoji3.length > EMOJI_MAX) {
29529
+ throw new Error(`Emoji is too long (max ${EMOJI_MAX} characters).`);
29530
+ }
29531
+ return emoji3;
29532
+ }
29533
+ async function fetchMessage(auth, messageId) {
29534
+ const data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}`, { timeoutMs: 20000 });
29535
+ if (!data.message?.id) {
29536
+ throw new Error(`Message ${messageId} not found.`);
29537
+ }
29538
+ return { ...data.message, reactions: data.message.reactions ?? [] };
29539
+ }
29540
+ function ownReaction(reactions, userId) {
29541
+ if (!userId)
29542
+ return null;
29543
+ const me = normalizeUserId(userId);
29544
+ const mine = reactions.find((r) => normalizeUserId(r.user_id) === me);
29545
+ return mine?.emoji ?? null;
29546
+ }
29547
+ function previewText(msg, max = 60) {
29548
+ const content = (msg.content ?? "").trim();
29549
+ if (content) {
29550
+ return content.length > max ? `${content.slice(0, max)}…` : content;
29551
+ }
29552
+ if (msg.message_type === "voice")
29553
+ return "[voice message]";
29554
+ if (msg.metadata?.images?.length)
29555
+ return "[image]";
29556
+ return `[${msg.message_type || "message"}]`;
29557
+ }
29558
+ function groupReactions(reactions) {
29559
+ const counts = new Map;
29560
+ for (const r of reactions) {
29561
+ counts.set(r.emoji, (counts.get(r.emoji) ?? 0) + 1);
29562
+ }
29563
+ return Array.from(counts.entries());
29564
+ }
29565
+ function summarizeReactions(reactions) {
29566
+ return groupReactions(reactions).map(([emoji3, count]) => `${emoji3}×${count}`).join(" ");
29567
+ }
29223
29568
 
29224
29569
  // src/commands/chat-group.ts
29225
29570
  var collect = (v, prev) => [...prev, v];
@@ -29359,7 +29704,7 @@ function registerChatGroupCommand(group) {
29359
29704
  g.id,
29360
29705
  String(g.participants?.length ?? "-"),
29361
29706
  g.unread_count ? String(g.unread_count) : "-",
29362
- g.last_message?.content?.slice(0, 40) ?? "-"
29707
+ g.last_message?.content ? mentionMarkupToPlainText(g.last_message.content).slice(0, 40) : "-"
29363
29708
  ])
29364
29709
  ];
29365
29710
  console.log(table(rows));
@@ -29376,6 +29721,7 @@ function registerChatGroupCommand(group) {
29376
29721
  console.log(heading(conv.title ?? "(untitled group)"));
29377
29722
  console.log(label("ID", conv.id));
29378
29723
  console.log(label("Members", String(active.length)));
29724
+ console.log(label("@all mentions", conv.allow_everyone_mention_all ? "everyone" : "admins only"));
29379
29725
  console.log("");
29380
29726
  const rows = [
29381
29727
  [dim("NAME"), dim("HANDLE"), dim("USER ID"), dim("TYPE"), dim("ROLE")],
@@ -29581,111 +29927,461 @@ function registerChatGroupCommand(group) {
29581
29927
  }
29582
29928
  console.log(success(`${memberLabel(member)} is now an admin.`));
29583
29929
  });
29584
- }
29585
-
29586
- // src/commands/chat.ts
29587
- async function findOrCreateDm(auth, userId) {
29588
- const data = await amikoWebFetch(auth, "/api/conversations", {
29589
- method: "POST",
29590
- body: {
29591
- participant_ids: [userId],
29592
- participant_types: ["user"],
29593
- conversation_type: "direct"
29594
- },
29595
- timeoutMs: 20000
29930
+ group.command("mention-all <group> <state>").description("Allow everyone (on) or only admins (off, the default) to use @all in a group (admins only)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, stateArg, opts) => {
29931
+ const state = stateArg.trim().toLowerCase();
29932
+ if (state !== "on" && state !== "off") {
29933
+ console.error(error(`Pass "on" or "off" (got "${stateArg}"): on lets every member use @all, off restricts it to admins.`));
29934
+ process.exit(1);
29935
+ }
29936
+ const enable = state === "on";
29937
+ const auth = authOrExit();
29938
+ const conv = await resolveGroupOrExit(auth, target);
29939
+ await confirmDestructive({
29940
+ action: enable ? `Allow EVERYONE in "${conv.title ?? conv.id}" to use @all (notifies every member)` : `Restrict @all in "${conv.title ?? conv.id}" to group admins only`,
29941
+ yes: opts.yes,
29942
+ commandExample: `amiko chat group mention-all ${JSON.stringify(target)} ${state}`
29943
+ });
29944
+ let data;
29945
+ try {
29946
+ data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(conv.id)}/mention-all-permission`, {
29947
+ method: "PATCH",
29948
+ body: { allow_everyone_mention_all: enable },
29949
+ timeoutMs: 20000
29950
+ });
29951
+ } catch (e5) {
29952
+ if (statusOf2(e5) === 403) {
29953
+ console.error(error(`Changing the @all setting failed. ${ADMIN_TIP}`));
29954
+ process.exit(1);
29955
+ }
29956
+ exitWithError(e5, "Failed to update the @all setting");
29957
+ }
29958
+ if (opts.raw) {
29959
+ console.log(JSON.stringify(data, null, 2));
29960
+ return;
29961
+ }
29962
+ const now = data.conversation?.allow_everyone_mention_all ?? enable;
29963
+ console.log(success(`@all in "${conv.title ?? conv.id}": ${now ? "everyone can use it" : "admins only"}`) + dim(` (${conv.id})`));
29596
29964
  });
29597
- const id = data.conversation?.id;
29598
- if (!id)
29599
- throw new Error("Could not open a conversation with that user.");
29600
- return id;
29601
29965
  }
29602
- async function fetchUserById(auth, id) {
29966
+
29967
+ // src/lib/gifs.ts
29968
+ function parseUrl(url2) {
29603
29969
  try {
29604
- const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(id)}`, { timeoutMs: 15000 });
29605
- return prof.user?.id ? { ...prof.user, id: prof.user.id } : null;
29606
- } catch (e5) {
29607
- if (e5.status === 404)
29608
- return null;
29609
- throw e5;
29970
+ return new URL(url2);
29971
+ } catch {
29972
+ return null;
29610
29973
  }
29611
29974
  }
29612
- async function findDmByUserId(auth, userId) {
29613
- const want = normalizeUserId(userId);
29614
- const limit = 100;
29615
- for (let offset = 0;offset < 1e4; offset += limit) {
29616
- const data = await amikoWebFetch(auth, "/api/conversations", {
29617
- query: { limit, offset },
29618
- timeoutMs: 20000
29619
- });
29620
- const page = data.conversations ?? [];
29621
- const dm = page.find((c) => c.conversation_type === "direct" && (c.participants ?? []).some((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === want));
29622
- if (dm)
29623
- return dm.id;
29624
- if (page.length < limit || data.pagination?.has_more === false)
29625
- return null;
29626
- }
29627
- return null;
29975
+ function isKlipyMediaUrl(url2) {
29976
+ const parsed = parseUrl(url2);
29977
+ if (!parsed)
29978
+ return false;
29979
+ return parsed.hostname === "klipy.com" || parsed.hostname.endsWith(".klipy.com");
29628
29980
  }
29629
- function personLabel(u) {
29630
- const name = u.name ?? u.id;
29631
- return u.handle ? `${name} (@${u.handle})` : name;
29981
+ var KLIPY_VIDEO_EXTENSION_RE = /\.(?:mp4|webm)(?:[?#].*)?$/i;
29982
+ function isKlipyVideoMediaUrl(url2) {
29983
+ if (!isKlipyMediaUrl(url2))
29984
+ return false;
29985
+ const parsed = parseUrl(url2);
29986
+ if (!parsed)
29987
+ return false;
29988
+ return KLIPY_VIDEO_EXTENSION_RE.test(parsed.pathname);
29632
29989
  }
29633
- async function resolveConversation(auth, target) {
29634
- const t = target.trim();
29635
- if (!t) {
29636
- throw new Error("No target given. Pass a conversation id, a user id, an @handle, or a name.");
29637
- }
29638
- const person = (u) => ({
29639
- kind: "user",
29640
- userId: u.id,
29641
- label: `DM with ${personLabel(u)}`
29990
+ async function searchGifs(auth, q, page = 1) {
29991
+ return amikoWebFetch(auth, "/api/gifs", {
29992
+ query: { q, page }
29642
29993
  });
29643
- if (t.startsWith("@")) {
29644
- const handle = t.slice(1);
29645
- const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(handle)}`, {
29646
- timeoutMs: 15000
29647
- });
29648
- if (!prof.user?.id)
29649
- throw new Error(`No user @${handle}.`);
29650
- return person({ ...prof.user, id: prof.user.id });
29994
+ }
29995
+ function urlBasename(url2) {
29996
+ const last = parseUrl(url2)?.pathname.split("/").pop();
29997
+ if (!last)
29998
+ return "GIF";
29999
+ try {
30000
+ return decodeURIComponent(last);
30001
+ } catch {
30002
+ return last;
29651
30003
  }
29652
- const idish = normalizeUserId(t);
29653
- if (looksLikeConversationId(idish)) {
29654
- if (idish === t) {
29655
- try {
29656
- const conv = await fetchConversationById(auth, t);
29657
- return {
29658
- kind: "conversation",
29659
- id: conv.id,
29660
- label: conv.conversation_type === "direct" ? `DM with ${convLabel(conv, auth.userId)}` : `group "${convLabel(conv, auth.userId)}"`
29661
- };
29662
- } catch (e5) {
29663
- const status = e5.status;
29664
- if (status === 403) {
29665
- throw new Error(`Conversation ${t} exists but you are not a participant.`);
30004
+ }
30005
+ function gifToMetadata(gif) {
30006
+ return {
30007
+ url: gif.url,
30008
+ name: gif.title || urlBasename(gif.url),
30009
+ contentType: isKlipyVideoMediaUrl(gif.url) ? "video/mp4" : "image/gif",
30010
+ kind: "gif",
30011
+ ...gif.width && gif.height ? { width: gif.width, height: gif.height } : {}
30012
+ };
30013
+ }
30014
+
30015
+ // src/commands/chat-gifs.ts
30016
+ var DEFAULT_LIMIT = 10;
30017
+ var MAX_TITLE = 40;
30018
+ function clampLimit(raw) {
30019
+ const n = Number.parseInt(raw, 10);
30020
+ if (!Number.isFinite(n))
30021
+ return DEFAULT_LIMIT;
30022
+ return Math.min(24, Math.max(1, n));
30023
+ }
30024
+ function truncate(text, max) {
30025
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
30026
+ }
30027
+ function registerChatGifsCommand(chat) {
30028
+ chat.command("gifs [query]").description("Search Klipy GIFs (trending when no query) — pass a result URL to `amiko chat send --gif`").option("--page <n>", "Result page (24 per page)", "1").option("--limit <n>", "Max results shown (1-24)", String(DEFAULT_LIMIT)).option("--raw", "Output raw JSON").action(async (query, opts) => {
30029
+ const auth = authOrExit();
30030
+ const q = query?.trim() ?? "";
30031
+ const page = Math.max(1, Number.parseInt(opts.page, 10) || 1);
30032
+ let data;
30033
+ try {
30034
+ data = await searchGifs(auth, q, page);
30035
+ } catch (e5) {
30036
+ console.error(error(e5 instanceof Error ? e5.message : "GIF search failed"));
30037
+ process.exit(1);
30038
+ }
30039
+ renderOutput(data, (d) => {
30040
+ const items = (d.items ?? []).slice(0, clampLimit(opts.limit));
30041
+ if (items.length === 0) {
30042
+ console.log(dim(q ? `No GIFs found for "${q}".` : "No trending GIFs."));
30043
+ console.log(dim("Powered by KLIPY"));
30044
+ return;
30045
+ }
30046
+ console.log(heading(q ? `GIFs matching "${q}" (page ${d.page})` : `Trending GIFs (page ${d.page})`));
30047
+ const rows = [
30048
+ [dim("TITLE"), dim("SIZE"), dim("URL")],
30049
+ ...items.map((gif) => [
30050
+ truncate(gif.title || "GIF", MAX_TITLE),
30051
+ gif.width && gif.height ? `${gif.width}x${gif.height}` : "-",
30052
+ gif.gifUrl
30053
+ ])
30054
+ ];
30055
+ console.log(table(rows));
30056
+ console.log(dim(`
30057
+ Send one: amiko chat send <target> --gif <url>${d.hasNext ? ` (more: --page ${d.page + 1})` : ""}`));
30058
+ console.log(dim("Powered by KLIPY"));
30059
+ }, { json: opts.raw });
30060
+ });
30061
+ }
30062
+
30063
+ // src/commands/chat-lists.ts
30064
+ var collect2 = (v, prev) => [...prev, v];
30065
+ function exitWithError2(e5, fallback2) {
30066
+ console.error(error(e5 instanceof Error ? e5.message : fallback2));
30067
+ process.exit(1);
30068
+ }
30069
+ function conversationFlags(tokens) {
30070
+ return tokens.map((t) => ` --conversation ${JSON.stringify(t)}`).join("");
30071
+ }
30072
+ function countNoun(n) {
30073
+ return `${n} conversation${n === 1 ? "" : "s"}`;
30074
+ }
30075
+ async function fetchChatLists(auth) {
30076
+ const data = await amikoWebFetch(auth, "/api/chat-lists", { timeoutMs: 20000 });
30077
+ return data.chatLists ?? [];
30078
+ }
30079
+ async function resolveList(auth, target) {
30080
+ const t = target.trim();
30081
+ if (!t) {
30082
+ throw new Error("No list given. Run `amiko chat lists` and pass a list id or name.");
30083
+ }
30084
+ const all = await fetchChatLists(auth);
30085
+ if (looksLikeConversationId(t)) {
30086
+ const byId = all.find((l) => l.id === t);
30087
+ if (byId)
30088
+ return byId;
30089
+ }
30090
+ const lower = t.toLowerCase();
30091
+ const exact = all.filter((l) => l.name.toLowerCase() === lower);
30092
+ const matches = exact.length ? exact : all.filter((l) => l.name.toLowerCase().includes(lower));
30093
+ if (matches.length === 0) {
30094
+ throw new Error(`No chat list matching "${t}". Run \`amiko chat lists\` to see them.`);
30095
+ }
30096
+ if (matches.length > 1) {
30097
+ const names = matches.map((l) => `"${l.name}" (${l.id})`).join(", ");
30098
+ throw new Error(`"${t}" is ambiguous — matches: ${names}. Use the list's id.`);
30099
+ }
30100
+ return matches[0];
30101
+ }
30102
+ async function resolveListOrExit(auth, target) {
30103
+ try {
30104
+ return await resolveList(auth, target);
30105
+ } catch (e5) {
30106
+ exitWithError2(e5, "Could not resolve the chat list");
30107
+ }
30108
+ }
30109
+ async function resolveEntriesOrExit(auth, tokens) {
30110
+ if (tokens.length === 0) {
30111
+ console.error(error("At least one --conversation is required."));
30112
+ process.exit(1);
30113
+ }
30114
+ const entries = [];
30115
+ const failures = [];
30116
+ const seen = new Set;
30117
+ for (const raw of tokens) {
30118
+ try {
30119
+ const resolved = await resolveConversation(auth, raw);
30120
+ let entry;
30121
+ if (resolved.kind === "conversation") {
30122
+ entry = { id: resolved.id, label: resolved.label };
30123
+ } else {
30124
+ const existing = await findDmByUserId(auth, resolved.userId);
30125
+ if (!existing) {
30126
+ throw new Error(`no ${resolved.label} exists yet — a chat list can only hold existing conversations.`);
29666
30127
  }
29667
- if (status !== 404)
29668
- throw e5;
30128
+ entry = { id: existing, label: resolved.label };
29669
30129
  }
30130
+ if (seen.has(entry.id))
30131
+ continue;
30132
+ seen.add(entry.id);
30133
+ entries.push(entry);
30134
+ } catch (e5) {
30135
+ failures.push({
30136
+ token: raw,
30137
+ reason: e5 instanceof Error ? e5.message : "could not be resolved"
30138
+ });
29670
30139
  }
29671
- const user = await fetchUserById(auth, idish);
29672
- if (!user) {
29673
- throw new Error(`"${t}" is neither a conversation nor a user id — check \`amiko chat list\` or \`amiko friends list --json\`.`);
30140
+ }
30141
+ if (failures.length > 0) {
30142
+ console.error(error("Could not resolve every conversation — nothing was changed."));
30143
+ for (const entry of entries) {
30144
+ console.error(dim(` resolved: ${entry.label} → ${entry.id}`));
29674
30145
  }
29675
- return person(user);
30146
+ for (const f of failures) {
30147
+ console.error(error(` ${JSON.stringify(f.token)}: ${f.reason}`));
30148
+ }
30149
+ process.exit(1);
29676
30150
  }
29677
- const groupId = await findGroupIdByExactTitle(auth, t);
29678
- if (groupId)
29679
- return { kind: "conversation", id: groupId, label: `group "${t}"` };
29680
- const { resolved, failures } = await resolveMembers(auth, [t]);
29681
- if (failures.length || resolved.length !== 1) {
29682
- const reason = failures[0]?.reason ?? "could not be resolved";
29683
- throw new Error(`"${t}" ${reason}`);
30151
+ return entries;
30152
+ }
30153
+ async function conversationLabels(auth) {
30154
+ const labels = new Map;
30155
+ try {
30156
+ const data = await amikoWebFetch(auth, "/api/conversations", { query: { limit: 100, archived: "all" }, timeoutMs: 20000 });
30157
+ for (const c of data.conversations ?? []) {
30158
+ labels.set(c.id, convLabel(c, auth.userId));
30159
+ }
30160
+ } catch {}
30161
+ return labels;
30162
+ }
30163
+ function listNotFound(e5) {
30164
+ return e5.status === 404;
30165
+ }
30166
+ function registerChatListsCommand(lists) {
30167
+ lists.option("--raw", "Output raw JSON").action(async (opts) => {
30168
+ const auth = authOrExit();
30169
+ let all;
30170
+ try {
30171
+ all = await fetchChatLists(auth);
30172
+ } catch (e5) {
30173
+ exitWithError2(e5, "Failed to load chat lists");
30174
+ }
30175
+ if (opts.raw) {
30176
+ console.log(JSON.stringify({ chatLists: all }, null, 2));
30177
+ return;
30178
+ }
30179
+ if (all.length === 0) {
30180
+ console.log(dim("No chat lists."));
30181
+ console.log(dim("Create one with `amiko chat lists create <name> --conversation <who>`."));
30182
+ return;
30183
+ }
30184
+ const labels = await conversationLabels(auth);
30185
+ console.log(heading(`Chat lists (${all.length})
30186
+ `));
30187
+ for (const l of all) {
30188
+ const ids = l.conversation_ids ?? [];
30189
+ console.log(label(l.name, dim(`${l.id} · ${countNoun(ids.length)}`)));
30190
+ for (const id of ids) {
30191
+ const name = labels.get(id);
30192
+ console.log(dim(name ? ` - ${name} (${id})` : ` - ${id}`));
30193
+ }
30194
+ }
30195
+ });
30196
+ lists.command("create <name>").description("Create a chat list, optionally with initial conversations").option("--conversation <target>", "Conversation to include (conversation id, user id, @handle, group title, or name); repeat per conversation", collect2, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (name, opts) => {
30197
+ const auth = authOrExit();
30198
+ const entries = opts.conversation.length > 0 ? await resolveEntriesOrExit(auth, opts.conversation) : [];
30199
+ await confirmDestructive({
30200
+ action: `Create chat list "${name}"${entries.length ? ` with ${entries.map((e5) => e5.label).join(", ")}` : " (empty)"}`,
30201
+ detail: "Chat lists are private folders — only you see them, in every Amiko app.",
30202
+ yes: opts.yes,
30203
+ commandExample: `amiko chat lists create ${JSON.stringify(name)}${conversationFlags(opts.conversation)}`
30204
+ });
30205
+ let data;
30206
+ try {
30207
+ data = await amikoWebFetch(auth, "/api/chat-lists", {
30208
+ method: "POST",
30209
+ body: { name, conversation_ids: entries.map((e5) => e5.id) },
30210
+ timeoutMs: 20000
30211
+ });
30212
+ } catch (e5) {
30213
+ exitWithError2(e5, "Failed to create the chat list");
30214
+ }
30215
+ if (opts.raw) {
30216
+ console.log(JSON.stringify(data, null, 2));
30217
+ return;
30218
+ }
30219
+ console.log(success(`Chat list created: ${name}`));
30220
+ if (data.chatList?.id)
30221
+ console.log(label("ID", data.chatList.id));
30222
+ if (entries.length) {
30223
+ console.log(label("Conversations", entries.map((e5) => e5.label).join(", ")));
30224
+ }
30225
+ });
30226
+ lists.command("rename <list> <newName>").description("Rename a chat list (list id or name)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, newName, opts) => {
30227
+ const auth = authOrExit();
30228
+ const list = await resolveListOrExit(auth, target);
30229
+ await confirmDestructive({
30230
+ action: `Rename chat list "${list.name}" to "${newName}"`,
30231
+ yes: opts.yes,
30232
+ commandExample: `amiko chat lists rename ${JSON.stringify(target)} ${JSON.stringify(newName)}`
30233
+ });
30234
+ let data;
30235
+ try {
30236
+ data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, { method: "PATCH", body: { name: newName }, timeoutMs: 20000 });
30237
+ } catch (e5) {
30238
+ exitWithError2(e5, "Rename failed");
30239
+ }
30240
+ if (opts.raw) {
30241
+ console.log(JSON.stringify(data, null, 2));
30242
+ return;
30243
+ }
30244
+ console.log(success(`Chat list renamed to: ${newName}`) + dim(` (${list.id})`));
30245
+ });
30246
+ lists.command("add <list>").description("Add conversations to a chat list (list id or name)").option("--conversation <target>", "Conversation to add (conversation id, user id, @handle, group title, or name); repeat per conversation", collect2, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
30247
+ const auth = authOrExit();
30248
+ const entries = await resolveEntriesOrExit(auth, opts.conversation);
30249
+ const list = await resolveListOrExit(auth, target);
30250
+ const current = list.conversation_ids ?? [];
30251
+ const currentSet = new Set(current);
30252
+ const fresh = entries.filter((e5) => !currentSet.has(e5.id));
30253
+ const already = entries.filter((e5) => currentSet.has(e5.id));
30254
+ if (fresh.length === 0) {
30255
+ console.log(dim(`Already in "${list.name}" — nothing to add.`));
30256
+ return;
30257
+ }
30258
+ await confirmDestructive({
30259
+ action: `Add ${fresh.map((e5) => e5.label).join(", ")} to chat list "${list.name}"`,
30260
+ yes: opts.yes,
30261
+ commandExample: `amiko chat lists add ${JSON.stringify(target)}${conversationFlags(opts.conversation)}`
30262
+ });
30263
+ let data;
30264
+ try {
30265
+ data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, {
30266
+ method: "PATCH",
30267
+ body: {
30268
+ conversation_ids: [...current, ...fresh.map((e5) => e5.id)]
30269
+ },
30270
+ timeoutMs: 20000
30271
+ });
30272
+ } catch (e5) {
30273
+ if (listNotFound(e5)) {
30274
+ exitWithError2(null, `Chat list "${list.name}" no longer exists.`);
30275
+ }
30276
+ exitWithError2(e5, "Failed to update the chat list");
30277
+ }
30278
+ if (opts.raw) {
30279
+ console.log(JSON.stringify(data, null, 2));
30280
+ return;
30281
+ }
30282
+ console.log(success(`Added ${fresh.map((e5) => e5.label).join(", ")} to "${list.name}"`) + dim(` (now ${countNoun(current.length + fresh.length)})`));
30283
+ for (const e5 of already) {
30284
+ console.log(dim(` ${e5.label} was already in the list.`));
30285
+ }
30286
+ });
30287
+ lists.command("remove <list>").description("Remove conversations from a chat list (the conversations themselves are untouched)").option("--conversation <target>", "Conversation to remove (conversation id, user id, @handle, group title, or name); repeat per conversation", collect2, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
30288
+ const auth = authOrExit();
30289
+ const entries = await resolveEntriesOrExit(auth, opts.conversation);
30290
+ const list = await resolveListOrExit(auth, target);
30291
+ const current = list.conversation_ids ?? [];
30292
+ const currentSet = new Set(current);
30293
+ const inList = entries.filter((e5) => currentSet.has(e5.id));
30294
+ const notInList = entries.filter((e5) => !currentSet.has(e5.id));
30295
+ if (inList.length === 0) {
30296
+ console.log(dim(`None of those are in "${list.name}".`));
30297
+ return;
30298
+ }
30299
+ await confirmDestructive({
30300
+ action: `Remove ${inList.map((e5) => e5.label).join(", ")} from chat list "${list.name}"`,
30301
+ yes: opts.yes,
30302
+ commandExample: `amiko chat lists remove ${JSON.stringify(target)}${conversationFlags(opts.conversation)}`
30303
+ });
30304
+ const removeIds = new Set(inList.map((e5) => e5.id));
30305
+ let data;
30306
+ try {
30307
+ data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, {
30308
+ method: "PATCH",
30309
+ body: {
30310
+ conversation_ids: current.filter((id) => !removeIds.has(id))
30311
+ },
30312
+ timeoutMs: 20000
30313
+ });
30314
+ } catch (e5) {
30315
+ if (listNotFound(e5)) {
30316
+ exitWithError2(null, `Chat list "${list.name}" no longer exists.`);
30317
+ }
30318
+ exitWithError2(e5, "Failed to update the chat list");
30319
+ }
30320
+ if (opts.raw) {
30321
+ console.log(JSON.stringify(data, null, 2));
30322
+ return;
30323
+ }
30324
+ console.log(success(`Removed ${inList.map((e5) => e5.label).join(", ")} from "${list.name}"`) + dim(` (now ${countNoun(current.length - inList.length)})`));
30325
+ for (const e5 of notInList) {
30326
+ console.log(dim(` ${e5.label} was not in the list.`));
30327
+ }
30328
+ });
30329
+ lists.command("delete <list>").description("Delete a chat list (the conversations in it are untouched)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
30330
+ const auth = authOrExit();
30331
+ const list = await resolveListOrExit(auth, target);
30332
+ const count = (list.conversation_ids ?? []).length;
30333
+ await confirmDestructive({
30334
+ action: `Delete chat list "${list.name}" (${countNoun(count)})`,
30335
+ detail: "The list's name and membership are gone for good; the conversations themselves are not affected.",
30336
+ yes: opts.yes,
30337
+ commandExample: `amiko chat lists delete ${JSON.stringify(target)}`
30338
+ });
30339
+ let data;
30340
+ try {
30341
+ data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, { method: "DELETE", timeoutMs: 20000 });
30342
+ } catch (e5) {
30343
+ exitWithError2(e5, "Failed to delete the chat list");
30344
+ }
30345
+ if (opts.raw) {
30346
+ console.log(JSON.stringify(data, null, 2));
30347
+ return;
30348
+ }
30349
+ console.log(success(`Chat list deleted: ${list.name}`));
30350
+ });
30351
+ }
30352
+
30353
+ // src/commands/chat.ts
30354
+ var PIN_LIMIT = 20;
30355
+ function pinErrorMessage(e5, fallback2) {
30356
+ const status = e5.status;
30357
+ if (status === 403) {
30358
+ return "Only group admins can pin or unpin messages in a group — check roles with `amiko chat group info`. In a DM either side can pin.";
29684
30359
  }
29685
- const p = resolved[0];
29686
- return person({ id: p.userId, name: p.name, handle: p.handle });
30360
+ if (status === 409) {
30361
+ return `Pin limit reached (${PIN_LIMIT}) — unpin one first (see them with \`amiko chat pinned\`).`;
30362
+ }
30363
+ if (status === 400) {
30364
+ return "This message can't be pinned (system and in-flight messages aren't pinnable).";
30365
+ }
30366
+ return e5 instanceof Error ? e5.message : fallback2;
30367
+ }
30368
+ async function findOrCreateDm(auth, userId) {
30369
+ const data = await amikoWebFetch(auth, "/api/conversations", {
30370
+ method: "POST",
30371
+ body: {
30372
+ participant_ids: [userId],
30373
+ participant_types: ["user"],
30374
+ conversation_type: "direct"
30375
+ },
30376
+ timeoutMs: 20000
30377
+ });
30378
+ const id = data.conversation?.id;
30379
+ if (!id)
30380
+ throw new Error("Could not open a conversation with that user.");
30381
+ return id;
29687
30382
  }
29688
30383
  var HTTPS_RE = /^https:\/\//i;
30384
+ var URL_SHAPED_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
29689
30385
  var CHAT_IMAGE_MIME = new Set([
29690
30386
  "image/jpeg",
29691
30387
  "image/jpg",
@@ -29732,7 +30428,7 @@ async function uploadChatVoice(auth, convId, path2) {
29732
30428
  await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(convId)}/audio/upload`, { method: "POST", multipart: form, timeoutMs: 120000 });
29733
30429
  }
29734
30430
  function registerChatCommand(chat) {
29735
- 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) => {
30431
+ chat.command("list").description("List your conversations (DMs and group chats)").option("--limit <n>", "Max conversations", "30").option("--archived", "Include archived conversations").option("--mentions", "Only conversations with an unread @mention of you, a permitted @all, or a reply to one of your messages").option("--raw", "Output raw JSON").action(async (opts) => {
29736
30432
  const auth = authOrExit();
29737
30433
  let data;
29738
30434
  try {
@@ -29747,24 +30443,26 @@ function registerChatCommand(chat) {
29747
30443
  console.error(error(e5 instanceof Error ? e5.message : "Failed to list conversations"));
29748
30444
  process.exit(1);
29749
30445
  }
30446
+ const mentionsMe = (c) => !!c.unread_count && !!c.has_unread_mention;
30447
+ const convs = opts.mentions ? (data.conversations ?? []).filter(mentionsMe) : data.conversations ?? [];
29750
30448
  if (opts.raw) {
29751
- console.log(JSON.stringify(data, null, 2));
30449
+ console.log(JSON.stringify(opts.mentions ? { ...data, conversations: convs } : data, null, 2));
29752
30450
  return;
29753
30451
  }
29754
- const convs = data.conversations ?? [];
29755
30452
  if (convs.length === 0) {
29756
- console.log(dim("No conversations."));
30453
+ console.log(dim(opts.mentions ? "No conversations with unread mentions or replies to you." : "No conversations."));
29757
30454
  return;
29758
30455
  }
29759
30456
  console.log(heading(`Conversations (${convs.length})
29760
30457
  `));
29761
30458
  for (const c of convs) {
29762
30459
  const kind = c.conversation_type === "direct" ? "DM" : c.conversation_type;
29763
- const unread = c.unread_count ? ` (${c.unread_count} unread)` : "";
30460
+ const unread = c.unread_count ? ` (${c.unread_count} unread${mentionsMe(c) ? ", @you" : ""})` : "";
29764
30461
  console.log(label(`[${kind}] ${convLabel(c, auth.userId)}`, dim(c.id)));
29765
30462
  if (c.last_message?.content) {
29766
30463
  const who = c.last_message.name ? `${c.last_message.name}: ` : "";
29767
- console.log(dim(` ${who}${c.last_message.content.slice(0, 80)}`));
30464
+ const preview = mentionMarkupToPlainText(c.last_message.content);
30465
+ console.log(dim(` ${who}${preview.slice(0, 80)}`));
29768
30466
  }
29769
30467
  if (unread)
29770
30468
  console.log(dim(` ${unread.trim()}`));
@@ -29809,15 +30507,23 @@ function registerChatCommand(chat) {
29809
30507
  for (const m of msgs) {
29810
30508
  const who = m.sender?.name ?? m.name ?? (m.sender_id ? m.sender_id.slice(0, 8) : "?");
29811
30509
  const when = m.created_at ? m.created_at.slice(0, 19).replace("T", " ") : "";
29812
- console.log(label(who, m.content));
29813
- if (when)
29814
- console.log(dim(` ${when}`));
30510
+ console.log(label(who, mentionMarkupToPlainText(m.content ?? "")));
30511
+ const meta3 = [when, `id ${m.id}`].filter(Boolean).join(" ");
30512
+ console.log(dim(` ${meta3}`));
30513
+ if (m.reactions?.length) {
30514
+ console.log(dim(` reactions: ${summarizeReactions(m.reactions)}`));
30515
+ }
29815
30516
  }
29816
30517
  });
29817
- 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) => {
30518
+ 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). The message is optional only with --gif.").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("--gif <queryOrUrl>", "Attach a GIF: search words (sends the top Klipy result) or a Klipy URL from `amiko chat gifs`").option("--all", "Mention @all — notifies every member of the group (admins/owners always; others only when enabled via `amiko chat group mention-all`)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, message, opts) => {
29818
30519
  const auth = authOrExit();
29819
- if (opts.image && opts.audio) {
29820
- console.error(error("Attach either --image or --audio, not both (one media block per message)."));
30520
+ const mediaFlagCount = [opts.image, opts.audio, opts.gif].filter(Boolean).length;
30521
+ if (mediaFlagCount > 1) {
30522
+ console.error(error("Attach only one of --image, --audio, or --gif (one media block per message)."));
30523
+ process.exit(1);
30524
+ }
30525
+ if (message === undefined && !opts.gif) {
30526
+ console.error(error("A message is required (it may only be omitted when sending a GIF with --gif)."));
29821
30527
  process.exit(1);
29822
30528
  }
29823
30529
  let resolved;
@@ -29827,12 +30533,73 @@ function registerChatCommand(chat) {
29827
30533
  console.error(error(e5 instanceof Error ? e5.message : "Could not resolve target"));
29828
30534
  process.exit(1);
29829
30535
  }
29830
- const media = opts.image ? `image ${opts.image}` : opts.audio ? `audio ${opts.audio}` : "";
30536
+ const wantsAll = Boolean(opts.all);
30537
+ const messageText = message ?? "";
30538
+ let outgoing = messageText;
30539
+ let mentionAllActive = false;
30540
+ if (wantsAll || hasMentionAllLiteral(messageText) || hasMentionAllToken(messageText)) {
30541
+ const plainTextNote = "Note: @all only notifies people in group chats — sending it as plain text.";
30542
+ let conv = null;
30543
+ if (resolved.kind === "conversation") {
30544
+ try {
30545
+ conv = resolved.conversation ?? await fetchConversationById(auth, resolved.id);
30546
+ } catch (e5) {
30547
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load the conversation"));
30548
+ process.exit(1);
30549
+ }
30550
+ }
30551
+ if (!conv || conv.conversation_type !== "group") {
30552
+ if (wantsAll) {
30553
+ console.error(error("@all only works in group chats — this target is a direct message."));
30554
+ process.exit(1);
30555
+ }
30556
+ console.log(dim(plainTextNote));
30557
+ } else if (!canMentionAll(conv, auth.userId)) {
30558
+ if (wantsAll) {
30559
+ console.error(error(`You can't @all in "${conv.title ?? conv.id}" — only group admins can, unless an admin allows everyone with \`amiko chat group mention-all ${JSON.stringify(target)} on\`.`));
30560
+ process.exit(1);
30561
+ }
30562
+ console.log(dim('Note: you don\'t have @all permission in this group — "@all" goes out as plain text. An admin can enable it with `amiko chat group mention-all <group> on`.'));
30563
+ } else {
30564
+ outgoing = rewriteMentionAllLiteral(messageText);
30565
+ if (wantsAll)
30566
+ outgoing = applyMentionAll(outgoing);
30567
+ mentionAllActive = true;
30568
+ }
30569
+ }
30570
+ let gifAttachment = null;
30571
+ if (opts.gif) {
30572
+ try {
30573
+ if (URL_SHAPED_RE.test(opts.gif)) {
30574
+ if (!HTTPS_RE.test(opts.gif) || !isKlipyMediaUrl(opts.gif)) {
30575
+ throw new Error("--gif accepts search words or an https Klipy URL (from `amiko chat gifs`). For other images use --image with a local file or an Amiko URL.");
30576
+ }
30577
+ gifAttachment = gifToMetadata({ url: opts.gif });
30578
+ } else {
30579
+ const results = await searchGifs(auth, opts.gif);
30580
+ const top = results.items?.[0];
30581
+ if (!top) {
30582
+ throw new Error(`No GIFs found for "${opts.gif}" — try different words or browse with \`amiko chat gifs\`.`);
30583
+ }
30584
+ gifAttachment = gifToMetadata({
30585
+ url: top.gifUrl,
30586
+ title: top.title,
30587
+ width: top.width,
30588
+ height: top.height
30589
+ });
30590
+ }
30591
+ } catch (e5) {
30592
+ console.error(error(e5 instanceof Error ? e5.message : "Could not resolve the GIF"));
30593
+ process.exit(1);
30594
+ }
30595
+ }
30596
+ const media = opts.image ? `image ${opts.image}` : opts.audio ? `audio ${opts.audio}` : gifAttachment ? `gif "${gifAttachment.name.slice(0, 40)}"` : "";
30597
+ const shown = mentionMarkupToPlainText(outgoing);
29831
30598
  await requireApproval({
29832
30599
  free: "sends a message as you (the owner) — the recipients see it",
29833
- summary: `Send to ${resolved.label}${media ? ` [+${media}]` : ""}: ${message.slice(0, 80)}${message.length > 80 ? "…" : ""}`,
30600
+ summary: `Send to ${resolved.label}${media ? ` [+${media}]` : ""}${mentionAllActive ? " [@all — notifies every member]" : ""}: ${shown.slice(0, 80)}${shown.length > 80 ? "…" : ""}`,
29834
30601
  yes: opts.yes,
29835
- commandExample: `amiko chat send ${JSON.stringify(target)} ${JSON.stringify(message)}`
30602
+ commandExample: `amiko chat send ${JSON.stringify(target)}${message !== undefined ? ` ${JSON.stringify(message)}` : ""}${opts.image ? ` --image ${JSON.stringify(opts.image)}` : ""}${opts.audio ? ` --audio ${JSON.stringify(opts.audio)}` : ""}${opts.gif ? ` --gif ${JSON.stringify(opts.gif)}` : ""}${opts.all ? " --all" : ""}`
29836
30603
  });
29837
30604
  let convId;
29838
30605
  try {
@@ -29849,22 +30616,35 @@ function registerChatCommand(chat) {
29849
30616
  });
29850
30617
  let data;
29851
30618
  try {
29852
- if (opts.image) {
30619
+ if (gifAttachment) {
30620
+ const isVideo = isKlipyVideoMediaUrl(gifAttachment.url);
30621
+ data = await post({
30622
+ content: outgoing || "GIF",
30623
+ message_type: isVideo ? "video" : "image",
30624
+ metadata: {
30625
+ [isVideo ? "videos" : "images"]: [gifAttachment],
30626
+ image_caption: outgoing || null
30627
+ }
30628
+ });
30629
+ } else if (opts.image) {
29853
30630
  const img = HTTPS_RE.test(opts.image) ? { url: opts.image, name: urlName(opts.image) } : await uploadChatImage(auth, convId, opts.image);
29854
- data = await post({ content: message, metadata: { images: [img] } });
30631
+ data = await post({
30632
+ content: outgoing,
30633
+ metadata: { images: [img], image_caption: outgoing || null }
30634
+ });
29855
30635
  } else if (opts.audio) {
29856
30636
  if (HTTPS_RE.test(opts.audio)) {
29857
30637
  data = await post({
29858
- content: message,
30638
+ content: outgoing,
29859
30639
  message_type: "voice",
29860
30640
  metadata: { audioUrl: opts.audio }
29861
30641
  });
29862
30642
  } else {
29863
- data = await post({ content: message });
30643
+ data = await post({ content: outgoing });
29864
30644
  await uploadChatVoice(auth, convId, opts.audio);
29865
30645
  }
29866
30646
  } else {
29867
- data = await post({ content: message });
30647
+ data = await post({ content: outgoing });
29868
30648
  }
29869
30649
  } catch (e5) {
29870
30650
  console.error(error(e5 instanceof Error ? e5.message : "Send failed"));
@@ -29874,11 +30654,326 @@ function registerChatCommand(chat) {
29874
30654
  console.log(JSON.stringify(data, null, 2));
29875
30655
  return;
29876
30656
  }
29877
- const what = opts.image ? "Message + image sent" : opts.audio ? "Message + voice note sent" : "Message sent";
30657
+ const what = opts.image ? "Message + image sent" : opts.audio ? "Message + voice note sent" : gifAttachment ? outgoing ? "Message + GIF sent" : "GIF sent" : "Message sent";
29878
30658
  console.log(success(what) + dim(` (conversation ${convId.slice(0, 8)}…)`));
29879
30659
  });
29880
- const group = chat.command("group").description("Group chats — create, list, info, rename, add/remove members, leave");
30660
+ chat.command("react <messageId> <emoji>").description("React to a message as yourself (the owner). Get the message id from `amiko chat read`. One reaction per message — a new emoji replaces your previous one.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, emojiArg, opts) => {
30661
+ const auth = authOrExit();
30662
+ let emoji3;
30663
+ try {
30664
+ emoji3 = validateEmoji(emojiArg);
30665
+ } catch (e5) {
30666
+ console.error(error(e5 instanceof Error ? e5.message : "Invalid emoji"));
30667
+ process.exit(1);
30668
+ }
30669
+ let msg;
30670
+ try {
30671
+ msg = await fetchMessage(auth, messageId);
30672
+ } catch (e5) {
30673
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
30674
+ process.exit(1);
30675
+ }
30676
+ if (ownReaction(msg.reactions, auth.userId) === emoji3) {
30677
+ if (opts.raw) {
30678
+ console.log(JSON.stringify({ success: true, action: "unchanged", reactions: msg.reactions }, null, 2));
30679
+ return;
30680
+ }
30681
+ console.log(success(`Already reacted with ${emoji3}`));
30682
+ return;
30683
+ }
30684
+ await requireApproval({
30685
+ free: "adds your reaction — people in the chat see it",
30686
+ summary: `React ${emoji3} to "${previewText(msg)}"`,
30687
+ yes: opts.yes,
30688
+ commandExample: `amiko chat react ${JSON.stringify(messageId)} ${JSON.stringify(emoji3)}`
30689
+ });
30690
+ let data;
30691
+ try {
30692
+ data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/reactions`, { method: "POST", body: { emoji: emoji3 }, timeoutMs: 20000 });
30693
+ } catch (e5) {
30694
+ console.error(error(e5 instanceof Error ? e5.message : "Failed to react"));
30695
+ process.exit(1);
30696
+ }
30697
+ if (opts.raw) {
30698
+ console.log(JSON.stringify(data, null, 2));
30699
+ return;
30700
+ }
30701
+ const what = data.action === "removed" ? `Removed your ${emoji3} reaction` : data.action === "updated" ? `Reaction changed to ${emoji3}` : `Reacted ${emoji3}`;
30702
+ console.log(success(what));
30703
+ });
30704
+ chat.command("unreact <messageId>").description("Remove your (the owner's) reaction from a message. Get the message id from `amiko chat read`.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
30705
+ const auth = authOrExit();
30706
+ let msg;
30707
+ try {
30708
+ msg = await fetchMessage(auth, messageId);
30709
+ } catch (e5) {
30710
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
30711
+ process.exit(1);
30712
+ }
30713
+ const mine = ownReaction(msg.reactions, auth.userId);
30714
+ if (!mine) {
30715
+ if (opts.raw) {
30716
+ console.log(JSON.stringify({ success: true, action: "none", reactions: msg.reactions }, null, 2));
30717
+ return;
30718
+ }
30719
+ console.log(dim("You have no reaction on that message."));
30720
+ return;
30721
+ }
30722
+ await requireApproval({
30723
+ free: "removes your reaction from the chat",
30724
+ summary: `Remove your ${mine} reaction from "${previewText(msg)}"`,
30725
+ yes: opts.yes,
30726
+ commandExample: `amiko chat unreact ${JSON.stringify(messageId)}`
30727
+ });
30728
+ let data;
30729
+ try {
30730
+ data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/reactions`, { method: "POST", body: { emoji: mine }, timeoutMs: 20000 });
30731
+ } catch (e5) {
30732
+ console.error(error(e5 instanceof Error ? e5.message : "Failed to remove reaction"));
30733
+ process.exit(1);
30734
+ }
30735
+ if (opts.raw) {
30736
+ console.log(JSON.stringify(data, null, 2));
30737
+ return;
30738
+ }
30739
+ const what = data.action === "removed" ? `Removed your ${mine} reaction` : data.action === "updated" ? `Reaction changed to ${mine}` : `Reacted ${mine}`;
30740
+ console.log(success(what));
30741
+ });
30742
+ chat.command("reactions <messageId>").description("Show the reactions on a message. Get the message id from `amiko chat read`.").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
30743
+ const auth = authOrExit();
30744
+ let msg;
30745
+ try {
30746
+ msg = await fetchMessage(auth, messageId);
30747
+ } catch (e5) {
30748
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
30749
+ process.exit(1);
30750
+ }
30751
+ if (opts.raw) {
30752
+ console.log(JSON.stringify({ reactions: msg.reactions }, null, 2));
30753
+ return;
30754
+ }
30755
+ if (msg.reactions.length === 0) {
30756
+ console.log(dim("No reactions yet."));
30757
+ return;
30758
+ }
30759
+ const mine = ownReaction(msg.reactions, auth.userId);
30760
+ console.log(heading(`Reactions (${msg.reactions.length})
30761
+ `));
30762
+ for (const [emoji3, count] of groupReactions(msg.reactions)) {
30763
+ const you = emoji3 === mine ? dim(" (includes you)") : "";
30764
+ console.log(label(emoji3, `×${count}`) + you);
30765
+ }
30766
+ });
30767
+ chat.command("receipts <messageId>").description("Show who read your message and when (sender-only). Get the message id from `amiko chat read`.").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
30768
+ const auth = authOrExit();
30769
+ let msg;
30770
+ try {
30771
+ msg = await fetchMessage(auth, messageId);
30772
+ } catch (e5) {
30773
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
30774
+ process.exit(1);
30775
+ }
30776
+ let data;
30777
+ try {
30778
+ data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/receipts`, { timeoutMs: 20000 });
30779
+ } catch (e5) {
30780
+ const status = e5.status;
30781
+ console.error(error(status === 403 ? "Read receipts are only available for messages you sent (as the owner)." : e5 instanceof Error ? e5.message : "Failed to load receipts"));
30782
+ process.exit(1);
30783
+ }
30784
+ const reads = data.reads ?? [];
30785
+ const delivered = data.delivered_to ?? [];
30786
+ if (opts.raw) {
30787
+ console.log(JSON.stringify(data, null, 2));
30788
+ return;
30789
+ }
30790
+ const names = new Map;
30791
+ if (msg.conversation_id) {
30792
+ try {
30793
+ const conv = await fetchConversationById(auth, msg.conversation_id);
30794
+ for (const p of conv.participants ?? []) {
30795
+ if (p.participant_type !== "user")
30796
+ continue;
30797
+ const id = normalizeUserId(p.participant_id);
30798
+ names.set(id, p.user?.name ?? p.user?.handle ?? id.slice(0, 8));
30799
+ }
30800
+ } catch {}
30801
+ }
30802
+ const nameFor = (userId) => {
30803
+ const id = normalizeUserId(userId);
30804
+ return names.get(id) ?? id.slice(0, 8);
30805
+ };
30806
+ const fmt = (iso) => iso.slice(0, 19).replace("T", " ");
30807
+ console.log(label("Message", previewText(msg)));
30808
+ console.log(label("Sent", fmt(data.sent_at)));
30809
+ console.log("");
30810
+ console.log(heading(`Read by (${reads.length})`));
30811
+ if (reads.length === 0) {
30812
+ console.log(dim(" No one has read this yet."));
30813
+ } else {
30814
+ for (const r of reads) {
30815
+ const when = r.approximate ? dim("read (exact time unknown)") : fmt(r.read_at);
30816
+ console.log(` ${label(nameFor(r.user_id), when)}`);
30817
+ }
30818
+ }
30819
+ if (delivered.length > 0) {
30820
+ console.log("");
30821
+ console.log(heading(`Delivered to (${delivered.length})`));
30822
+ for (const userId of delivered) {
30823
+ console.log(` ${nameFor(userId)}`);
30824
+ }
30825
+ }
30826
+ });
30827
+ chat.command("pin <messageId>").description("Pin a message for everyone in the conversation. Get the message id from `amiko chat read`. Groups require the admin role; the chat sees a pinned-message announcement.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
30828
+ const auth = authOrExit();
30829
+ let msg;
30830
+ try {
30831
+ msg = await fetchMessage(auth, messageId);
30832
+ } catch (e5) {
30833
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
30834
+ process.exit(1);
30835
+ }
30836
+ await requireApproval({
30837
+ free: "pins the message for everyone — the chat sees a pinned-message announcement",
30838
+ summary: `Pin "${previewText(msg)}"`,
30839
+ yes: opts.yes,
30840
+ commandExample: `amiko chat pin ${JSON.stringify(messageId)}`
30841
+ });
30842
+ let data;
30843
+ try {
30844
+ data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/pin`, { method: "POST", timeoutMs: 20000 });
30845
+ } catch (e5) {
30846
+ console.error(error(pinErrorMessage(e5, "Failed to pin the message")));
30847
+ process.exit(1);
30848
+ }
30849
+ if (opts.raw) {
30850
+ console.log(JSON.stringify(data, null, 2));
30851
+ return;
30852
+ }
30853
+ console.log(success(`Pinned "${previewText(msg)}"`));
30854
+ const total = data.pins?.length;
30855
+ if (typeof total === "number") {
30856
+ console.log(dim(` ${total}/${PIN_LIMIT} pinned in this conversation — see them with \`amiko chat pinned\`.`));
30857
+ }
30858
+ });
30859
+ chat.command("unpin <messageId>").description("Unpin a message — removes it from the conversation's pinned list for everyone. Get the message id from `amiko chat read` or `amiko chat pinned`.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
30860
+ const auth = authOrExit();
30861
+ let msg;
30862
+ try {
30863
+ msg = await fetchMessage(auth, messageId);
30864
+ } catch (e5) {
30865
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
30866
+ process.exit(1);
30867
+ }
30868
+ await confirmDestructive({
30869
+ action: `Unpin "${previewText(msg)}" for everyone in the conversation`,
30870
+ yes: opts.yes,
30871
+ commandExample: `amiko chat unpin ${JSON.stringify(messageId)}`
30872
+ });
30873
+ let data;
30874
+ try {
30875
+ data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/pin`, { method: "DELETE", timeoutMs: 20000 });
30876
+ } catch (e5) {
30877
+ console.error(error(pinErrorMessage(e5, "Failed to unpin the message")));
30878
+ process.exit(1);
30879
+ }
30880
+ if (opts.raw) {
30881
+ console.log(JSON.stringify(data, null, 2));
30882
+ return;
30883
+ }
30884
+ console.log(success(`Unpinned "${previewText(msg)}"`));
30885
+ const remaining = data.pins?.length;
30886
+ if (typeof remaining === "number" && remaining > 0) {
30887
+ console.log(dim(` ${remaining} still pinned in this conversation.`));
30888
+ }
30889
+ });
30890
+ chat.command("pinned <target>").description("List a conversation's pinned messages, oldest first (conversation id, user id, @handle, or name)").option("--raw", "Output raw JSON").action(async (target, opts) => {
30891
+ const auth = authOrExit();
30892
+ let convId;
30893
+ try {
30894
+ const resolved = await resolveConversation(auth, target);
30895
+ if (resolved.kind === "user") {
30896
+ const existing = await findDmByUserId(auth, resolved.userId);
30897
+ if (!existing) {
30898
+ throw new Error(`No ${resolved.label} yet — start one with \`amiko chat send\`.`);
30899
+ }
30900
+ convId = existing;
30901
+ } else {
30902
+ convId = resolved.id;
30903
+ }
30904
+ } catch (e5) {
30905
+ console.error(error(e5 instanceof Error ? e5.message : "Could not resolve target"));
30906
+ process.exit(1);
30907
+ }
30908
+ let data;
30909
+ try {
30910
+ data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(convId)}/pinned-messages`, { timeoutMs: 20000 });
30911
+ } catch (e5) {
30912
+ console.error(error(e5 instanceof Error ? e5.message : "Failed to load pinned messages"));
30913
+ process.exit(1);
30914
+ }
30915
+ if (opts.raw) {
30916
+ console.log(JSON.stringify(data, null, 2));
30917
+ return;
30918
+ }
30919
+ const pins = data.pins ?? [];
30920
+ if (pins.length === 0) {
30921
+ console.log(dim("No pinned messages."));
30922
+ return;
30923
+ }
30924
+ console.log(heading(`Pinned messages (${pins.length})
30925
+ `));
30926
+ for (const pin of pins) {
30927
+ const m = pin.message;
30928
+ const who = m.name ?? (m.sender_id ? m.sender_id.slice(0, 8) : "?");
30929
+ const when = m.created_at ? m.created_at.slice(0, 19).replace("T", " ") : "";
30930
+ console.log(label(who, mentionMarkupToPlainText(m.content ?? "")));
30931
+ const meta3 = [when, `id ${m.id}`].filter(Boolean).join(" ");
30932
+ console.log(dim(` ${meta3}`));
30933
+ if (pin.pinned_by_name) {
30934
+ console.log(dim(` pinned by ${pin.pinned_by_name}`));
30935
+ }
30936
+ }
30937
+ });
30938
+ chat.command("mark-read").description("Mark ALL conversations as read (requires --all). Clears every unread badge on the owner's devices; senders see read ticks.").option("--all", "Mark every conversation as read").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (opts) => {
30939
+ if (!opts.all) {
30940
+ console.error(error("Pass --all to mark every conversation as read"));
30941
+ process.exit(1);
30942
+ }
30943
+ const auth = authOrExit();
30944
+ await confirmDestructive({
30945
+ action: "Mark ALL of the owner's conversations as read",
30946
+ detail: "Clears every unread badge on web and mobile; senders see read ticks on their messages. This cannot be undone.",
30947
+ yes: opts.yes,
30948
+ commandExample: "amiko chat mark-read --all"
30949
+ });
30950
+ let data;
30951
+ try {
30952
+ data = await amikoWebFetch(auth, "/api/conversations/read-all", {
30953
+ method: "POST",
30954
+ timeoutMs: 20000
30955
+ });
30956
+ } catch (e5) {
30957
+ console.error(error(e5 instanceof Error ? e5.message : "Failed to mark all as read"));
30958
+ process.exit(1);
30959
+ }
30960
+ if (opts.raw) {
30961
+ console.log(JSON.stringify(data, null, 2));
30962
+ return;
30963
+ }
30964
+ console.log(success("All conversations marked as read."));
30965
+ const cleared = data.cleared_notifications;
30966
+ if (typeof cleared === "number" && cleared > 0) {
30967
+ console.log(dim(` ${cleared} chat notification${cleared === 1 ? "" : "s"} cleared`));
30968
+ } else if ((data.unread_conversation_ids ?? []).length === 0) {
30969
+ console.log(dim(" Nothing was unread."));
30970
+ }
30971
+ });
30972
+ registerChatGifsCommand(chat);
30973
+ const group = chat.command("group").description("Group chats — create, list, info, rename, add/remove members, @all permission, leave");
29881
30974
  registerChatGroupCommand(group);
30975
+ const lists = chat.command("lists").description("Your chat lists (private folders of conversations) — run bare to see them; create, rename, add/remove conversations, delete");
30976
+ registerChatListsCommand(lists);
29882
30977
  }
29883
30978
 
29884
30979
  // src/commands/card.ts
@@ -30314,18 +31409,32 @@ async function fetchWithTimeout2(url2, init = {}, timeoutMs) {
30314
31409
  }
30315
31410
  function registerServicesCommand(program2) {
30316
31411
  const svc = program2.command("service").alias("svc").description("Call any MPP service — pay in AMIKO, USDC, or USDT");
30317
- 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) => {
31412
+ svc.command("tts <voiceId> <text>").description("Text-to-speech via mpp /v1/tts (pay-per-call)").option("--provider <name>", "elevenlabs (default) | minimax").option("--model <model>", "MiniMax speech-* model when provider=minimax").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) => {
31413
+ const provider = typeof opts.provider === "string" ? opts.provider.trim().toLowerCase() : "";
31414
+ const body = { text };
31415
+ if (provider === "minimax") {
31416
+ body.provider = "minimax";
31417
+ if (opts.model)
31418
+ body.model_id = opts.model;
31419
+ else
31420
+ body.model_id = "speech-2.8-turbo";
31421
+ } else if (provider && provider !== "elevenlabs") {
31422
+ console.error(error("--provider must be minimax or elevenlabs"));
31423
+ process.exit(1);
31424
+ }
31425
+ const providerArgs = provider ? ` --provider ${provider}` : "";
31426
+ const modelArgs = provider === "minimax" ? ` --model ${JSON.stringify(typeof opts.model === "string" && opts.model ? opts.model : "speech-2.8-turbo")}` : "";
30318
31427
  await requireApproval({
30319
- cost: "1 AMIKO",
30320
- summary: `Text-to-speech (voice ${voiceId}): "${text.slice(0, 60)}${text.length > 60 ? "…" : ""}"`,
31428
+ cost: "variable (see mpp pricing)",
31429
+ summary: `Text-to-speech (voice ${voiceId}${provider ? `, ${provider}` : ""}): "${text.slice(0, 60)}${text.length > 60 ? "…" : ""}"`,
30321
31430
  yes: opts.yes,
30322
- commandExample: `amiko markets service tts ${voiceId} ${JSON.stringify(text)}`
31431
+ commandExample: `amiko markets service tts ${voiceId} ${JSON.stringify(text)}${providerArgs}${modelArgs}`
30323
31432
  });
30324
31433
  await callService({
30325
31434
  name: "Text-to-Speech",
30326
31435
  method: "POST",
30327
31436
  path: `/v1/tts/${encodeURIComponent(voiceId)}`,
30328
- body: { text },
31437
+ body,
30329
31438
  amiko: "1",
30330
31439
  opts
30331
31440
  });
@@ -30537,7 +31646,7 @@ function registerAccountsCommand(program2) {
30537
31646
  }
30538
31647
  }, { json: opts.json });
30539
31648
  });
30540
- program2.command("info").description("Get twin info (name, description, voice, avatar)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
31649
+ program2.command("info").description("Get twin info (name, description, voice ids, avatar)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
30541
31650
  const config2 = loadConfig();
30542
31651
  const auth = resolveAuth();
30543
31652
  let twinId;
@@ -30561,13 +31670,24 @@ function registerAccountsCommand(program2) {
30561
31670
  console.log(label("Description", t.description));
30562
31671
  console.log(label("Public", t.is_public ? "yes" : "no"));
30563
31672
  if (t.voice_status)
30564
- console.log(label("Voice", t.voice_status));
31673
+ console.log(label("Voice status", t.voice_status));
31674
+ if (t.voice_id)
31675
+ console.log(label("ElevenLabs voice_id", t.voice_id));
31676
+ else
31677
+ console.log(label("ElevenLabs voice_id", dim("(none)")));
31678
+ if (t.minimax_voice_id)
31679
+ console.log(label("MiniMax voice_id", t.minimax_voice_id));
31680
+ else
31681
+ console.log(label("MiniMax voice_id", dim("(none — clone with amiko voice clone)")));
30565
31682
  if (t.avatar_url)
30566
31683
  console.log(label("Avatar", t.avatar_url));
30567
31684
  if (t.shipped_at)
30568
31685
  console.log(label("Shipped", t.shipped_at));
30569
31686
  if (t.updated_at)
30570
31687
  console.log(dim(`Updated ${t.updated_at}`));
31688
+ console.log("");
31689
+ console.log(dim('MiniMax TTS: amiko create tts "…" --provider minimax --voice <MiniMax voice_id> --yes'));
31690
+ console.log(dim('ElevenLabs TTS: amiko create tts "…" --provider elevenlabs --voice <ElevenLabs voice_id> --yes'));
30571
31691
  }, { json: opts.json });
30572
31692
  } catch (e5) {
30573
31693
  spinner?.stop();
@@ -30649,6 +31769,9 @@ function registerTwinCommand(program2) {
30649
31769
  // src/commands/drive.ts
30650
31770
  import { writeFile as writeFile2 } from "node:fs/promises";
30651
31771
  import { basename as basename2, resolve as resolvePath } from "node:path";
31772
+ var DIRECT_UPLOAD_MAX_BYTES = 4 * 1024 * 1024;
31773
+ var MAX_DRIVE_UPLOAD_BYTES = 200 * 1024 * 1024;
31774
+ var PRESIGNED_PUT_TIMEOUT_MS = 10 * 60 * 1000;
30652
31775
  function shareBase(auth) {
30653
31776
  return (auth.platformUrl ?? "https://platform.heyamiko.com").replace(/\/$/, "");
30654
31777
  }
@@ -30783,26 +31906,56 @@ Search hits filename, title, description, and parsed RAG content (once processed
30783
31906
  try {
30784
31907
  const file2 = await resolveFileInput(source, {
30785
31908
  stdin: opts.stdin,
30786
- hintName: opts.name
31909
+ hintName: opts.name,
31910
+ maxBytes: MAX_DRIVE_UPLOAD_BYTES
30787
31911
  });
30788
31912
  if (spinner)
30789
- spinner.text = `Uploading ${file2.name}...`;
30790
- const form = new FormData;
30791
- form.set("file", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
30792
- const upload = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/upload`, {
30793
- method: "POST",
30794
- multipart: form
30795
- });
30796
- if (!upload.url || !upload.filename || !upload.fileType) {
30797
- throw new Error(`Upload response missing required fields: ${JSON.stringify(upload)}`);
31913
+ spinner.text = `Uploading ${file2.name} (${fmtBytes(file2.size)})...`;
31914
+ let uploadedUrl;
31915
+ let uploadedName;
31916
+ let uploadedType;
31917
+ if (file2.size > DIRECT_UPLOAD_MAX_BYTES) {
31918
+ const pres = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/presigned-url`, {
31919
+ method: "POST",
31920
+ body: { filename: file2.name }
31921
+ });
31922
+ if (!pres.uploadUrl || !pres.publicUrl) {
31923
+ throw new Error(`Presigned-URL response missing fields: ${JSON.stringify(pres)}`);
31924
+ }
31925
+ const putRes = await fetch(pres.uploadUrl, {
31926
+ method: "PUT",
31927
+ headers: { "Content-Type": file2.mime },
31928
+ body: new Uint8Array(file2.buffer),
31929
+ signal: AbortSignal.timeout(PRESIGNED_PUT_TIMEOUT_MS)
31930
+ });
31931
+ if (!putRes.ok) {
31932
+ const detail = await putRes.text().catch(() => "");
31933
+ throw new Error(`Direct storage upload failed: ${putRes.status} ${putRes.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ""}`);
31934
+ }
31935
+ uploadedUrl = pres.publicUrl;
31936
+ uploadedName = file2.name;
31937
+ uploadedType = file2.mime;
31938
+ } else {
31939
+ const form = new FormData;
31940
+ form.set("file", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
31941
+ const upload = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/upload`, {
31942
+ method: "POST",
31943
+ multipart: form
31944
+ });
31945
+ if (!upload.url || !upload.filename || !upload.fileType) {
31946
+ throw new Error(`Upload response missing required fields: ${JSON.stringify(upload)}`);
31947
+ }
31948
+ uploadedUrl = upload.url;
31949
+ uploadedName = upload.filename;
31950
+ uploadedType = upload.fileType;
30798
31951
  }
30799
31952
  if (spinner)
30800
- spinner.text = `Registering ${upload.filename}...`;
30801
- const title = opts.title || upload.filename.replace(/\.[^.]+$/, "");
31953
+ spinner.text = `Registering ${uploadedName}...`;
31954
+ const title = opts.title || uploadedName.replace(/\.[^.]+$/, "");
30802
31955
  const body = {
30803
- filename: upload.filename,
30804
- fileUrl: upload.url,
30805
- fileType: upload.fileType,
31956
+ filename: uploadedName,
31957
+ fileUrl: uploadedUrl,
31958
+ fileType: uploadedType,
30806
31959
  title,
30807
31960
  doc_type: opts.docType ?? "other"
30808
31961
  };
@@ -30818,10 +31971,8 @@ Search hits filename, title, description, and parsed RAG content (once processed
30818
31971
  console.log(label("Title", d.title || "-"));
30819
31972
  if (d.folder_id)
30820
31973
  console.log(label("Folder", d.folder_id));
30821
- if (upload.fileSize)
30822
- console.log(label("Size", fmtBytes(upload.fileSize)));
30823
- if (upload.fileType)
30824
- console.log(label("Type", upload.fileType));
31974
+ console.log(label("Size", fmtBytes(file2.size)));
31975
+ console.log(label("Type", uploadedType));
30825
31976
  if (d.job_id) {
30826
31977
  console.log(label("RAG job", d.job_id));
30827
31978
  console.log(dim("Processing runs in the background. Use `amiko drive status <id>` to check."));
@@ -31173,8 +32324,18 @@ function twinFromFlag2(opts) {
31173
32324
  throw e5;
31174
32325
  }
31175
32326
  }
32327
+ function parseProvider(raw, fallback2) {
32328
+ if (raw == null || raw.trim() === "")
32329
+ return fallback2;
32330
+ const p = raw.trim().toLowerCase();
32331
+ if (p !== "minimax" && p !== "elevenlabs") {
32332
+ console.error(error("--provider must be minimax or elevenlabs"));
32333
+ process.exit(1);
32334
+ }
32335
+ return p;
32336
+ }
31176
32337
  function registerVoiceCommand(program2) {
31177
- program2.command("design").description("Design a voice from a text description").requiredOption("--description <text>", "Voice description (min 20 chars)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
32338
+ program2.command("design").description("Design a voice from a text description (ElevenLabs)").requiredOption("--description <text>", "Voice description (min 20 chars)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
31178
32339
  if (opts.description.trim().length < 20) {
31179
32340
  console.error(error("--description must be at least 20 characters"));
31180
32341
  process.exit(1);
@@ -31202,7 +32363,7 @@ function registerVoiceCommand(program2) {
31202
32363
  process.exit(1);
31203
32364
  }
31204
32365
  });
31205
- program2.command("create").description("Create a voice from a previously designed preview").requiredOption("--sample <id>", "generated_voice_id from voice design").option("--description <text>", "Optional voice description").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
32366
+ program2.command("create").description("Create a voice from a previously designed preview (ElevenLabs)").requiredOption("--sample <id>", "generated_voice_id from voice design").option("--description <text>", "Optional voice description").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
31206
32367
  const { twinId, auth } = twinFromFlag2(opts);
31207
32368
  const spinner = opts.json ? null : ora("Creating voice...").start();
31208
32369
  try {
@@ -31228,7 +32389,8 @@ function registerVoiceCommand(program2) {
31228
32389
  process.exit(1);
31229
32390
  }
31230
32391
  });
31231
- program2.command("clone <source>").description("Clone a voice from an audio file (path | https URL | -)").option("--twin <idOrName>", "Target twin id or name").option("--name <filename>", "Override filename").option("--stdin", "Read from stdin (source must be -)").option("--json", "Output as JSON").action(async (source, opts) => {
32392
+ program2.command("clone <source>").description("Clone a voice from an audio file (path | https URL | -). Default provider: minimax").option("--provider <name>", "minimax (default, mpp rapid clone) | elevenlabs (platform IVC)", "minimax").option("--voice-id <id>", "MiniMax: optional custom voice_id (8–256 chars)").option("--model <model>", "MiniMax preview TTS model", "speech-2.8-turbo").option("--preview-text <text>", "MiniMax: preview line (empty string skips preview)").option("--twin <idOrName>", "Target twin id or name").option("--name <filename>", "Override filename").option("--stdin", "Read from stdin (source must be -)").option("--json", "Output as JSON").action(async (source, opts) => {
32393
+ const provider = parseProvider(opts.provider, "minimax");
31232
32394
  const { twinId, auth } = twinFromFlag2(opts);
31233
32395
  const spinner = opts.json ? null : ora("Reading audio...").start();
31234
32396
  try {
@@ -31238,17 +32400,30 @@ function registerVoiceCommand(program2) {
31238
32400
  hintMime: "audio/mpeg"
31239
32401
  });
31240
32402
  if (spinner)
31241
- spinner.text = `Cloning voice from ${file2.name}...`;
32403
+ spinner.text = `Cloning voice (${provider}) from ${file2.name}...`;
31242
32404
  const form = new FormData;
31243
32405
  form.set("audio", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
32406
+ form.set("provider", provider);
32407
+ if (provider === "minimax") {
32408
+ if (opts.voiceId)
32409
+ form.set("voice_id", opts.voiceId);
32410
+ if (opts.model)
32411
+ form.set("model_id", opts.model);
32412
+ if (opts.previewText !== undefined)
32413
+ form.set("text", opts.previewText);
32414
+ }
31244
32415
  const result = await amikoWebFetch(auth, `/api/agents/${twinId}/voice/clone`, { method: "POST", multipart: form, timeoutMs: 180000 });
31245
32416
  spinner?.stop();
31246
32417
  renderOutput(result, (r) => {
31247
- console.log(success("Voice cloned"));
32418
+ console.log(success(`Voice cloned (${String(r.provider ?? provider)})`));
31248
32419
  if (r.voice_id)
31249
32420
  console.log(label("Voice ID", String(r.voice_id)));
31250
32421
  if (r.voice_status)
31251
32422
  console.log(label("Status", String(r.voice_status)));
32423
+ if (r.demo_audio)
32424
+ console.log(label("Demo", String(r.demo_audio)));
32425
+ console.log("");
32426
+ console.log(dim(`Synthesize: amiko create tts "Hello" --provider ${provider} --voice ${String(r.voice_id ?? "<id>")} --yes`));
31252
32427
  }, { json: opts.json });
31253
32428
  } catch (e5) {
31254
32429
  spinner?.stop();
@@ -31469,16 +32644,206 @@ async function mppFriendReportGenerate(auth, body, opts = {}) {
31469
32644
  body: data
31470
32645
  };
31471
32646
  }
31472
- return { ok: true, status: res.status, body: data, txHash, amikoAmount };
31473
- } catch (err) {
31474
- return {
31475
- ok: false,
31476
- status: 0,
31477
- error: err instanceof Error ? err.message : "mpp-service fetch failed",
31478
- stage: "generate"
31479
- };
32647
+ return { ok: true, status: res.status, body: data, txHash, amikoAmount };
32648
+ } catch (err) {
32649
+ return {
32650
+ ok: false,
32651
+ status: 0,
32652
+ error: err instanceof Error ? err.message : "mpp-service fetch failed",
32653
+ stage: "generate"
32654
+ };
32655
+ }
32656
+ }
32657
+
32658
+ // src/lib/nickname.ts
32659
+ var NICKNAME_MAX_CODE_POINTS = 50;
32660
+ var CONTROL_CHARS = /\p{Cc}/u;
32661
+ function validateNickname(input) {
32662
+ if (CONTROL_CHARS.test(input)) {
32663
+ return {
32664
+ ok: false,
32665
+ error: "invalid_characters",
32666
+ message: "Nickname contains unsupported characters (control characters are not allowed)."
32667
+ };
32668
+ }
32669
+ const trimmed = input.trim();
32670
+ if (!trimmed) {
32671
+ return { ok: false, error: "empty", message: "Nickname is empty." };
32672
+ }
32673
+ if ([...trimmed].length > NICKNAME_MAX_CODE_POINTS) {
32674
+ return {
32675
+ ok: false,
32676
+ error: "too_long",
32677
+ message: `Nickname is too long (max ${NICKNAME_MAX_CODE_POINTS} characters).`
32678
+ };
32679
+ }
32680
+ return { ok: true, value: trimmed };
32681
+ }
32682
+
32683
+ // src/commands/friends-nickname.ts
32684
+ function exitWithError3(e5, fallback2) {
32685
+ console.error(error(e5 instanceof Error ? e5.message : fallback2));
32686
+ process.exit(1);
32687
+ }
32688
+ function friendLabel(f) {
32689
+ const name = f.friend.name ?? f.friend.id;
32690
+ return f.friend.handle ? `${name} (@${f.friend.handle})` : name;
32691
+ }
32692
+ async function fetchUserFriends(auth) {
32693
+ const limit = 100;
32694
+ const all = [];
32695
+ for (let offset = 0;offset < 1e4; offset += limit) {
32696
+ const data = await amikoWebFetch(auth, "/api/friends", {
32697
+ query: { type: "user", limit, offset },
32698
+ timeoutMs: 20000
32699
+ });
32700
+ const page = data.friends ?? [];
32701
+ all.push(...page);
32702
+ if (page.length < limit || data.pagination?.has_more === false)
32703
+ break;
32704
+ }
32705
+ return all;
32706
+ }
32707
+ async function resolveFriend(auth, target) {
32708
+ const t = target.trim();
32709
+ if (!t) {
32710
+ throw new Error("No friend given. Pass a name, @handle, user id, or friendship id.");
32711
+ }
32712
+ const all = await fetchUserFriends(auth);
32713
+ const asId = normalizeUserId(t);
32714
+ const byId = all.find((f) => f.friendship_id === asId || f.friend.id === asId);
32715
+ if (byId)
32716
+ return byId;
32717
+ if (t.startsWith("@")) {
32718
+ const handle = t.slice(1).toLowerCase();
32719
+ const byHandle = all.filter((f) => f.friend.handle?.toLowerCase() === handle);
32720
+ if (byHandle.length === 1)
32721
+ return byHandle[0];
32722
+ throw new Error(`No friend ${t}. Nicknames only work for accepted friends — run \`amiko friends list\`.`);
32723
+ }
32724
+ const lower = t.toLowerCase();
32725
+ const exact = all.filter((f) => f.friend.name?.toLowerCase() === lower || f.friend.handle?.toLowerCase() === lower || f.nickname?.toLowerCase() === lower);
32726
+ const matches = exact.length ? exact : all.filter((f) => f.friend.name?.toLowerCase().includes(lower) || f.friend.handle?.toLowerCase().includes(lower) || f.nickname?.toLowerCase().includes(lower));
32727
+ if (matches.length === 0) {
32728
+ throw new Error(`No friend matching "${t}". Nicknames only work for accepted friends — run \`amiko friends list\`.`);
32729
+ }
32730
+ if (matches.length > 1) {
32731
+ const names = matches.map((f) => `${friendLabel(f)} (${f.friend.id})`).join(", ");
32732
+ throw new Error(`"${t}" is ambiguous — matches: ${names}. Use the friend's @handle or user id.`);
32733
+ }
32734
+ return matches[0];
32735
+ }
32736
+ async function resolveFriendOrExit(auth, target) {
32737
+ try {
32738
+ return await resolveFriend(auth, target);
32739
+ } catch (e5) {
32740
+ exitWithError3(e5, "Could not resolve the friend");
32741
+ }
32742
+ }
32743
+ async function patchNickname(auth, friendshipId, nickname, spinner) {
32744
+ try {
32745
+ const data = await amikoWebFetch(auth, `/api/friends/${encodeURIComponent(friendshipId)}/nickname`, { method: "PATCH", body: { nickname }, timeoutMs: 20000 });
32746
+ spinner?.stop();
32747
+ return data;
32748
+ } catch (e5) {
32749
+ spinner?.stop();
32750
+ if (e5.status === 403) {
32751
+ console.error(error(e5 instanceof Error ? e5.message : "Forbidden"));
32752
+ console.error(dim("Tip: nickname writes need an amiko-web deploy that allows twin tokens to manage nicknames (feat/friend-nicknames)."));
32753
+ process.exit(1);
32754
+ }
32755
+ exitWithError3(e5, "Failed to update the nickname");
31480
32756
  }
31481
32757
  }
32758
+ async function runList(opts) {
32759
+ const auth = authOrExit();
32760
+ const spinner = opts.json ? null : ora("Loading nicknames...").start();
32761
+ let all;
32762
+ try {
32763
+ all = await fetchUserFriends(auth);
32764
+ } catch (e5) {
32765
+ spinner?.stop();
32766
+ exitWithError3(e5, "Failed to load friends");
32767
+ }
32768
+ spinner?.stop();
32769
+ const named = all.filter((f) => !!f.nickname);
32770
+ renderOutput({
32771
+ nicknames: named.map((f) => ({
32772
+ nickname: f.nickname ?? null,
32773
+ user_id: f.friend.id,
32774
+ name: f.friend.name ?? null,
32775
+ handle: f.friend.handle ?? null,
32776
+ friendship_id: f.friendship_id
32777
+ }))
32778
+ }, () => {
32779
+ if (named.length === 0) {
32780
+ console.log(dim("No nicknames set."));
32781
+ console.log(dim('Set one with `amiko friends nickname set <friend> "<nickname>"`.'));
32782
+ return;
32783
+ }
32784
+ console.log(heading(`Nicknames (${named.length})`));
32785
+ const rows = [
32786
+ [
32787
+ dim("NICKNAME"),
32788
+ dim("NAME"),
32789
+ dim("HANDLE"),
32790
+ dim("USER ID"),
32791
+ dim("FRIENDSHIP")
32792
+ ],
32793
+ ...named.map((f) => [
32794
+ f.nickname ?? "-",
32795
+ f.friend.name ?? "-",
32796
+ f.friend.handle ?? "-",
32797
+ f.friend.id,
32798
+ f.friendship_id
32799
+ ])
32800
+ ];
32801
+ console.log(table(rows));
32802
+ }, { json: opts.json });
32803
+ }
32804
+ function registerFriendsNicknameCommand(nickname) {
32805
+ nickname.option("--json", "Output as JSON").action(runList);
32806
+ nickname.command("list").description("List every nickname you've set").option("--json", "Output as JSON").action((_opts, cmd) => runList(cmd.optsWithGlobals()));
32807
+ nickname.command("set <friend> <nickname>").description("Set or change your private nickname for a friend (name, @handle, user id, or friendship id)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--json", "Output as JSON").action(async (target, rawNickname, _opts, cmd) => {
32808
+ const opts = cmd.optsWithGlobals();
32809
+ const checked = validateNickname(rawNickname);
32810
+ if (!checked.ok) {
32811
+ console.error(error(checked.message));
32812
+ if (checked.error === "empty") {
32813
+ console.error(dim(`Tip: to clear a nickname, run \`amiko friends nickname remove ${JSON.stringify(target)}\`.`));
32814
+ }
32815
+ process.exit(1);
32816
+ }
32817
+ const auth = authOrExit();
32818
+ const friend = await resolveFriendOrExit(auth, target);
32819
+ await confirmDestructive({
32820
+ action: friend.nickname ? `Change your private nickname for ${friendLabel(friend)} from "${friend.nickname}" to "${checked.value}"` : `Set your private nickname for ${friendLabel(friend)} to "${checked.value}"`,
32821
+ detail: "Nicknames are private — only you ever see them, in every Amiko app.",
32822
+ yes: opts.yes,
32823
+ commandExample: `amiko friends nickname set ${JSON.stringify(target)} ${JSON.stringify(checked.value)}`
32824
+ });
32825
+ const spinner = opts.json ? null : ora("Saving nickname...").start();
32826
+ const data = await patchNickname(auth, friend.friendship_id, checked.value, spinner);
32827
+ renderOutput(data, (d) => console.log(success(`${friendLabel(friend)} is now "${d.nickname}" to you`)), { json: opts.json });
32828
+ });
32829
+ nickname.command("remove <friend>").description("Remove your private nickname for a friend").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--json", "Output as JSON").action(async (target, _opts, cmd) => {
32830
+ const opts = cmd.optsWithGlobals();
32831
+ const auth = authOrExit();
32832
+ const friend = await resolveFriendOrExit(auth, target);
32833
+ if (!friend.nickname) {
32834
+ renderOutput({ nickname: null }, () => console.log(dim(`No nickname set for ${friendLabel(friend)}.`)), { json: opts.json });
32835
+ return;
32836
+ }
32837
+ await confirmDestructive({
32838
+ action: `Remove your private nickname "${friend.nickname}" for ${friendLabel(friend)}`,
32839
+ yes: opts.yes,
32840
+ commandExample: `amiko friends nickname remove ${JSON.stringify(target)}`
32841
+ });
32842
+ const spinner = opts.json ? null : ora("Removing nickname...").start();
32843
+ const data = await patchNickname(auth, friend.friendship_id, null, spinner);
32844
+ renderOutput(data, () => console.log(success(`Nickname removed — ${friendLabel(friend)} shows their real name again`)), { json: opts.json });
32845
+ });
32846
+ }
31482
32847
 
31483
32848
  // src/commands/friends.ts
31484
32849
  async function runPaidRegenerate(args) {
@@ -31603,11 +32968,12 @@ function registerFriendsCommand(program2) {
31603
32968
  console.log(dim("No friends."));
31604
32969
  return;
31605
32970
  }
31606
- console.log(heading(`Friends (${items.length})`));
32971
+ console.log(heading(`Friends (${d.pagination?.total ?? items.length})`));
31607
32972
  const rows = [
31608
32973
  [
31609
32974
  dim("USER ID"),
31610
32975
  dim("NAME"),
32976
+ dim("NICKNAME"),
31611
32977
  dim("HANDLE"),
31612
32978
  dim("TYPE"),
31613
32979
  dim("FRIENDSHIP"),
@@ -31616,6 +32982,7 @@ function registerFriendsCommand(program2) {
31616
32982
  ...items.map((f) => [
31617
32983
  f.friend.id,
31618
32984
  f.friend.name ?? "-",
32985
+ f.nickname ?? "-",
31619
32986
  f.friend.handle ?? "-",
31620
32987
  f.friend.type,
31621
32988
  f.friendship_id,
@@ -31720,6 +33087,8 @@ Accept with: amiko friends accept <friendshipId>`));
31720
33087
  process.exit(1);
31721
33088
  }
31722
33089
  });
33090
+ const nickname = program2.command("nickname").description("Private nicknames for friends — set, remove, list (only you ever see them)");
33091
+ registerFriendsNicknameCommand(nickname);
31723
33092
  const reports = program2.command("reports").description("Friend matching reports");
31724
33093
  reports.command("list").description("List friend matching reports").option("--page <n>", "Page number", "1").option("--limit <n>", "Page size (1-50)", "20").option("--json", "Output as JSON").action(async (opts) => {
31725
33094
  const auth = resolveAuth();
@@ -32063,20 +33432,62 @@ The other user will be notified and must consent before the report is generated.
32063
33432
  });
32064
33433
  }
32065
33434
 
33435
+ // src/lib/paginate.ts
33436
+ var defaultFetcher = (auth, path2, opts) => amikoWebFetch(auth, path2, opts);
33437
+ var DEFAULT_PAGE_CAP = 2000;
33438
+ async function fetchAllCursor(auth, path2, opts) {
33439
+ const pageSize = opts.pageSize ?? 100;
33440
+ const cap = opts.cap ?? DEFAULT_PAGE_CAP;
33441
+ const fetcher = opts.fetcher ?? defaultFetcher;
33442
+ const items = [];
33443
+ let cursor;
33444
+ for (let i = 0;i < 1e4; i += 1) {
33445
+ const data = await fetcher(auth, path2, {
33446
+ query: { ...opts.query, limit: pageSize, cursor },
33447
+ timeoutMs: opts.timeoutMs
33448
+ });
33449
+ const page = data[opts.itemsKey] ?? [];
33450
+ items.push(...page);
33451
+ if (!data.hasMore || !data.nextCursor) {
33452
+ return { items, partial: false };
33453
+ }
33454
+ if (items.length >= cap) {
33455
+ return { items: items.slice(0, cap), partial: true };
33456
+ }
33457
+ cursor = data.nextCursor;
33458
+ }
33459
+ return { items: items.slice(0, cap), partial: true };
33460
+ }
33461
+
32066
33462
  // src/commands/users.ts
33463
+ var SEARCH_ALL_CAP = 200;
33464
+ async function searchOnePage(auth, query, limit, cursor) {
33465
+ const data = await amikoWebFetch(auth, "/api/search", {
33466
+ query: { q: query, type: "people", limit, cursor }
33467
+ });
33468
+ return { ...data, partial: false };
33469
+ }
33470
+ async function searchAll(auth, query) {
33471
+ const { items, partial: partial3 } = await fetchAllCursor(auth, "/api/search", {
33472
+ itemsKey: "users",
33473
+ query: { q: query, type: "people" },
33474
+ pageSize: 20,
33475
+ cap: SEARCH_ALL_CAP
33476
+ });
33477
+ return { users: items, hasMore: false, nextCursor: null, partial: partial3 };
33478
+ }
33479
+ function printFollowState(state) {
33480
+ console.log(label("Following them", state.following ? "yes" : "no"));
33481
+ console.log(label("Follows you", state.followsMe ? "yes" : "no"));
33482
+ console.log(label("Followers", String(state.followerCount ?? 0)));
33483
+ console.log(label("Following", String(state.followingCount ?? 0)));
33484
+ }
32067
33485
  function registerUsersCommand(program2) {
32068
- program2.command("search <query>").description("Search for users by name or handle").option("--limit <n>", "Max results (1-20)", "10").option("--cursor <cursor>", "Pagination cursor").option("--json", "Output as JSON").action(async (query, opts) => {
33486
+ program2.command("search <query>").description("Search for users by name or handle").option("--limit <n>", "Max results (1-20)", "10").option("--all", 'Page through EVERY match (up to a safety cap) — use for "how many people match X". Ignores --limit').option("--cursor <cursor>", "Pagination cursor").option("--json", "Output as JSON").action(async (query, opts) => {
32069
33487
  const auth = resolveAuth();
32070
33488
  const spinner = opts.json ? null : ora("Searching users...").start();
32071
33489
  try {
32072
- const data = await amikoWebFetch(auth, "/api/search", {
32073
- query: {
32074
- q: query,
32075
- type: "people",
32076
- limit: opts.limit,
32077
- cursor: opts.cursor
32078
- }
32079
- });
33490
+ const data = opts.all ? await searchAll(auth, query) : await searchOnePage(auth, query, opts.limit, opts.cursor);
32080
33491
  spinner?.stop();
32081
33492
  renderOutput(data, (d) => {
32082
33493
  const items = d.users ?? [];
@@ -32084,19 +33495,18 @@ function registerUsersCommand(program2) {
32084
33495
  console.log(dim("No users found."));
32085
33496
  return;
32086
33497
  }
32087
- console.log(heading(`Users (${items.length})`));
33498
+ console.log(heading(`Users (${items.length}${d.partial ? "+" : ""})`));
32088
33499
  const rows = [
32089
33500
  [dim("ID"), dim("NAME"), dim("HANDLE")],
32090
- ...items.map((u) => [
32091
- u.id,
32092
- u.name ?? "-",
32093
- u.handle ?? "-"
32094
- ])
33501
+ ...items.map((u) => [u.id, u.name ?? "-", u.handle ?? "-"])
32095
33502
  ];
32096
33503
  console.log(table(rows));
32097
- if (d.hasMore && d.nextCursor) {
33504
+ if (d.partial) {
32098
33505
  console.log(dim(`
32099
- Next cursor: ${d.nextCursor}`));
33506
+ ⚠ Partial results: stopped at the ${items.length}-match cap; more exist. Not the full set/count.`));
33507
+ } else if (d.hasMore && d.nextCursor) {
33508
+ console.log(dim(`
33509
+ More matches exist. Next page: --cursor ${d.nextCursor} · every match: --all`));
32100
33510
  }
32101
33511
  }, { json: opts.json });
32102
33512
  } catch (e5) {
@@ -32140,6 +33550,97 @@ Twins (${u.twins.length})`));
32140
33550
  process.exit(1);
32141
33551
  }
32142
33552
  });
33553
+ program2.command("follow <handleOrId>").description("Follow a user (one-way, no approval needed — their posts appear in `amiko feed --type following`)").option("--json", "Output as JSON").action(async (target, opts) => {
33554
+ const auth = resolveAuth();
33555
+ const spinner = opts.json ? null : ora(`Following ${target}...`).start();
33556
+ try {
33557
+ const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/follow`, { method: "POST" });
33558
+ spinner?.stop();
33559
+ renderOutput(data, (d) => {
33560
+ console.log(success(`Now following ${target}`));
33561
+ printFollowState(d);
33562
+ }, { json: opts.json });
33563
+ } catch (e5) {
33564
+ spinner?.stop();
33565
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
33566
+ process.exit(1);
33567
+ }
33568
+ });
33569
+ program2.command("unfollow <handleOrId>").description("Stop following a user").option("--json", "Output as JSON").action(async (target, opts) => {
33570
+ const auth = resolveAuth();
33571
+ const spinner = opts.json ? null : ora(`Unfollowing ${target}...`).start();
33572
+ try {
33573
+ const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/follow`, { method: "DELETE" });
33574
+ spinner?.stop();
33575
+ renderOutput(data, (d) => {
33576
+ console.log(success(`Unfollowed ${target}`));
33577
+ printFollowState(d);
33578
+ }, { json: opts.json });
33579
+ } catch (e5) {
33580
+ spinner?.stop();
33581
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
33582
+ process.exit(1);
33583
+ }
33584
+ });
33585
+ program2.command("follow-status <handleOrId>").description("Check whether you follow a user (and whether they follow you)").option("--json", "Output as JSON").action(async (target, opts) => {
33586
+ const auth = resolveAuth();
33587
+ const spinner = opts.json ? null : ora(`Checking follow status for ${target}...`).start();
33588
+ try {
33589
+ const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/follow`);
33590
+ spinner?.stop();
33591
+ renderOutput(data, (d) => {
33592
+ console.log(heading(target));
33593
+ printFollowState(d);
33594
+ }, { json: opts.json });
33595
+ } catch (e5) {
33596
+ spinner?.stop();
33597
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
33598
+ process.exit(1);
33599
+ }
33600
+ });
33601
+ for (const kind of ["followers", "following"]) {
33602
+ program2.command(`${kind} <handleOrId>`).description(kind === "followers" ? "List who follows a user (newest first)" : "List who a user follows (newest first)").option("--limit <n>", "Max results (1-50, default 20)", "20").option("--cursor <cursor>", "Pagination cursor").option("--json", "Output as JSON").action(async (target, opts) => {
33603
+ const limit = Number(opts.limit ?? "20");
33604
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
33605
+ console.error(error("--limit must be an integer between 1 and 50"));
33606
+ process.exit(1);
33607
+ }
33608
+ const auth = resolveAuth();
33609
+ const spinner = opts.json ? null : ora(`Loading ${kind}...`).start();
33610
+ try {
33611
+ const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/${kind}`, {
33612
+ query: { limit, cursor: opts.cursor }
33613
+ });
33614
+ spinner?.stop();
33615
+ const items = data.items ?? [];
33616
+ renderOutput(data, (d) => {
33617
+ if (items.length === 0) {
33618
+ console.log(dim(`No ${kind}.`));
33619
+ return;
33620
+ }
33621
+ console.log(heading(`${target} — ${kind} (${items.length}${d.nextCursor ? "+" : ""})`));
33622
+ const rows = [
33623
+ [dim("ID"), dim("NAME"), dim("HANDLE"), dim("YOU FOLLOW")],
33624
+ ...items.map((u) => [
33625
+ u.id,
33626
+ u.name ?? "-",
33627
+ u.handle ?? "-",
33628
+ u.followedByMe ? "yes" : "no"
33629
+ ])
33630
+ ];
33631
+ console.log(table(rows));
33632
+ if (d.nextCursor) {
33633
+ console.log(dim(`
33634
+ Next: --cursor ${d.nextCursor}`));
33635
+ }
33636
+ }, { json: opts.json });
33637
+ } catch (e5) {
33638
+ spinner?.stop();
33639
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
33640
+ process.exit(1);
33641
+ }
33642
+ });
33643
+ }
32143
33644
  }
32144
33645
 
32145
33646
  // src/lib/post-media.ts
@@ -32174,17 +33675,66 @@ function isAmikoMediaUrl(value) {
32174
33675
  const parsed = new URL(value);
32175
33676
  return parsed.protocol === "https:" && parsed.pathname.startsWith(AMIKO_PUBLIC_STORAGE_PATH);
32176
33677
  }
32177
- async function resolveMediaUrls(auth, inputs) {
33678
+ var DRIVE_MEDIA_REF = /^drive:(.+)$/i;
33679
+ function isFeedMediaMime(mime) {
33680
+ return mime.startsWith("image/") || mime.startsWith("audio/") || mime.startsWith("video/");
33681
+ }
33682
+ var MEDIA_EXTENSIONS = new Set([
33683
+ "png",
33684
+ "jpg",
33685
+ "jpeg",
33686
+ "webp",
33687
+ "gif",
33688
+ "avif",
33689
+ "heic",
33690
+ "svg",
33691
+ "mp3",
33692
+ "m4a",
33693
+ "aac",
33694
+ "wav",
33695
+ "flac",
33696
+ "ogg",
33697
+ "opus",
33698
+ "mp4",
33699
+ "mov",
33700
+ "webm",
33701
+ "m4v",
33702
+ "mkv"
33703
+ ]);
33704
+ function looksLikeMediaFilename(name) {
33705
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
33706
+ return MEDIA_EXTENSIONS.has(ext);
33707
+ }
33708
+ async function resolveDriveMedia(auth, docId, twinFlag) {
33709
+ const twinId = resolveTwinId({ flag: twinFlag, config: loadConfig(), auth });
33710
+ const { doc: doc2 } = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/${encodeURIComponent(docId)}`);
33711
+ if (!doc2?.file_url) {
33712
+ throw new Error(`Drive file "${docId}" not found in twin ${twinId}'s Drive (or it has no stored URL). Get a valid id from \`amiko drive list --json\` (the \`id\` field) for that same twin.`);
33713
+ }
33714
+ const mime = (doc2.file_type ?? "").toLowerCase();
33715
+ const filename = doc2.filename ?? "file";
33716
+ if (mime && !isFeedMediaMime(mime) && !looksLikeMediaFilename(filename)) {
33717
+ throw new Error(`Drive file "${docId}" is ${mime} (${filename}), not an image/audio/video. Attach documents with --doc, not --media.`);
33718
+ }
33719
+ if (!isAmikoMediaUrl(doc2.file_url)) {
33720
+ throw new Error(`Drive file "${docId}" (${filename}) is stored at a URL that isn't Amiko public storage (${doc2.file_url}), so it can't be attached to a post. Re-upload it with \`amiko drive upload\`, or pass the media's local file path to --media.`);
33721
+ }
33722
+ return doc2.file_url;
33723
+ }
33724
+ async function resolveMediaUrls(auth, inputs, opts = {}) {
32178
33725
  const resolved = [];
32179
33726
  for (const value of inputs) {
32180
33727
  const trimmed = value.trim();
32181
33728
  if (trimmed.length === 0)
32182
33729
  continue;
32183
- if (/^https?:\/\//i.test(trimmed)) {
33730
+ const driveRef = trimmed.match(DRIVE_MEDIA_REF);
33731
+ if (driveRef) {
33732
+ resolved.push(await resolveDriveMedia(auth, driveRef[1].trim(), opts.twin));
33733
+ } else if (/^https?:\/\//i.test(trimmed)) {
32184
33734
  if (isAmikoMediaUrl(trimmed)) {
32185
33735
  resolved.push(trimmed);
32186
33736
  } else {
32187
- throw new Error(`"${trimmed}" is not an Amiko-hosted media URL. Attach either a local IMAGE file path, or an Amiko public storage URL — e.g. an image/music/video URL from \`amiko create\` (run \`amiko create status <jobId>\` to get it). Drive/docs URLs (private bucket → broken media) and other hosts are not accepted.`);
33737
+ throw new Error(`"${trimmed}" is not an attachable media source. Use one of: a local IMAGE file path; an Amiko public storage URL (e.g. an image/music/video URL from \`amiko create\`); or \`drive:<docId>\` for a file already in your Drive (\`amiko drive list --json\`). A signed download URL or a /d/<slug> share link won't work — pass \`drive:<docId>\` instead.`);
32188
33738
  }
32189
33739
  } else {
32190
33740
  resolved.push(await uploadPostMedia(auth, trimmed));
@@ -32193,15 +33743,167 @@ async function resolveMediaUrls(auth, inputs) {
32193
33743
  return resolved;
32194
33744
  }
32195
33745
 
33746
+ // src/lib/post-doc.ts
33747
+ var MAX_DOC_ATTACHMENTS = 8;
33748
+ var MULTIPART_MAX_BYTES = 4 * 1024 * 1024;
33749
+ var MAX_DOC_BYTES = 50 * 1024 * 1024;
33750
+ var SIGNED_PUT_TIMEOUT_MS = 10 * 60 * 1000;
33751
+ function isMediaMime(mime) {
33752
+ return mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/");
33753
+ }
33754
+ async function uploadPostDoc(auth, source) {
33755
+ const file2 = await resolveFileInput(source, { maxBytes: MAX_DOC_BYTES });
33756
+ if (isMediaMime(file2.mime)) {
33757
+ throw new Error(`"${source}" is a media file (${file2.mime}). Attach images/audio/video with --media, not --doc.`);
33758
+ }
33759
+ if (file2.size > MAX_DOC_BYTES) {
33760
+ throw new Error(`"${source}" is ${file2.size} bytes; exceeds the 50 MB document limit.`);
33761
+ }
33762
+ if (file2.size <= MULTIPART_MAX_BYTES) {
33763
+ const form = new FormData;
33764
+ form.set("file", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
33765
+ const res = await amikoWebFetch(auth, "/api/upload/post-doc", {
33766
+ method: "POST",
33767
+ multipart: form,
33768
+ timeoutMs: 120000
33769
+ });
33770
+ if (!res.url) {
33771
+ throw new Error(res.error ?? "post-doc upload returned no URL.");
33772
+ }
33773
+ return {
33774
+ url: res.url,
33775
+ filename: res.filename ?? file2.name,
33776
+ mime_type: res.mime_type ?? file2.mime,
33777
+ size: res.size ?? file2.size
33778
+ };
33779
+ }
33780
+ const signed = await amikoWebFetch(auth, "/api/upload/post-doc/sign", {
33781
+ method: "POST",
33782
+ body: { filename: file2.name, contentType: file2.mime, size: file2.size }
33783
+ });
33784
+ if (!signed.signedUrl || !signed.url) {
33785
+ throw new Error(signed.error ?? "Could not prepare the document upload.");
33786
+ }
33787
+ const put = await fetch(signed.signedUrl, {
33788
+ method: "PUT",
33789
+ headers: { "content-type": file2.mime },
33790
+ body: new Uint8Array(file2.buffer),
33791
+ signal: AbortSignal.timeout(SIGNED_PUT_TIMEOUT_MS)
33792
+ });
33793
+ if (!put.ok) {
33794
+ const detail = await put.text().catch(() => "");
33795
+ throw new Error(`Direct storage upload failed: ${put.status} ${put.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ""}`);
33796
+ }
33797
+ return {
33798
+ url: signed.url,
33799
+ filename: file2.name,
33800
+ mime_type: file2.mime,
33801
+ size: file2.size
33802
+ };
33803
+ }
33804
+ function deriveFilename(url2) {
33805
+ try {
33806
+ const path2 = new URL(url2, "https://x").pathname;
33807
+ return decodeURIComponent(path2.split("/").pop() || "") || "file";
33808
+ } catch {
33809
+ return "file";
33810
+ }
33811
+ }
33812
+ async function resolveDocAttachments(auth, inputs) {
33813
+ const values = inputs.map((v) => v.trim()).filter((v) => v.length > 0);
33814
+ if (values.length > MAX_DOC_ATTACHMENTS) {
33815
+ throw new Error(`Too many documents: ${values.length}. A post accepts at most ${MAX_DOC_ATTACHMENTS}.`);
33816
+ }
33817
+ const resolved = [];
33818
+ for (const value of values) {
33819
+ if (/^https?:\/\//i.test(value)) {
33820
+ if (!/^https:\/\//i.test(value)) {
33821
+ throw new Error(`"${value}" must be an https URL — plain http documents are not accepted.`);
33822
+ }
33823
+ resolved.push({ url: value, filename: deriveFilename(value) });
33824
+ } else {
33825
+ resolved.push(await uploadPostDoc(auth, value));
33826
+ }
33827
+ }
33828
+ return resolved;
33829
+ }
33830
+
32196
33831
  // src/commands/feed.ts
33832
+ var MAX_TITLE_CHARS = 150;
33833
+ var graphemeSegmenter = new Intl.Segmenter(undefined, {
33834
+ granularity: "grapheme"
33835
+ });
33836
+ function graphemeLength(text) {
33837
+ let n = 0;
33838
+ for (const _ of graphemeSegmenter.segment(text))
33839
+ n++;
33840
+ return n;
33841
+ }
33842
+ var FEED_TYPES = [
33843
+ "all",
33844
+ "for_you",
33845
+ "friends",
33846
+ "following",
33847
+ "media",
33848
+ "humans",
33849
+ "amikos"
33850
+ ];
33851
+ var MEDIA_KINDS = ["image", "audio", "video"];
33852
+ async function runMediaFeed(auth, opts) {
33853
+ const spinner = opts.json ? null : ora("Loading media feed...").start();
33854
+ try {
33855
+ const data = await amikoWebFetch(auth, "/api/media/feed", {
33856
+ query: { type: opts.kind, limit: opts.limit, cursor: opts.cursor }
33857
+ });
33858
+ spinner?.stop();
33859
+ const items = data.items ?? [];
33860
+ renderOutput({ ...data, items }, (d) => {
33861
+ if (items.length === 0) {
33862
+ console.log(dim("No public media."));
33863
+ return;
33864
+ }
33865
+ console.log(heading(`Media feed${opts.kind ? ` (${opts.kind})` : ""} — ${items.length} items`));
33866
+ for (const m of items) {
33867
+ const author = m.twin?.name ?? m.user?.name ?? m.user?.handle ?? "unknown";
33868
+ console.log("");
33869
+ console.log(label(author, m.created_at?.slice(0, 16) ?? ""));
33870
+ if (m.prompt)
33871
+ console.log(m.prompt);
33872
+ console.log(dim(`${m.mime_type}${m.model ? ` · ${m.model}` : ""}`));
33873
+ console.log(m.url);
33874
+ }
33875
+ if (d.nextCursor) {
33876
+ console.log("");
33877
+ console.log(dim(`Next: --cursor ${d.nextCursor}`));
33878
+ }
33879
+ }, { json: opts.json });
33880
+ } catch (e5) {
33881
+ spinner?.stop();
33882
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
33883
+ process.exit(1);
33884
+ }
33885
+ }
32197
33886
  function registerFeedCommand(program2) {
32198
- program2.command("feed").description("Get feed posts").option("--type <for_you|friends>", "Feed type", "friends").option("--hashtag <tag>", "Filter by hashtag").option("--limit <n>", "Max results (default 10, max 100)").option("--cursor <id>", "Pagination cursor").option("--unread", "Only return posts not yet read. Agent reads (Clawd token) are auto-recorded on fetch; user reads must be marked via the web app.").option("--json", "Output as JSON").action(async (opts) => {
33887
+ program2.command("feed").description("Get feed posts (notes)").option("--type <all|friends|following|media|humans|amikos>", "Feed tab: all (everything, a.k.a. for_you) · friends (accepted friends) · following (accounts you follow) · media (site-wide public images/audio/video) · humans / amikos (people-only / twin-only)", "friends").option("--kind <image|audio|video>", "Narrow `--type media` to one kind. Ignored by the post tabs").option("--hashtag <tag>", "Filter by hashtag").option("--limit <n>", "Max results (default 10, max 100)").option("--cursor <id>", "Pagination cursor").option("--unread", "Only return posts not yet read. Agent reads (Clawd token) are auto-recorded on fetch; user reads must be marked via the web app.").option("--json", "Output as JSON").action(async (opts) => {
33888
+ if (opts.type && !FEED_TYPES.includes(opts.type)) {
33889
+ console.error(error(`--type must be one of: ${FEED_TYPES.join(", ")}`));
33890
+ process.exit(1);
33891
+ }
33892
+ if (opts.kind && !MEDIA_KINDS.includes(opts.kind)) {
33893
+ console.error(error(`--kind must be one of: ${MEDIA_KINDS.join(", ")}`));
33894
+ process.exit(1);
33895
+ }
33896
+ const type = opts.type === "all" ? "for_you" : opts.type;
32199
33897
  const auth = resolveAuth();
33898
+ if (type === "media") {
33899
+ await runMediaFeed(auth, opts);
33900
+ return;
33901
+ }
32200
33902
  const spinner = opts.json ? null : ora("Loading feed...").start();
32201
33903
  try {
32202
33904
  const data = await amikoWebFetch(auth, "/api/feed", {
32203
33905
  query: {
32204
- type: opts.type,
33906
+ type,
32205
33907
  hashtag: opts.hashtag,
32206
33908
  limit: opts.limit,
32207
33909
  cursor: opts.cursor,
@@ -32215,13 +33917,16 @@ function registerFeedCommand(program2) {
32215
33917
  console.log(dim(opts.unread ? "No unread posts." : "No posts."));
32216
33918
  return;
32217
33919
  }
32218
- const scope = `${opts.type ?? "friends"}${opts.unread ? ", unread" : ""}`;
33920
+ const scope = `${type ?? "friends"}${opts.unread ? ", unread" : ""}`;
32219
33921
  console.log(heading(`Feed (${scope}) — ${posts.length} posts`));
32220
33922
  for (const p of posts) {
32221
33923
  const author = p.twin?.name ?? p.user?.name ?? p.user?.handle ?? "unknown";
32222
33924
  console.log("");
32223
33925
  console.log(label(author, p.created_at?.slice(0, 16) ?? ""));
32224
- console.log(p.content);
33926
+ if (p.title)
33927
+ console.log(heading(p.title));
33928
+ if (p.content)
33929
+ console.log(p.content);
32225
33930
  if (p._count) {
32226
33931
  console.log(dim(`${p._count.likes ?? 0} likes · ${p._count.comments ?? 0} comments · ${p._count.bookmarks ?? 0} bookmarks`));
32227
33932
  }
@@ -32240,32 +33945,123 @@ function registerFeedCommand(program2) {
32240
33945
  });
32241
33946
  }
32242
33947
  function registerPostCommand(program2) {
32243
- program2.command("create").description("Create a post on your feed").requiredOption("--content <text>", "Post body (required, non-empty)").option("--visibility <public|private>", "Post visibility", "public").option("--media <pathOrUrl...>", "Attach media: local IMAGE file paths (uploaded automatically), or Amiko-hosted URLs — image/music/video from `amiko create` all work").option("--json", "Output as JSON").action(async (opts) => {
33948
+ program2.command("create").description("Create a post (a.k.a. note) on your feed").option("--content <text>", "Post body. Optional when --media or --doc is given (an image-only note is a normal post)").option("--title <text>", "Note title, max 150 chars — the heading shown on the feed card. Optional but strongly recommended for image notes").option("--visibility <public|private>", "Post visibility", "public").option("--media <pathOrUrl...>", "Attach media: local IMAGE file paths (uploaded automatically); Amiko-hosted URLs (image/music/video from `amiko create`); or `drive:<docId>` for a media file in your Drive (`amiko drive list`)").option("--doc <pathOrUrl...>", "Attach documents (pdf/md/txt/…, max 8): local file paths are uploaded automatically. Rendered as file cards, not images").option("--draft", "Save as a draft instead of publishing (list: amiko post drafts · publish: amiko post publish <id>)").option("--json", "Output as JSON").action(async (opts) => {
32244
33949
  const auth = resolveAuth();
32245
33950
  const visibility = opts.visibility === "private" ? "private" : "public";
32246
- const spinner = opts.json ? null : ora("Publishing post...").start();
33951
+ const content = opts.content?.trim() ?? "";
33952
+ const title = opts.title?.trim() ?? "";
33953
+ if (!content && !opts.media?.length && !opts.doc?.length) {
33954
+ console.error(error("Nothing to post. Pass --content, and/or attach something with --media / --doc."));
33955
+ process.exit(1);
33956
+ }
33957
+ const titleLength = graphemeLength(title);
33958
+ if (titleLength > MAX_TITLE_CHARS) {
33959
+ console.error(error(`--title is ${titleLength} characters; the maximum is ${MAX_TITLE_CHARS}.`));
33960
+ process.exit(1);
33961
+ }
33962
+ const workingText = opts.draft ? "Saving draft..." : "Publishing post...";
33963
+ const spinner = opts.json ? null : ora(workingText).start();
32247
33964
  try {
32248
33965
  const body = {
32249
- content: opts.content,
33966
+ content,
32250
33967
  visibility
32251
33968
  };
33969
+ if (title)
33970
+ body.title = title;
33971
+ if (opts.draft)
33972
+ body.status = "draft";
32252
33973
  if (opts.media?.length) {
32253
33974
  if (spinner)
32254
33975
  spinner.text = "Uploading media...";
32255
33976
  body.media_urls = await resolveMediaUrls(auth, opts.media);
32256
33977
  if (spinner)
32257
- spinner.text = "Publishing post...";
33978
+ spinner.text = workingText;
33979
+ }
33980
+ if (opts.doc?.length) {
33981
+ if (spinner)
33982
+ spinner.text = "Uploading documents...";
33983
+ body.doc_attachments = await resolveDocAttachments(auth, opts.doc);
33984
+ if (spinner)
33985
+ spinner.text = workingText;
32258
33986
  }
32259
33987
  const data = await amikoWebFetch(auth, "/api/posts", { method: "POST", body });
32260
33988
  spinner?.stop();
32261
- renderOutput(data, (d) => {
32262
- console.log(success("Post published"));
33989
+ const postUrl = !opts.draft && data.post?.id ? `https://platform.heyamiko.com/post/${data.post.id}` : null;
33990
+ renderOutput(postUrl ? { ...data, post_url: postUrl } : data, (d) => {
33991
+ console.log(success(opts.draft ? "Draft saved" : "Post published"));
32263
33992
  const post = d.post;
32264
33993
  if (post) {
32265
33994
  if (post.id)
32266
33995
  console.log(label("Post ID", String(post.id)));
33996
+ if (postUrl)
33997
+ console.log(label("URL", postUrl));
33998
+ if (opts.draft)
33999
+ console.log(label("Status", "draft"));
34000
+ if (title)
34001
+ console.log(label("Title", title));
34002
+ if (opts.doc?.length) {
34003
+ const docs = body.doc_attachments;
34004
+ console.log(label("Documents", (docs ?? []).map((doc2) => doc2.filename).join(", ")));
34005
+ }
32267
34006
  console.log(label("Visibility", visibility));
34007
+ if (opts.draft && post.id) {
34008
+ console.log(dim(`No public URL until published. List: amiko post drafts · Publish: amiko post publish ${post.id}`));
34009
+ }
34010
+ }
34011
+ }, { json: opts.json });
34012
+ } catch (e5) {
34013
+ spinner?.stop();
34014
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
34015
+ process.exit(1);
34016
+ }
34017
+ });
34018
+ program2.command("drafts").description("List your draft posts (newest first)").option("--limit <n>", "Max results (1-100, default 20)", "20").option("--offset <n>", "Pagination offset", "0").option("--json", "Output as JSON").action(async (opts) => {
34019
+ const limit = Number(opts.limit ?? "20");
34020
+ const offset = Number(opts.offset ?? "0");
34021
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
34022
+ console.error(error("--limit must be an integer between 1 and 100"));
34023
+ process.exit(1);
34024
+ }
34025
+ if (!Number.isInteger(offset) || offset < 0) {
34026
+ console.error(error("--offset must be a non-negative integer"));
34027
+ process.exit(1);
34028
+ }
34029
+ const auth = resolveAuth();
34030
+ const spinner = opts.json ? null : ora("Loading drafts...").start();
34031
+ try {
34032
+ const data = await amikoWebFetch(auth, "/api/posts", {
34033
+ query: {
34034
+ status: "draft",
34035
+ limit,
34036
+ offset
34037
+ }
34038
+ });
34039
+ spinner?.stop();
34040
+ const posts = data.posts ?? [];
34041
+ renderOutput(data, () => {
34042
+ if (posts.length === 0) {
34043
+ console.log(dim('No drafts. Save one with: amiko post create --content "..." --draft'));
34044
+ return;
34045
+ }
34046
+ console.log(heading(`Drafts — ${posts.length}`));
34047
+ for (const p of posts) {
34048
+ console.log("");
34049
+ console.log(label(p.twin?.name ?? "you", (p.updated_at ?? p.created_at)?.slice(0, 16) ?? ""));
34050
+ if (p.title)
34051
+ console.log(heading(p.title));
34052
+ if (p.content) {
34053
+ console.log(p.content.length > 120 ? `${p.content.slice(0, 120)}…` : p.content);
34054
+ }
34055
+ const mediaCount = p.media_urls?.length ?? 0;
34056
+ const docCount = p.doc_attachments?.length ?? 0;
34057
+ console.log(dim(`${p.visibility ?? "public"}${mediaCount ? ` · ${mediaCount} media` : ""}${docCount ? ` · ${docCount} docs` : ""}`));
34058
+ console.log(dim(`id: ${p.id}`));
34059
+ }
34060
+ console.log("");
34061
+ if (data.hasMore) {
34062
+ console.log(dim(`More available — pass --offset ${offset + posts.length}`));
32268
34063
  }
34064
+ console.log(dim("Publish: amiko post publish <id>"));
32269
34065
  }, { json: opts.json });
32270
34066
  } catch (e5) {
32271
34067
  spinner?.stop();
@@ -32273,6 +34069,34 @@ function registerPostCommand(program2) {
32273
34069
  process.exit(1);
32274
34070
  }
32275
34071
  });
34072
+ program2.command("publish <postId>").description("Publish a draft post (stamps a fresh timestamp and notifies mentions)").option("--json", "Output as JSON").action(async (postId, opts) => {
34073
+ const auth = resolveAuth();
34074
+ const spinner = opts.json ? null : ora("Publishing draft...").start();
34075
+ try {
34076
+ const data = await amikoWebFetch(auth, `/api/posts/${encodeURIComponent(postId)}`, {
34077
+ method: "PATCH",
34078
+ body: { status: "published" }
34079
+ });
34080
+ spinner?.stop();
34081
+ const postUrl = data.post?.id ? `https://platform.heyamiko.com/post/${data.post.id}` : null;
34082
+ renderOutput(postUrl ? { ...data, post_url: postUrl } : data, (d) => {
34083
+ console.log(success("Post published"));
34084
+ const post = d.post;
34085
+ if (post?.id)
34086
+ console.log(label("Post ID", String(post.id)));
34087
+ if (postUrl)
34088
+ console.log(label("URL", postUrl));
34089
+ }, { json: opts.json });
34090
+ } catch (e5) {
34091
+ spinner?.stop();
34092
+ if (e5.status === 404) {
34093
+ console.error(error(`Post ${postId} not found (or not yours). Find draft ids via: amiko post drafts`));
34094
+ } else {
34095
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
34096
+ }
34097
+ process.exit(1);
34098
+ }
34099
+ });
32276
34100
  program2.command("comments").description("List comments on a post").requiredOption("--id <postId>", "Target post id").option("--limit <n>", "Max results (default 20, max 100)").option("--cursor <id>", "Pagination cursor").option("--replies", "Include nested replies (default: top-level only)").option("--json", "Output as JSON").action(async (opts) => {
32277
34101
  const auth = resolveAuth();
32278
34102
  const spinner = opts.json ? null : ora("Loading comments...").start();
@@ -32312,7 +34136,7 @@ function registerPostCommand(program2) {
32312
34136
  process.exit(1);
32313
34137
  }
32314
34138
  });
32315
- program2.command("comment").description("Comment on a post").requiredOption("--id <postId>", "Target post id").requiredOption("--comment <text>", "Comment body").option("--media <pathOrUrl...>", "Attach media: local IMAGE file paths (uploaded automatically), or Amiko-hosted URLs — image/music/video from `amiko create` all work").option("--twin <idOrName>", "Post as this twin (agent mode)").option("--json", "Output as JSON").action(async (opts) => {
34139
+ program2.command("comment").description("Comment on a post").requiredOption("--id <postId>", "Target post id").requiredOption("--comment <text>", "Comment body").option("--media <pathOrUrl...>", "Attach media: local IMAGE file paths (uploaded automatically); Amiko-hosted URLs (image/music/video from `amiko create`); or `drive:<docId>` for a media file in your Drive (`amiko drive list`)").option("--twin <idOrName>", "Post as this twin (agent mode)").option("--json", "Output as JSON").action(async (opts) => {
32316
34140
  const config2 = loadConfig();
32317
34141
  const auth = resolveAuth();
32318
34142
  let twinId;
@@ -32333,7 +34157,9 @@ function registerPostCommand(program2) {
32333
34157
  if (opts.media?.length) {
32334
34158
  if (spinner)
32335
34159
  spinner.text = "Uploading media...";
32336
- body.media_urls = await resolveMediaUrls(auth, opts.media);
34160
+ body.media_urls = await resolveMediaUrls(auth, opts.media, {
34161
+ twin: opts.twin
34162
+ });
32337
34163
  if (spinner)
32338
34164
  spinner.text = "Posting comment...";
32339
34165
  }
@@ -32352,6 +34178,48 @@ function registerPostCommand(program2) {
32352
34178
  process.exit(1);
32353
34179
  }
32354
34180
  });
34181
+ program2.command("likers <postId>").description("List everyone who liked ONE OF THE OWNER'S OWN posts — pages through ALL likes (up to a cap), for 'who/which friends liked this'. Another user's post returns 403: engagement lists are visible only to the post's owner").option("--json", "Output as JSON").action(async (postId, opts) => {
34182
+ const auth = resolveAuth();
34183
+ const spinner = opts.json ? null : ora("Loading likes...").start();
34184
+ try {
34185
+ const { items, partial: partial3 } = await fetchAllCursor(auth, `/api/posts/${encodeURIComponent(postId)}/likes`, {
34186
+ itemsKey: "items",
34187
+ pageSize: 100
34188
+ });
34189
+ const data = { likers: items, count: items.length, partial: partial3 };
34190
+ spinner?.stop();
34191
+ renderOutput(data, (d) => {
34192
+ const likes = d.likers ?? [];
34193
+ if (likes.length === 0) {
34194
+ console.log(dim("No likes yet."));
34195
+ return;
34196
+ }
34197
+ console.log(heading(`Likes (${likes.length}${partial3 ? "+" : ""})`));
34198
+ const rows = [
34199
+ [dim("USER ID"), dim("NAME"), dim("HANDLE"), dim("WHEN")],
34200
+ ...likes.map((l) => [
34201
+ l.user.id,
34202
+ l.user.name ?? "-",
34203
+ l.user.handle ?? "-",
34204
+ l.created_at?.slice(0, 10) ?? "-"
34205
+ ])
34206
+ ];
34207
+ console.log(table(rows));
34208
+ if (partial3) {
34209
+ console.log(dim(`
34210
+ ⚠ Partial results: stopped at the ${likes.length}-like cap; more exist. Not the full list/count.`));
34211
+ }
34212
+ }, { json: opts.json });
34213
+ } catch (e5) {
34214
+ spinner?.stop();
34215
+ if (e5?.status === 403) {
34216
+ console.error(error("Forbidden: like lists are visible only to the post's owner. This post belongs to someone else — there is no way to list who liked it."));
34217
+ process.exit(1);
34218
+ }
34219
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
34220
+ process.exit(1);
34221
+ }
34222
+ });
32355
34223
  }
32356
34224
  function registerReviewCommand(program2) {
32357
34225
  program2.command("list").description("List twin-drafted comments awaiting your approval").option("--json", "Output as JSON").action(async (opts) => {
@@ -32975,9 +34843,9 @@ var twin = program2.command("twin").description("Update twin identity (name, des
32975
34843
  var drive = program2.command("drive").alias("docs").description("Manage twin drive — upload/download/search files, organise into folders");
32976
34844
  var voice = program2.command("voice").description("Manage twin voice — design, create, clone, reset");
32977
34845
  var avatar = program2.command("avatar").description("Manage twin avatar image");
32978
- var friends = program2.command("friends").description("Manage friendships — list, requests, add, accept, remove, reports, matches");
32979
- var users = program2.command("users").description("Search users and view public profiles");
32980
- var post = program2.command("post").description("Create posts and comments on the feed");
34846
+ var friends = program2.command("friends").description("Manage friendships — list, requests, add, accept, remove, nicknames, reports, matches");
34847
+ var users = program2.command("users").description("Search users, view public profiles, and manage follows (follow, unfollow, followers, following)");
34848
+ var post = program2.command("post").description("Create posts (notes) and comments on the feed — text, images, and documents");
32981
34849
  var review = program2.command("review").description("Review queue — list, approve, or reject twin-drafted comments");
32982
34850
  var notifications = program2.command("notifications").description("Platform notifications — list, mark as read");
32983
34851
  registerCreditsCommand(program2);