@semanticist14/clco 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.
@@ -0,0 +1,129 @@
1
+ // Minimal mock Copilot upstream for end-to-end testing without GitHub auth.
2
+ // Usage: bun run scripts/mock-upstream.ts [port] then
3
+ // CLCO_UPSTREAM=http://127.0.0.1:<port> clco ...
4
+
5
+ const port = Number(process.argv[2] ?? 9099)
6
+
7
+ Bun.serve({
8
+ hostname: "127.0.0.1",
9
+ port,
10
+ fetch: async (req) => {
11
+ const url = new URL(req.url)
12
+ if (url.pathname === "/models") {
13
+ const native = ["/v1/messages", "/chat/completions"]
14
+ return Response.json({
15
+ data: [
16
+ // Claude-style models route through the native Anthropic endpoint,
17
+ // exactly as real Copilot declares them.
18
+ { id: "mock-opus", supported_endpoints: native },
19
+ { id: "mock-sonnet", supported_endpoints: native },
20
+ { id: "mock-haiku", supported_endpoints: native },
21
+ // Translation-path model, for exercising the chat dialect.
22
+ { id: "mock-chat", supported_endpoints: ["/chat/completions"] },
23
+ ].map((m) => ({
24
+ ...m,
25
+ // Matches production: GitHub returns false for every model.
26
+ model_picker_enabled: false,
27
+ policy: { state: "enabled" },
28
+ capabilities: {
29
+ type: "chat",
30
+ family: m.id,
31
+ limits: { max_prompt_tokens: 200000, max_context_window_tokens: 264000 },
32
+ supports: { reasoning_effort: ["low", "medium", "high", "xhigh", "max"] },
33
+ },
34
+ })),
35
+ })
36
+ }
37
+
38
+ if (url.pathname === "/v1/messages") {
39
+ const body = (await req.json()) as { stream?: boolean; model: string }
40
+ const text = "Mock native response: OK"
41
+ if (body.stream) {
42
+ return new Response(
43
+ [
44
+ `event: message_start\ndata: ${JSON.stringify({
45
+ type: "message_start",
46
+ message: {
47
+ id: "msg_mock",
48
+ type: "message",
49
+ role: "assistant",
50
+ content: [],
51
+ model: body.model,
52
+ stop_reason: null,
53
+ stop_sequence: null,
54
+ usage: { input_tokens: 5, output_tokens: 0 },
55
+ },
56
+ })}\n\n`,
57
+ `event: content_block_start\ndata: ${JSON.stringify({
58
+ type: "content_block_start",
59
+ index: 0,
60
+ content_block: { type: "text", text: "" },
61
+ })}\n\n`,
62
+ `event: content_block_delta\ndata: ${JSON.stringify({
63
+ type: "content_block_delta",
64
+ index: 0,
65
+ delta: { type: "text_delta", text },
66
+ })}\n\n`,
67
+ `event: content_block_stop\ndata: ${JSON.stringify({
68
+ type: "content_block_stop",
69
+ index: 0,
70
+ })}\n\n`,
71
+ `event: message_delta\ndata: ${JSON.stringify({
72
+ type: "message_delta",
73
+ delta: { stop_reason: "end_turn", stop_sequence: null },
74
+ usage: { output_tokens: 6 },
75
+ })}\n\n`,
76
+ `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
77
+ ].join(""),
78
+ { headers: { "content-type": "text/event-stream" } },
79
+ )
80
+ }
81
+ return Response.json({
82
+ id: "msg_mock",
83
+ type: "message",
84
+ role: "assistant",
85
+ model: body.model,
86
+ content: [{ type: "text", text }],
87
+ stop_reason: "end_turn",
88
+ stop_sequence: null,
89
+ usage: { input_tokens: 5, output_tokens: 6 },
90
+ })
91
+ }
92
+
93
+ if (url.pathname !== "/chat/completions") {
94
+ return new Response("not found", { status: 404 })
95
+ }
96
+ const body = (await req.json()) as { stream?: boolean; model: string }
97
+ const chunk = (delta: unknown, finish: string | null = null) =>
98
+ `data: ${JSON.stringify({
99
+ id: "mock-1",
100
+ model: body.model,
101
+ choices: [{ index: 0, finish_reason: finish, delta }],
102
+ })}\n\n`
103
+
104
+ if (body.stream) {
105
+ return new Response(
106
+ [
107
+ chunk({ role: "assistant", content: "Mock upstream response: OK" }),
108
+ chunk({}, "stop"),
109
+ "data: [DONE]\n\n",
110
+ ].join(""),
111
+ { headers: { "content-type": "text/event-stream" } },
112
+ )
113
+ }
114
+ return Response.json({
115
+ id: "mock-1",
116
+ model: body.model,
117
+ choices: [
118
+ {
119
+ index: 0,
120
+ finish_reason: "stop",
121
+ message: { role: "assistant", content: "Mock upstream response: OK" },
122
+ },
123
+ ],
124
+ usage: { prompt_tokens: 5, completion_tokens: 3 },
125
+ })
126
+ },
127
+ })
128
+
129
+ console.log(`mock upstream on http://127.0.0.1:${port}`)
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # Writes the clco launcher. Lives here rather than inline in install.sh so
3
+ # `clco update` can refresh it too.
4
+ #
5
+ # Usage: write-launcher.sh <app-dir> <bin-dir> [bun-path]
6
+ set -euo pipefail
7
+
8
+ CLCO_DIR="${1:?app dir required}"
9
+ BIN_DIR="${2:?bin dir required}"
10
+ BUN_BIN="${3:-$(command -v bun)}"
11
+ [ -n "$BUN_BIN" ] || { echo "bun not found" >&2; exit 1; }
12
+
13
+ # Bump when the launcher body changes; clco compares it to warn about a stale
14
+ # launcher that predates a feature.
15
+ LAUNCHER_VERSION=4
16
+
17
+ mkdir -p "$BIN_DIR"
18
+ [ ! -d "$BIN_DIR/clco" ] || {
19
+ echo "$BIN_DIR/clco is a directory, not a launcher file" >&2
20
+ exit 1
21
+ }
22
+ TMP_LAUNCHER="$(mktemp "$BIN_DIR/.clco-launcher.XXXXXX")"
23
+ trap 'rm -f "$TMP_LAUNCHER"' EXIT
24
+ {
25
+ printf '%s\n' '#!/usr/bin/env bash'
26
+ printf '# clco launcher v%s (generated by scripts/write-launcher.sh)\n' "$LAUNCHER_VERSION"
27
+ printf 'CLCO_APP_DIR=%q\n' "$CLCO_DIR"
28
+ printf 'CLCO_BIN_DIR=%q\n' "$BIN_DIR"
29
+ printf 'CLCO_BUN_BIN=%q\n' "$BUN_BIN"
30
+ printf 'CLCO_LAUNCHER_VERSION=%s\n' "$LAUNCHER_VERSION"
31
+ printf '%s\n' 'export CLCO_APP_DIR CLCO_BIN_DIR CLCO_BUN_BIN CLCO_LAUNCHER_VERSION'
32
+ printf '%s\n' '' \
33
+ '# Corporate TLS: clco adds a CA to the OS trust store itself, per request, via' \
34
+ '# CLCO_CA_BUNDLE — see src/tls.ts. Nothing to set here; NODE_USE_SYSTEM_CA is' \
35
+ '# a no-op on bun, whose default trust set already merges the system roots.' \
36
+ '' \
37
+ '# bun strips a leading "--" before scripts see it — translate it into a' \
38
+ '# sentinel that cli.ts understands as "everything after belongs to claude".' \
39
+ 'if [ "${1:-}" = "--" ]; then' \
40
+ ' shift' \
41
+ ' set -- "__clco_passthrough__" "$@"' \
42
+ 'fi' \
43
+ 'exec "$CLCO_BUN_BIN" run --no-install "$CLCO_APP_DIR/src/cli.ts" "$@"'
44
+ } > "$TMP_LAUNCHER"
45
+ chmod +x "$TMP_LAUNCHER"
46
+ mv -f "$TMP_LAUNCHER" "$BIN_DIR/clco"
47
+ trap - EXIT
package/src/api.ts ADDED
@@ -0,0 +1,90 @@
1
+ // Upstream endpoints, OAuth constants, and client identity headers.
2
+ // The editor identity mirrors an official Copilot Chat client, which is what
3
+ // the Copilot backend expects on api.githubcopilot.com.
4
+
5
+ import { caBundle } from "./tls"
6
+
7
+ // Every outbound HTTPS request clco makes goes through here so a corporate CA
8
+ // configured via CLCO_CA_BUNDLE applies uniformly. Per-request `tls.ca` needs
9
+ // no process-level env, so it works regardless of how clco was launched.
10
+ export function copilotFetch(
11
+ url: string,
12
+ init?: RequestInit,
13
+ ): Promise<Response> {
14
+ const ca = caBundle()
15
+ return fetch(url, ca ? { ...init, tls: { ca } } : init)
16
+ }
17
+
18
+ export const GITHUB_BASE_URL = "https://github.com"
19
+ export const GITHUB_API_BASE_URL = "https://api.github.com"
20
+ export const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98"
21
+ export const GITHUB_APP_SCOPES = "read:user"
22
+ export const GITHUB_API_VERSION = "2025-04-01"
23
+
24
+ const COPILOT_CHAT_VERSION =
25
+ process.env.CLCO_CHAT_VERSION ?? "0.26.7"
26
+ const EDITOR_VERSION = process.env.CLCO_EDITOR_VERSION ?? "1.104.0"
27
+
28
+ // Test/dev hook: point the adapter at a mock upstream; GitHub auth is skipped.
29
+ // Must be a non-empty URL — an empty string is treated as unset everywhere
30
+ // (single source of truth via isMockMode/copilotBaseUrl).
31
+ export function mockUpstream(): string | undefined {
32
+ const value = process.env.CLCO_UPSTREAM?.trim()
33
+ return value ? value : undefined
34
+ }
35
+
36
+ export function isMockMode(): boolean {
37
+ return mockUpstream() !== undefined
38
+ }
39
+
40
+ // Business/Enterprise accounts get a different API host in the Copilot
41
+ // token response's endpoints.api field — official clients route there, so
42
+ // clco does too (set by token.ts after each token exchange).
43
+ let dynamicCopilotBase: string | null = null
44
+
45
+ export function setCopilotBase(url: string | null): void {
46
+ dynamicCopilotBase =
47
+ url && url.startsWith("https://") ? url.replace(/\/$/, "") : null
48
+ }
49
+
50
+ export function copilotBaseUrl(): string {
51
+ return mockUpstream() ?? dynamicCopilotBase ?? "https://api.githubcopilot.com"
52
+ }
53
+
54
+ export function copilotRequestHeaders(
55
+ token: string,
56
+ opts?: {
57
+ agentInitiated?: boolean
58
+ vision?: boolean
59
+ accept?: string
60
+ },
61
+ ): Record<string, string> {
62
+ const headers: Record<string, string> = {
63
+ authorization: `Bearer ${token}`,
64
+ "content-type": "application/json",
65
+ accept: opts?.accept ?? "application/json",
66
+ "copilot-integration-id": "vscode-chat",
67
+ "editor-version": `vscode/${EDITOR_VERSION}`,
68
+ "editor-plugin-version": `copilot-chat/${COPILOT_CHAT_VERSION}`,
69
+ "user-agent": `GitHubCopilotChat/${COPILOT_CHAT_VERSION}`,
70
+ "openai-intent": "conversation-panel",
71
+ "x-github-api-version": GITHUB_API_VERSION,
72
+ "x-request-id": crypto.randomUUID(),
73
+ "x-vscode-user-agent-library-version": "electron-fetch",
74
+ "x-initiator": opts?.agentInitiated ? "agent" : "user",
75
+ }
76
+ if (opts?.vision) headers["copilot-vision-request"] = "true"
77
+ return headers
78
+ }
79
+
80
+ export function githubRequestHeaders(githubToken: string): Record<string, string> {
81
+ return {
82
+ "content-type": "application/json",
83
+ accept: "application/json",
84
+ authorization: `token ${githubToken}`,
85
+ "editor-version": `vscode/${EDITOR_VERSION}`,
86
+ "editor-plugin-version": `copilot-chat/${COPILOT_CHAT_VERSION}`,
87
+ "user-agent": `GitHubCopilotChat/${COPILOT_CHAT_VERSION}`,
88
+ "x-github-api-version": GITHUB_API_VERSION,
89
+ }
90
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,130 @@
1
+ // GitHub OAuth device flow. The resulting user token (gho_...) is long-lived
2
+ // and stored via config.ts; it is exchanged for short-lived Copilot tokens in
3
+ // token.ts.
4
+
5
+ import {
6
+ GITHUB_API_BASE_URL,
7
+ GITHUB_APP_SCOPES,
8
+ GITHUB_BASE_URL,
9
+ GITHUB_CLIENT_ID,
10
+ copilotFetch,
11
+ githubRequestHeaders,
12
+ isMockMode,
13
+ } from "./api"
14
+ import { loadAuth, saveAuth } from "./config"
15
+
16
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
17
+
18
+ export interface GithubIdentity {
19
+ token: string
20
+ login?: string
21
+ fresh: boolean
22
+ }
23
+
24
+ export async function ensureGithubToken(): Promise<GithubIdentity> {
25
+ // Mock mode (CLCO_UPSTREAM set): no GitHub involved at all.
26
+ if (isMockMode()) {
27
+ return { token: "mock", fresh: false }
28
+ }
29
+ const saved = await loadAuth()
30
+ if (saved?.github_token) {
31
+ return { token: saved.github_token, login: saved.login, fresh: false }
32
+ }
33
+ const { token, login } = await runDeviceFlow()
34
+ return { token, login, fresh: true }
35
+ }
36
+
37
+ export async function runDeviceFlow(): Promise<{ token: string; login?: string }> {
38
+ const res = await copilotFetch(`${GITHUB_BASE_URL}/login/device/code`, {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json", accept: "application/json" },
41
+ body: JSON.stringify({
42
+ client_id: GITHUB_CLIENT_ID,
43
+ scope: GITHUB_APP_SCOPES,
44
+ }),
45
+ signal: AbortSignal.timeout(15_000),
46
+ })
47
+ if (!res.ok) {
48
+ throw new Error(`device code request failed: HTTP ${res.status}`)
49
+ }
50
+ const dc = (await res.json()) as {
51
+ device_code: string
52
+ user_code: string
53
+ verification_uri: string
54
+ expires_in: number
55
+ interval: number
56
+ }
57
+
58
+ console.log("\nGitHub authentication required:")
59
+ console.log(` 1. Open in your browser: ${dc.verification_uri}`)
60
+ console.log(` 2. Enter the code: \x1b[1m${dc.user_code}\x1b[0m\n`)
61
+
62
+ if (process.platform === "darwin") {
63
+ const open = Bun.spawn(["open", dc.verification_uri], {
64
+ stdout: "ignore",
65
+ stderr: "ignore",
66
+ })
67
+ await open.exited.catch(() => {})
68
+ }
69
+
70
+ let interval = (dc.interval + 1) * 1000
71
+ const deadline = Date.now() + dc.expires_in * 1000
72
+ while (Date.now() < deadline) {
73
+ await sleep(interval)
74
+ let body: {
75
+ access_token?: string
76
+ error?: string
77
+ error_description?: string
78
+ }
79
+ try {
80
+ const poll = await copilotFetch(`${GITHUB_BASE_URL}/login/oauth/access_token`, {
81
+ method: "POST",
82
+ headers: {
83
+ "content-type": "application/json",
84
+ accept: "application/json",
85
+ },
86
+ body: JSON.stringify({
87
+ client_id: GITHUB_CLIENT_ID,
88
+ device_code: dc.device_code,
89
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
90
+ }),
91
+ signal: AbortSignal.timeout(15_000),
92
+ })
93
+ if (!poll.ok) {
94
+ // 4xx won't heal by waiting; 5xx might.
95
+ if (poll.status >= 400 && poll.status < 500) {
96
+ throw new Error(`device flow polling failed: HTTP ${poll.status}`)
97
+ }
98
+ console.error(`[clco] polling error HTTP ${poll.status} - retrying`)
99
+ continue
100
+ }
101
+ body = (await poll.json()) as typeof body
102
+ } catch (err) {
103
+ if (err instanceof Error && err.message.startsWith("device flow")) throw err
104
+ continue
105
+ }
106
+
107
+ if (body.access_token) {
108
+ const token = body.access_token
109
+ let login: string | undefined
110
+ try {
111
+ const user = await copilotFetch(`${GITHUB_API_BASE_URL}/user`, {
112
+ headers: githubRequestHeaders(token),
113
+ })
114
+ if (user.ok) login = ((await user.json()) as { login?: string }).login
115
+ } catch {
116
+ // login display is best-effort
117
+ }
118
+ await saveAuth({ github_token: token, login })
119
+ return { token, login }
120
+ }
121
+ if (body.error === "slow_down") {
122
+ interval += 5000
123
+ } else if (body.error && body.error !== "authorization_pending") {
124
+ throw new Error(
125
+ `device flow failed: ${body.error} ${body.error_description ?? ""}`,
126
+ )
127
+ }
128
+ }
129
+ throw new Error("device flow timed out")
130
+ }
package/src/blocks.ts ADDED
@@ -0,0 +1,198 @@
1
+ // The one place that decides what an Anthropic content block means.
2
+ //
3
+ // It used to be twenty `block.type === "..."` comparisons spread across
4
+ // translate.ts and responses.ts, each an if/else-if chain with no final else.
5
+ // A block type none of them listed therefore contributed nothing and said
6
+ // nothing: a PDF attachment (`document`) was dropped on the floor and the
7
+ // model answered confidently about a file it had never seen. Nothing in the
8
+ // type system could catch that, because every block arrives through
9
+ // `JSON.parse` and Claude Code adds block types on its own schedule.
10
+ //
11
+ // So the knowledge lives here, once, and the dispatch is exhaustive: adding a
12
+ // type to KNOWN_BLOCKS without handling it fails `tsc`, and anything NOT in
13
+ // KNOWN_BLOCKS becomes a visible placeholder rather than silence. The dialects
14
+ // then only decide how to render a classified block, never what one is.
15
+
16
+ export interface TextBlock {
17
+ type: "text"
18
+ text: string
19
+ /** Copilot's prompt-cache marker rides on the block Claude Code sends. */
20
+ cache_control?: { type: string }
21
+ }
22
+
23
+ export interface ThinkingBlock {
24
+ type: "thinking"
25
+ thinking: string
26
+ }
27
+
28
+ /** Thinking the upstream redacted; it carries no readable text. */
29
+ export interface RedactedThinkingBlock {
30
+ type: "redacted_thinking"
31
+ data: string
32
+ }
33
+
34
+ export interface ImageBlock {
35
+ type: "image"
36
+ source: { type: string; media_type: string; data: string }
37
+ }
38
+
39
+ /** A file attachment. No translated dialect has anywhere to put one. */
40
+ export interface DocumentBlock {
41
+ type: "document"
42
+ source: { type: string; media_type?: string; data?: string; url?: string }
43
+ title?: string
44
+ }
45
+
46
+ export interface ToolUseBlock {
47
+ type: "tool_use"
48
+ id: string
49
+ name: string
50
+ input: Record<string, unknown>
51
+ }
52
+
53
+ export interface ToolResultBlock {
54
+ type: "tool_result"
55
+ tool_use_id: string
56
+ is_error?: boolean
57
+ content?: string | Array<TextBlock | ImageBlock | ThinkingBlock>
58
+ }
59
+
60
+ export type ContentBlock =
61
+ | TextBlock
62
+ | ThinkingBlock
63
+ | RedactedThinkingBlock
64
+ | ImageBlock
65
+ | DocumentBlock
66
+ | ToolUseBlock
67
+ | ToolResultBlock
68
+
69
+ /**
70
+ * Every type this file knows how to classify. The exhaustive switch below is
71
+ * checked against it, so extending this list without extending the switch is
72
+ * a compile error - which is the whole point of the file.
73
+ */
74
+ export const KNOWN_BLOCKS = [
75
+ "text",
76
+ "thinking",
77
+ "redacted_thinking",
78
+ "image",
79
+ "document",
80
+ "tool_use",
81
+ "tool_result",
82
+ ] as const
83
+
84
+ export type KnownBlockType = (typeof KNOWN_BLOCKS)[number]
85
+
86
+ const KNOWN = new Set<string>(KNOWN_BLOCKS)
87
+
88
+ /**
89
+ * A block as it arrives: off the wire via JSON.parse, so nothing about it is
90
+ * guaranteed. Taking `unknown` rather than a shaped type is deliberate - the
91
+ * whole failure this file fixes was code that trusted a shape the wire never
92
+ * promised.
93
+ */
94
+ export type RawBlock = unknown
95
+
96
+ function field(block: unknown, key: string): unknown {
97
+ return typeof block === "object" && block !== null
98
+ ? (block as Record<string, unknown>)[key]
99
+ : undefined
100
+ }
101
+
102
+ /** What a dialect has to render. `unsupported` is never silence. */
103
+ export type Classified =
104
+ | { kind: "text"; text: string }
105
+ | { kind: "image"; block: ImageBlock }
106
+ | { kind: "tool_use"; block: ToolUseBlock }
107
+ | { kind: "tool_result"; block: ToolResultBlock }
108
+ /**
109
+ * Carried forward as text so the model is told something was there. A
110
+ * dropped block changes the answer; a named placeholder only shortens it,
111
+ * and the user can see which it was.
112
+ */
113
+ | { kind: "unsupported"; blockType: string; text: string }
114
+
115
+ /** Wire strings are not guaranteed to be strings. */
116
+ function str(value: unknown): string {
117
+ return typeof value === "string" ? value : ""
118
+ }
119
+
120
+ function assertNever(value: never): never {
121
+ throw new Error(`unhandled content block: ${JSON.stringify(value)}`)
122
+ }
123
+
124
+ function placeholder(blockType: string, detail?: string): Classified {
125
+ return {
126
+ kind: "unsupported",
127
+ blockType,
128
+ text: `[clco: a ${blockType} block was not forwarded - this model's dialect has no equivalent${
129
+ detail ? `, ${detail}` : ""
130
+ }]`,
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Classify one block. Unknown types and known-but-unforwardable types both
136
+ * come back as `unsupported`, distinguishable by `blockType`.
137
+ */
138
+ export function classifyBlock(block: RawBlock): Classified {
139
+ const raw = field(block, "type")
140
+ const type = typeof raw === "string" ? raw : "(missing type)"
141
+ if (!KNOWN.has(type)) return placeholder(type)
142
+ return classifyKnown(block, type as KnownBlockType)
143
+ }
144
+
145
+ // `block` stays raw here rather than being asserted to ContentBlock: it came
146
+ // off the wire, so a field the interface calls required may simply be absent,
147
+ // and each case narrows only what it actually reads.
148
+ function classifyKnown(block: unknown, type: KnownBlockType): Classified {
149
+ switch (type) {
150
+ case "text":
151
+ return { kind: "text", text: str(field(block, "text")) }
152
+ // Reasoning text is ordinary text to a dialect that has no thinking
153
+ // channel; dropping it loses the assistant's own stated reasoning from
154
+ // the transcript it is about to continue.
155
+ case "thinking":
156
+ return { kind: "text", text: str(field(block, "thinking")) }
157
+ // Nothing readable to forward, and it is not a loss worth narrating on
158
+ // every turn - the upstream redacted it, not clco.
159
+ case "redacted_thinking":
160
+ return { kind: "text", text: "" }
161
+ case "image":
162
+ return { kind: "image", block: block as unknown as ImageBlock }
163
+ case "document": {
164
+ const title = str(field(block, "title"))
165
+ return placeholder(
166
+ "document",
167
+ title ? `titled ${JSON.stringify(title)}` : "no text extracted",
168
+ )
169
+ }
170
+ case "tool_use":
171
+ return { kind: "tool_use", block: block as unknown as ToolUseBlock }
172
+ case "tool_result":
173
+ return { kind: "tool_result", block: block as unknown as ToolResultBlock }
174
+ default:
175
+ return assertNever(type)
176
+ }
177
+ }
178
+
179
+ /** Classify a whole message body, with the string shorthand expanded. */
180
+ export function classifyContent(content: unknown): Classified[] {
181
+ if (content === undefined || content === null) return []
182
+ if (typeof content === "string") return [{ kind: "text", text: content }]
183
+ if (!Array.isArray(content)) return []
184
+ return content.map(classifyBlock)
185
+ }
186
+
187
+ /** The block types in a body that clco could not forward, for diagnostics. */
188
+ export function unsupportedTypes(blocks: Classified[]): string[] {
189
+ return [
190
+ ...new Set(
191
+ blocks
192
+ .filter((b): b is Extract<Classified, { kind: "unsupported" }> =>
193
+ b.kind === "unsupported",
194
+ )
195
+ .map((b) => b.blockType),
196
+ ),
197
+ ]
198
+ }