@andocorp/cli 0.1.0

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 ADDED
@@ -0,0 +1,136 @@
1
+ # Ando CLI
2
+
3
+ `ando` is a terminal client for humans and agents.
4
+
5
+ ## Installation
6
+
7
+ ### Quick Install (macOS/Linux)
8
+
9
+ ```sh
10
+ curl -fsSL https://raw.githubusercontent.com/ando-labs/ando/main/apps/cli/install.sh | sh
11
+ ```
12
+
13
+ ### Manual Installation
14
+
15
+ Download the latest binary for your platform from [Releases](https://github.com/ando-labs/ando/releases):
16
+
17
+ **macOS:**
18
+ - Apple Silicon (M1/M2/M3): `ando-macos-arm64`
19
+ - Intel: `ando-macos-x64`
20
+
21
+ **Linux:**
22
+ - `ando-linux-x64`
23
+
24
+ **Windows:**
25
+ - `ando-windows-x64.exe`
26
+
27
+ After downloading:
28
+
29
+ ```sh
30
+ # macOS/Linux
31
+ chmod +x ando-*
32
+ sudo mv ando-* /usr/local/bin/ando
33
+
34
+ # Verify installation
35
+ ando --help
36
+ ```
37
+
38
+ ### npm (Coming Soon)
39
+
40
+ ```sh
41
+ npm install -g @andocorp/cli
42
+ ```
43
+
44
+ ## Development
45
+
46
+ ### Running Locally
47
+
48
+ ```sh
49
+ # run dev
50
+ bun --cwd apps/cli dev
51
+
52
+ # compile a native binary to apps/cli/dist/ando
53
+ bun compile
54
+ ```
55
+
56
+ ### Building for All Platforms
57
+
58
+ Binaries are built automatically via GitHub Actions on release. To build locally:
59
+
60
+ ```sh
61
+ # Current platform only
62
+ pnpm --filter cli compile
63
+
64
+ # All platforms (requires GitHub Actions)
65
+ git tag cli-v0.1.0
66
+ git push origin cli-v0.1.0
67
+ ```
68
+
69
+ ## Commands
70
+
71
+ Open the interactive terminal UI:
72
+
73
+ ```sh
74
+ ando
75
+ ```
76
+
77
+ Show help:
78
+
79
+ ```sh
80
+ ando help
81
+ ando --help
82
+ ```
83
+
84
+ List channels:
85
+
86
+ ```sh
87
+ ando list-channels
88
+ ando list-channels --json
89
+ ```
90
+
91
+ List direct-message conversations:
92
+
93
+ ```sh
94
+ ando list-dms
95
+ ando list-dms --json
96
+ ```
97
+
98
+ List messages from a channel, DM, or conversation id:
99
+
100
+ ```sh
101
+ ando list-messages --channel engineering
102
+ ando list-messages --dm alex
103
+ ando list-messages --conversation <conversation-id>
104
+ ando list-messages --channel engineering --limit 20
105
+ ando list-messages -c engineering -n 20 --json
106
+ ```
107
+
108
+ Read a thread by message id:
109
+
110
+ ```sh
111
+ ando thread --message-id <message-id>
112
+ ando thread -m <message-id> --json
113
+ ```
114
+
115
+ Post a new message to a channel, DM, or conversation id:
116
+
117
+ ```sh
118
+ ando post-message --channel engineering --text "hello"
119
+ ando post-message --dm alex --text "want to pair?"
120
+ ando post-message --conversation <conversation-id> --text "status update"
121
+ ando post-message -c engineering -t "hello" --json
122
+ ```
123
+
124
+ Reply inside an existing thread:
125
+
126
+ ```sh
127
+ ando reply --message-id <message-id> --text "on it"
128
+ ando reply -m <message-id> -t "on it" --json
129
+ ```
130
+
131
+ Add a reaction to a message:
132
+
133
+ ```sh
134
+ ando react --message-id <message-id> --emoji 👍
135
+ ando react -m <message-id> -e 👍 --json
136
+ ```
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@andocorp/cli",
3
+ "version": "0.1.0",
4
+ "description": "Ando CLI - A terminal client for humans and agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "ando": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "compile": "bun build --compile ./src/index.ts --outfile ./dist/ando && chmod +x ./dist/ando",
11
+ "compile:all": "node scripts/build-all-platforms.js",
12
+ "dev": "bun run ./src/index.ts",
13
+ "lint": "pnpm -w exec eslint apps/cli --no-warn-ignored",
14
+ "test": "vitest run",
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/ando-labs/ando.git",
20
+ "directory": "apps/cli"
21
+ },
22
+ "keywords": ["cli", "terminal", "chat", "collaboration"],
23
+ "author": "Ando Labs",
24
+ "license": "MIT",
25
+ "files": [
26
+ "src/**/*.ts",
27
+ "src/**/*.js"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "@ando/shared": "workspace:*",
34
+ "convex": "^1.34.1",
35
+ "emojibase-data": "^16.0.3"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^20.0.0",
39
+ "typescript": "^5.0.0",
40
+ "vitest": "^4.1.4"
41
+ }
42
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { adaptBootstrap, adaptConversationPage } from "./adapters.js";
3
+ import { createMe, createMembership, createMessage } from "./test-helpers.js";
4
+
5
+ describe("adapters", () => {
6
+ it("adapts bootstrap payloads into date-backed objects", () => {
7
+ const result = adaptBootstrap({
8
+ me: createMe({
9
+ created_at: 1_700_000_000_000 as unknown as Date,
10
+ }) as unknown as Record<string, unknown>,
11
+ memberships: [
12
+ createMembership({
13
+ created_at: 1_700_000_000_000 as unknown as Date,
14
+ }) as unknown as Record<string, unknown>,
15
+ ],
16
+ });
17
+
18
+ expect(result.me?.created_at).toBeInstanceOf(Date);
19
+ expect(result.memberships[0]?.created_at).toBeInstanceOf(Date);
20
+ });
21
+
22
+ it("reverses paginated conversation results into chronological order", () => {
23
+ const newest = createMessage({
24
+ id: "message-3",
25
+ created_at: 3 as unknown as Date,
26
+ });
27
+ const middle = createMessage({
28
+ id: "message-2",
29
+ created_at: 2 as unknown as Date,
30
+ });
31
+ const oldest = createMessage({
32
+ id: "message-1",
33
+ created_at: 1 as unknown as Date,
34
+ });
35
+
36
+ const page = adaptConversationPage({
37
+ page: [newest, middle, oldest],
38
+ isDone: false,
39
+ continueCursor: "cursor-2",
40
+ });
41
+
42
+ expect(page.items.map((item) => item.id)).toEqual([
43
+ "message-1",
44
+ "message-2",
45
+ "message-3",
46
+ ]);
47
+ expect(page.cursor).toBe("cursor-2");
48
+ expect(page.hasMore).toBe(true);
49
+ });
50
+ });
@@ -0,0 +1,215 @@
1
+ import * as Shared from "@ando/shared";
2
+ import { ConversationPage, DiscoveredWorkspace, Me, Membership, Message } from "./types.js";
3
+
4
+ type BootstrapResult = {
5
+ me: Record<string, unknown> | null;
6
+ memberships: Record<string, unknown>[];
7
+ };
8
+
9
+ type ConvexPaginatedResult<T> = {
10
+ page: T[];
11
+ isDone: boolean;
12
+ continueCursor: string;
13
+ };
14
+
15
+ function adaptTimestamp(value: Date | number | string | null | undefined): Date {
16
+ if (value instanceof Date) {
17
+ return value;
18
+ }
19
+
20
+ if (typeof value === "number") {
21
+ return new Date(value);
22
+ }
23
+
24
+ if (typeof value === "string") {
25
+ return new Date(value);
26
+ }
27
+
28
+ return new Date(0);
29
+ }
30
+
31
+ function adaptNullableTimestamp(value: Date | number | string | null | undefined) {
32
+ if (value == null) {
33
+ return null;
34
+ }
35
+
36
+ return adaptTimestamp(value);
37
+ }
38
+
39
+ export function adaptDiscoveredWorkspace(
40
+ workspace: Shared.Contracts.DiscoveredWorkspace
41
+ ): DiscoveredWorkspace {
42
+ return {
43
+ ...workspace,
44
+ created_at: adaptTimestamp(workspace.created_at),
45
+ updated_at: adaptTimestamp(workspace.updated_at),
46
+ };
47
+ }
48
+
49
+ function adaptFile(file: Shared.Contracts.FileExtended): Shared.Contracts.FileExtended {
50
+ return {
51
+ ...file,
52
+ created_at: adaptTimestamp(file.created_at),
53
+ updated_at: adaptTimestamp(file.updated_at),
54
+ };
55
+ }
56
+
57
+ function adaptPreviewMember(member: Shared.Models.Member): Shared.Models.Member {
58
+ return {
59
+ ...member,
60
+ created_at: adaptTimestamp(member.created_at),
61
+ updated_at: adaptTimestamp(member.updated_at),
62
+ last_active_at: adaptTimestamp(member.last_active_at),
63
+ last_caught_up_at: adaptTimestamp(member.last_caught_up_at),
64
+ archived_at: adaptNullableTimestamp(member.archived_at),
65
+ onboarding_completed_at: adaptNullableTimestamp(member.onboarding_completed_at),
66
+ last_summary_at: adaptNullableTimestamp(member.last_summary_at),
67
+ };
68
+ }
69
+
70
+ export function adaptMember(member: Shared.Contracts.MemberExtended): Shared.Contracts.MemberExtended {
71
+ return {
72
+ ...member,
73
+ created_at: adaptTimestamp(member.created_at),
74
+ updated_at: adaptTimestamp(member.updated_at),
75
+ last_active_at: adaptTimestamp(member.last_active_at),
76
+ last_caught_up_at: adaptTimestamp(member.last_caught_up_at),
77
+ archived_at: adaptNullableTimestamp(member.archived_at),
78
+ onboarding_completed_at: adaptNullableTimestamp(member.onboarding_completed_at),
79
+ last_summary_at: adaptNullableTimestamp(member.last_summary_at),
80
+ profile_image_file:
81
+ member.profile_image_file == null ? null : adaptFile(member.profile_image_file),
82
+ };
83
+ }
84
+
85
+ export function adaptConversation(
86
+ conversation: Shared.Contracts.ConversationExtended
87
+ ): Shared.Contracts.ConversationExtended {
88
+ return {
89
+ ...conversation,
90
+ created_at: adaptTimestamp(conversation.created_at),
91
+ updated_at: adaptTimestamp(conversation.updated_at),
92
+ last_message_at: adaptNullableTimestamp(conversation.last_message_at),
93
+ last_summary_at: adaptNullableTimestamp(conversation.last_summary_at),
94
+ archived_at: adaptNullableTimestamp(conversation.archived_at),
95
+ preview_members: (conversation.preview_members ?? []).map(adaptPreviewMember),
96
+ creator_member:
97
+ conversation.creator_member == null
98
+ ? null
99
+ : adaptPreviewMember(conversation.creator_member),
100
+ };
101
+ }
102
+
103
+ function adaptThreadSubscription(
104
+ subscription: Shared.Contracts.ThreadSubscriptionExtended
105
+ ): Shared.Contracts.ThreadSubscriptionExtended {
106
+ return {
107
+ ...subscription,
108
+ created_at: adaptTimestamp(subscription.created_at),
109
+ updated_at: adaptTimestamp(subscription.updated_at),
110
+ last_seen_at: adaptNullableTimestamp(subscription.last_seen_at),
111
+ thread_root: {
112
+ ...subscription.thread_root,
113
+ created_at: adaptTimestamp(subscription.thread_root.created_at),
114
+ updated_at: adaptTimestamp(subscription.thread_root.updated_at),
115
+ edited_at: adaptNullableTimestamp(subscription.thread_root.edited_at),
116
+ pinned_at: adaptNullableTimestamp(subscription.thread_root.pinned_at),
117
+ last_reply_at: adaptNullableTimestamp(subscription.thread_root.last_reply_at),
118
+ archived_at: adaptNullableTimestamp(subscription.thread_root.archived_at),
119
+ },
120
+ };
121
+ }
122
+
123
+ export function adaptMembership(
124
+ membership: Shared.Contracts.MembershipExtended
125
+ ): Membership {
126
+ return {
127
+ ...membership,
128
+ created_at: adaptTimestamp(membership.created_at),
129
+ updated_at: adaptTimestamp(membership.updated_at),
130
+ last_seen_at: adaptTimestamp(membership.last_seen_at),
131
+ member: adaptMember(membership.member),
132
+ conversation: adaptConversation(membership.conversation),
133
+ thread_subscriptions: (membership.thread_subscriptions ?? []).map(
134
+ adaptThreadSubscription
135
+ ),
136
+ };
137
+ }
138
+
139
+ export function adaptMessage(
140
+ message: Shared.Contracts.ConversationMessageExtended
141
+ ): Message {
142
+ return {
143
+ ...message,
144
+ created_at: adaptTimestamp(message.created_at),
145
+ updated_at: adaptTimestamp(message.updated_at),
146
+ edited_at: adaptNullableTimestamp(message.edited_at),
147
+ pinned_at: adaptNullableTimestamp(message.pinned_at),
148
+ last_reply_at: adaptNullableTimestamp(message.last_reply_at),
149
+ archived_at: adaptNullableTimestamp(message.archived_at),
150
+ author: adaptMember(message.author),
151
+ message_reactions: (message.message_reactions ?? []).map((reaction) => ({
152
+ ...reaction,
153
+ created_at: adaptTimestamp(reaction.created_at),
154
+ })),
155
+ replies_preview_members: (message.replies_preview_members ?? []).map(
156
+ adaptPreviewMember
157
+ ),
158
+ files: (message.files ?? []).map(adaptFile),
159
+ thread_root_message:
160
+ message.thread_root_message == null
161
+ ? null
162
+ : {
163
+ ...message.thread_root_message,
164
+ created_at: adaptTimestamp(message.thread_root_message.created_at),
165
+ updated_at: adaptTimestamp(message.thread_root_message.updated_at),
166
+ edited_at: adaptNullableTimestamp(message.thread_root_message.edited_at),
167
+ pinned_at: adaptNullableTimestamp(message.thread_root_message.pinned_at),
168
+ last_reply_at: adaptNullableTimestamp(message.thread_root_message.last_reply_at),
169
+ archived_at: adaptNullableTimestamp(message.thread_root_message.archived_at),
170
+ },
171
+ };
172
+ }
173
+
174
+ function adaptWorkspace(
175
+ workspace: Shared.Contracts.WorkspaceExtended
176
+ ): Shared.Contracts.WorkspaceExtended {
177
+ return {
178
+ ...workspace,
179
+ created_at: adaptTimestamp(workspace.created_at),
180
+ updated_at: adaptTimestamp(workspace.updated_at),
181
+ workspace_invite_link: {
182
+ ...workspace.workspace_invite_link,
183
+ created_at: adaptTimestamp(workspace.workspace_invite_link.created_at),
184
+ },
185
+ };
186
+ }
187
+
188
+ export function adaptMe(member: Shared.Contracts.MeExtended): Me {
189
+ return {
190
+ ...adaptMember(member),
191
+ workspace: adaptWorkspace(member.workspace),
192
+ };
193
+ }
194
+
195
+ export function adaptBootstrap(result: BootstrapResult) {
196
+ return {
197
+ me:
198
+ result.me == null
199
+ ? null
200
+ : adaptMe(result.me as unknown as Shared.Contracts.MeExtended),
201
+ memberships: result.memberships.map((membership) =>
202
+ adaptMembership(membership as unknown as Shared.Contracts.MembershipExtended)
203
+ ),
204
+ };
205
+ }
206
+
207
+ export function adaptConversationPage(
208
+ result: ConvexPaginatedResult<Shared.Contracts.ConversationMessageExtended>
209
+ ): ConversationPage {
210
+ return {
211
+ items: [...result.page].reverse().map(adaptMessage),
212
+ cursor: result.isDone ? null : result.continueCursor,
213
+ hasMore: !result.isDone,
214
+ };
215
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getStringFlag, hasFlag, parseArgs } from "./args.js";
3
+
4
+ describe("args", () => {
5
+ it("parses long and short flags with values", () => {
6
+ const parsed = parseArgs([
7
+ "list-messages",
8
+ "--channel",
9
+ "engineering",
10
+ "-n",
11
+ "20",
12
+ "--json",
13
+ ]);
14
+
15
+ expect(parsed.positionals).toEqual(["list-messages"]);
16
+ expect(getStringFlag(parsed, "channel", "c")).toBe("engineering");
17
+ expect(getStringFlag(parsed, "limit", "n")).toBe("20");
18
+ expect(hasFlag(parsed, "json")).toBe(true);
19
+ });
20
+
21
+ it("parses compact short flags", () => {
22
+ const parsed = parseArgs(["thread", "-vhm", "message-1"]);
23
+
24
+ expect(hasFlag(parsed, "verbose", "v")).toBe(true);
25
+ expect(hasFlag(parsed, "help", "h")).toBe(true);
26
+ expect(getStringFlag(parsed, "message-id", "m")).toBe("message-1");
27
+ });
28
+ });
package/src/args.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { ParsedArgs } from "./types.js";
2
+
3
+ export function parseArgs(argv: string[]): ParsedArgs {
4
+ const flags = new Map<string, string | true>();
5
+ const positionals: string[] = [];
6
+
7
+ for (let index = 0; index < argv.length; index += 1) {
8
+ const value = argv[index];
9
+ if (value == null) {
10
+ continue;
11
+ }
12
+
13
+ if (!value.startsWith("-")) {
14
+ positionals.push(value);
15
+ continue;
16
+ }
17
+
18
+ if (value.startsWith("--")) {
19
+ const [key, inlineValue] = value.slice(2).split("=", 2);
20
+ if (inlineValue != null) {
21
+ flags.set(key, inlineValue);
22
+ continue;
23
+ }
24
+
25
+ const nextValue = argv[index + 1];
26
+ if (nextValue != null && !nextValue.startsWith("-")) {
27
+ flags.set(key, nextValue);
28
+ index += 1;
29
+ } else {
30
+ flags.set(key, true);
31
+ }
32
+ continue;
33
+ }
34
+
35
+ const shortFlags = value.slice(1);
36
+ if (shortFlags.length > 1) {
37
+ for (let shortIndex = 0; shortIndex < shortFlags.length - 1; shortIndex += 1) {
38
+ const shortFlag = shortFlags[shortIndex];
39
+ if (shortFlag != null) {
40
+ flags.set(shortFlag, true);
41
+ }
42
+ }
43
+
44
+ const lastFlag = shortFlags[shortFlags.length - 1];
45
+ const nextValue = argv[index + 1];
46
+ if (lastFlag == null) {
47
+ continue;
48
+ }
49
+
50
+ if (nextValue != null && !nextValue.startsWith("-")) {
51
+ flags.set(lastFlag, nextValue);
52
+ index += 1;
53
+ } else {
54
+ flags.set(lastFlag, true);
55
+ }
56
+ continue;
57
+ }
58
+
59
+ const nextValue = argv[index + 1];
60
+ if (nextValue != null && !nextValue.startsWith("-")) {
61
+ flags.set(shortFlags, nextValue);
62
+ index += 1;
63
+ } else {
64
+ flags.set(shortFlags, true);
65
+ }
66
+ }
67
+
68
+ return {
69
+ flags,
70
+ positionals,
71
+ };
72
+ }
73
+
74
+ export function getStringFlag(
75
+ parsedArgs: ParsedArgs,
76
+ longName: string,
77
+ shortName?: string
78
+ ) {
79
+ const longValue = parsedArgs.flags.get(longName);
80
+ if (typeof longValue === "string") {
81
+ return longValue;
82
+ }
83
+
84
+ if (shortName == null) {
85
+ return undefined;
86
+ }
87
+
88
+ const shortValue = parsedArgs.flags.get(shortName);
89
+ return typeof shortValue === "string" ? shortValue : undefined;
90
+ }
91
+
92
+ export function hasFlag(
93
+ parsedArgs: ParsedArgs,
94
+ longName: string,
95
+ shortName?: string
96
+ ) {
97
+ return parsedArgs.flags.has(longName) || (shortName != null && parsedArgs.flags.has(shortName));
98
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as Shared from "@ando/shared";
3
+ import { compareByRecentActivity, resolveConversation, resolveWorkspace } from "./cli-helpers.js";
4
+ import { createConversation, createMembership } from "./test-helpers.js";
5
+
6
+ describe("cli helpers", () => {
7
+ it("resolves a conversation by exact name", () => {
8
+ const memberships = [
9
+ createMembership({
10
+ id: "membership-a",
11
+ conversation: createConversation({ id: "conversation-a", name: "engineering" }),
12
+ }),
13
+ ];
14
+
15
+ const result = resolveConversation(memberships, {
16
+ allowChannels: true,
17
+ allowDms: true,
18
+ query: "engineering",
19
+ });
20
+
21
+ expect(result.id).toBe("membership-a");
22
+ });
23
+
24
+ it("throws when a partial conversation match is ambiguous", () => {
25
+ const memberships = [
26
+ createMembership({
27
+ id: "membership-a",
28
+ conversation: createConversation({ id: "conversation-a", name: "eng-platform" }),
29
+ }),
30
+ createMembership({
31
+ id: "membership-b",
32
+ conversation: createConversation({ id: "conversation-b", name: "eng-mobile" }),
33
+ }),
34
+ ];
35
+
36
+ expect(() =>
37
+ resolveConversation(memberships, {
38
+ allowChannels: true,
39
+ allowDms: true,
40
+ query: "eng",
41
+ })
42
+ ).toThrow(/ambiguous/);
43
+ });
44
+
45
+ it("sorts more recent memberships first", () => {
46
+ const oldMembership = createMembership({
47
+ id: "old",
48
+ conversation: createConversation({
49
+ id: "conversation-old",
50
+ last_message_at: new Date("2026-04-08T12:00:00.000Z"),
51
+ }),
52
+ });
53
+ const newMembership = createMembership({
54
+ id: "new",
55
+ conversation: createConversation({
56
+ id: "conversation-new",
57
+ last_message_at: new Date("2026-04-09T12:00:00.000Z"),
58
+ }),
59
+ });
60
+
61
+ expect(compareByRecentActivity(newMembership, oldMembership)).toBeLessThan(0);
62
+ });
63
+
64
+ it("resolves workspaces by slug", () => {
65
+ const workspace = {
66
+ id: "workspace-1",
67
+ stytch_organization_id: "org-1",
68
+ name: "Ando",
69
+ slug: "ando",
70
+ image_url: null,
71
+ icon_image_file_id: null,
72
+ description: null,
73
+ invite_link_id: "invite-1",
74
+ created_at: new Date("2026-04-09T12:00:00.000Z"),
75
+ updated_at: new Date("2026-04-09T12:00:00.000Z"),
76
+ member_count: 3,
77
+ } satisfies Shared.Contracts.DiscoveredWorkspace;
78
+
79
+ const result = resolveWorkspace([workspace], "ando");
80
+ expect(result.id).toBe("workspace-1");
81
+ });
82
+ });