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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @heyamiko/amiko-cli (v0.14.0-beta.15)
1
+ # @heyamiko/amiko-cli (v0.14.0-beta.24)
2
2
 
3
3
  Manage wallets, credits, swaps, MPP marketplace services, and your Amiko twin (identity, documents, voice, avatar, friends, feed) from the terminal. Works for both human users and AI agents running on OpenClaw.
4
4
 
@@ -249,6 +249,7 @@ Your conversations — **DMs and group chats** — acting **as you (the owner)**
249
249
  ```bash
250
250
  amiko chat list # all conversations (DM + group): id, peer/title, last msg, unread
251
251
  amiko chat list --limit 50 --archived # include archived
252
+ amiko chat list --mentions # only chats with an unread @mention of you or a reply to you
252
253
  amiko chat read @sophie # recent messages with a user (by @handle)
253
254
  amiko chat read <conversationId> --limit 40 # by conversation id (from `chat list`) — works for groups too
254
255
  amiko chat send @sophie "on my way!" --yes # send as the owner
@@ -271,6 +272,7 @@ amiko chat mark-read --all --yes # mark EVERY conversation as re
271
272
 
272
273
  - **`<target>`** for `read`/`send` is a **conversation id** (from `chat list` — use this for groups), a **user id**, an **`@handle`**, or a **name**. Names resolve against your **friends first**, then people search; if ambiguous, the CLI lists candidates instead of guessing. A cuid that isn't one of your conversations is retried as a user id, so pasting a user id "just works".
273
274
  - `send` to a person you have no DM with yet **creates the DM automatically** (find-or-create, after the confirmation). `read` never creates one — it errors if no DM exists yet.
275
+ - **Unread mentions & replies**: rows whose unread messages **concern you directly** get an `@you` marker next to the unread count, and `chat list --mentions` filters to just those. The flag is server-computed over your unread messages and covers three cases: a direct **@mention** of you, a permitted **@all** in a group, and a **reply to one of your messages**. Composes with `--limit`/`--archived`/`--raw` (the raw payload is filtered too). Requires the amiko-web `has_unread_mention` deploy. On an older server the field is absent and reads as **false for every conversation**: plain `chat list` still shows unread counts, just without `@you`, and `chat list --mentions` returns no matches.
274
276
  - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≤10MB or an Amiko URL), `--audio <pathOrUrl>` (local ≤25MB or an Amiko URL → sent as a voice note), and `--gif <queryOrUrl>` (search words send the **top Klipy result**; or pass an exact Klipy URL from `amiko chat gifs` — non-Klipy URLs are refused; to send another image, use `--image` with a local file or an Amiko URL). One media block per message (image *or* audio *or* gif). **General video files aren't supported in chat yet.** A local file is uploaded; an Amiko URL (e.g. a generated asset from `amiko create`) attaches directly with no re-upload. (A local *audio* file is sent as a voice note, with your text as a separate message.)
275
277
  - **GIFs**: `amiko chat gifs [query]` lists Klipy GIFs (trending with no query; `--page`/`--limit`/`--raw`) — read-only and ungated, powered by KLIPY. The message text is optional with `--gif` (a captionless GIF sends as just the GIF); a caption renders under the GIF on every client.
276
278
  - **@all in groups**: `--all` prepends an @all mention, and a standalone `@all` word typed in the message converts too — either way **every member** is notified. Group **admins/owners** can always use it; everyone else only after an admin runs `amiko chat group mention-all <group> on`. Without permission, `--all` fails with the fix named, while a typed `@all` is delivered as plain text (with a note). Incoming mention markup renders as plain `@name`/`@all` in `chat read` and `chat list`.
@@ -301,6 +303,23 @@ amiko chat group leave <groupIdOrTitle> --yes # alias: delete — see caveat
301
303
  - **`mention-all <group> <on|off>`** controls who may @all in the group: `on` lets every member, `off` (the default) restricts it to admins. The current setting shows in `amiko chat group info` as `@all mentions: everyone | admins only`.
302
304
  - `create`/`add` are outward social actions and `rename`/`remove`/`leave`/`promote`/`mention-all` are destructive, so all are **gated on `--yes`** in non-interactive shells. `--raw` prints JSON everywhere.
303
305
 
306
+ ### Chat lists
307
+
308
+ Your **chat lists** — private folders of conversations (the same lists as the app sidebar). Only you ever see them; putting a chat in a list notifies nobody and changes nothing about the conversation itself.
309
+
310
+ ```bash
311
+ amiko chat lists # every list with its conversations (names resolved)
312
+ amiko chat lists create "Work" --conversation "Trip planning" --conversation @sophie --yes
313
+ amiko chat lists rename "Work" "Focus" --yes
314
+ amiko chat lists add "Focus" --conversation <conversationId> --yes
315
+ amiko chat lists remove "Focus" --conversation @sophie --yes
316
+ amiko chat lists delete "Focus" --yes # the conversations themselves are untouched
317
+ ```
318
+
319
+ - **`<list>`** is a list id or name (case-insensitive; exact beats substring; ambiguity errors listing candidates). **`--conversation`** is repeatable and takes the same targets as `chat send` — conversation id, user id, `@handle`, group title, or name — except a person must already have a DM with you: organizing lists **never creates conversations**.
320
+ - The server stores a list as a plain conversation-id array and `PATCH` replaces it wholesale, so `add`/`remove` read-modify-write the full array — existing entries always survive an `add`, and one unresolvable `--conversation` aborts the whole command before anything is written.
321
+ - Lists are private, but mutations still take the `--yes` gate in non-interactive shells (they reshape your chat sidebar on every device). The bare listing is an ungated read; `--raw` prints JSON everywhere.
322
+
304
323
  ## Twin Cards
305
324
 
306
325
  Generate, view, download, and mint your **Twin Card** (template `twins_take`, flavors **work / play / love**).
@@ -562,6 +581,9 @@ amiko friends requests # pending requests (incoming + outgoing)
562
581
  amiko friends add --id <userId>
563
582
  amiko friends accept <friendshipId>
564
583
  amiko friends remove <friendshipId> --yes
584
+ amiko friends nickname # every private nickname you've set
585
+ amiko friends nickname set Sophie "Soph" --yes # set/change (name, @handle, user id, or friendship id)
586
+ amiko friends nickname remove Sophie --yes # clear it
565
587
  amiko friends matches # personality match candidates
566
588
  amiko friends reports list # friend matching reports
567
589
  amiko friends reports view <reportId>
@@ -585,7 +607,7 @@ amiko post comment --id <postId> --comment "nice!" --media https://...jpg
585
607
 
586
608
  Every new command accepts `--json` for structured output and `--twin <id>` where a target twin is needed.
587
609
 
588
- **Destructive or identity-changing commands** (`docs delete`, `friends remove`, `voice reset`, `avatar update`, `twin update --public`) prompt for confirmation in a TTY and require `--yes` in non-interactive shells.
610
+ **Destructive or identity-changing commands** (`docs delete`, `friends remove`, `friends nickname set/remove`, `voice reset`, `avatar update`, `twin update --public`) prompt for confirmation in a TTY and require `--yes` in non-interactive shells.
589
611
 
590
612
  ## API Endpoints
591
613
 
@@ -603,6 +625,15 @@ npm publish
603
625
 
604
626
  ## Changelog
605
627
 
628
+ ### 0.14.0-beta.24
629
+
630
+ - **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).
631
+
632
+ ### 0.14.0-beta.23
633
+
634
+ - **Chat lists: `amiko chat lists`.** View and manage the owner's private conversation folders against amiko-web `/api/chat-lists`: bare `lists` renders every list with conversation names resolved, plus `create` / `rename` / `add` / `remove` / `delete` subcommands (all `--yes`-gated). The server stores a wholesale `conversation_ids` array, so `add`/`remove` read the current array, mutate locally, and PATCH it back — existing entries always survive an `add`. `--conversation` targets resolve like `chat send` (id / user id / `@handle` / group title / name) but map people to their **existing** DM only — list organization never creates a conversation.
635
+ - **Unread mentions in `chat list`: `@you` marker + `--mentions` filter.** Conversations whose unread messages contain a direct @mention of the owner, a permitted @all, or a **reply to one of the owner's messages** now render `(N unread, @you)`, and `chat list --mentions` keeps only those rows (the `--raw` payload is filtered too, and the flag is ignored on fully read conversations). Driven by the server-computed `has_unread_mention` field; requires the companion amiko-web deploy (`leandrogavidia/global-quick-fixes`) — on an older server the field is absent and reads as false for every conversation, so plain `chat list` still shows unread counts without `@you` and `--mentions` returns no matches. Also moves `resolveConversation`/`findDmByUserId` from `chat.ts` into `lib/conversations.ts` so `chat lists` can reuse them.
636
+
606
637
  ### 0.14.0-beta.22
607
638
 
608
639
  - **Mark all conversations read: `amiko chat mark-read --all`.** Hits `POST /api/conversations/read-all` as the owner — bulk monotonic `last_read_at` update across every active membership, clears the owner's unread chat/mention notifications (count echoed as "N chat notifications cleared"), busts the per-user conversation cache, and broadcasts read ticks to peers in up to 50 most-recently-active unread conversations. Requires `--all` explicitly (mirrors `notifications read --all`) and sits behind the destructive `--yes` gate — irreversible, and senders see read ticks. `--raw` prints the server payload; success copy never counts conversations (the response's id list is capped at 50). Requires the companion amiko-web read-all deploy (`leandrogavidia/global-quick-fixes`).
package/dist/index.js CHANGED
@@ -27195,6 +27195,93 @@ async function resolveGroup(auth, target) {
27195
27195
  }
27196
27196
  return fetchConversationById(auth, matches[0].id);
27197
27197
  }
27198
+ async function fetchUserById(auth, id) {
27199
+ try {
27200
+ const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(id)}`, { timeoutMs: 15000 });
27201
+ return prof.user?.id ? { ...prof.user, id: prof.user.id } : null;
27202
+ } catch (e5) {
27203
+ if (e5.status === 404)
27204
+ return null;
27205
+ throw e5;
27206
+ }
27207
+ }
27208
+ async function findDmByUserId(auth, userId) {
27209
+ const want = normalizeUserId(userId);
27210
+ const limit = 100;
27211
+ for (let offset = 0;offset < 1e4; offset += limit) {
27212
+ const data = await amikoWebFetch(auth, "/api/conversations", {
27213
+ query: { limit, offset },
27214
+ timeoutMs: 20000
27215
+ });
27216
+ const page = data.conversations ?? [];
27217
+ const dm = page.find((c) => c.conversation_type === "direct" && (c.participants ?? []).some((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === want));
27218
+ if (dm)
27219
+ return dm.id;
27220
+ if (page.length < limit || data.pagination?.has_more === false)
27221
+ return null;
27222
+ }
27223
+ return null;
27224
+ }
27225
+ function personLabel(u) {
27226
+ const name = u.name ?? u.id;
27227
+ return u.handle ? `${name} (@${u.handle})` : name;
27228
+ }
27229
+ async function resolveConversation(auth, target) {
27230
+ const t = target.trim();
27231
+ if (!t) {
27232
+ throw new Error("No target given. Pass a conversation id, a user id, an @handle, or a name.");
27233
+ }
27234
+ const person = (u) => ({
27235
+ kind: "user",
27236
+ userId: u.id,
27237
+ label: `DM with ${personLabel(u)}`
27238
+ });
27239
+ if (t.startsWith("@")) {
27240
+ const handle = t.slice(1);
27241
+ const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(handle)}`, {
27242
+ timeoutMs: 15000
27243
+ });
27244
+ if (!prof.user?.id)
27245
+ throw new Error(`No user @${handle}.`);
27246
+ return person({ ...prof.user, id: prof.user.id });
27247
+ }
27248
+ const idish = normalizeUserId(t);
27249
+ if (looksLikeConversationId(idish)) {
27250
+ if (idish === t) {
27251
+ try {
27252
+ const conv = await fetchConversationById(auth, t);
27253
+ return {
27254
+ kind: "conversation",
27255
+ id: conv.id,
27256
+ label: conv.conversation_type === "direct" ? `DM with ${convLabel(conv, auth.userId)}` : `group "${convLabel(conv, auth.userId)}"`,
27257
+ conversation: conv
27258
+ };
27259
+ } catch (e5) {
27260
+ const status = e5.status;
27261
+ if (status === 403) {
27262
+ throw new Error(`Conversation ${t} exists but you are not a participant.`);
27263
+ }
27264
+ if (status !== undefined && status !== 404)
27265
+ throw e5;
27266
+ }
27267
+ }
27268
+ const user = await fetchUserById(auth, idish);
27269
+ if (!user) {
27270
+ throw new Error(`"${t}" is neither a conversation nor a user id — check \`amiko chat list\` or \`amiko friends list --json\`.`);
27271
+ }
27272
+ return person(user);
27273
+ }
27274
+ const groupId = await findGroupIdByExactTitle(auth, t);
27275
+ if (groupId)
27276
+ return { kind: "conversation", id: groupId, label: `group "${t}"` };
27277
+ const { resolved, failures } = await resolveMembers(auth, [t]);
27278
+ if (failures.length || resolved.length !== 1) {
27279
+ const reason = failures[0]?.reason ?? "could not be resolved";
27280
+ throw new Error(`"${t}" ${reason}`);
27281
+ }
27282
+ const p = resolved[0];
27283
+ return person({ id: p.userId, name: p.name, handle: p.handle });
27284
+ }
27198
27285
 
27199
27286
  // src/lib/mentions.ts
27200
27287
  var MENTION_ALL_TOKEN = "@[all](all:all)";
@@ -27767,6 +27854,296 @@ Send one: amiko chat send <target> --gif <url>${d.hasNext ? ` (more: --page ${d
27767
27854
  });
27768
27855
  }
27769
27856
 
27857
+ // src/commands/chat-lists.ts
27858
+ var collect2 = (v, prev) => [...prev, v];
27859
+ function exitWithError2(e5, fallback2) {
27860
+ console.error(error(e5 instanceof Error ? e5.message : fallback2));
27861
+ process.exit(1);
27862
+ }
27863
+ function conversationFlags(tokens) {
27864
+ return tokens.map((t) => ` --conversation ${JSON.stringify(t)}`).join("");
27865
+ }
27866
+ function countNoun(n) {
27867
+ return `${n} conversation${n === 1 ? "" : "s"}`;
27868
+ }
27869
+ async function fetchChatLists(auth) {
27870
+ const data = await amikoWebFetch(auth, "/api/chat-lists", { timeoutMs: 20000 });
27871
+ return data.chatLists ?? [];
27872
+ }
27873
+ async function resolveList(auth, target) {
27874
+ const t = target.trim();
27875
+ if (!t) {
27876
+ throw new Error("No list given. Run `amiko chat lists` and pass a list id or name.");
27877
+ }
27878
+ const all = await fetchChatLists(auth);
27879
+ if (looksLikeConversationId(t)) {
27880
+ const byId = all.find((l) => l.id === t);
27881
+ if (byId)
27882
+ return byId;
27883
+ }
27884
+ const lower = t.toLowerCase();
27885
+ const exact = all.filter((l) => l.name.toLowerCase() === lower);
27886
+ const matches = exact.length ? exact : all.filter((l) => l.name.toLowerCase().includes(lower));
27887
+ if (matches.length === 0) {
27888
+ throw new Error(`No chat list matching "${t}". Run \`amiko chat lists\` to see them.`);
27889
+ }
27890
+ if (matches.length > 1) {
27891
+ const names = matches.map((l) => `"${l.name}" (${l.id})`).join(", ");
27892
+ throw new Error(`"${t}" is ambiguous — matches: ${names}. Use the list's id.`);
27893
+ }
27894
+ return matches[0];
27895
+ }
27896
+ async function resolveListOrExit(auth, target) {
27897
+ try {
27898
+ return await resolveList(auth, target);
27899
+ } catch (e5) {
27900
+ exitWithError2(e5, "Could not resolve the chat list");
27901
+ }
27902
+ }
27903
+ async function resolveEntriesOrExit(auth, tokens) {
27904
+ if (tokens.length === 0) {
27905
+ console.error(error("At least one --conversation is required."));
27906
+ process.exit(1);
27907
+ }
27908
+ const entries = [];
27909
+ const failures = [];
27910
+ const seen = new Set;
27911
+ for (const raw of tokens) {
27912
+ try {
27913
+ const resolved = await resolveConversation(auth, raw);
27914
+ let entry;
27915
+ if (resolved.kind === "conversation") {
27916
+ entry = { id: resolved.id, label: resolved.label };
27917
+ } else {
27918
+ const existing = await findDmByUserId(auth, resolved.userId);
27919
+ if (!existing) {
27920
+ throw new Error(`no ${resolved.label} exists yet — a chat list can only hold existing conversations.`);
27921
+ }
27922
+ entry = { id: existing, label: resolved.label };
27923
+ }
27924
+ if (seen.has(entry.id))
27925
+ continue;
27926
+ seen.add(entry.id);
27927
+ entries.push(entry);
27928
+ } catch (e5) {
27929
+ failures.push({
27930
+ token: raw,
27931
+ reason: e5 instanceof Error ? e5.message : "could not be resolved"
27932
+ });
27933
+ }
27934
+ }
27935
+ if (failures.length > 0) {
27936
+ console.error(error("Could not resolve every conversation — nothing was changed."));
27937
+ for (const entry of entries) {
27938
+ console.error(dim(` resolved: ${entry.label} → ${entry.id}`));
27939
+ }
27940
+ for (const f of failures) {
27941
+ console.error(error(` ${JSON.stringify(f.token)}: ${f.reason}`));
27942
+ }
27943
+ process.exit(1);
27944
+ }
27945
+ return entries;
27946
+ }
27947
+ async function conversationLabels(auth) {
27948
+ const labels = new Map;
27949
+ try {
27950
+ const data = await amikoWebFetch(auth, "/api/conversations", { query: { limit: 100, archived: "all" }, timeoutMs: 20000 });
27951
+ for (const c of data.conversations ?? []) {
27952
+ labels.set(c.id, convLabel(c, auth.userId));
27953
+ }
27954
+ } catch {}
27955
+ return labels;
27956
+ }
27957
+ function listNotFound(e5) {
27958
+ return e5.status === 404;
27959
+ }
27960
+ function registerChatListsCommand(lists) {
27961
+ lists.option("--raw", "Output raw JSON").action(async (opts) => {
27962
+ const auth = authOrExit();
27963
+ let all;
27964
+ try {
27965
+ all = await fetchChatLists(auth);
27966
+ } catch (e5) {
27967
+ exitWithError2(e5, "Failed to load chat lists");
27968
+ }
27969
+ if (opts.raw) {
27970
+ console.log(JSON.stringify({ chatLists: all }, null, 2));
27971
+ return;
27972
+ }
27973
+ if (all.length === 0) {
27974
+ console.log(dim("No chat lists."));
27975
+ console.log(dim("Create one with `amiko chat lists create <name> --conversation <who>`."));
27976
+ return;
27977
+ }
27978
+ const labels = await conversationLabels(auth);
27979
+ console.log(heading(`Chat lists (${all.length})
27980
+ `));
27981
+ for (const l of all) {
27982
+ const ids = l.conversation_ids ?? [];
27983
+ console.log(label(l.name, dim(`${l.id} · ${countNoun(ids.length)}`)));
27984
+ for (const id of ids) {
27985
+ const name = labels.get(id);
27986
+ console.log(dim(name ? ` - ${name} (${id})` : ` - ${id}`));
27987
+ }
27988
+ }
27989
+ });
27990
+ lists.command("create <name>").description("Create a chat list, optionally with initial conversations").option("--conversation <target>", "Conversation to include (conversation id, user id, @handle, group title, or name); repeat per conversation", collect2, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (name, opts) => {
27991
+ const auth = authOrExit();
27992
+ const entries = opts.conversation.length > 0 ? await resolveEntriesOrExit(auth, opts.conversation) : [];
27993
+ await confirmDestructive({
27994
+ action: `Create chat list "${name}"${entries.length ? ` with ${entries.map((e5) => e5.label).join(", ")}` : " (empty)"}`,
27995
+ detail: "Chat lists are private folders — only you see them, in every Amiko app.",
27996
+ yes: opts.yes,
27997
+ commandExample: `amiko chat lists create ${JSON.stringify(name)}${conversationFlags(opts.conversation)}`
27998
+ });
27999
+ let data;
28000
+ try {
28001
+ data = await amikoWebFetch(auth, "/api/chat-lists", {
28002
+ method: "POST",
28003
+ body: { name, conversation_ids: entries.map((e5) => e5.id) },
28004
+ timeoutMs: 20000
28005
+ });
28006
+ } catch (e5) {
28007
+ exitWithError2(e5, "Failed to create the chat list");
28008
+ }
28009
+ if (opts.raw) {
28010
+ console.log(JSON.stringify(data, null, 2));
28011
+ return;
28012
+ }
28013
+ console.log(success(`Chat list created: ${name}`));
28014
+ if (data.chatList?.id)
28015
+ console.log(label("ID", data.chatList.id));
28016
+ if (entries.length) {
28017
+ console.log(label("Conversations", entries.map((e5) => e5.label).join(", ")));
28018
+ }
28019
+ });
28020
+ lists.command("rename <list> <newName>").description("Rename a chat list (list id or name)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, newName, opts) => {
28021
+ const auth = authOrExit();
28022
+ const list = await resolveListOrExit(auth, target);
28023
+ await confirmDestructive({
28024
+ action: `Rename chat list "${list.name}" to "${newName}"`,
28025
+ yes: opts.yes,
28026
+ commandExample: `amiko chat lists rename ${JSON.stringify(target)} ${JSON.stringify(newName)}`
28027
+ });
28028
+ let data;
28029
+ try {
28030
+ data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, { method: "PATCH", body: { name: newName }, timeoutMs: 20000 });
28031
+ } catch (e5) {
28032
+ exitWithError2(e5, "Rename failed");
28033
+ }
28034
+ if (opts.raw) {
28035
+ console.log(JSON.stringify(data, null, 2));
28036
+ return;
28037
+ }
28038
+ console.log(success(`Chat list renamed to: ${newName}`) + dim(` (${list.id})`));
28039
+ });
28040
+ lists.command("add <list>").description("Add conversations to a chat list (list id or name)").option("--conversation <target>", "Conversation to add (conversation id, user id, @handle, group title, or name); repeat per conversation", collect2, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
28041
+ const auth = authOrExit();
28042
+ const entries = await resolveEntriesOrExit(auth, opts.conversation);
28043
+ const list = await resolveListOrExit(auth, target);
28044
+ const current = list.conversation_ids ?? [];
28045
+ const currentSet = new Set(current);
28046
+ const fresh = entries.filter((e5) => !currentSet.has(e5.id));
28047
+ const already = entries.filter((e5) => currentSet.has(e5.id));
28048
+ if (fresh.length === 0) {
28049
+ console.log(dim(`Already in "${list.name}" — nothing to add.`));
28050
+ return;
28051
+ }
28052
+ await confirmDestructive({
28053
+ action: `Add ${fresh.map((e5) => e5.label).join(", ")} to chat list "${list.name}"`,
28054
+ yes: opts.yes,
28055
+ commandExample: `amiko chat lists add ${JSON.stringify(target)}${conversationFlags(opts.conversation)}`
28056
+ });
28057
+ let data;
28058
+ try {
28059
+ data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, {
28060
+ method: "PATCH",
28061
+ body: {
28062
+ conversation_ids: [...current, ...fresh.map((e5) => e5.id)]
28063
+ },
28064
+ timeoutMs: 20000
28065
+ });
28066
+ } catch (e5) {
28067
+ if (listNotFound(e5)) {
28068
+ exitWithError2(null, `Chat list "${list.name}" no longer exists.`);
28069
+ }
28070
+ exitWithError2(e5, "Failed to update the chat list");
28071
+ }
28072
+ if (opts.raw) {
28073
+ console.log(JSON.stringify(data, null, 2));
28074
+ return;
28075
+ }
28076
+ console.log(success(`Added ${fresh.map((e5) => e5.label).join(", ")} to "${list.name}"`) + dim(` (now ${countNoun(current.length + fresh.length)})`));
28077
+ for (const e5 of already) {
28078
+ console.log(dim(` ${e5.label} was already in the list.`));
28079
+ }
28080
+ });
28081
+ lists.command("remove <list>").description("Remove conversations from a chat list (the conversations themselves are untouched)").option("--conversation <target>", "Conversation to remove (conversation id, user id, @handle, group title, or name); repeat per conversation", collect2, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
28082
+ const auth = authOrExit();
28083
+ const entries = await resolveEntriesOrExit(auth, opts.conversation);
28084
+ const list = await resolveListOrExit(auth, target);
28085
+ const current = list.conversation_ids ?? [];
28086
+ const currentSet = new Set(current);
28087
+ const inList = entries.filter((e5) => currentSet.has(e5.id));
28088
+ const notInList = entries.filter((e5) => !currentSet.has(e5.id));
28089
+ if (inList.length === 0) {
28090
+ console.log(dim(`None of those are in "${list.name}".`));
28091
+ return;
28092
+ }
28093
+ await confirmDestructive({
28094
+ action: `Remove ${inList.map((e5) => e5.label).join(", ")} from chat list "${list.name}"`,
28095
+ yes: opts.yes,
28096
+ commandExample: `amiko chat lists remove ${JSON.stringify(target)}${conversationFlags(opts.conversation)}`
28097
+ });
28098
+ const removeIds = new Set(inList.map((e5) => e5.id));
28099
+ let data;
28100
+ try {
28101
+ data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, {
28102
+ method: "PATCH",
28103
+ body: {
28104
+ conversation_ids: current.filter((id) => !removeIds.has(id))
28105
+ },
28106
+ timeoutMs: 20000
28107
+ });
28108
+ } catch (e5) {
28109
+ if (listNotFound(e5)) {
28110
+ exitWithError2(null, `Chat list "${list.name}" no longer exists.`);
28111
+ }
28112
+ exitWithError2(e5, "Failed to update the chat list");
28113
+ }
28114
+ if (opts.raw) {
28115
+ console.log(JSON.stringify(data, null, 2));
28116
+ return;
28117
+ }
28118
+ console.log(success(`Removed ${inList.map((e5) => e5.label).join(", ")} from "${list.name}"`) + dim(` (now ${countNoun(current.length - inList.length)})`));
28119
+ for (const e5 of notInList) {
28120
+ console.log(dim(` ${e5.label} was not in the list.`));
28121
+ }
28122
+ });
28123
+ lists.command("delete <list>").description("Delete a chat list (the conversations in it are untouched)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
28124
+ const auth = authOrExit();
28125
+ const list = await resolveListOrExit(auth, target);
28126
+ const count = (list.conversation_ids ?? []).length;
28127
+ await confirmDestructive({
28128
+ action: `Delete chat list "${list.name}" (${countNoun(count)})`,
28129
+ detail: "The list's name and membership are gone for good; the conversations themselves are not affected.",
28130
+ yes: opts.yes,
28131
+ commandExample: `amiko chat lists delete ${JSON.stringify(target)}`
28132
+ });
28133
+ let data;
28134
+ try {
28135
+ data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, { method: "DELETE", timeoutMs: 20000 });
28136
+ } catch (e5) {
28137
+ exitWithError2(e5, "Failed to delete the chat list");
28138
+ }
28139
+ if (opts.raw) {
28140
+ console.log(JSON.stringify(data, null, 2));
28141
+ return;
28142
+ }
28143
+ console.log(success(`Chat list deleted: ${list.name}`));
28144
+ });
28145
+ }
28146
+
27770
28147
  // src/commands/chat.ts
27771
28148
  var PIN_LIMIT = 20;
27772
28149
  function pinErrorMessage(e5, fallback2) {
@@ -27797,93 +28174,6 @@ async function findOrCreateDm(auth, userId) {
27797
28174
  throw new Error("Could not open a conversation with that user.");
27798
28175
  return id;
27799
28176
  }
27800
- async function fetchUserById(auth, id) {
27801
- try {
27802
- const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(id)}`, { timeoutMs: 15000 });
27803
- return prof.user?.id ? { ...prof.user, id: prof.user.id } : null;
27804
- } catch (e5) {
27805
- if (e5.status === 404)
27806
- return null;
27807
- throw e5;
27808
- }
27809
- }
27810
- async function findDmByUserId(auth, userId) {
27811
- const want = normalizeUserId(userId);
27812
- const limit = 100;
27813
- for (let offset = 0;offset < 1e4; offset += limit) {
27814
- const data = await amikoWebFetch(auth, "/api/conversations", {
27815
- query: { limit, offset },
27816
- timeoutMs: 20000
27817
- });
27818
- const page = data.conversations ?? [];
27819
- const dm = page.find((c) => c.conversation_type === "direct" && (c.participants ?? []).some((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === want));
27820
- if (dm)
27821
- return dm.id;
27822
- if (page.length < limit || data.pagination?.has_more === false)
27823
- return null;
27824
- }
27825
- return null;
27826
- }
27827
- function personLabel(u) {
27828
- const name = u.name ?? u.id;
27829
- return u.handle ? `${name} (@${u.handle})` : name;
27830
- }
27831
- async function resolveConversation(auth, target) {
27832
- const t = target.trim();
27833
- if (!t) {
27834
- throw new Error("No target given. Pass a conversation id, a user id, an @handle, or a name.");
27835
- }
27836
- const person = (u) => ({
27837
- kind: "user",
27838
- userId: u.id,
27839
- label: `DM with ${personLabel(u)}`
27840
- });
27841
- if (t.startsWith("@")) {
27842
- const handle = t.slice(1);
27843
- const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(handle)}`, {
27844
- timeoutMs: 15000
27845
- });
27846
- if (!prof.user?.id)
27847
- throw new Error(`No user @${handle}.`);
27848
- return person({ ...prof.user, id: prof.user.id });
27849
- }
27850
- const idish = normalizeUserId(t);
27851
- if (looksLikeConversationId(idish)) {
27852
- if (idish === t) {
27853
- try {
27854
- const conv = await fetchConversationById(auth, t);
27855
- return {
27856
- kind: "conversation",
27857
- id: conv.id,
27858
- label: conv.conversation_type === "direct" ? `DM with ${convLabel(conv, auth.userId)}` : `group "${convLabel(conv, auth.userId)}"`,
27859
- conversation: conv
27860
- };
27861
- } catch (e5) {
27862
- const status = e5.status;
27863
- if (status === 403) {
27864
- throw new Error(`Conversation ${t} exists but you are not a participant.`);
27865
- }
27866
- if (status !== 404)
27867
- throw e5;
27868
- }
27869
- }
27870
- const user = await fetchUserById(auth, idish);
27871
- if (!user) {
27872
- throw new Error(`"${t}" is neither a conversation nor a user id — check \`amiko chat list\` or \`amiko friends list --json\`.`);
27873
- }
27874
- return person(user);
27875
- }
27876
- const groupId = await findGroupIdByExactTitle(auth, t);
27877
- if (groupId)
27878
- return { kind: "conversation", id: groupId, label: `group "${t}"` };
27879
- const { resolved, failures } = await resolveMembers(auth, [t]);
27880
- if (failures.length || resolved.length !== 1) {
27881
- const reason = failures[0]?.reason ?? "could not be resolved";
27882
- throw new Error(`"${t}" ${reason}`);
27883
- }
27884
- const p = resolved[0];
27885
- return person({ id: p.userId, name: p.name, handle: p.handle });
27886
- }
27887
28177
  var HTTPS_RE = /^https:\/\//i;
27888
28178
  var URL_SHAPED_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
27889
28179
  var CHAT_IMAGE_MIME = new Set([
@@ -27932,7 +28222,7 @@ async function uploadChatVoice(auth, convId, path2) {
27932
28222
  await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(convId)}/audio/upload`, { method: "POST", multipart: form, timeoutMs: 120000 });
27933
28223
  }
27934
28224
  function registerChatCommand(chat) {
27935
- chat.command("list").description("List your conversations (DMs and group chats)").option("--limit <n>", "Max conversations", "30").option("--archived", "Include archived conversations").option("--raw", "Output raw JSON").action(async (opts) => {
28225
+ chat.command("list").description("List your conversations (DMs and group chats)").option("--limit <n>", "Max conversations", "30").option("--archived", "Include archived conversations").option("--mentions", "Only conversations with an unread @mention of you, a permitted @all, or a reply to one of your messages").option("--raw", "Output raw JSON").action(async (opts) => {
27936
28226
  const auth = authOrExit();
27937
28227
  let data;
27938
28228
  try {
@@ -27947,20 +28237,21 @@ function registerChatCommand(chat) {
27947
28237
  console.error(error(e5 instanceof Error ? e5.message : "Failed to list conversations"));
27948
28238
  process.exit(1);
27949
28239
  }
28240
+ const mentionsMe = (c) => !!c.unread_count && !!c.has_unread_mention;
28241
+ const convs = opts.mentions ? (data.conversations ?? []).filter(mentionsMe) : data.conversations ?? [];
27950
28242
  if (opts.raw) {
27951
- console.log(JSON.stringify(data, null, 2));
28243
+ console.log(JSON.stringify(opts.mentions ? { ...data, conversations: convs } : data, null, 2));
27952
28244
  return;
27953
28245
  }
27954
- const convs = data.conversations ?? [];
27955
28246
  if (convs.length === 0) {
27956
- console.log(dim("No conversations."));
28247
+ console.log(dim(opts.mentions ? "No conversations with unread mentions or replies to you." : "No conversations."));
27957
28248
  return;
27958
28249
  }
27959
28250
  console.log(heading(`Conversations (${convs.length})
27960
28251
  `));
27961
28252
  for (const c of convs) {
27962
28253
  const kind = c.conversation_type === "direct" ? "DM" : c.conversation_type;
27963
- const unread = c.unread_count ? ` (${c.unread_count} unread)` : "";
28254
+ const unread = c.unread_count ? ` (${c.unread_count} unread${mentionsMe(c) ? ", @you" : ""})` : "";
27964
28255
  console.log(label(`[${kind}] ${convLabel(c, auth.userId)}`, dim(c.id)));
27965
28256
  if (c.last_message?.content) {
27966
28257
  const who = c.last_message.name ? `${c.last_message.name}: ` : "";
@@ -28475,6 +28766,8 @@ function registerChatCommand(chat) {
28475
28766
  registerChatGifsCommand(chat);
28476
28767
  const group = chat.command("group").description("Group chats — create, list, info, rename, add/remove members, @all permission, leave");
28477
28768
  registerChatGroupCommand(group);
28769
+ const lists = chat.command("lists").description("Your chat lists (private folders of conversations) — run bare to see them; create, rename, add/remove conversations, delete");
28770
+ registerChatListsCommand(lists);
28478
28771
  }
28479
28772
 
28480
28773
  // src/commands/card.ts
@@ -30156,6 +30449,196 @@ async function mppFriendReportGenerate(auth, body, opts = {}) {
30156
30449
  }
30157
30450
  }
30158
30451
 
30452
+ // src/lib/nickname.ts
30453
+ var NICKNAME_MAX_CODE_POINTS = 50;
30454
+ var CONTROL_CHARS = /\p{Cc}/u;
30455
+ function validateNickname(input) {
30456
+ if (CONTROL_CHARS.test(input)) {
30457
+ return {
30458
+ ok: false,
30459
+ error: "invalid_characters",
30460
+ message: "Nickname contains unsupported characters (control characters are not allowed)."
30461
+ };
30462
+ }
30463
+ const trimmed = input.trim();
30464
+ if (!trimmed) {
30465
+ return { ok: false, error: "empty", message: "Nickname is empty." };
30466
+ }
30467
+ if ([...trimmed].length > NICKNAME_MAX_CODE_POINTS) {
30468
+ return {
30469
+ ok: false,
30470
+ error: "too_long",
30471
+ message: `Nickname is too long (max ${NICKNAME_MAX_CODE_POINTS} characters).`
30472
+ };
30473
+ }
30474
+ return { ok: true, value: trimmed };
30475
+ }
30476
+
30477
+ // src/commands/friends-nickname.ts
30478
+ function exitWithError3(e5, fallback2) {
30479
+ console.error(error(e5 instanceof Error ? e5.message : fallback2));
30480
+ process.exit(1);
30481
+ }
30482
+ function friendLabel(f) {
30483
+ const name = f.friend.name ?? f.friend.id;
30484
+ return f.friend.handle ? `${name} (@${f.friend.handle})` : name;
30485
+ }
30486
+ async function fetchUserFriends(auth) {
30487
+ const limit = 100;
30488
+ const all = [];
30489
+ for (let offset = 0;offset < 1e4; offset += limit) {
30490
+ const data = await amikoWebFetch(auth, "/api/friends", {
30491
+ query: { type: "user", limit, offset },
30492
+ timeoutMs: 20000
30493
+ });
30494
+ const page = data.friends ?? [];
30495
+ all.push(...page);
30496
+ if (page.length < limit || data.pagination?.has_more === false)
30497
+ break;
30498
+ }
30499
+ return all;
30500
+ }
30501
+ async function resolveFriend(auth, target) {
30502
+ const t = target.trim();
30503
+ if (!t) {
30504
+ throw new Error("No friend given. Pass a name, @handle, user id, or friendship id.");
30505
+ }
30506
+ const all = await fetchUserFriends(auth);
30507
+ const asId = normalizeUserId(t);
30508
+ const byId = all.find((f) => f.friendship_id === asId || f.friend.id === asId);
30509
+ if (byId)
30510
+ return byId;
30511
+ if (t.startsWith("@")) {
30512
+ const handle = t.slice(1).toLowerCase();
30513
+ const byHandle = all.filter((f) => f.friend.handle?.toLowerCase() === handle);
30514
+ if (byHandle.length === 1)
30515
+ return byHandle[0];
30516
+ throw new Error(`No friend ${t}. Nicknames only work for accepted friends — run \`amiko friends list\`.`);
30517
+ }
30518
+ const lower = t.toLowerCase();
30519
+ const exact = all.filter((f) => f.friend.name?.toLowerCase() === lower || f.friend.handle?.toLowerCase() === lower || f.nickname?.toLowerCase() === lower);
30520
+ const matches = exact.length ? exact : all.filter((f) => f.friend.name?.toLowerCase().includes(lower) || f.friend.handle?.toLowerCase().includes(lower) || f.nickname?.toLowerCase().includes(lower));
30521
+ if (matches.length === 0) {
30522
+ throw new Error(`No friend matching "${t}". Nicknames only work for accepted friends — run \`amiko friends list\`.`);
30523
+ }
30524
+ if (matches.length > 1) {
30525
+ const names = matches.map((f) => `${friendLabel(f)} (${f.friend.id})`).join(", ");
30526
+ throw new Error(`"${t}" is ambiguous — matches: ${names}. Use the friend's @handle or user id.`);
30527
+ }
30528
+ return matches[0];
30529
+ }
30530
+ async function resolveFriendOrExit(auth, target) {
30531
+ try {
30532
+ return await resolveFriend(auth, target);
30533
+ } catch (e5) {
30534
+ exitWithError3(e5, "Could not resolve the friend");
30535
+ }
30536
+ }
30537
+ async function patchNickname(auth, friendshipId, nickname, spinner) {
30538
+ try {
30539
+ const data = await amikoWebFetch(auth, `/api/friends/${encodeURIComponent(friendshipId)}/nickname`, { method: "PATCH", body: { nickname }, timeoutMs: 20000 });
30540
+ spinner?.stop();
30541
+ return data;
30542
+ } catch (e5) {
30543
+ spinner?.stop();
30544
+ if (e5.status === 403) {
30545
+ console.error(error(e5 instanceof Error ? e5.message : "Forbidden"));
30546
+ console.error(dim("Tip: nickname writes need an amiko-web deploy that allows twin tokens to manage nicknames (feat/friend-nicknames)."));
30547
+ process.exit(1);
30548
+ }
30549
+ exitWithError3(e5, "Failed to update the nickname");
30550
+ }
30551
+ }
30552
+ async function runList(opts) {
30553
+ const auth = authOrExit();
30554
+ const spinner = opts.json ? null : ora("Loading nicknames...").start();
30555
+ let all;
30556
+ try {
30557
+ all = await fetchUserFriends(auth);
30558
+ } catch (e5) {
30559
+ spinner?.stop();
30560
+ exitWithError3(e5, "Failed to load friends");
30561
+ }
30562
+ spinner?.stop();
30563
+ const named = all.filter((f) => !!f.nickname);
30564
+ renderOutput({
30565
+ nicknames: named.map((f) => ({
30566
+ nickname: f.nickname ?? null,
30567
+ user_id: f.friend.id,
30568
+ name: f.friend.name ?? null,
30569
+ handle: f.friend.handle ?? null,
30570
+ friendship_id: f.friendship_id
30571
+ }))
30572
+ }, () => {
30573
+ if (named.length === 0) {
30574
+ console.log(dim("No nicknames set."));
30575
+ console.log(dim('Set one with `amiko friends nickname set <friend> "<nickname>"`.'));
30576
+ return;
30577
+ }
30578
+ console.log(heading(`Nicknames (${named.length})`));
30579
+ const rows = [
30580
+ [
30581
+ dim("NICKNAME"),
30582
+ dim("NAME"),
30583
+ dim("HANDLE"),
30584
+ dim("USER ID"),
30585
+ dim("FRIENDSHIP")
30586
+ ],
30587
+ ...named.map((f) => [
30588
+ f.nickname ?? "-",
30589
+ f.friend.name ?? "-",
30590
+ f.friend.handle ?? "-",
30591
+ f.friend.id,
30592
+ f.friendship_id
30593
+ ])
30594
+ ];
30595
+ console.log(table(rows));
30596
+ }, { json: opts.json });
30597
+ }
30598
+ function registerFriendsNicknameCommand(nickname) {
30599
+ nickname.option("--json", "Output as JSON").action(runList);
30600
+ nickname.command("list").description("List every nickname you've set").option("--json", "Output as JSON").action((_opts, cmd) => runList(cmd.optsWithGlobals()));
30601
+ nickname.command("set <friend> <nickname>").description("Set or change your private nickname for a friend (name, @handle, user id, or friendship id)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--json", "Output as JSON").action(async (target, rawNickname, _opts, cmd) => {
30602
+ const opts = cmd.optsWithGlobals();
30603
+ const checked = validateNickname(rawNickname);
30604
+ if (!checked.ok) {
30605
+ console.error(error(checked.message));
30606
+ if (checked.error === "empty") {
30607
+ console.error(dim(`Tip: to clear a nickname, run \`amiko friends nickname remove ${JSON.stringify(target)}\`.`));
30608
+ }
30609
+ process.exit(1);
30610
+ }
30611
+ const auth = authOrExit();
30612
+ const friend = await resolveFriendOrExit(auth, target);
30613
+ await confirmDestructive({
30614
+ action: friend.nickname ? `Change your private nickname for ${friendLabel(friend)} from "${friend.nickname}" to "${checked.value}"` : `Set your private nickname for ${friendLabel(friend)} to "${checked.value}"`,
30615
+ detail: "Nicknames are private — only you ever see them, in every Amiko app.",
30616
+ yes: opts.yes,
30617
+ commandExample: `amiko friends nickname set ${JSON.stringify(target)} ${JSON.stringify(checked.value)}`
30618
+ });
30619
+ const spinner = opts.json ? null : ora("Saving nickname...").start();
30620
+ const data = await patchNickname(auth, friend.friendship_id, checked.value, spinner);
30621
+ renderOutput(data, (d) => console.log(success(`${friendLabel(friend)} is now "${d.nickname}" to you`)), { json: opts.json });
30622
+ });
30623
+ nickname.command("remove <friend>").description("Remove your private nickname for a friend").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--json", "Output as JSON").action(async (target, _opts, cmd) => {
30624
+ const opts = cmd.optsWithGlobals();
30625
+ const auth = authOrExit();
30626
+ const friend = await resolveFriendOrExit(auth, target);
30627
+ if (!friend.nickname) {
30628
+ renderOutput({ nickname: null }, () => console.log(dim(`No nickname set for ${friendLabel(friend)}.`)), { json: opts.json });
30629
+ return;
30630
+ }
30631
+ await confirmDestructive({
30632
+ action: `Remove your private nickname "${friend.nickname}" for ${friendLabel(friend)}`,
30633
+ yes: opts.yes,
30634
+ commandExample: `amiko friends nickname remove ${JSON.stringify(target)}`
30635
+ });
30636
+ const spinner = opts.json ? null : ora("Removing nickname...").start();
30637
+ const data = await patchNickname(auth, friend.friendship_id, null, spinner);
30638
+ renderOutput(data, () => console.log(success(`Nickname removed — ${friendLabel(friend)} shows their real name again`)), { json: opts.json });
30639
+ });
30640
+ }
30641
+
30159
30642
  // src/commands/friends.ts
30160
30643
  async function runPaidRegenerate(args) {
30161
30644
  let auth;
@@ -30284,6 +30767,7 @@ function registerFriendsCommand(program2) {
30284
30767
  [
30285
30768
  dim("USER ID"),
30286
30769
  dim("NAME"),
30770
+ dim("NICKNAME"),
30287
30771
  dim("HANDLE"),
30288
30772
  dim("TYPE"),
30289
30773
  dim("FRIENDSHIP"),
@@ -30292,6 +30776,7 @@ function registerFriendsCommand(program2) {
30292
30776
  ...items.map((f) => [
30293
30777
  f.friend.id,
30294
30778
  f.friend.name ?? "-",
30779
+ f.nickname ?? "-",
30295
30780
  f.friend.handle ?? "-",
30296
30781
  f.friend.type,
30297
30782
  f.friendship_id,
@@ -30396,6 +30881,8 @@ Accept with: amiko friends accept <friendshipId>`));
30396
30881
  process.exit(1);
30397
30882
  }
30398
30883
  });
30884
+ const nickname = program2.command("nickname").description("Private nicknames for friends — set, remove, list (only you ever see them)");
30885
+ registerFriendsNicknameCommand(nickname);
30399
30886
  const reports = program2.command("reports").description("Friend matching reports");
30400
30887
  reports.command("list").description("List friend matching reports").option("--page <n>", "Page number", "1").option("--limit <n>", "Page size (1-50)", "20").option("--json", "Output as JSON").action(async (opts) => {
30401
30888
  const auth = resolveAuth();
@@ -31739,7 +32226,7 @@ var twin = program2.command("twin").description("Update twin identity (name, des
31739
32226
  var drive = program2.command("drive").alias("docs").description("Manage twin drive — upload/download/search files, organise into folders");
31740
32227
  var voice = program2.command("voice").description("Manage twin voice — design, create, clone, reset");
31741
32228
  var avatar = program2.command("avatar").description("Manage twin avatar image");
31742
- var friends = program2.command("friends").description("Manage friendships — list, requests, add, accept, remove, reports, matches");
32229
+ var friends = program2.command("friends").description("Manage friendships — list, requests, add, accept, remove, nicknames, reports, matches");
31743
32230
  var users = program2.command("users").description("Search users and view public profiles");
31744
32231
  var post = program2.command("post").description("Create posts and comments on the feed");
31745
32232
  var review = program2.command("review").description("Review queue — list, approve, or reject twin-drafted comments");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyamiko/amiko-cli",
3
- "version": "0.14.0-beta.22",
3
+ "version": "0.14.0-beta.24",
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, 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 / 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, 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.
4
4
  homepage: https://platform.heyamiko.com
5
5
  metadata: {"openclaw":{"emoji":"🤖","requires":{"bins":["node"]}}}
6
6
  ---
@@ -41,6 +41,11 @@ Call your shell tool (your runtime calls it `bash`, `shell`, `run`, or similar)
41
41
  | "pin that hackathon message in the builders group" | shell → `amiko chat read "<group>"` (find the message, copy its `id`) → `amiko chat pin <messageId> --yes` |
42
42
  | "what's pinned in the team chat?" | shell → `amiko chat pinned "<group>"` |
43
43
  | "mark all my chats as read" | shell → `amiko chat mark-read --all --yes` (after the owner confirms — it clears every badge and senders see read ticks) |
44
+ | "any unread mentions or replies to me?" | shell → `amiko chat list --mentions` (chats whose unread messages @mention the owner or reply to them) |
45
+ | "what chat lists do I have? what's in my Work list?" | shell → `amiko chat lists` |
46
+ | "call Sophie 'Soph' from now on" | shell → `amiko friends nickname set Sophie "Soph" --yes` (after the owner confirms) |
47
+ | "what nicknames have I given my friends?" | shell → `amiko friends nickname` |
48
+ | "add the team group to my Work list" | shell → `amiko chat lists add "Work" --conversation "<group>" --yes` |
44
49
  | "what can amiko do?" | shell → `amiko --help` |
45
50
  | "what's my MiniMax / ElevenLabs voice id?" | shell → `amiko info` |
46
51
  | "speak with my cloned MiniMax voice" | shell → `amiko info`, quote the cost, obtain explicit approval, then run `amiko create tts "…" --provider minimax --voice <minimax_voice_id> --yes` |
@@ -60,7 +65,7 @@ Discover everything via `amiko --help`. Groups: `markets` (paid MPP services), `
60
65
 
61
66
  ## Critical Rules
62
67
 
63
- 1. **Every paid / value-moving command is hard-gated on `--yes` in a non-interactive shell.** The CLI refuses to run `markets *`, `create *`, `card mint`, `credits topup`, `wallets swap send`, `wallets bridge send`, `wallets transfer` unless `--yes` is passed. The refusal prints the cost and the re-run command. **Quote the cost, get explicit approval, THEN append `--yes`.** The same `--yes` gate also covers **free outward social actions** — `chat send`, `chat pin` (the whole chat sees a pinned-message announcement), `chat group create`, `chat group add` (real people see them, so get the owner's explicit approval for the action itself) — and **destructive ops** like `chat unpin` (removes the pin for everyone), `chat mark-read --all` (irreversibly marks every conversation read — senders see read ticks), `chat group remove/leave/rename/promote/mention-all`, `twin update --public`, `drive delete`, `drive share` / `drive folder share` (exposes the file — or the folder's ENTIRE subtree — to anyone with the link), `friends remove`, `friends reports request`, `avatar update`, `voice reset`, `review reject`. **For these free actions never mention cost, never say "the cost is 0", and never call it a paid operation** — ask for plain confirmation of the action itself (e.g. "Send "hi" to Mars — go ahead?").
68
+ 1. **Every paid / value-moving command is hard-gated on `--yes` in a non-interactive shell.** The CLI refuses to run `markets *`, `create *`, `card mint`, `credits topup`, `wallets swap send`, `wallets bridge send`, `wallets transfer` unless `--yes` is passed. The refusal prints the cost and the re-run command. **Quote the cost, get explicit approval, THEN append `--yes`.** The same `--yes` gate also covers **free outward social actions** — `chat send`, `chat pin` (the whole chat sees a pinned-message announcement), `chat group create`, `chat group add` (real people see them, so get the owner's explicit approval for the action itself) — and **destructive ops** like `chat unpin` (removes the pin for everyone), `chat mark-read --all` (irreversibly marks every conversation read — senders see read ticks), `chat group remove/leave/rename/promote/mention-all`, `chat lists create/rename/add/remove/delete` (private to the owner, but they reshape the owner's chat UI), `friends nickname set/remove` (private to the owner, but it changes how that friend is displayed everywhere in the owner's apps), `twin update --public`, `drive delete`, `drive share` / `drive folder share` (exposes the file — or the folder's ENTIRE subtree — to anyone with the link), `friends remove`, `friends reports request`, `avatar update`, `voice reset`, `review reject`. **For these free actions never mention cost, never say "the cost is 0", and never call it a paid operation** — ask for plain confirmation of the action itself (e.g. "Send "hi" to Mars — go ahead?").
64
69
  2. **After every paid command, report the remaining balance.** The CLI prints a `Balance: N credits` line — include that figure in your reply.
65
70
  3. **Never retry a failed command.** Report and stop. Every paid call costs tokens even on failure.
66
71
  4. **Auth is automatic.** Never suggest `amiko login` / `amiko connect`. Run from the agent's workspace folder; if anything looks off, `amiko accounts` shows the resolved `userId` / `twinId`.
@@ -149,7 +154,7 @@ MiniMax Hailuo is silent — there is no CLI mux step to attach a separate music
149
154
 
150
155
  `amiko chat` is the **owner's** conversations, acting **as the owner** (人对人) — NOT the agent's own sessions (those are the gateway's `session_*`, as the agent).
151
156
 
152
- - `amiko chat list` — all conversations, **DMs and group chats** (id, type, peer/title, last message, unread).
157
+ - `amiko chat list` — all conversations, **DMs and group chats** (id, type, peer/title, last message, unread). Rows with unread messages that concern the owner directly show an **`@you` marker**, and `--mentions` filters to just those. `@you` / `--mentions` means the unread messages include a **direct @mention of the owner, a permitted @all in a group, or a reply to one of the owner's messages** — so "did anyone reply to me?" is also answered here, not just literal mentions. No `@you` rows under `--mentions` = nothing unread needs the owner's attention specifically. (On servers that predate the flag it reads as false for every conversation: unread counts still show, `@you` never does, and `--mentions` matches nothing — an empty result there is NOT proof nobody mentioned or replied.)
153
158
  - `amiko chat read <target>` — recent messages. `<target>` = a conversation id (from `list`), a **user id**, an `@handle`, or a name. Reading never creates a conversation — if there's no DM with that person yet, it says so.
154
159
  - `amiko chat send <target> "message"` — send **as the owner**. Delivered in real time. `--yes` required in non-interactive shells (it's an outward message to a real person). `<target>` = a conversation id, a **user id**, an `@handle`, or a name. A user id / @handle / unambiguous name opens (or reuses) the DM for you — but only **after** the owner approves the send (via the confirmation, or `--yes` in your shell); nothing is created if approval is refused. **The owner never needs to start the conversation first, and there is no platform authorization step for starting a DM.** For **group chats**, use the conversation id from `list`. **@all in groups:** add `--all` (or write a standalone `@all` word in the message) to notify **every member** — allowed for group admins/owners, or for everyone once an admin enables it (`amiko chat group mention-all <group> on`). The CLI pre-checks permission: `--all` without it fails and names the fix; a plain `@all` word still sends, as ordinary text, with a note. It pings the whole group — use it only when the owner clearly wants everyone notified.
155
160
  - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≤10MB or an Amiko URL — e.g. a generated image), `--audio <pathOrUrl>` (local ≤25MB or an Amiko URL → sent as a voice note), and `--gif <queryOrUrl>` (see GIFs below). One media block per message (image XOR audio XOR gif). **General video files are not supported** on chat yet. To share a generated asset, pass its URL from `amiko create status` (no re-upload). A local audio file is sent as a voice note with the text as a separate message.
@@ -173,7 +178,14 @@ Create and manage the owner's group chats: `create <name> --member <who>…`, `l
173
178
  - `amiko chat group mention-all <group> <on|off>` — allow **everyone** in the group to use @all (`on`) or restrict it to admins (`off`, the default). **Admins only**, server-enforced — a 403 means the owner isn't an admin; report it, don't retry. The current setting shows in `amiko chat group info` (`@all mentions: everyone | admins only`).
174
179
  - Gating: `create`/`add` message real people and `remove`/`leave`/`rename`/`promote`/`mention-all` are destructive — all require `--yes` in your shell (see Critical Rules). Reads (`list`, `info`, `invite`) are free and ungated.
175
180
 
176
- ## Card — behavior notes
181
+ ### Chat lists — `amiko chat lists`
182
+
183
+ The owner's **chat lists**: private folders of conversations (the sidebar folders in the Amiko apps). **Only the owner ever sees them** — putting a chat in a list notifies nobody and changes nothing about the conversation itself.
184
+
185
+ - `amiko chat lists` (bare) — every list with its conversations, names resolved. Ungated read; `--raw` for JSON.
186
+ - `amiko chat lists create <name> [--conversation <who>…]`, `rename <list> <newName>`, `add <list> --conversation <who>…`, `remove <list> --conversation <who>…`, `delete <list>` — all mutations are `--yes`-gated. They're private, so confirm the action itself; never mention cost or "other people will see this." Every mutation also accepts `--raw` to print the server's JSON response instead of the summary line.
187
+ - `<list>` is a list id or name (case-insensitive, exact wins before substring; ambiguity errors listing candidates). `--conversation` repeats and takes a conversation id, user id, `@handle`, group title, or name — the same targets as `chat send`, except a person must **already have a DM** with the owner (organizing lists never creates conversations; the CLI errors if no DM exists yet).
188
+ - `add`/`remove` only change list membership. `delete` removes the list itself; the conversations in it are untouched — say so if the owner hesitates.
177
189
 
178
190
  `amiko card` = the owner's **Twin Card** (shareable card, template `twins_take`, flavors **work / play / love** — DB-managed, discover with `card templates`).
179
191
 
@@ -190,6 +202,15 @@ The drive is **shared between the user and the agent** — anything either side
190
202
 
191
203
  ## Friends & relationships
192
204
 
205
+ ### Private nicknames — `amiko friends nickname`
206
+
207
+ The owner's private pet names for friends. **Only the owner ever sees a nickname** — it becomes that friend's display name across the owner's feed, chats, and profile views; the friend is never notified and never sees it. Nicknames exist only for **accepted, person-to-person** friends (not twins, not pending requests).
208
+
209
+ - `amiko friends nickname` (or `... nickname list`) — every nickname the owner has set. Ungated read; `--json` for ids.
210
+ - `amiko friends nickname set <friend> "<nickname>"` — set or change (max 50 characters). `<friend>` takes a name, `@handle`, user id, or friendship id — and also matches the **current nickname** ("rename Soph to Sophy" works). Ambiguity errors listing candidates; ask the owner, don't guess.
211
+ - `amiko friends nickname remove <friend>` — clear it (the friend's real name shows again). Removing when none is set is a harmless no-op.
212
+ - `set`/`remove` are `--yes`-gated. They're private — confirm the action itself; never mention cost or "other people will see this."
213
+
193
214
  ### "How is my relationship with X?" playbook
194
215
 
195
216
  1. **Resolve to a user id**: `amiko friends list --json` first; if not a friend, `amiko users search "<name>" --json`. If ambiguous, ask.
@@ -227,7 +248,7 @@ Workflow: start with `friends matches --limit 20 --json`; narrow with `--dimensi
227
248
  >
228
249
  > **Owner DMs / group chats: `amiko chat`.** For "did X message me?" / "what did Sophie and I say?" / "message Y for me", use `amiko chat list` / `amiko chat read <who>` / `amiko chat send <who> "…"` — these act **as the owner** (人对人). The gateway's `session_*` tools are the **agent's own** sessions (as the agent), a separate surface (see Chat behavior notes).
229
250
 
230
- Platform notifications cover friend requests, mentions, system alerts, and post-related events.
251
+ Platform notifications cover friend requests, mentions, system alerts, and post-related events. For "**was I mentioned / did anyone reply to me** in chat?" specifically, prefer `amiko chat list --mentions` — it covers replies to the owner's messages (which notification rows don't) and reflects what's still unread.
231
252
 
232
253
  ## Where to run
233
254