@heyamiko/amiko-cli 0.14.0-beta.24 → 0.14.0-beta.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,6 +128,7 @@ amiko create image "logo" --aspect 16:9 --model gpt-image-2 # widescreen, Op
128
128
  amiko create image "a sunset" --pay credits --yes # pay with Amiko account credits (unified accounts)
129
129
  amiko create video "a whale in space" --resolution 768P --seconds 6
130
130
  amiko create video "animate cover" --first-frame https://.../cover.png --yes
131
+ amiko create video "a slow pan over dunes at dusk" --model MiniMax-H3 --seconds 12 --resolution 2K --aspect 21:9 --yes
131
132
  amiko create video "dance clip" --model dreamina-seedance-2-0-fast-260128 \
132
133
  --reference-image https://.../ref1.png --reference-audio https://.../beat.mp3 --generate-audio --yes
133
134
  amiko create tts "Hello world" --voice 21m00Tcm4TlvDq8ikWAM
@@ -135,7 +136,7 @@ amiko create tts "Hello world" --provider minimax --model speech-2.8-turbo
135
136
  amiko create tts "Hello" --provider minimax --voice English_expressive_narrator --yes
136
137
  amiko voice clone ./me.mp3 --provider minimax
137
138
  amiko voice clone ./me.mp3 --provider elevenlabs
138
- amiko create music "lo-fi chill beat" --duration 30000
139
+ amiko create music "lo-fi chill beat" --duration 30 # seconds
139
140
  amiko create music --lyrics "..." --instrumental # instrumental
140
141
  amiko create sfx "thunder and heavy rain" --duration 5
141
142
  amiko create image "banner" --raw # raw JSON
@@ -163,21 +164,23 @@ Modes: `image`, `video`, `tts`, `music`, `sfx` (mirrors the platform Create Stud
163
164
 
164
165
  | Flag | Default | Description |
165
166
  |------|---------|-------------|
166
- | `--model <model>` | smart | Video model (`MiniMax-Hailuo-*`, `dreamina-seedance-2-0-fast-260128`, …). Default: `MiniMax-Hailuo-02` for prompt-only (T2V), `MiniMax-Hailuo-2.3-Fast` when `--first-frame` is given (I2V) |
167
- | `--resolution <res>` | `768P` | `512P`, `720P`, `768P`, `1080P` |
168
- | `--seconds <n>` | `6` | Clip length: `6` or `10` |
169
- | `--aspect <ratio>` | — | Aspect ratio (provider-dependent) |
167
+ | `--model <model>` | smart | Video model (`MiniMax-Hailuo-*`, `MiniMax-H3`, `dreamina-seedance-2-0-fast-260128`, …). Default: `MiniMax-Hailuo-02` for prompt-only (T2V), `MiniMax-Hailuo-2.3-Fast` when `--first-frame` is given (I2V) |
168
+ | `--resolution <res>` | `768P` | `512P`, `720P`, `768P`, `1080P`; `MiniMax-H3` only `768P` or `2K` |
169
+ | `--seconds <n>` | `6` | Clip length: `6`/`10` (Hailuo), `4`/`6`/`8` (Veo), `4`/`6`/`8`/`10` (Grok, Seedance), any whole number `4`–`15` (`MiniMax-H3`) |
170
+ | `--aspect <ratio>` | — | Aspect ratio (provider-dependent). `MiniMax-H3` text-to-video needs a concrete ratio (`21:9`, `16:9`, `4:3`, `1:1`, `3:4`, `9:16`) and defaults to `16:9` when omitted |
170
171
  | `--first-frame <url>` | — | Image-to-video: first frame URL or data URI |
171
172
  | `--last-frame <url>` | — | First-and-last-frame video: ending frame URL or data URI |
172
- | `--reference-image <url>` | — | Seedance multimodal reference image (repeatable, max 9) |
173
- | `--reference-video <url>` | — | Seedance multimodal reference video (repeatable, max 3) |
174
- | `--reference-audio <url>` | — | Seedance multimodal reference audio (repeatable, max 3) |
175
- | `--subject-reference <url>` | — | MiniMax S2V subject/character reference image |
173
+ | `--reference-image <url>` | — | Seedance / `MiniMax-H3` reference image (repeatable, max 9; H3 surcharges images past the 5th) |
174
+ | `--reference-video <url>` | — | Seedance / `MiniMax-H3` reference video (repeatable, max 3; H3 bills the input seconds too) |
175
+ | `--reference-audio <url>` | — | Seedance / `MiniMax-H3` reference audio (repeatable, max 3; H3 needs an image or video alongside) |
176
+ | `--subject-reference <url>` | — | MiniMax S2V / H3 subject/character reference image |
176
177
  | `--generate-audio` | — | Seedance: generate audio in the output |
177
178
  | `--no-generate-audio` | — | Seedance: output silent video |
178
179
 
179
180
  Reference flags accept URLs or data URIs (not local paths). Typical I2V flow: `create image` → `create status <jobId>` for the image URL → `create video --first-frame <url>`.
180
181
 
182
+ **MiniMax-H3** (aliases `h3`, `hailuo-03`) is MiniMax's v2 video API and follows its own rules: a prompt is required in every mode (≤7000 chars), clips run any whole number of seconds from 4 to 15, only `768P` and `2K` exist, and it is billed **per second** (plus reference-video input seconds and a per-image surcharge past 5 reference images), so the quote scales with `--seconds`. It does text-to-video, first/last-frame image-to-video, and reference-to-video (`--reference-image`/`--reference-video`/`--reference-audio`), but a frame and a reference cannot be combined in one job. The CLI checks all of that before the quote and approval prompt, so a bad combination never costs a confirmation round-trip.
183
+
181
184
  **`amiko create tts <text>`**
182
185
 
183
186
  | Flag | Default | Description |
@@ -203,7 +206,7 @@ Examples: `amiko create tts "Hi" --provider minimax --yes` · `amiko create tts
203
206
  |------|---------|-------------|
204
207
  | `--model <model>` | `music-2.6` | `music_v1`, `music-2.6`, `music-cover` |
205
208
  | `--lyrics <lyrics>` | — | Lyrics (optional) |
206
- | `--duration <ms>` | — | Length in milliseconds |
209
+ | `--duration <seconds>` | — | Track length in seconds, 1–300. `music-3.0` bills per second, so this sets the price |
207
210
  | `--instrumental` | — | Instrumental (no vocals) |
208
211
 
209
212
  **`amiko create sfx <text>`**
@@ -533,7 +536,7 @@ src/
533
536
  │ ├── voice.ts # voice design, voice create, voice clone, voice reset
534
537
  │ ├── avatar.ts # avatar update
535
538
  │ ├── friends.ts # friends list, requests, add, accept, remove, matches, reports
536
- │ ├── users.ts # users search, profile
539
+ │ ├── users.ts # users search, profile, follow/unfollow/followers/following
537
540
  │ └── feed.ts # feed, post create/drafts/publish, post comment
538
541
  └── lib/
539
542
  ├── config.ts # defaults + resolved auth wrapper
@@ -576,7 +579,7 @@ amiko voice reset --yes
576
579
  amiko avatar update --file ./portrait.png --yes
577
580
 
578
581
  # Friends
579
- amiko friends list # list
582
+ amiko friends list # ENTIRE friend list + exact total, in one call
580
583
  amiko friends requests # pending requests (incoming + outgoing)
581
584
  amiko friends add --id <userId>
582
585
  amiko friends accept <friendshipId>
@@ -589,17 +592,28 @@ amiko friends reports list # friend matching reports
589
592
  amiko friends reports view <reportId>
590
593
 
591
594
  # Users
592
- amiko users search <query> # find users by name/handle
595
+ amiko users search <query> # find users by name/handle (one page, --limit default 10)
596
+ amiko users search <query> --all # every match, for "how many people match X"
593
597
  amiko users profile <handle> # public profile
598
+ amiko users follow <handleOrId> # follow (one-way, no approval); unfollow to undo
599
+ amiko users follow-status <handleOrId> # do I follow them / do they follow me
600
+ amiko users followers <handleOrId> # who follows them (--limit 1-50, --cursor)
601
+ amiko users following <handleOrId> # who they follow
594
602
 
595
- # Feed & posts
603
+ # Feed & posts (a.k.a. notes)
596
604
  amiko feed # friends feed (default)
597
- amiko feed --type for_you --limit 20
605
+ amiko feed --type all --limit 20 # the "All" tab (a.k.a. for_you)
606
+ amiko feed --type following # posts from accounts you follow
607
+ amiko feed --type media --kind image # site-wide public media (hits /api/media/feed)
598
608
  amiko post create --content "hello from the CLI" # public post — prints the canonical share URL (post_url in --json); share that verbatim, never hand-build one from the id
609
+ amiko post create --title "Kyoto, 6am" --media ./shot.webp # image-only note — --content is optional when media/docs are attached
610
+ amiko post create --title "Q3 report" --doc ./q3.pdf # document note — rendered as a file card
599
611
  amiko post create --content "private note" --visibility private
600
612
  amiko post create --content "look" --media https://...jpg
613
+ amiko post create --title "the track" --media drive:<docId> # media already in the Drive (id from `amiko drive list`) — works for audio/video too
601
614
  amiko post create --content "wip idea" --draft # save a draft — no share URL until published
602
615
  amiko post drafts # list draft posts (--limit/--offset/--json)
616
+ amiko post likers <postId> # who liked one of YOUR posts (403 on someone else's)
603
617
  amiko post publish <postId> # publish a draft (fresh timestamp, prints the share URL)
604
618
  amiko post comment --id <postId> --comment "great post"
605
619
  amiko post comment --id <postId> --comment "nice!" --media https://...jpg
@@ -625,6 +639,35 @@ npm publish
625
639
 
626
640
  ## Changelog
627
641
 
642
+ ### 0.14.0-beta.26 (`beta` dist-tag — `npm i -g @heyamiko/amiko-cli@beta`)
643
+
644
+ - **`amiko create video --model MiniMax-H3`** (aliases `h3`, `hailuo-03`): MiniMax's v2 video model, already live in mpp-service and amiko-web, is now reachable from the CLI. H3 breaks every local rule the video command enforced for Hailuo, so it gets its own pre-flight: `--seconds` accepts any whole number 4–15 (was hard-coded `6|10`), `--resolution` must be `768P` or `2K` (case-insensitive, canonicalized on the wire), and text-to-video needs a concrete `--aspect` — the CLI defaults it to `16:9` and prints a dim note, because the server only rejects the missing ratio *after* the owner has approved the spend. Frame/reference runs are left alone (H3 infers the ratio from the input). All checks run before the quote so an invalid job never costs an approval round-trip.
645
+ - **`--seconds` for non-H3 models widened from `6|10` to `4|6|8|10`**, matching amiko-web's own gate. The old check rejected `8`, which Veo and Seedance accept, and `4`, which Veo/Grok/Seedance accept; mpp still enforces the per-provider subset (Hailuo remains `6|10`). The error now points at `--model MiniMax-H3` for anything else.
646
+ - Reference flags (`--reference-image/video/audio`, `--subject-reference`) document that they drive H3 reference-to-video too.
647
+
648
+ ### 0.14.2
649
+
650
+ - **`--media drive:<docId>`** (new source for `post create` and `post comment`): attach a media file already in the twin's Drive — an upload, or a Create Studio generation, which `saveCreationToDrive` mirrors into the "Create Studio Files" folder. The id comes from `amiko drive list --json`. This closes the gap where the only way to post an *audio or video* file the twin already owned was to have kept the original `amiko create` URL: `--media` takes local paths for images only, and a Drive signed URL expires.
651
+ - **No amiko-web change was needed, and this is why.** A Drive doc's `file_url` is stored in *public* form (`…/storage/v1/object/public/docs/<path>`) by all three write paths (`uploadDocumentToSupabase`, `createDocSignedUpload`, `saveCreationToDrive`) even though the `docs` bucket is private. `/api/posts` accepts it (`isAllowedPostMediaUrl` checks host + `/storage/v1/object/` only), then `ensurePostMediaUrl` sees a non-`post-media` bucket and does a **service-role server-side copy into `post-media`**, returning a durable URL — so the post never links the private object and never expires. If that copy fails, the fallback reachability probe on a private-bucket URL 4xxs and the route rejects the post rather than storing a dead link. This mirrors what amiko-web's own `DriveMediaPicker` already does (it forwards the same `file_url` under the same MIME allowlist), so the CLI is matching shipped behaviour, not inventing a path.
652
+ - **The drive ref honours the command's `--twin`.** Docs are scoped by `twin_id`; `resolveMediaUrls` now takes the caller's `--twin` instead of re-resolving the default twin, so `post comment --twin <other> --media drive:<id>` no longer 404s on an id `drive list --twin <other>` had just printed.
653
+ - **Two guards on the resolved row.** A `file_url` that isn't Amiko public storage is refused locally — only `source`-tagged Doc rows are pinned to the twin's storage namespace server-side, so an ordinary row can hold any URL, and letting it through produced a `/api/posts` 400 naming neither the ref nor the doc. And the image/audio/video MIME check falls back to the **filename extension** when `file_type` is `application/octet-stream`: Create Studio stores `assetMime || "application/octet-stream"` and the job's `mime_type` is nullable, so a real mp3 can arrive typeless — refusing it with "attach it with `--doc`" would be wrong advice about an attachable file.
654
+ - **SKILL.md**: `--media` now documents its three sources (local image path / Amiko-hosted URL / `drive:<docId>`), says a Drive **signed URL** and a `/d/<slug>` share link are both rejected, and notes that a `drive:` ref pointing at a pdf belongs on `--doc`. New Examples row for "post the audio/image from my Drive".
655
+
656
+ ### 0.14.1
657
+
658
+ - **`amiko post likers <postId>`** (new): lists everyone who liked a post, paging `GET /api/posts/[id]/likes` to completeness so "who / which friends liked this" is answered from the whole set. Cross-reference the printed user ids against `amiko friends list` for the "which *friends*" variant. The route is **owner-only** (`post.user_id !== user.id` → 403), so the command says so in `--help` and turns the server's generic 403 into a message naming the rule — an agent shouldn't read it as a broken login and retry.
659
+ - **`amiko users search --all`** (new flag). Search stays a one-page lookup by default and `--limit` works again; `--all` is what pages through every match, for "how many people match X". Full paging is capped at 200 (10 requests at the server's 20-row page limit) rather than the shared 2000, because search is ranked recall — nobody needs the 2000th match, and a broad query shouldn't cost a hundred round trips.
660
+ - **`amiko friends list` reports the server's exact total.** `/api/friends` without `limit` already returns the whole list *and* an authoritative `pagination.total` in one request; the header count now comes from `total` instead of the row count. An earlier attempt to paginate this command was reverted — it added ~19 requests, capped the set at 2000, and replaced an exact server-side number with an approximation.
661
+ - **New `src/lib/paginate.ts`**: `fetchAllCursor` follows `hasMore`/`nextCursor` to completeness behind a safety cap, returning `{ items, partial }`. Exhaustion is checked *before* the cap so a set that ends exactly on the cap reports complete — flagging it `partial` would be the same lie about a count, inverted. When the cap really is hit, callers print `⚠ Partial results` and JSON carries `"partial": true` with an `N+` count.
662
+ - **SKILL.md**: new top-level **"Counts & full lists — never answer from the first page"** section (promoted out of Friends, since two of its three commands aren't friends commands) with a question → command → where-the-number-comes-from table; `post likers`'s owner-only rule; the `users search` row in the "Who should I meet?" table now notes one-page-by-default vs `--all`; Examples table gains "谁给我点赞了" and "我有多少好友".
663
+
664
+ ### 0.14.0-beta.25
665
+
666
+ - **Notes parity: `amiko post create --title` and `--doc`.** amiko-web's Notes composer (the 小红书-style card grid — same `Post` rows, new name) writes fields the CLI had no way to set. `--title <text>` sets the card heading (max 150 chars, counted in **grapheme clusters** to match the server's `Intl.Segmenter`, validated before any request; omitted from the body when unset so older amiko-web deploys still accept the payload). `--doc <pathOrUrl...>` attaches non-media documents that render as downloadable file cards: local paths upload to the public `docs` bucket via `POST /api/upload/post-doc` (≤4 MB) or presigned `POST /api/upload/post-doc/sign` + direct PUT (>4 MB, 50 MB cap — the multipart route dies at the ~4.5 MB serverless body limit), https URLs pass through, and media MIME types are refused with a pointer to `--media`. Max 8 documents — the server silently truncates past that, so the CLI errors instead. `--content` is no longer a `requiredOption`: with `--media` or `--doc` attached the body may be empty (an image-only note is normal), but a post with nothing in it — including `--title` alone — is still rejected client-side, mirroring the server's rule. `feed` and `post drafts` now render titles and a document count.
667
+ - **Follows: `amiko users follow / unfollow / follow-status / followers / following`.** Wraps `POST|DELETE|GET /api/users/<handleOrId>/follow` and the two list routes (`--limit` 1–50, `--cursor`; the CLI rejects a larger limit rather than letting the server silently clamp). Every slot takes a **handle or a user id**. Following is one-way and takes effect immediately — distinct from `amiko friends add`, which is a mutual request the other side must accept; the SKILL now carries a comparison table so agents stop conflating them.
668
+ - **`amiko feed --type` now mirrors the app's tabs: `all` / `following` / `media`** (plus `friends`, and `humans` / `amikos`). `all` is the UI's name for the API's `for_you`, so both spellings are accepted and normalized on the way out. The server silently degrades an unrecognized type to `for_you`, which reads as "the filter worked" when it didn't — the CLI validates up front and exits with the allowed list rather than issuing a request. `media` is the trap that rule was written for: `/api/feed?type=media` degrades to `for_you`, because the Media tab is served by a **different endpoint** (`/api/media/feed`, site-wide public creations with a different response shape) — so `--type media` routes there instead, with `--kind image|audio|video` for that endpoint's own filter (named `--kind` because its wire param is also called `type`).
669
+ - **SKILL.md**: notes/posts are the same surface; a note is a card so lead with `--title`; image- and document-only notes need no body; documents go through `--doc`, never `amiko drive upload`; **@-mentions must be written `@[Name](userId)`** — a bare `@handle` produces no mention and no notification; quoting a post means pasting its canonical URL into `--content`; follow-vs-friend routing table; which `--type` answers which question.
670
+
628
671
  ### 0.14.0-beta.24
629
672
 
630
673
  - **Private friend nicknames: `amiko friends nickname`.** Manage the owner's private nicknames for friends against amiko-web `/api/friends/{friendshipId}/nickname`: bare `nickname` (or `nickname list`) pages the whole friends list and shows every nickname set, `set <friend> "<nickname>"` adds or changes one (max 50 characters, validated client-side before any request), `remove <friend>` clears it (soft no-op when none is set). `<friend>` resolves against accepted user friends only — by name, `@handle`, user id, friendship id, or the current nickname — with ambiguity reported, never guessed. Mutations are `--yes`-gated (private resource, plain confirm); `amiko friends list` now also renders a NICKNAME column. **Requires the companion amiko-web deploy (`feat/friend-nicknames`)** — on an older server, reads work but `set`/`remove` return 403 (the CLI prints the deploy hint).
package/dist/index.js CHANGED
@@ -861,7 +861,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
861
861
  this._exitCallback = (err) => {
862
862
  if (err.code !== "commander.executeSubCommandAsync") {
863
863
  throw err;
864
- }
864
+ } else {}
865
865
  };
866
866
  }
867
867
  return this;
@@ -3596,7 +3596,7 @@ function getHumanReadableErrorMessage(code, context = {}) {
3596
3596
  function getErrorMessage(code, context = {}) {
3597
3597
  if (true) {
3598
3598
  return getHumanReadableErrorMessage(code, context);
3599
- }
3599
+ } else {}
3600
3600
  }
3601
3601
  function isSolanaError(e, code) {
3602
3602
  const isSolanaError2 = e instanceof Error && e.name === "SolanaError";
@@ -19992,7 +19992,7 @@ function finalize(ctx, schema) {
19992
19992
  result.$schema = "http://json-schema.org/draft-07/schema#";
19993
19993
  } else if (ctx.target === "draft-04") {
19994
19994
  result.$schema = "http://json-schema.org/draft-04/schema#";
19995
- } else if (ctx.target === "openapi-3.0") {}
19995
+ } else if (ctx.target === "openapi-3.0") {} else {}
19996
19996
  if (ctx.external?.uri) {
19997
19997
  const id = ctx.external.registry.get(schema)?.id;
19998
19998
  if (!id)
@@ -20257,7 +20257,7 @@ var formatMap, stringProcessor = (schema, ctx, _json, _params) => {
20257
20257
  if (val === undefined) {
20258
20258
  if (ctx.unrepresentable === "throw") {
20259
20259
  throw new Error("Literal `undefined` cannot be represented in JSON Schema");
20260
- }
20260
+ } else {}
20261
20261
  } else if (typeof val === "bigint") {
20262
20262
  if (ctx.unrepresentable === "throw") {
20263
20263
  throw new Error("BigInt literals cannot be represented in JSON Schema");
@@ -26485,10 +26485,11 @@ function registerImageCommand(program2) {
26485
26485
 
26486
26486
  // src/commands/create.ts
26487
26487
  import { randomUUID } from "node:crypto";
26488
+ var MUSIC_MAX_SECONDS = 300;
26488
26489
  var ETA = {
26489
26490
  IMAGE: "~15–60s",
26490
26491
  VIDEO: "~2–9 min",
26491
- MUSIC: "~30–120s",
26492
+ MUSIC: "~1–5 min",
26492
26493
  TTS: "~5–20s",
26493
26494
  SFX: "~5–20s"
26494
26495
  };
@@ -26511,6 +26512,16 @@ function assertMaxUrls(resourceName, urls, max) {
26511
26512
  process.exit(1);
26512
26513
  }
26513
26514
  }
26515
+ var H3_VIDEO_MODEL = /^(minimax-h3|h3|hailuo-0?3)$/i;
26516
+ var H3_MIN_SECONDS = 4;
26517
+ var H3_MAX_SECONDS = 15;
26518
+ var H3_RESOLUTIONS = new Set(["768P", "2K"]);
26519
+ var H3_CONCRETE_RATIOS = new Set(["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"]);
26520
+ var H3_DEFAULT_T2V_ASPECT = "16:9";
26521
+ var NON_H3_VIDEO_SECONDS = new Set([4, 6, 8, 10]);
26522
+ function isH3VideoModel(model) {
26523
+ return H3_VIDEO_MODEL.test(model);
26524
+ }
26514
26525
  function printCompleted(final, title, deliveredToThisSession = false) {
26515
26526
  console.log(heading(`${title}
26516
26527
  `));
@@ -26684,12 +26695,28 @@ function registerCreateCommand(create2) {
26684
26695
  }
26685
26696
  });
26686
26697
  });
26687
- 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) => {
26698
+ 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), MiniMax-H3 (aliases h3, hailuo-03; T2V+I2V+reference-to-video, 768P|2K, any 4–15s, billed per second), 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; MiniMax-H3: 768P or 2K", "768P").option("--seconds <n>", "Clip length: 6 or 10 (Hailuo), 4/6/8 (Veo), 4/6/8/10 (Grok, Seedance), any whole number 4–15 (MiniMax-H3)", "6").option("--aspect <ratio>", "Aspect ratio (provider-dependent). MiniMax-H3 text-to-video needs a concrete ratio (21:9, 16:9, 4:3, 1:1, 3:4, 9:16); defaults to 16:9 when omitted").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 / MiniMax-H3 reference-to-video: reference image URL (repeatable, max 9)", collectOption, []).option("--reference-video <url>", "Seedance / MiniMax-H3 reference-to-video: reference video URL (repeatable, max 3; H3 bills the input seconds too)", collectOption, []).option("--reference-audio <url>", "Seedance / MiniMax-H3 reference-to-video: reference audio URL (repeatable, max 3; H3 needs an image or video alongside)", collectOption, []).option("--subject-reference <url>", "MiniMax S2V / H3: 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) => {
26699
+ const model = opts.model ?? (opts.firstFrame ? "MiniMax-Hailuo-2.3-Fast" : "MiniMax-Hailuo-02");
26700
+ const isH3 = isH3VideoModel(model);
26688
26701
  const seconds = Number(opts.seconds);
26689
- if (seconds !== 6 && seconds !== 10) {
26690
- console.error(error("--seconds must be 6 or 10."));
26702
+ if (isH3) {
26703
+ if (!Number.isInteger(seconds) || seconds < H3_MIN_SECONDS || seconds > H3_MAX_SECONDS) {
26704
+ console.error(error(`--seconds must be a whole number from ${H3_MIN_SECONDS} to ${H3_MAX_SECONDS} for MiniMax-H3.`));
26705
+ process.exit(1);
26706
+ }
26707
+ } else if (!NON_H3_VIDEO_SECONDS.has(seconds)) {
26708
+ console.error(error("--seconds must be 4, 6, 8, or 10 (Hailuo accepts 6 or 10; use --model MiniMax-H3 for any 4–15s)."));
26691
26709
  process.exit(1);
26692
26710
  }
26711
+ let resolution = opts.resolution;
26712
+ if (isH3) {
26713
+ const upper = resolution.toUpperCase();
26714
+ if (!H3_RESOLUTIONS.has(upper)) {
26715
+ console.error(error(`MiniMax-H3 only supports 768P and 2K, not ${resolution}.`));
26716
+ process.exit(1);
26717
+ }
26718
+ resolution = upper;
26719
+ }
26693
26720
  const referenceImageUrls = opts.referenceImage ?? [];
26694
26721
  const referenceVideoUrls = opts.referenceVideo ?? [];
26695
26722
  const referenceAudioUrls = opts.referenceAudio ?? [];
@@ -26697,7 +26724,19 @@ function registerCreateCommand(create2) {
26697
26724
  assertMaxUrls("--reference-video", referenceVideoUrls, 3);
26698
26725
  assertMaxUrls("--reference-audio", referenceAudioUrls, 3);
26699
26726
  const generateAudio = opts.generateAudio;
26700
- const model = opts.model ?? (opts.firstFrame ? "MiniMax-Hailuo-2.3-Fast" : "MiniMax-Hailuo-02");
26727
+ const hasVisualInput = Boolean(opts.firstFrame) || Boolean(opts.lastFrame) || Boolean(opts.subjectReference) || referenceImageUrls.length > 0 || referenceVideoUrls.length > 0 || referenceAudioUrls.length > 0;
26728
+ let aspect = opts.aspect;
26729
+ if (isH3 && !hasVisualInput) {
26730
+ if (!aspect) {
26731
+ aspect = H3_DEFAULT_T2V_ASPECT;
26732
+ if (!opts.raw) {
26733
+ console.error(dim(`MiniMax-H3 text-to-video needs an aspect ratio; using ${aspect}.`));
26734
+ }
26735
+ } else if (!H3_CONCRETE_RATIOS.has(aspect)) {
26736
+ console.error(error(`MiniMax-H3 text-to-video needs a specific aspect ratio (${[...H3_CONCRETE_RATIOS].join(", ")}), not ${aspect}.`));
26737
+ process.exit(1);
26738
+ }
26739
+ }
26701
26740
  await submitCreateJob({
26702
26741
  mode: "VIDEO",
26703
26742
  summary: `Create video: ${prompt.slice(0, 80)}${prompt.length > 80 ? "…" : ""}`,
@@ -26707,7 +26746,7 @@ function registerCreateCommand(create2) {
26707
26746
  quoteBody: {
26708
26747
  mode: "VIDEO",
26709
26748
  model,
26710
- size: opts.resolution,
26749
+ size: resolution,
26711
26750
  seconds,
26712
26751
  ...referenceVideoUrls.length ? { referenceVideoUrls } : {}
26713
26752
  },
@@ -26715,9 +26754,9 @@ function registerCreateCommand(create2) {
26715
26754
  mode: "VIDEO",
26716
26755
  prompt,
26717
26756
  model,
26718
- resolution: opts.resolution,
26757
+ resolution,
26719
26758
  seconds,
26720
- ...opts.aspect ? { aspectRatio: opts.aspect } : {},
26759
+ ...aspect ? { aspectRatio: aspect } : {},
26721
26760
  ...opts.firstFrame ? { firstFrameImage: opts.firstFrame } : {},
26722
26761
  ...opts.lastFrame ? { lastFrameImage: opts.lastFrame } : {},
26723
26762
  ...referenceImageUrls.length ? { referenceImageUrls } : {},
@@ -26788,24 +26827,49 @@ function registerCreateCommand(create2) {
26788
26827
  }
26789
26828
  });
26790
26829
  });
26791
- 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) => {
26792
- if (!prompt && !opts.lyrics) {
26793
- console.error(error("Provide a prompt or --lyrics."));
26830
+ create2.command("music [prompt]").description("Generate music (Create Studio)").option("--model <model>", "music-3.0 (default), music-cover, music_v1. The retired MiniMax ids (music-2.0/2.5/2.6, -free) still resolve to music-3.0", "music-3.0").option("--lyrics <lyrics>", "Lyrics (optional; a prompt is still required for the style). Needs section tags on their own lines, e.g. [verse] / [chorus] — plain lyrics are rejected").option("--duration <seconds>", "Track length in seconds, 1-300 — music-3.0 bills per second, so this sets the price").option("--audio-url <url>", "Public audio URL to cover (required by --model music-cover)").option("--instrumental", "Instrumental (no vocals)").option("--lyrics-optimizer", "Auto-write lyrics from the prompt. Only affects music_v1 — the fal models always write their own when no --lyrics is given").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) => {
26831
+ const isCover = String(opts.model ?? "").startsWith("music-cover");
26832
+ if (isCover) {
26833
+ if (!opts.audioUrl) {
26834
+ console.error(error("--model music-cover requires --audio-url."));
26835
+ process.exit(1);
26836
+ }
26837
+ if (!prompt && !opts.lyrics) {
26838
+ console.error(error("--model music-cover requires a prompt or --lyrics describing the cover."));
26839
+ process.exit(1);
26840
+ }
26841
+ } else if (!prompt) {
26842
+ console.error(error("Provide a prompt. --lyrics alone is not enough — the prompt carries the style."));
26843
+ process.exit(1);
26844
+ }
26845
+ const durationSeconds = opts.duration ? Number(opts.duration) : undefined;
26846
+ if (durationSeconds !== undefined && (!Number.isFinite(durationSeconds) || durationSeconds <= 0)) {
26847
+ console.error(error("--duration must be a positive number of seconds."));
26848
+ process.exit(1);
26849
+ }
26850
+ if (durationSeconds !== undefined && durationSeconds > MUSIC_MAX_SECONDS) {
26851
+ console.error(error(`--duration is in SECONDS (1-${MUSIC_MAX_SECONDS}); got ${durationSeconds}.` + (durationSeconds >= 1000 ? ` That looks like milliseconds — use ${Math.round(durationSeconds / 1000)}.` : "")));
26794
26852
  process.exit(1);
26795
26853
  }
26854
+ const durationMs = durationSeconds !== undefined ? Math.round(durationSeconds * 1000) : undefined;
26796
26855
  await submitCreateJob({
26797
26856
  mode: "MUSIC",
26798
- summary: `Create music: ${(prompt ?? opts.lyrics ?? "").slice(0, 80)}`,
26857
+ summary: `Create music: ${(prompt ?? opts.audioUrl ?? opts.lyrics ?? "").slice(0, 80)}`,
26799
26858
  raw: opts.raw,
26800
26859
  pay: opts.pay,
26801
26860
  yes: opts.yes,
26802
- quoteBody: { mode: "MUSIC", model: opts.model },
26861
+ quoteBody: {
26862
+ mode: "MUSIC",
26863
+ model: opts.model,
26864
+ ...durationMs ? { durationMs } : {}
26865
+ },
26803
26866
  jobBody: {
26804
26867
  mode: "MUSIC",
26805
26868
  model: opts.model,
26806
26869
  ...prompt ? { prompt } : {},
26807
26870
  ...opts.lyrics ? { lyrics: opts.lyrics } : {},
26808
- ...opts.duration ? { durationMs: Number(opts.duration) } : {},
26871
+ ...durationMs ? { durationMs } : {},
26872
+ ...opts.audioUrl ? { audioUrl: opts.audioUrl } : {},
26809
26873
  ...opts.instrumental ? { isInstrumental: true } : {},
26810
26874
  ...opts.lyricsOptimizer ? { lyricsOptimizer: true } : {},
26811
26875
  ...opts.token ? { preferredToken: opts.token } : {}
@@ -30762,7 +30826,7 @@ function registerFriendsCommand(program2) {
30762
30826
  console.log(dim("No friends."));
30763
30827
  return;
30764
30828
  }
30765
- console.log(heading(`Friends (${items.length})`));
30829
+ console.log(heading(`Friends (${d.pagination?.total ?? items.length})`));
30766
30830
  const rows = [
30767
30831
  [
30768
30832
  dim("USER ID"),
@@ -31226,20 +31290,62 @@ The other user will be notified and must consent before the report is generated.
31226
31290
  });
31227
31291
  }
31228
31292
 
31293
+ // src/lib/paginate.ts
31294
+ var defaultFetcher = (auth, path2, opts) => amikoWebFetch(auth, path2, opts);
31295
+ var DEFAULT_PAGE_CAP = 2000;
31296
+ async function fetchAllCursor(auth, path2, opts) {
31297
+ const pageSize = opts.pageSize ?? 100;
31298
+ const cap = opts.cap ?? DEFAULT_PAGE_CAP;
31299
+ const fetcher = opts.fetcher ?? defaultFetcher;
31300
+ const items = [];
31301
+ let cursor;
31302
+ for (let i = 0;i < 1e4; i += 1) {
31303
+ const data = await fetcher(auth, path2, {
31304
+ query: { ...opts.query, limit: pageSize, cursor },
31305
+ timeoutMs: opts.timeoutMs
31306
+ });
31307
+ const page = data[opts.itemsKey] ?? [];
31308
+ items.push(...page);
31309
+ if (!data.hasMore || !data.nextCursor) {
31310
+ return { items, partial: false };
31311
+ }
31312
+ if (items.length >= cap) {
31313
+ return { items: items.slice(0, cap), partial: true };
31314
+ }
31315
+ cursor = data.nextCursor;
31316
+ }
31317
+ return { items: items.slice(0, cap), partial: true };
31318
+ }
31319
+
31229
31320
  // src/commands/users.ts
31321
+ var SEARCH_ALL_CAP = 200;
31322
+ async function searchOnePage(auth, query, limit, cursor) {
31323
+ const data = await amikoWebFetch(auth, "/api/search", {
31324
+ query: { q: query, type: "people", limit, cursor }
31325
+ });
31326
+ return { ...data, partial: false };
31327
+ }
31328
+ async function searchAll(auth, query) {
31329
+ const { items, partial: partial3 } = await fetchAllCursor(auth, "/api/search", {
31330
+ itemsKey: "users",
31331
+ query: { q: query, type: "people" },
31332
+ pageSize: 20,
31333
+ cap: SEARCH_ALL_CAP
31334
+ });
31335
+ return { users: items, hasMore: false, nextCursor: null, partial: partial3 };
31336
+ }
31337
+ function printFollowState(state) {
31338
+ console.log(label("Following them", state.following ? "yes" : "no"));
31339
+ console.log(label("Follows you", state.followsMe ? "yes" : "no"));
31340
+ console.log(label("Followers", String(state.followerCount ?? 0)));
31341
+ console.log(label("Following", String(state.followingCount ?? 0)));
31342
+ }
31230
31343
  function registerUsersCommand(program2) {
31231
- 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) => {
31344
+ 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) => {
31232
31345
  const auth = resolveAuth();
31233
31346
  const spinner = opts.json ? null : ora("Searching users...").start();
31234
31347
  try {
31235
- const data = await amikoWebFetch(auth, "/api/search", {
31236
- query: {
31237
- q: query,
31238
- type: "people",
31239
- limit: opts.limit,
31240
- cursor: opts.cursor
31241
- }
31242
- });
31348
+ const data = opts.all ? await searchAll(auth, query) : await searchOnePage(auth, query, opts.limit, opts.cursor);
31243
31349
  spinner?.stop();
31244
31350
  renderOutput(data, (d) => {
31245
31351
  const items = d.users ?? [];
@@ -31247,19 +31353,18 @@ function registerUsersCommand(program2) {
31247
31353
  console.log(dim("No users found."));
31248
31354
  return;
31249
31355
  }
31250
- console.log(heading(`Users (${items.length})`));
31356
+ console.log(heading(`Users (${items.length}${d.partial ? "+" : ""})`));
31251
31357
  const rows = [
31252
31358
  [dim("ID"), dim("NAME"), dim("HANDLE")],
31253
- ...items.map((u) => [
31254
- u.id,
31255
- u.name ?? "-",
31256
- u.handle ?? "-"
31257
- ])
31359
+ ...items.map((u) => [u.id, u.name ?? "-", u.handle ?? "-"])
31258
31360
  ];
31259
31361
  console.log(table(rows));
31260
- if (d.hasMore && d.nextCursor) {
31362
+ if (d.partial) {
31363
+ console.log(dim(`
31364
+ ⚠ Partial results: stopped at the ${items.length}-match cap; more exist. Not the full set/count.`));
31365
+ } else if (d.hasMore && d.nextCursor) {
31261
31366
  console.log(dim(`
31262
- Next cursor: ${d.nextCursor}`));
31367
+ More matches exist. Next page: --cursor ${d.nextCursor} · every match: --all`));
31263
31368
  }
31264
31369
  }, { json: opts.json });
31265
31370
  } catch (e5) {
@@ -31303,6 +31408,97 @@ Twins (${u.twins.length})`));
31303
31408
  process.exit(1);
31304
31409
  }
31305
31410
  });
31411
+ 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) => {
31412
+ const auth = resolveAuth();
31413
+ const spinner = opts.json ? null : ora(`Following ${target}...`).start();
31414
+ try {
31415
+ const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/follow`, { method: "POST" });
31416
+ spinner?.stop();
31417
+ renderOutput(data, (d) => {
31418
+ console.log(success(`Now following ${target}`));
31419
+ printFollowState(d);
31420
+ }, { json: opts.json });
31421
+ } catch (e5) {
31422
+ spinner?.stop();
31423
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
31424
+ process.exit(1);
31425
+ }
31426
+ });
31427
+ program2.command("unfollow <handleOrId>").description("Stop following a user").option("--json", "Output as JSON").action(async (target, opts) => {
31428
+ const auth = resolveAuth();
31429
+ const spinner = opts.json ? null : ora(`Unfollowing ${target}...`).start();
31430
+ try {
31431
+ const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/follow`, { method: "DELETE" });
31432
+ spinner?.stop();
31433
+ renderOutput(data, (d) => {
31434
+ console.log(success(`Unfollowed ${target}`));
31435
+ printFollowState(d);
31436
+ }, { json: opts.json });
31437
+ } catch (e5) {
31438
+ spinner?.stop();
31439
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
31440
+ process.exit(1);
31441
+ }
31442
+ });
31443
+ 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) => {
31444
+ const auth = resolveAuth();
31445
+ const spinner = opts.json ? null : ora(`Checking follow status for ${target}...`).start();
31446
+ try {
31447
+ const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/follow`);
31448
+ spinner?.stop();
31449
+ renderOutput(data, (d) => {
31450
+ console.log(heading(target));
31451
+ printFollowState(d);
31452
+ }, { json: opts.json });
31453
+ } catch (e5) {
31454
+ spinner?.stop();
31455
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
31456
+ process.exit(1);
31457
+ }
31458
+ });
31459
+ for (const kind of ["followers", "following"]) {
31460
+ 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) => {
31461
+ const limit = Number(opts.limit ?? "20");
31462
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
31463
+ console.error(error("--limit must be an integer between 1 and 50"));
31464
+ process.exit(1);
31465
+ }
31466
+ const auth = resolveAuth();
31467
+ const spinner = opts.json ? null : ora(`Loading ${kind}...`).start();
31468
+ try {
31469
+ const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/${kind}`, {
31470
+ query: { limit, cursor: opts.cursor }
31471
+ });
31472
+ spinner?.stop();
31473
+ const items = data.items ?? [];
31474
+ renderOutput(data, (d) => {
31475
+ if (items.length === 0) {
31476
+ console.log(dim(`No ${kind}.`));
31477
+ return;
31478
+ }
31479
+ console.log(heading(`${target} — ${kind} (${items.length}${d.nextCursor ? "+" : ""})`));
31480
+ const rows = [
31481
+ [dim("ID"), dim("NAME"), dim("HANDLE"), dim("YOU FOLLOW")],
31482
+ ...items.map((u) => [
31483
+ u.id,
31484
+ u.name ?? "-",
31485
+ u.handle ?? "-",
31486
+ u.followedByMe ? "yes" : "no"
31487
+ ])
31488
+ ];
31489
+ console.log(table(rows));
31490
+ if (d.nextCursor) {
31491
+ console.log(dim(`
31492
+ Next: --cursor ${d.nextCursor}`));
31493
+ }
31494
+ }, { json: opts.json });
31495
+ } catch (e5) {
31496
+ spinner?.stop();
31497
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
31498
+ process.exit(1);
31499
+ }
31500
+ });
31501
+ }
31306
31502
  }
31307
31503
 
31308
31504
  // src/lib/post-media.ts
@@ -31337,17 +31533,66 @@ function isAmikoMediaUrl(value) {
31337
31533
  const parsed = new URL(value);
31338
31534
  return parsed.protocol === "https:" && parsed.pathname.startsWith(AMIKO_PUBLIC_STORAGE_PATH);
31339
31535
  }
31340
- async function resolveMediaUrls(auth, inputs) {
31536
+ var DRIVE_MEDIA_REF = /^drive:(.+)$/i;
31537
+ function isFeedMediaMime(mime) {
31538
+ return mime.startsWith("image/") || mime.startsWith("audio/") || mime.startsWith("video/");
31539
+ }
31540
+ var MEDIA_EXTENSIONS = new Set([
31541
+ "png",
31542
+ "jpg",
31543
+ "jpeg",
31544
+ "webp",
31545
+ "gif",
31546
+ "avif",
31547
+ "heic",
31548
+ "svg",
31549
+ "mp3",
31550
+ "m4a",
31551
+ "aac",
31552
+ "wav",
31553
+ "flac",
31554
+ "ogg",
31555
+ "opus",
31556
+ "mp4",
31557
+ "mov",
31558
+ "webm",
31559
+ "m4v",
31560
+ "mkv"
31561
+ ]);
31562
+ function looksLikeMediaFilename(name) {
31563
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
31564
+ return MEDIA_EXTENSIONS.has(ext);
31565
+ }
31566
+ async function resolveDriveMedia(auth, docId, twinFlag) {
31567
+ const twinId = resolveTwinId({ flag: twinFlag, config: loadConfig(), auth });
31568
+ const { doc: doc2 } = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/${encodeURIComponent(docId)}`);
31569
+ if (!doc2?.file_url) {
31570
+ 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.`);
31571
+ }
31572
+ const mime = (doc2.file_type ?? "").toLowerCase();
31573
+ const filename = doc2.filename ?? "file";
31574
+ if (mime && !isFeedMediaMime(mime) && !looksLikeMediaFilename(filename)) {
31575
+ throw new Error(`Drive file "${docId}" is ${mime} (${filename}), not an image/audio/video. Attach documents with --doc, not --media.`);
31576
+ }
31577
+ if (!isAmikoMediaUrl(doc2.file_url)) {
31578
+ 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.`);
31579
+ }
31580
+ return doc2.file_url;
31581
+ }
31582
+ async function resolveMediaUrls(auth, inputs, opts = {}) {
31341
31583
  const resolved = [];
31342
31584
  for (const value of inputs) {
31343
31585
  const trimmed = value.trim();
31344
31586
  if (trimmed.length === 0)
31345
31587
  continue;
31346
- if (/^https?:\/\//i.test(trimmed)) {
31588
+ const driveRef = trimmed.match(DRIVE_MEDIA_REF);
31589
+ if (driveRef) {
31590
+ resolved.push(await resolveDriveMedia(auth, driveRef[1].trim(), opts.twin));
31591
+ } else if (/^https?:\/\//i.test(trimmed)) {
31347
31592
  if (isAmikoMediaUrl(trimmed)) {
31348
31593
  resolved.push(trimmed);
31349
31594
  } else {
31350
- 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.`);
31595
+ 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.`);
31351
31596
  }
31352
31597
  } else {
31353
31598
  resolved.push(await uploadPostMedia(auth, trimmed));
@@ -31356,15 +31601,168 @@ async function resolveMediaUrls(auth, inputs) {
31356
31601
  return resolved;
31357
31602
  }
31358
31603
 
31604
+ // src/lib/post-doc.ts
31605
+ var MAX_DOC_ATTACHMENTS = 8;
31606
+ var MULTIPART_MAX_BYTES = 4 * 1024 * 1024;
31607
+ var MAX_DOC_BYTES = 50 * 1024 * 1024;
31608
+ var SIGNED_PUT_TIMEOUT_MS = 10 * 60 * 1000;
31609
+ function isMediaMime(mime) {
31610
+ return mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/");
31611
+ }
31612
+ async function uploadPostDoc(auth, source) {
31613
+ const file2 = await resolveFileInput(source, { maxBytes: MAX_DOC_BYTES });
31614
+ if (isMediaMime(file2.mime)) {
31615
+ throw new Error(`"${source}" is a media file (${file2.mime}). Attach images/audio/video with --media, not --doc.`);
31616
+ }
31617
+ if (file2.size > MAX_DOC_BYTES) {
31618
+ throw new Error(`"${source}" is ${file2.size} bytes; exceeds the 50 MB document limit.`);
31619
+ }
31620
+ if (file2.size <= MULTIPART_MAX_BYTES) {
31621
+ const form = new FormData;
31622
+ form.set("file", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
31623
+ const res = await amikoWebFetch(auth, "/api/upload/post-doc", {
31624
+ method: "POST",
31625
+ multipart: form,
31626
+ timeoutMs: 120000
31627
+ });
31628
+ if (!res.url) {
31629
+ throw new Error(res.error ?? "post-doc upload returned no URL.");
31630
+ }
31631
+ return {
31632
+ url: res.url,
31633
+ filename: res.filename ?? file2.name,
31634
+ mime_type: res.mime_type ?? file2.mime,
31635
+ size: res.size ?? file2.size
31636
+ };
31637
+ }
31638
+ const signed = await amikoWebFetch(auth, "/api/upload/post-doc/sign", {
31639
+ method: "POST",
31640
+ body: { filename: file2.name, contentType: file2.mime, size: file2.size }
31641
+ });
31642
+ if (!signed.signedUrl || !signed.url) {
31643
+ throw new Error(signed.error ?? "Could not prepare the document upload.");
31644
+ }
31645
+ const put = await fetch(signed.signedUrl, {
31646
+ method: "PUT",
31647
+ headers: { "content-type": file2.mime },
31648
+ body: new Uint8Array(file2.buffer),
31649
+ signal: AbortSignal.timeout(SIGNED_PUT_TIMEOUT_MS)
31650
+ });
31651
+ if (!put.ok) {
31652
+ const detail = await put.text().catch(() => "");
31653
+ throw new Error(`Direct storage upload failed: ${put.status} ${put.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ""}`);
31654
+ }
31655
+ return {
31656
+ url: signed.url,
31657
+ filename: file2.name,
31658
+ mime_type: file2.mime,
31659
+ size: file2.size
31660
+ };
31661
+ }
31662
+ function deriveFilename(url2) {
31663
+ try {
31664
+ const path2 = new URL(url2, "https://x").pathname;
31665
+ return decodeURIComponent(path2.split("/").pop() || "") || "file";
31666
+ } catch {
31667
+ return "file";
31668
+ }
31669
+ }
31670
+ async function resolveDocAttachments(auth, inputs) {
31671
+ const values = inputs.map((v) => v.trim()).filter((v) => v.length > 0);
31672
+ if (values.length > MAX_DOC_ATTACHMENTS) {
31673
+ throw new Error(`Too many documents: ${values.length}. A post accepts at most ${MAX_DOC_ATTACHMENTS}.`);
31674
+ }
31675
+ const resolved = [];
31676
+ for (const value of values) {
31677
+ if (/^https?:\/\//i.test(value)) {
31678
+ if (!/^https:\/\//i.test(value)) {
31679
+ throw new Error(`"${value}" must be an https URL — plain http documents are not accepted.`);
31680
+ }
31681
+ resolved.push({ url: value, filename: deriveFilename(value) });
31682
+ } else {
31683
+ resolved.push(await uploadPostDoc(auth, value));
31684
+ }
31685
+ }
31686
+ return resolved;
31687
+ }
31688
+
31359
31689
  // src/commands/feed.ts
31690
+ var MAX_TITLE_CHARS = 150;
31691
+ var graphemeSegmenter = new Intl.Segmenter(undefined, {
31692
+ granularity: "grapheme"
31693
+ });
31694
+ function graphemeLength(text) {
31695
+ let n = 0;
31696
+ for (const _ of graphemeSegmenter.segment(text))
31697
+ n++;
31698
+ return n;
31699
+ }
31700
+ var FEED_TYPES = [
31701
+ "all",
31702
+ "for_you",
31703
+ "friends",
31704
+ "following",
31705
+ "media",
31706
+ "humans",
31707
+ "amikos",
31708
+ "twin_scan"
31709
+ ];
31710
+ var MEDIA_KINDS = ["image", "audio", "video"];
31711
+ async function runMediaFeed(auth, opts) {
31712
+ const spinner = opts.json ? null : ora("Loading media feed...").start();
31713
+ try {
31714
+ const data = await amikoWebFetch(auth, "/api/media/feed", {
31715
+ query: { type: opts.kind, limit: opts.limit, cursor: opts.cursor }
31716
+ });
31717
+ spinner?.stop();
31718
+ const items = data.items ?? [];
31719
+ renderOutput({ ...data, items }, (d) => {
31720
+ if (items.length === 0) {
31721
+ console.log(dim("No public media."));
31722
+ return;
31723
+ }
31724
+ console.log(heading(`Media feed${opts.kind ? ` (${opts.kind})` : ""} — ${items.length} items`));
31725
+ for (const m of items) {
31726
+ const author = m.twin?.name ?? m.user?.name ?? m.user?.handle ?? "unknown";
31727
+ console.log("");
31728
+ console.log(label(author, m.created_at?.slice(0, 16) ?? ""));
31729
+ if (m.prompt)
31730
+ console.log(m.prompt);
31731
+ console.log(dim(`${m.mime_type}${m.model ? ` · ${m.model}` : ""}`));
31732
+ console.log(m.url);
31733
+ }
31734
+ if (d.nextCursor) {
31735
+ console.log("");
31736
+ console.log(dim(`Next: --cursor ${d.nextCursor}`));
31737
+ }
31738
+ }, { json: opts.json });
31739
+ } catch (e5) {
31740
+ spinner?.stop();
31741
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
31742
+ process.exit(1);
31743
+ }
31744
+ }
31360
31745
  function registerFeedCommand(program2) {
31361
- 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) => {
31746
+ program2.command("feed").description("Get feed posts (notes)").option("--type <all|friends|following|media|humans|amikos|twin_scan>", "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) · twin_scan (blended candidate feed for a twin's scheduled feed-scan)", "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) => {
31747
+ if (opts.type && !FEED_TYPES.includes(opts.type)) {
31748
+ console.error(error(`--type must be one of: ${FEED_TYPES.join(", ")}`));
31749
+ process.exit(1);
31750
+ }
31751
+ if (opts.kind && !MEDIA_KINDS.includes(opts.kind)) {
31752
+ console.error(error(`--kind must be one of: ${MEDIA_KINDS.join(", ")}`));
31753
+ process.exit(1);
31754
+ }
31755
+ const type = opts.type === "all" ? "for_you" : opts.type;
31362
31756
  const auth = resolveAuth();
31757
+ if (type === "media") {
31758
+ await runMediaFeed(auth, opts);
31759
+ return;
31760
+ }
31363
31761
  const spinner = opts.json ? null : ora("Loading feed...").start();
31364
31762
  try {
31365
31763
  const data = await amikoWebFetch(auth, "/api/feed", {
31366
31764
  query: {
31367
- type: opts.type,
31765
+ type,
31368
31766
  hashtag: opts.hashtag,
31369
31767
  limit: opts.limit,
31370
31768
  cursor: opts.cursor,
@@ -31378,13 +31776,16 @@ function registerFeedCommand(program2) {
31378
31776
  console.log(dim(opts.unread ? "No unread posts." : "No posts."));
31379
31777
  return;
31380
31778
  }
31381
- const scope = `${opts.type ?? "friends"}${opts.unread ? ", unread" : ""}`;
31779
+ const scope = `${type ?? "friends"}${opts.unread ? ", unread" : ""}`;
31382
31780
  console.log(heading(`Feed (${scope}) — ${posts.length} posts`));
31383
31781
  for (const p of posts) {
31384
31782
  const author = p.twin?.name ?? p.user?.name ?? p.user?.handle ?? "unknown";
31385
31783
  console.log("");
31386
31784
  console.log(label(author, p.created_at?.slice(0, 16) ?? ""));
31387
- console.log(p.content);
31785
+ if (p.title)
31786
+ console.log(heading(p.title));
31787
+ if (p.content)
31788
+ console.log(p.content);
31388
31789
  if (p._count) {
31389
31790
  console.log(dim(`${p._count.likes ?? 0} likes · ${p._count.comments ?? 0} comments · ${p._count.bookmarks ?? 0} bookmarks`));
31390
31791
  }
@@ -31403,16 +31804,29 @@ function registerFeedCommand(program2) {
31403
31804
  });
31404
31805
  }
31405
31806
  function registerPostCommand(program2) {
31406
- 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("--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) => {
31807
+ 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) => {
31407
31808
  const auth = resolveAuth();
31408
31809
  const visibility = opts.visibility === "private" ? "private" : "public";
31810
+ const content = opts.content?.trim() ?? "";
31811
+ const title = opts.title?.trim() ?? "";
31812
+ if (!content && !opts.media?.length && !opts.doc?.length) {
31813
+ console.error(error("Nothing to post. Pass --content, and/or attach something with --media / --doc."));
31814
+ process.exit(1);
31815
+ }
31816
+ const titleLength = graphemeLength(title);
31817
+ if (titleLength > MAX_TITLE_CHARS) {
31818
+ console.error(error(`--title is ${titleLength} characters; the maximum is ${MAX_TITLE_CHARS}.`));
31819
+ process.exit(1);
31820
+ }
31409
31821
  const workingText = opts.draft ? "Saving draft..." : "Publishing post...";
31410
31822
  const spinner = opts.json ? null : ora(workingText).start();
31411
31823
  try {
31412
31824
  const body = {
31413
- content: opts.content,
31825
+ content,
31414
31826
  visibility
31415
31827
  };
31828
+ if (title)
31829
+ body.title = title;
31416
31830
  if (opts.draft)
31417
31831
  body.status = "draft";
31418
31832
  if (opts.media?.length) {
@@ -31422,6 +31836,13 @@ function registerPostCommand(program2) {
31422
31836
  if (spinner)
31423
31837
  spinner.text = workingText;
31424
31838
  }
31839
+ if (opts.doc?.length) {
31840
+ if (spinner)
31841
+ spinner.text = "Uploading documents...";
31842
+ body.doc_attachments = await resolveDocAttachments(auth, opts.doc);
31843
+ if (spinner)
31844
+ spinner.text = workingText;
31845
+ }
31425
31846
  const data = await amikoWebFetch(auth, "/api/posts", { method: "POST", body });
31426
31847
  spinner?.stop();
31427
31848
  const postUrl = !opts.draft && data.post?.id ? `https://platform.heyamiko.com/post/${data.post.id}` : null;
@@ -31435,6 +31856,12 @@ function registerPostCommand(program2) {
31435
31856
  console.log(label("URL", postUrl));
31436
31857
  if (opts.draft)
31437
31858
  console.log(label("Status", "draft"));
31859
+ if (title)
31860
+ console.log(label("Title", title));
31861
+ if (opts.doc?.length) {
31862
+ const docs = body.doc_attachments;
31863
+ console.log(label("Documents", (docs ?? []).map((doc2) => doc2.filename).join(", ")));
31864
+ }
31438
31865
  console.log(label("Visibility", visibility));
31439
31866
  if (opts.draft && post.id) {
31440
31867
  console.log(dim(`No public URL until published. List: amiko post drafts · Publish: amiko post publish ${post.id}`));
@@ -31479,9 +31906,14 @@ function registerPostCommand(program2) {
31479
31906
  for (const p of posts) {
31480
31907
  console.log("");
31481
31908
  console.log(label(p.twin?.name ?? "you", (p.updated_at ?? p.created_at)?.slice(0, 16) ?? ""));
31482
- console.log(p.content.length > 120 ? `${p.content.slice(0, 120)}…` : p.content);
31909
+ if (p.title)
31910
+ console.log(heading(p.title));
31911
+ if (p.content) {
31912
+ console.log(p.content.length > 120 ? `${p.content.slice(0, 120)}…` : p.content);
31913
+ }
31483
31914
  const mediaCount = p.media_urls?.length ?? 0;
31484
- console.log(dim(`${p.visibility ?? "public"}${mediaCount ? ` · ${mediaCount} media` : ""}`));
31915
+ const docCount = p.doc_attachments?.length ?? 0;
31916
+ console.log(dim(`${p.visibility ?? "public"}${mediaCount ? ` · ${mediaCount} media` : ""}${docCount ? ` · ${docCount} docs` : ""}`));
31485
31917
  console.log(dim(`id: ${p.id}`));
31486
31918
  }
31487
31919
  console.log("");
@@ -31563,7 +31995,7 @@ function registerPostCommand(program2) {
31563
31995
  process.exit(1);
31564
31996
  }
31565
31997
  });
31566
- 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) => {
31998
+ 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) => {
31567
31999
  const config2 = loadConfig();
31568
32000
  const auth = resolveAuth();
31569
32001
  let twinId;
@@ -31584,7 +32016,9 @@ function registerPostCommand(program2) {
31584
32016
  if (opts.media?.length) {
31585
32017
  if (spinner)
31586
32018
  spinner.text = "Uploading media...";
31587
- body.media_urls = await resolveMediaUrls(auth, opts.media);
32019
+ body.media_urls = await resolveMediaUrls(auth, opts.media, {
32020
+ twin: opts.twin
32021
+ });
31588
32022
  if (spinner)
31589
32023
  spinner.text = "Posting comment...";
31590
32024
  }
@@ -31603,6 +32037,48 @@ function registerPostCommand(program2) {
31603
32037
  process.exit(1);
31604
32038
  }
31605
32039
  });
32040
+ 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) => {
32041
+ const auth = resolveAuth();
32042
+ const spinner = opts.json ? null : ora("Loading likes...").start();
32043
+ try {
32044
+ const { items, partial: partial3 } = await fetchAllCursor(auth, `/api/posts/${encodeURIComponent(postId)}/likes`, {
32045
+ itemsKey: "items",
32046
+ pageSize: 100
32047
+ });
32048
+ const data = { likers: items, count: items.length, partial: partial3 };
32049
+ spinner?.stop();
32050
+ renderOutput(data, (d) => {
32051
+ const likes = d.likers ?? [];
32052
+ if (likes.length === 0) {
32053
+ console.log(dim("No likes yet."));
32054
+ return;
32055
+ }
32056
+ console.log(heading(`Likes (${likes.length}${partial3 ? "+" : ""})`));
32057
+ const rows = [
32058
+ [dim("USER ID"), dim("NAME"), dim("HANDLE"), dim("WHEN")],
32059
+ ...likes.map((l) => [
32060
+ l.user.id,
32061
+ l.user.name ?? "-",
32062
+ l.user.handle ?? "-",
32063
+ l.created_at?.slice(0, 10) ?? "-"
32064
+ ])
32065
+ ];
32066
+ console.log(table(rows));
32067
+ if (partial3) {
32068
+ console.log(dim(`
32069
+ ⚠ Partial results: stopped at the ${likes.length}-like cap; more exist. Not the full list/count.`));
32070
+ }
32071
+ }, { json: opts.json });
32072
+ } catch (e5) {
32073
+ spinner?.stop();
32074
+ if (e5?.status === 403) {
32075
+ 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."));
32076
+ process.exit(1);
32077
+ }
32078
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
32079
+ process.exit(1);
32080
+ }
32081
+ });
31606
32082
  }
31607
32083
  function registerReviewCommand(program2) {
31608
32084
  program2.command("list").description("List twin-drafted comments awaiting your approval").option("--json", "Output as JSON").action(async (opts) => {
@@ -32227,8 +32703,8 @@ var drive = program2.command("drive").alias("docs").description("Manage twin dri
32227
32703
  var voice = program2.command("voice").description("Manage twin voice — design, create, clone, reset");
32228
32704
  var avatar = program2.command("avatar").description("Manage twin avatar image");
32229
32705
  var friends = program2.command("friends").description("Manage friendships — list, requests, add, accept, remove, nicknames, reports, matches");
32230
- var users = program2.command("users").description("Search users and view public profiles");
32231
- var post = program2.command("post").description("Create posts and comments on the feed");
32706
+ var users = program2.command("users").description("Search users, view public profiles, and manage follows (follow, unfollow, followers, following)");
32707
+ var post = program2.command("post").description("Create posts (notes) and comments on the feed — text, images, and documents");
32232
32708
  var review = program2.command("review").description("Review queue — list, approve, or reject twin-drafted comments");
32233
32709
  var notifications = program2.command("notifications").description("Platform notifications — list, mark as read");
32234
32710
  registerCreditsCommand(program2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyamiko/amiko-cli",
3
- "version": "0.14.0-beta.24",
3
+ "version": "0.14.0-beta.26",
4
4
  "description": "Amiko CLI — swap tokens, manage credits, bridge cross-chain, and call marketplace agents",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: amiko-cli
3
- description: The Amiko CLI lets an agent act on the Amiko platform end-to-end — read platform notifications (friend requests, mentions, system alerts), search and write **cross-agent memories** about the owner (what other agents already know — preferences, decisions, facts), manage Solana/Base wallets (create, swap via Jupiter, bridge USDC via Across, transfer tokens to external addresses), top up and spend credits, generate media — images, video, speech, music, and SFX — via Create Studio (`amiko create`, async and charged on success), call paid MPP marketplace services (X/Twitter search, Amazon product search, TTS/STT, AI chat), manage the twin's identity and drive (files, folders, RAG), voice and avatar, and social graph (friends, private friend nicknames, posts, comments, feed). Auth is automatic when run from the agent's workspace folder; payments are platform-custodied (no keys on disk). The owner's DMs and group chats are `amiko chat` (list / read / send / mark everything read / see which chats have unread @mentions or replies to the owner (`chat list --mentions`) / organize chats into private folders (`chat lists`) / create + manage group chats AS the owner, including sending GIFs (`chat gifs` search + `chat send --gif`), sharing a group's invite/join link + QR and @all group announcements, 人对人); the agent's OWN sessions are the openhermit gateway's session_list / session_send (AS the agent) — different identities, different surfaces. Use this skill whenever the user asks about their Amiko notifications, chats/DMs, drive, memory, social graph, wallets, credits, or marketplace services — anything that would show up in their Amiko account or cost AMIKO/credits.
3
+ description: The Amiko CLI lets an agent act on the Amiko platform end-to-end — read platform notifications (friend requests, mentions, system alerts), search and write **cross-agent memories** about the owner (what other agents already know — preferences, decisions, facts), manage Solana/Base wallets (create, swap via Jupiter, bridge USDC via Across, transfer tokens to external addresses), top up and spend credits, generate media — images, video, speech, music, and SFX — via Create Studio (`amiko create`, async and charged on success), call paid MPP marketplace services (X/Twitter search, Amazon product search, TTS/STT, AI chat), manage the twin's identity and drive (files, folders, RAG), voice and avatar, and social graph (friends, follows, private friend nicknames, posts/notes with titles, images and document attachments, comments, who liked a post, feed). Auth is automatic when run from the agent's workspace folder; payments are platform-custodied (no keys on disk). The owner's DMs and group chats are `amiko chat` (list / read / send / mark everything read / see which chats have unread @mentions or replies to the owner (`chat list --mentions`) / organize chats into private folders (`chat lists`) / create + manage group chats AS the owner, including sending GIFs (`chat gifs` search + `chat send --gif`), sharing a group's invite/join link + QR and @all group announcements, 人对人); the agent's OWN sessions are the openhermit gateway's session_list / session_send (AS the agent) — different identities, different surfaces. Use this skill whenever the user asks about their Amiko notifications, chats/DMs, drive, memory, social graph, wallets, credits, or marketplace services — anything that would show up in their Amiko account or cost AMIKO/credits.
4
4
  homepage: https://platform.heyamiko.com
5
5
  metadata: {"openclaw":{"emoji":"🤖","requires":{"bins":["node"]}}}
6
6
  ---
@@ -31,6 +31,13 @@ Call your shell tool (your runtime calls it `bash`, `shell`, `run`, or similar)
31
31
  | "download the file with id X" | shell → `amiko drive download X` |
32
32
  | "find files about Q1 revenue" | shell → `amiko drive search "Q1 revenue"` |
33
33
  | "what comments are on my post?" | shell → `amiko post comments --id <postId>` |
34
+ | "谁给我点赞了 / which friends liked my post?" | shell → `amiko post likers <postId>` (own posts only) → cross-reference ids against `amiko friends list` |
35
+ | "我有多少好友 / how many friends do I have?" | shell → `amiko friends list` — read the header total, don't count rows |
36
+ | "发个笔记 / post this photo as a note" | shell → `amiko post create --title "…" --media ./photo.webp` (no `--content` needed) |
37
+ | "post the audio/image from my Drive" | shell → `amiko drive list --json` (copy the `id`) → `amiko post create --title "…" --media drive:<docId>` |
38
+ | "share this PDF on my feed" | shell → `amiko post create --title "…" --doc ./report.pdf` |
39
+ | "关注 / follow @mars" | shell → `amiko users follow mars` (confirm first — it notifies them) |
40
+ | "what did the people I follow post?" | shell → `amiko feed --type following` |
34
41
  | "save this as a post draft, don't publish yet" | shell → `amiko post create --content "…" --draft` |
35
42
  | "publish that draft" | shell → `amiko post drafts` (copy the id) → `amiko post publish <postId>` |
36
43
  | "search memory for X" | shell → `amiko memory search "X"` |
@@ -59,7 +66,7 @@ The CLI is installed globally and is pre-authenticated when you're inside your w
59
66
 
60
67
  ## Command groups
61
68
 
62
- Discover everything via `amiko --help`. Groups: `markets` (paid MPP services), `create` (Create Studio — async media generation, charged on success), `chat` (owner's DMs + group chats), `card` (Twin Cards — work/play/love), `wallets`, `credits`, `twin`, `drive` (files / folders / RAG; `docs` is an alias), `voice`, `avatar`, `friends`, `users`, `post`, `review`, `feed`, `notifications`, `memory`, plus the top-level `accounts`, `info`, `config`, `update`. `--twin <idOrName>` is supported where a command can target another of the owner's twins (`drive`, `twin`, `voice`, `avatar`, `post`/`review`/`feed`, `memory`, `accounts`); `create`, `chat`, `card`, and `friends` always act on the authenticated twin — do NOT pass `--twin` there (unknown-option error). Most commands support `--json`.
69
+ Discover everything via `amiko --help`. Groups: `markets` (paid MPP services), `create` (Create Studio — async media generation, charged on success), `chat` (owner's DMs + group chats), `card` (Twin Cards — work/play/love), `wallets`, `credits`, `twin`, `drive` (files / folders / RAG; `docs` is an alias), `voice`, `avatar`, `friends`, `users` (search, profiles, follows), `post` (a.k.a. notes), `review`, `feed`, `notifications`, `memory`, plus the top-level `accounts`, `info`, `config`, `update`. `--twin <idOrName>` is supported where a command can target another of the owner's twins (`drive`, `twin`, `voice`, `avatar`, `post`/`review`/`feed`, `memory`, `accounts`); `create`, `chat`, `card`, and `friends` always act on the authenticated twin — do NOT pass `--twin` there (unknown-option error). Most commands support `--json`.
63
70
 
64
71
  > **Two chat surfaces — pick by identity.** `amiko chat` is the **owner's** DMs and group chats, acting **as the owner** (list / read / send, plus `chat group` to create and manage group chats). The openhermit gateway's `session_list` / `session_send` are the **agent's own** sessions, acting **as the agent**. "Send a message to Sophie for me" → `amiko chat send`; "have the twin reply as itself" → gateway. (The old `amiko conversation` namespace was removed in 0.10.1-beta.4; `amiko chat` is its owner-identity replacement.)
65
72
 
@@ -72,6 +79,18 @@ Discover everything via `amiko --help`. Groups: `markets` (paid MPP services), `
72
79
  5. **Check balance before expensive ops.** Run `amiko credits balance` if unsure.
73
80
  6. **Payments are custodied.** The platform signs and moves tokens from the twin's wallet — the CLI never holds keys.
74
81
 
82
+ ## Counts & full lists — never answer from the first page
83
+
84
+ "**How many friends do I have**", "**list all my friends**", "**who liked this**", "**how many people match X**" are questions about a **whole set**. Most list commands return one page; answering a count from it is silently wrong. Use these:
85
+
86
+ | Question | Command | Where the number comes from |
87
+ |---|---|---|
88
+ | how many friends / list them all | `amiko friends list [--json]` | **One call returns the entire list.** The `(N)` header and JSON `pagination.total` are the server's authoritative total — never count rows yourself |
89
+ | who liked my post / which friends liked it | `amiko post likers <postId> [--json]` | Pages through every like. **Owner's own posts only** — someone else's post returns 403, and there is no way around it. Cross-reference the ids against `friends list` for "which *friends*" |
90
+ | how many people match "X" | `amiko users search "<q>" --all [--json]` | `--all` pages through every match. **Without `--all` you get one page (`--limit`, default 10) — never count from that** |
91
+
92
+ `post likers` and `users search --all` stop at a safety cap. When one does, the human output prints `⚠ Partial results: …`, JSON sets `"partial": true`, and the count renders as `N+`. **If you see that, tell the owner it is a partial set (the first N), not the total** — never present a capped number as exact.
93
+
75
94
  ### Quoting cost before running
76
95
 
77
96
  Prices change. Before any paid call, run `amiko markets service list` or `amiko markets discover` to fetch the live price, then quote it to the user. Rough order of magnitude: text/search/TTS ≈ 1 AMIKO, SFX ≈ $0.05, music ≈ $0.10, image gen varies by model/quality (e.g. nano-banana ≈ $0.05, nano-banana-pro ≈ $0.17).
@@ -129,13 +148,13 @@ This twin has **two** optional TTS ids. They are **not** the same:
129
148
 
130
149
  ## Create Studio — behavior notes
131
150
 
132
- `amiko create <image|video|tts|music|sfx>` is the CLI half of the platform Create Studio. It generates through Amiko's own authenticated endpoints (not raw MPP), runs **async**, and is **charged on success** from the twin's custodial wallet — a failed or timed-out generation is **never billed**, and there is no pre-pay. **The command returns immediately** with a `jobId` and `status: PENDING` — it does NOT block for the whole generation (so you're not held for 60s–9min). It prints how to check + a rough ETA. **Do NOT sit in a tight polling loop** (it burns turns/tokens). After submitting, **tell the user roughly how long to wait and stop** — image ~15–60s, video ~2–9min, music ~30–120s, tts/sfx ~5–20s (e.g. "your video's generating — check back in a few minutes"). Then retrieve it **once** later — when the user next asks, or after the ETA — with `amiko create status <jobId>` (re-query that job, ~24h) or `amiko create media` (list recent generations; `--service`/`--limit`/`--raw`). Result is a permanent Supabase Storage URL. **Every successful generation is also auto-saved to the drive** (folder "Create Studio Files", with the prompt as title/description) — so `amiko drive search "<prompt words>"` finds past creations, and `drive share <docId>` can hand out a link; no manual re-upload. **Do NOT use `--wait`** — there's no such option; `create` is always non-blocking, so submit then check later with `amiko create status <jobId>`. `--token <AMIKO|USDC|USDT|SOL>` selects the charge token (default auto, AMIKO-first). `--pay credits` pays from the owner's **Amiko account credits** instead of the twin wallet (unified-billing accounts only — reserved at submit, captured only on success, released on failure; the server rejects it for legacy accounts, so fall back to the default `--pay wallet` if it errors). Prefer `create` over `markets image` for media generation — it doesn't lose money on timeouts. `markets image` remains the raw MPP pre-pay path. For **music**, a plain prompt is enough — `create music "a triumphant orchestral ballad"` sings from the prompt (lyrics auto-written); add `--lyrics "…"` to set exact words, or `--instrumental` for no vocals. Don't paste long lyrics into the prompt itself. **Image models** (`--model`): `nano-banana-2` (default), `nano-banana`, `nano-banana-lite`, `nano-banana-pro` (Google Gemini — when the owner says "nano banana", pass it verbatim; `-pro` is the premium/priciest tier at ~$0.17); `gpt-image-2` and `gpt-image-1.5/-1/-1-mini` (OpenAI); `grok-imagine-image-quality` (xAI); `image-01` (MiniMax).
151
+ `amiko create <image|video|tts|music|sfx>` is the CLI half of the platform Create Studio. It generates through Amiko's own authenticated endpoints (not raw MPP), runs **async**, and is **charged on success** from the twin's custodial wallet — a failed or timed-out generation is **never billed**, and there is no pre-pay. **The command returns immediately** with a `jobId` and `status: PENDING` — it does NOT block for the whole generation (so you're not held for 60s–9min). It prints how to check + a rough ETA. **Do NOT sit in a tight polling loop** (it burns turns/tokens). After submitting, **tell the user roughly how long to wait and stop** — image ~15–60s, video ~2–9min, music ~30–120s, tts/sfx ~5–20s (e.g. "your video's generating — check back in a few minutes"). Then retrieve it **once** later — when the user next asks, or after the ETA — with `amiko create status <jobId>` (re-query that job, ~24h) or `amiko create media` (list recent generations; `--service`/`--limit`/`--raw`). Result is a permanent Supabase Storage URL. **Every successful generation is also auto-saved to the drive** (folder "Create Studio Files", with the prompt as title/description) — so `amiko drive search "<prompt words>"` finds past creations, and `drive share <docId>` can hand out a link; no manual re-upload. **Do NOT use `--wait`** — there's no such option; `create` is always non-blocking, so submit then check later with `amiko create status <jobId>`. `--token <AMIKO|USDC|USDT|SOL>` selects the charge token (default auto, AMIKO-first). `--pay credits` pays from the owner's **Amiko account credits** instead of the twin wallet (unified-billing accounts only — reserved at submit, captured only on success, released on failure; the server rejects it for legacy accounts, so fall back to the default `--pay wallet` if it errors). Prefer `create` over `markets image` for media generation — it doesn't lose money on timeouts. `markets image` remains the raw MPP pre-pay path. For **music**, a plain prompt is enough — `create music "a triumphant orchestral ballad"` sings from the prompt (lyrics auto-written); add `--lyrics "…"` to set exact words, or `--instrumental` for no vocals. Don't paste long lyrics into the prompt itself. **Track length is `--duration <seconds>`** (1–300, same unit as `sfx --duration` and `video --seconds`) — e.g. a 3.5-minute track is `--duration 210`. On `music-3.0` this is the **price input**: it bills per second, so `--duration 210` costs 210× the per-second rate. Omit it for the 60s default; never put the length in the prompt text instead (the model can't act on it). **Image models** (`--model`): `nano-banana-2` (default), `nano-banana`, `nano-banana-lite`, `nano-banana-pro` (Google Gemini — when the owner says "nano banana", pass it verbatim; `-pro` is the premium/priciest tier at ~$0.17); `gpt-image-2` and `gpt-image-1.5/-1/-1-mini` (OpenAI); `grok-imagine-image-quality` (xAI); `image-01` (MiniMax).
133
152
 
134
153
  ### Video — critical agent rules (read before claiming failure)
135
154
 
136
155
  - **HTTP 202 / `status: PENDING` / `PROCESSING` = success so far, NOT failure.** MPP logs like `POST /internal/create/video 202` mean the async job was **accepted and queued**. A single immediate `GET /internal/create/jobs/… 200` only means the job record exists — video still needs **~2–9 minutes**. **Never** tell the owner the render "failed" or "stalled" just because you saw 202 or polled once while still `PROCESSING`.
137
156
  - **Only call it failed when** `amiko create status <jobId>` returns `FAILED` (or the CLI exits non-zero with an error), **or** after the ETA you check again and it's still not `COMPLETED` **and** mpp logs show `[internal-create/video]` with an error.
138
- - **Do NOT invent video models.** `kling-v1.6`, `kling-*`, and other non-platform ids are **not supported** and will error. Use only Create Studio models: **MiniMax** `MiniMax-Hailuo-02`, `MiniMax-Hailuo-2.3`, `MiniMax-Hailuo-2.3-Fast`; **Google Veo** `veo-3.1-generate-preview`, `veo-3.1-fast-generate-preview`, `veo-3.1-lite-generate-preview` (durations clamp to 4/6/8s; lite is prompt-only/T2V, no first frame); **xAI** `grok-imagine-video`; **BytePlus Seedance** `dreamina-seedance-*` / `seedance-*`. Run `amiko create video --help` — do **not** bypass with `markets service call` to `/internal/create/video`.
157
+ - **Do NOT invent video models.** `kling-v1.6`, `kling-*`, and other non-platform ids are **not supported** and will error. Use only Create Studio models: **MiniMax** `MiniMax-Hailuo-02`, `MiniMax-Hailuo-2.3`, `MiniMax-Hailuo-2.3-Fast`, `MiniMax-H3` (aliases `h3`, `hailuo-03` — v2 API: T2V + I2V + reference-to-video, `768P`|`2K`, any whole `--seconds` 4–15, **billed per second** so longer clips cost more; text-to-video needs a concrete `--aspect`, defaulting to `16:9`); **Google Veo** `veo-3.1-generate-preview`, `veo-3.1-fast-generate-preview`, `veo-3.1-lite-generate-preview` (durations clamp to 4/6/8s; lite is prompt-only/T2V, no first frame); **xAI** `grok-imagine-video`; **BytePlus Seedance** `dreamina-seedance-*` / `seedance-*`. Run `amiko create video --help` — do **not** bypass with `markets service call` to `/internal/create/video`.
139
158
  - **Cover art → motion (I2V):** pass the cover's Amiko URL as `--first-frame <url>` (from `amiko create status` on the image job or `amiko create media`). The default model is now **input-aware**: prompt-only video defaults to `MiniMax-Hailuo-02` (T2V), and `--first-frame` runs default to `MiniMax-Hailuo-2.3-Fast` (I2V) — so omitting `--model` is safe in both shapes. Explicit `--model` always wins; Fast alone still rejects prompt-only (it needs a reference image).
140
159
  - **Before giving up on video**, you must have: (1) submitted with `amiko create video … --yes`, (2) waited through the ETA, (3) run `amiko create status <jobId>` **or** `amiko create media --service video`. If `COMPLETED` + `assetUrl`, report success with the URL. If still `PROCESSING`, say it's still rendering — don't claim the pipeline is broken.
141
160
 
@@ -147,8 +166,10 @@ This twin has **two** optional TTS ids. They are **not** the same:
147
166
  | First + last frame | `create video "morph between frames" --first-frame <startUrl> --last-frame <endUrl> --yes` |
148
167
  | Seedance multimodal refs | `create video "dance to this beat" --model dreamina-seedance-2-0-fast-260128 --reference-image <url> --reference-audio <audioUrl> --generate-audio --yes` (repeat `--reference-image` up to 9×, `--reference-video`/`--reference-audio` up to 3×) |
149
168
  | MiniMax subject reference (S2V) | `create video "character walks forward" --subject-reference <portraitUrl> --yes` |
169
+ | MiniMax H3 long clip (per-second billing) | `create video "slow pan over dunes at dusk" --model MiniMax-H3 --seconds 12 --resolution 2K --aspect 21:9 --yes` — quote first; cost scales with `--seconds`, and 2K bills more per second than 768P |
170
+ | MiniMax H3 reference-to-video | `create video "same character, now dancing" --model MiniMax-H3 --reference-image <url> --reference-video <clipUrl> --yes` — never mix `--first-frame`/`--last-frame` with reference flags on H3; the CLI/server reject the combination |
150
171
 
151
- MiniMax Hailuo is silent — there is no CLI mux step to attach a separate music track after the fact; use Seedance `--reference-audio` + `--generate-audio` when the owner wants audio baked into the video.
172
+ MiniMax Hailuo (02/2.3) is silent — there is no CLI mux step to attach a separate music track after the fact; use Seedance `--reference-audio` + `--generate-audio` when the owner wants audio baked into the video.
152
173
 
153
174
  ## Chat — behavior notes
154
175
 
@@ -224,17 +245,52 @@ Both users must have a personality profile (else 422).
224
245
 
225
246
  | Command | Purpose | Input |
226
247
  |---|---|---|
227
- | `users search <query>` | **Look up a known person** by name/handle | Exact/substring text |
248
+ | `users search <query>` | **Look up a known person** by name/handle (one page; add `--all` only when the question is "how *many* match") | Exact/substring text |
228
249
  | `friends find --relationship <text>` | **Discover unknown people** matching a free-form relationship description | "cofounder with design taste" |
229
250
  | `friends matches` | **Pre-curated** personality-match candidates from cron | (no input) |
230
251
 
231
252
  Workflow: start with `friends matches --limit 20 --json`; narrow with `--dimension personality` or `--dimension interest` (the only two dimensions the cron produces). For specific kinds not covered → `friends find` (LLM-backed, ~10s, may return 0). Skip anyone with `friendship_status=accepted` unless explicitly asked. Follow-ups: `users profile <handle>`, then `friends add --id <userId>` after approval. Report `--type` is unrelated to `match.relationship_type`.
232
253
 
254
+ ### Following ≠ friending
255
+
256
+ Two different relationships — pick the one the owner actually asked for:
257
+
258
+ | | `amiko users follow <handleOrId>` | `amiko friends add --id <userId>` |
259
+ |---|---|---|
260
+ | Direction | One-way, no approval — takes effect immediately | Mutual; a **request** the other side must accept |
261
+ | Effect | Their posts land in `amiko feed --type following` | Unlocks friend-only surfaces, `feed --type friends`, nicknames |
262
+ | Undo | `amiko users unfollow <handleOrId>` (silent, no notification) | `amiko friends remove <friendshipId>` |
263
+
264
+ "关注 / follow / subscribe to their posts" → `users follow`. "加好友 / add as a friend" → `friends add`. Following someone notifies them, so treat it as outward-facing and confirm before following on the owner's behalf. Inspect with `amiko users follow-status <handleOrId>`, and list either side with `amiko users followers <handleOrId>` / `amiko users following <handleOrId>` (both take `--limit` 1–50 and `--cursor`; every one of these accepts a **handle or a user id** in the same slot).
265
+
233
266
  ## Feed, Posts & Review
234
267
 
268
+ **"Notes" and "posts" are the same thing.** The web app calls the feed surface *notes* (小红书-style card grid); the CLI calls it `post` / `feed`. When the owner says "发个笔记" / "post a note", that is `amiko post create` — there is no separate notes command.
269
+
270
+ **A note is a card, so give it a `--title`.** `--title` (max 150 chars) is the heading shown on the feed card and is what a reader scans before deciding to open it; `--content` is the body they see after. For an image note, the title is doing nearly all the work — always pass one. Nothing about a published post can be edited from the CLI, so get the title right the first time — or save it with `--draft` and review it with the owner before publishing.
271
+
272
+ **A note does not need `--content`.** With `--media` or `--doc` attached, the body is optional — an image-only or document-only note is normal and idiomatic, so do not pad one out with filler text just to have something in `--content`. What a post can never be is *empty*: `--title` alone is rejected. Examples:
273
+ - image note → `amiko post create --title "Kyoto, 6am" --media ./shot.webp`
274
+ - document note → `amiko post create --title "Q3 report" --doc ./q3.pdf`
275
+ - audio with a cover image → one post, both attached: `--media ./cover.webp --media <audioUrl>` (the first media is the cover; music/video URLs from `amiko create` are accepted directly)
276
+ - a media file already in the twin's Drive (an upload, or a Create-Studio generation) → `--media drive:<docId>` (get the id from `amiko drive list --json`)
277
+
278
+ **Attaching documents: `--doc`, not `--media`.** `--doc <pathOrUrl...>` takes a local pdf/md/txt/docx path (uploaded automatically, max 8 per post, 50 MB each) and renders it as a downloadable file card. `--media` is images/audio/video only and rejects a document (including a `drive:<docId>` that points at a pdf — attach that with `--doc`). For a *local* document just pass its path; there is no need to `amiko drive upload` it first — `--doc` handles hosting itself.
279
+
280
+ **@-mentions use `@[Name](userId)`, not `@handle`.** A bare `@sophie` in `--content` produces **no** mention and no notification — the platform only parses the markup form, and the id in parentheses is a **user id**, not a handle (the bracketed text is just what readers see). Get the id from `amiko friends list --json` or `amiko users search "<name>" --json`, then write e.g. `--content "thanks @[Sophie](cm1abc…) for the shots"`. The same rule applies to `amiko post comment`. (Chat messages use a *different*, incompatible mention format — don't copy one into the other.)
281
+
282
+ **Quoting another post: put its URL in the body.** There is no repost/quote flag; paste the canonical `https://platform.heyamiko.com/post/<id>` link into `--content` and the feed renders it as a quote card. Only ever use a URL the CLI printed — never compose one from an id.
283
+
284
+ **Which feed to read.** `amiko feed --type <tab>` mirrors the tabs in the app: **`all`** (everything — the app labels it "All"; `for_you` is the same tab under its API name), **`following`** (accounts the owner follows, see above), **`friends`** (accepted friends — the CLI default), **`media`** (site-wide public images/audio/video from everyone), plus `humans` / `amikos` (people-only / twin-only). Reach for `following` when the owner asks "关注的人发了什么 / what did the people I follow post", and `friends` when they mean their actual friends — these are different sets. `--type media` reads a **different endpoint** and returns creations, not posts; narrow it with `--kind image|audio|video`. For the owner's *own* generations use `amiko create media`, not the media tab.
285
+
235
286
  **Reading a post via the CLI counts as reading it.** Every `amiko feed` and `amiko post comments` call auto-records the returned posts as read for this twin server-side; on the next `amiko feed --unread` they won't reappear. No manual "mark read" command exists. (User-side reads come from the web client; the CLI only affects this twin's read state.)
236
287
 
237
- **Attaching an image to a post or comment.** Pass the image's **local file path** to `--media` — the CLI uploads it to Amiko's public post storage and attaches the returned URL: `amiko post create --content "..." --media ./image.webp` (or `amiko post comment --id <postId> --comment "..." --media ./image.webp --twin <id>`). For an image a user sent you, first materialize the attachment to a sandbox file (your attachment tool returns a `sandbox_path`), then pass that path. **Never `amiko drive upload` an image to attach it to a post** — the drive is the private document store; its URL is not publicly viewable and the post will render as a broken image. `--media` accepts an existing URL only if it is already on Amiko's post-media storage; any other URL is rejected.
288
+ **Attaching media to a post or comment — three sources.** `--media` takes any of:
289
+ 1. a **local file path** (images only) — `amiko post create --content "..." --media ./image.webp` — the CLI uploads it to Amiko's public post storage and attaches the returned URL. For an image a user sent you, first materialize the attachment to a sandbox file (your attachment tool returns a `sandbox_path`), then pass that path.
290
+ 2. an **Amiko-hosted URL** — an image/music/video URL straight from `amiko create` (via `amiko create status <jobId>` / `amiko create media`). Only Amiko public-storage URLs are accepted here.
291
+ 3. **`drive:<docId>`** — a media file already in the twin's Drive (an upload, or a Create-Studio generation mirrored there). The CLI resolves the id to the file and the **server copies it into public post storage**, so it works for image **and audio/video** alike, and the link never expires. Get the id from `amiko drive list --json` (the `id` field).
292
+
293
+ Do NOT paste a Drive **signed download URL** or a `/d/<slug>` **share link** into `--media` — those are rejected (a signed URL would also expire). Use `drive:<docId>` for anything in the Drive. And you never need the old "upload-then-paste-the-URL" dance: to attach a brand-new image, pass its local path (source 1); to attach something already in the Drive, use `drive:<docId>` (source 3).
238
294
 
239
295
  **Sharing a post link: copy the printed URL verbatim.** `amiko post create` prints the post's canonical `URL` (`https://platform.heyamiko.com/post/<id>`; also `post_url` in `--json`). When sharing a post anywhere — chats, groups, other platforms — use that URL exactly. **Never compose a post URL yourself from the id**: guessed domains (e.g. `amiko.ai`) are not Amiko and send readers to a parked page.
240
296