@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 +33 -2
- package/dist/index.js +580 -93
- package/package.json +1 -1
- package/skills/SKILL.md +26 -5
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @heyamiko/amiko-cli (v0.14.0-beta.
|
|
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
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
|
-
|
|
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
|
|