@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 +136 -0
- package/package.json +42 -0
- package/src/adapters.test.ts +50 -0
- package/src/adapters.ts +215 -0
- package/src/args.test.ts +28 -0
- package/src/args.ts +98 -0
- package/src/cli-helpers.test.ts +82 -0
- package/src/cli-helpers.ts +149 -0
- package/src/client.test.ts +235 -0
- package/src/client.ts +378 -0
- package/src/components/prompt-line.ts +179 -0
- package/src/components/transcript-pane.test.ts +26 -0
- package/src/components/transcript-pane.ts +457 -0
- package/src/config.ts +53 -0
- package/src/emoji-suggestions.ts +152 -0
- package/src/format.test.ts +54 -0
- package/src/format.ts +611 -0
- package/src/help.test.ts +13 -0
- package/src/help.ts +48 -0
- package/src/index.ts +466 -0
- package/src/interactive.ts +832 -0
- package/src/test-helpers.ts +207 -0
- package/src/types.ts +24 -0
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
|
+
});
|
package/src/adapters.ts
ADDED
|
@@ -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
|
+
}
|
package/src/args.test.ts
ADDED
|
@@ -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
|
+
});
|