diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts
index fb8679b..fb20dbe 100644
--- a/src/api/schema.d.ts
+++ b/src/api/schema.d.ts
@@ -154,6 +154,9 @@ export interface paths {
*
* You can connect the block to multiple channels at once (up to 20).
*
+ * Specify target channels using either `channels` (preferred) or `channel_ids` (legacy), but not both.
+ * The `channels` form allows per-channel position and connection metadata.
+ *
* **Authentication required.**
*/
post: operations["createBlock"];
@@ -393,6 +396,9 @@ export interface paths {
* @description Connects a block or channel to one or more channels.
* Returns the created connection(s).
*
+ * Specify target channels using either `channels` (preferred) or `channel_ids` (legacy), but not both.
+ * The `channels` form allows per-channel position and connection metadata.
+ *
* **Authentication required.**
*/
post: operations["createConnection"];
@@ -418,7 +424,14 @@ export interface paths {
* are returned as part of channel contents.
*/
get: operations["getConnection"];
- put?: never;
+ /**
+ * Update a connection
+ * @description Updates a connection's metadata. Uses merge semantics: new keys are added,
+ * existing keys are updated, keys set to null are removed.
+ *
+ * **Authentication required.**
+ */
+ put: operations["updateConnection"];
post?: never;
/**
* Delete a connection
@@ -623,6 +636,28 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/v3/users/{id}/groups": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get user groups
+ * @description Returns paginated list of groups the user belongs to (as owner or member).
+ * When authenticated as the target user, includes private groups.
+ * Otherwise only public groups are returned.
+ */
+ get: operations["getUserGroups"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/v3/groups/{id}": {
parameters: {
query?: never;
@@ -894,6 +929,28 @@ export interface components {
*/
initials: string;
};
+ /**
+ * @description Arbitrary key-value pairs stored on an entity.
+ * Keys are alphanumeric/underscore, max 40 characters.
+ * Values are scalars (string, number, boolean). Max 50 keys, 32KB total.
+ * @example {
+ * "status": "reviewed",
+ * "score": 0.95,
+ * "featured": true
+ * }
+ */
+ Metadata: {
+ [key: string]: string | number | boolean;
+ };
+ /**
+ * @description Arbitrary key-value pairs to set on an entity. Uses merge semantics:
+ * new keys are added, existing keys are updated, keys set to null are removed.
+ * Keys must be alphanumeric/underscore, max 40 characters.
+ * Values must be scalars (string, number, boolean) or null (to delete). Max 50 keys, 32KB total.
+ */
+ MetadataInput: {
+ [key: string]: string | number | boolean | null;
+ };
/** @description Embedded connection representation used when connection is nested in other resources */
EmbeddedConnection: {
/**
@@ -902,7 +959,7 @@ export interface components {
*/
id: number;
/**
- * @description Position of the item within the channel
+ * @description Position of the item within the channel (1-indexed)
* @example 1
*/
position: number;
@@ -911,6 +968,8 @@ export interface components {
* @example false
*/
pinned: boolean;
+ /** @description Custom key-value metadata */
+ metadata?: components["schemas"]["Metadata"] | null;
/**
* Format: date-time
* @description When the item was connected
@@ -1262,6 +1321,23 @@ export interface components {
| "created_at_desc"
| "updated_at_asc"
| "updated_at_desc";
+ /**
+ * @description Sort order for groups.
+ * - `name_asc`: Alphabetical (default)
+ * - `name_desc`: Reverse alphabetical
+ * - `created_at_desc`: Newest first
+ * - `created_at_asc`: Oldest first
+ * - `updated_at_desc`: Recently updated first
+ * - `updated_at_asc`: Least recently updated first
+ * @enum {string}
+ */
+ GroupSort:
+ | "name_asc"
+ | "name_desc"
+ | "created_at_asc"
+ | "created_at_desc"
+ | "updated_at_asc"
+ | "updated_at_desc";
/**
* @description Limit search to a specific context.
* - `all`: Everything accessible to the user (default)
@@ -1326,6 +1402,15 @@ export interface components {
* ]
*/
ChannelIds: (number | string)[];
+ /** @description A channel to connect to, with optional position and connection metadata. */
+ ConnectTo: {
+ /** @description Channel ID or slug. */
+ id: number | string;
+ /** @description Position to insert at within this channel (1-indexed). */
+ position?: number;
+ /** @description Connection metadata for this specific connection. */
+ metadata?: components["schemas"]["Metadata"];
+ };
/** @description A presigned S3 upload URL for a single file */
PresignedFile: {
/**
@@ -1392,6 +1477,8 @@ export interface components {
* @example Beige flags
*/
alt_text?: string;
+ /** @description Custom key-value metadata to set on the new block. */
+ metadata?: components["schemas"]["Metadata"];
};
/** @description Response returned when a batch is accepted for processing */
BatchResponse: {
@@ -1511,6 +1598,8 @@ export interface components {
*/
updated_at: string;
user: components["schemas"]["EmbeddedUser"];
+ /** @description Custom key-value metadata */
+ metadata?: components["schemas"]["Metadata"] | null;
/** @description Source URL and metadata (if block was created from a URL) */
source?: components["schemas"]["BlockSource"] | null;
/** @description HATEOAS links for navigation */
@@ -1920,6 +2009,8 @@ export interface components {
* @example 2023-01-15T14:45:00Z
*/
updated_at: string;
+ /** @description Custom key-value metadata */
+ metadata?: components["schemas"]["Metadata"] | null;
owner: components["schemas"]["ChannelOwner"];
counts: components["schemas"]["ChannelCounts"];
/**
@@ -2083,6 +2174,14 @@ export interface components {
/** @description Paginated list of channels with total count */
ChannelListResponse: components["schemas"]["ChannelList"] &
components["schemas"]["PaginatedResponse"];
+ /** @description Data payload containing an array of groups */
+ GroupList: {
+ /** @description Array of groups */
+ data: components["schemas"]["Group"][];
+ };
+ /** @description Paginated list of groups with total count */
+ GroupListResponse: components["schemas"]["GroupList"] &
+ components["schemas"]["PaginatedResponse"];
/** @description Paginated list of connectable content (blocks and channels) */
ConnectableListResponse: components["schemas"]["ConnectableList"] &
components["schemas"]["PaginatedResponse"];
@@ -2241,6 +2340,11 @@ export interface components {
* @example created_at_desc
*/
ContentSortParam: components["schemas"]["ContentSort"];
+ /**
+ * @description Sort groups by name or date.
+ * @example name_asc
+ */
+ GroupSortParam: components["schemas"]["GroupSort"];
/**
* @description Sort channel contents. Use `position` for the owner's manual
* arrangement, or sort by date. Defaults to `position_desc`.
@@ -2483,15 +2587,22 @@ export interface operations {
};
requestBody: {
content: {
- "application/json": components["schemas"]["BlockInput"] & {
- channel_ids: components["schemas"]["ChannelIds"];
- /**
- * @description Position to insert the block in the channel.
- * Only valid when connecting to a single channel.
- * @example 0
- */
- insert_at?: number;
- };
+ "application/json": components["schemas"]["BlockInput"] &
+ (
+ | {
+ channel_ids: components["schemas"]["ChannelIds"];
+ /**
+ * @description Position to insert the block in the channel (1-indexed).
+ * Only valid when connecting to a single channel.
+ * @example 1
+ */
+ insert_at?: number;
+ }
+ | {
+ /** @description Target channels with optional per-channel position and connection metadata. */
+ channels: components["schemas"]["ConnectTo"][];
+ }
+ );
};
};
responses: {
@@ -2648,6 +2759,11 @@ export interface operations {
* @example A descriptive alt text for accessibility
*/
alt_text?: string;
+ /**
+ * @description Custom key-value metadata. Uses merge semantics: new keys are added,
+ * existing keys are updated, keys set to null are removed.
+ */
+ metadata?: components["schemas"]["MetadataInput"];
};
};
};
@@ -2852,6 +2968,8 @@ export interface operations {
* @example 12345
*/
group_id?: number;
+ /** @description Custom key-value metadata to set on the new channel. */
+ metadata?: components["schemas"]["Metadata"];
};
};
};
@@ -2923,6 +3041,11 @@ export interface operations {
* @example Updated description
*/
description?: string | null;
+ /**
+ * @description Custom key-value metadata. Uses merge semantics: new keys are added,
+ * existing keys are updated, keys set to null are removed.
+ */
+ metadata?: components["schemas"]["MetadataInput"];
};
};
};
@@ -2983,13 +3106,20 @@ export interface operations {
connectable_id: number;
/** @description Type of the connectable. */
connectable_type: components["schemas"]["ConnectableType"];
- channel_ids: components["schemas"]["ChannelIds"];
- /**
- * @description Position to insert at within the channel. Only valid
- * when connecting to a single channel.
- */
- position?: number;
- };
+ } & (
+ | {
+ channel_ids: components["schemas"]["ChannelIds"];
+ /**
+ * @description Position to insert at within the channel (1-indexed).
+ * Only valid when connecting to a single channel.
+ */
+ position?: number;
+ }
+ | {
+ /** @description Target channels with optional per-channel position and connection metadata. */
+ channels: components["schemas"]["ConnectTo"][];
+ }
+ );
};
};
responses: {
@@ -3039,6 +3169,45 @@ export interface operations {
429: components["responses"]["RateLimitResponse"];
};
};
+ updateConnection: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Resource ID */
+ id: components["parameters"]["IdParam"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /**
+ * @description Custom key-value metadata. Uses merge semantics: new keys are added,
+ * existing keys are updated, keys set to null are removed.
+ */
+ metadata?: components["schemas"]["MetadataInput"];
+ };
+ };
+ };
+ responses: {
+ /** @description Connection updated successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Connection"];
+ };
+ };
+ 400: components["responses"]["ValidationErrorResponse"];
+ 401: components["responses"]["UnauthorizedResponse"];
+ 403: components["responses"]["ForbiddenResponse"];
+ 404: components["responses"]["NotFoundResponse"];
+ 422: components["responses"]["UnprocessableEntityResponse"];
+ 429: components["responses"]["RateLimitResponse"];
+ };
+ };
deleteConnection: {
parameters: {
query?: never;
@@ -3079,7 +3248,7 @@ export interface operations {
"application/json": {
/** @default insert_at */
movement?: components["schemas"]["Movement"];
- /** @description Target position (required when movement is insert_at) */
+ /** @description Target position, 1-indexed (required when movement is insert_at) */
position?: number;
};
};
@@ -3425,6 +3594,49 @@ export interface operations {
429: components["responses"]["RateLimitResponse"];
};
};
+ getUserGroups: {
+ parameters: {
+ query?: {
+ /**
+ * @description Page number for pagination
+ * @example 1
+ */
+ page?: components["parameters"]["PageParam"];
+ /**
+ * @description Number of items per page (max 100)
+ * @example 24
+ */
+ per?: components["parameters"]["PerParam"];
+ /**
+ * @description Sort groups by name or date.
+ * @example name_asc
+ */
+ sort?: components["parameters"]["GroupSortParam"];
+ };
+ header?: never;
+ path: {
+ /** @description Resource ID or slug */
+ id: components["parameters"]["SlugOrIdParam"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description List of groups the user belongs to */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["GroupListResponse"];
+ };
+ };
+ 401: components["responses"]["UnauthorizedResponse"];
+ 403: components["responses"]["ForbiddenResponse"];
+ 404: components["responses"]["NotFoundResponse"];
+ 429: components["responses"]["RateLimitResponse"];
+ };
+ };
getGroup: {
parameters: {
query?: never;
diff --git a/src/api/types.ts b/src/api/types.ts
index 7855fbf..d453948 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -35,6 +35,9 @@ export type ChannelContentSort = Schemas["ChannelContentSort"];
export type ContentSort = Schemas["ContentSort"];
export type ConnectionFilter = Schemas["ConnectionFilter"];
export type UserTier = Schemas["UserTier"];
+export type GroupSort = Schemas["GroupSort"];
+export type Metadata = Schemas["Metadata"];
+export type MetadataInput = Schemas["MetadataInput"];
export type PresignedFile = Schemas["PresignedFile"];
export type PaginationMeta = Schemas["PaginationMeta"];
diff --git a/src/commands/add.tsx b/src/commands/add.tsx
index 20c2765..86ea0ba 100644
--- a/src/commands/add.tsx
+++ b/src/commands/add.tsx
@@ -1,5 +1,6 @@
import { Box, Text } from "ink";
import { client, getData } from "../api/client";
+import type { Metadata } from "../api/types";
import { readStdin } from "../lib/args";
import { Spinner } from "../components/Spinner";
import { useCommand } from "../hooks/use-command";
@@ -13,6 +14,8 @@ interface Props {
originalSourceUrl?: string;
originalSourceTitle?: string;
insertAt?: number;
+ metadata?: Metadata;
+ connectionMetadata?: Metadata;
}
export function AddCommand({
@@ -24,6 +27,8 @@ export function AddCommand({
originalSourceUrl,
originalSourceTitle,
insertAt,
+ metadata,
+ connectionMetadata,
}: Props) {
const { data, error, loading } = useCommand(async () => {
const resolvedValue = valueProp ?? (await readStdin());
@@ -38,13 +43,19 @@ export function AddCommand({
client.POST("/v3/blocks", {
body: {
value: resolvedValue,
- channel_ids: [ch.id],
+ channels: [
+ {
+ id: ch.id,
+ position: insertAt,
+ metadata: connectionMetadata,
+ },
+ ],
title,
description,
alt_text: altText,
original_source_url: originalSourceUrl,
original_source_title: originalSourceTitle,
- insert_at: insertAt,
+ metadata,
},
}),
);
diff --git a/src/commands/block.tsx b/src/commands/block.tsx
index c6de489..a51318b 100644
--- a/src/commands/block.tsx
+++ b/src/commands/block.tsx
@@ -1,6 +1,6 @@
import { Box, Text } from "ink";
import { client, getData } from "../api/client";
-import type { ConnectionSort } from "../api/types";
+import type { ConnectionSort, MetadataInput } from "../api/types";
import { BlockContent } from "../components/BlockContent";
import { Spinner } from "../components/Spinner";
import { useCommand } from "../hooks/use-command";
@@ -28,18 +28,20 @@ export function BlockUpdateCommand({
description,
content,
altText,
+ metadata,
}: {
id: number;
title?: string;
description?: string;
content?: string;
altText?: string;
+ metadata?: MetadataInput;
}) {
const { data, error, loading } = useCommand(() =>
getData(
client.PUT("/v3/blocks/{id}", {
params: { path: { id } },
- body: { title, description, content, alt_text: altText },
+ body: { title, description, content, alt_text: altText, metadata },
}),
),
);
diff --git a/src/commands/channel.tsx b/src/commands/channel.tsx
index ee97eba..5f19bf9 100644
--- a/src/commands/channel.tsx
+++ b/src/commands/channel.tsx
@@ -1,6 +1,11 @@
import { Box, Text, useApp } from "ink";
import { client, getData } from "../api/client";
-import type { ChannelContentSort, Visibility } from "../api/types";
+import type {
+ ChannelContentSort,
+ Metadata,
+ MetadataInput,
+ Visibility,
+} from "../api/types";
import { BlockItem } from "../components/BlockItem";
import { ChannelBlockViewer } from "../components/ChannelBlockViewer";
import {
@@ -11,6 +16,7 @@ import { Spinner } from "../components/Spinner";
import { useCommand } from "../hooks/use-command";
import { useStackNavigator } from "../hooks/useStackNavigator";
import { plural, timeAgo } from "../lib/format";
+import { formatMetadata } from "../lib/metadata";
import { clearTerminalViewport } from "../lib/terminalViewport";
import { visibilityLabel } from "../lib/theme";
@@ -147,6 +153,9 @@ function StaticChannelView({
{plural(channel.counts.contents, "block")}
Updated {timeAgo(channel.updated_at)}
+ {channel.metadata && (
+ Metadata {formatMetadata(channel.metadata)}
+ )}
@@ -228,16 +237,18 @@ export function ChannelCreateCommand({
visibility,
description,
groupId,
+ metadata,
}: {
title: string;
visibility?: Visibility;
description?: string;
groupId?: number;
+ metadata?: Metadata;
}) {
const { data, error, loading } = useCommand(() =>
getData(
client.POST("/v3/channels", {
- body: { title, visibility, description, group_id: groupId },
+ body: { title, visibility, description, group_id: groupId, metadata },
}),
),
);
@@ -264,17 +275,19 @@ export function ChannelUpdateCommand({
title,
visibility,
description,
+ metadata,
}: {
slug: string;
title?: string;
visibility?: Visibility;
description?: string;
+ metadata?: MetadataInput;
}) {
const { data, error, loading } = useCommand(() =>
getData(
client.PUT("/v3/channels/{id}", {
params: { path: { id: slug } },
- body: { title, visibility, description },
+ body: { title, visibility, description, metadata },
}),
),
);
diff --git a/src/commands/connect.tsx b/src/commands/connect.tsx
index ed5b7e5..c2c2ba2 100644
--- a/src/commands/connect.tsx
+++ b/src/commands/connect.tsx
@@ -1,6 +1,6 @@
import { Box, Text } from "ink";
import { client, getData } from "../api/client";
-import type { ConnectableType } from "../api/types";
+import type { ConnectableType, Metadata } from "../api/types";
import { Spinner } from "../components/Spinner";
import { useCommand } from "../hooks/use-command";
@@ -9,6 +9,7 @@ interface Props {
channel: string;
connectableType?: ConnectableType;
position?: number;
+ metadata?: Metadata;
}
export function ConnectCommand({
@@ -16,6 +17,7 @@ export function ConnectCommand({
channel,
connectableType = "Block",
position,
+ metadata,
}: Props) {
const { data, error, loading } = useCommand(async () => {
const ch = await getData(
@@ -28,8 +30,7 @@ export function ConnectCommand({
body: {
connectable_id: blockId,
connectable_type: connectableType,
- channel_ids: [ch.id],
- position,
+ channels: [{ id: ch.id, position, metadata }],
},
});
return { channel: ch };
diff --git a/src/commands/connection.tsx b/src/commands/connection.tsx
index 942c70d..8591c46 100644
--- a/src/commands/connection.tsx
+++ b/src/commands/connection.tsx
@@ -1,9 +1,15 @@
import { Box, Text } from "ink";
import { client, getData } from "../api/client";
-import type { ConnectionFilter, ConnectionSort, Movement } from "../api/types";
+import type {
+ ConnectionFilter,
+ ConnectionSort,
+ MetadataInput,
+ Movement,
+} from "../api/types";
import { Spinner } from "../components/Spinner";
import { useCommand } from "../hooks/use-command";
import { plural } from "../lib/format";
+import { formatMetadata } from "../lib/metadata";
import { channelColor, INDICATORS } from "../lib/theme";
export function ConnectionGetCommand({ id }: { id: number }) {
@@ -29,6 +35,37 @@ export function ConnectionGetCommand({ id }: { id: number }) {
{data.connected_by && (
Connected by {data.connected_by.name}
)}
+ {data.metadata && (
+ Metadata {formatMetadata(data.metadata)}
+ )}
+
+ );
+}
+
+export function ConnectionUpdateCommand({
+ id,
+ metadata,
+}: {
+ id: number;
+ metadata: MetadataInput;
+}) {
+ const { data, error, loading } = useCommand(() =>
+ getData(
+ client.PUT("/v3/connections/{id}", {
+ params: { path: { id } },
+ body: { metadata },
+ }),
+ ),
+ );
+
+ if (loading) return ;
+ if (error) return โ {error};
+ if (!data) return null;
+
+ return (
+
+ โ
+ Updated connection {data.id}
);
}
diff --git a/src/commands/user.tsx b/src/commands/user.tsx
index 200bdb9..963020e 100644
--- a/src/commands/user.tsx
+++ b/src/commands/user.tsx
@@ -5,6 +5,7 @@ import type {
ContentSort,
ContentTypeFilter,
FollowableType,
+ GroupSort,
} from "../api/types";
import { BlockItem } from "../components/BlockItem";
import { Spinner } from "../components/Spinner";
@@ -172,3 +173,43 @@ export function UserFollowingCommand({
);
}
+
+export function UserGroupsCommand({
+ slug,
+ page = 1,
+ per,
+ sort,
+}: {
+ slug: string;
+ page?: number;
+ per?: number;
+ sort?: GroupSort;
+}) {
+ const { data, error, loading } = useCommand(() =>
+ getData(
+ client.GET("/v3/users/{id}/groups", {
+ params: { path: { id: slug }, query: { page, per, sort } },
+ }),
+ ),
+ );
+
+ if (loading) return ;
+ if (error) return โ {error};
+ if (!data) return null;
+
+ if (data.data.length === 0) return No groups;
+
+ return (
+
+ {data.data.map((group) => (
+
+ {group.name} @{group.slug}
+
+ ))}
+
+ {"\n"}Page {data.meta.current_page}/{data.meta.total_pages} ยท{" "}
+ {plural(data.meta.total_count, "group")}
+
+
+ );
+}
diff --git a/src/components/BlockContent.tsx b/src/components/BlockContent.tsx
index 437552b..b735e8c 100644
--- a/src/components/BlockContent.tsx
+++ b/src/components/BlockContent.tsx
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { Box, Text } from "ink";
import type { Block } from "../api/types";
import { formatFileSize, timeAgo } from "../lib/format";
+import { formatMetadata } from "../lib/metadata";
import { blockTextColor } from "../lib/theme";
import { TerminalImage } from "./TerminalImage";
@@ -74,6 +75,7 @@ export function BlockContent({
value: `${block.updated_at} (${timeAgo(block.updated_at)})`,
},
{ label: "By", value: block.user.name },
+ { label: "Metadata", value: formatMetadata(block.metadata) },
...(previewImage
? [
{ label: "Image", value: previewImage.filename },
diff --git a/src/lib/metadata.test.ts b/src/lib/metadata.test.ts
new file mode 100644
index 0000000..a30ea62
--- /dev/null
+++ b/src/lib/metadata.test.ts
@@ -0,0 +1,54 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { metadataInputFlag, parseMetadata } from "./metadata";
+
+test("parseMetadata parses key=value scalars", () => {
+ assert.deepEqual(parseMetadata("status=reviewed,score=0.95,featured=true"), {
+ status: "reviewed",
+ score: 0.95,
+ featured: true,
+ });
+});
+
+test("parseMetadata parses JSON object values", () => {
+ assert.deepEqual(
+ parseMetadata('{"status":"reviewed","score":1,"featured":false}'),
+ {
+ status: "reviewed",
+ score: 1,
+ featured: false,
+ },
+ );
+});
+
+test("metadataInputFlag allows null values for merge removal", () => {
+ assert.deepEqual(metadataInputFlag({ metadata: "status=null" }), {
+ status: null,
+ });
+});
+
+test("parseMetadata rejects nested values", () => {
+ assert.throws(
+ () => parseMetadata('{"tags":["nested"]}'),
+ /Invalid metadata value/,
+ );
+});
+
+test("parseMetadata rejects invalid keys", () => {
+ assert.throws(() => parseMetadata("bad-key=value"), /Invalid metadata key/);
+});
+
+test("parseMetadata preserves strings that don't round-trip as numbers", () => {
+ assert.deepEqual(parseMetadata("sku=00123,zip=05401,decimal=1.10"), {
+ sku: "00123",
+ zip: "05401",
+ decimal: "1.10",
+ });
+});
+
+test("parseMetadata supports backslash-escaped commas in values", () => {
+ assert.deepEqual(parseMetadata("note=hello\\, world,status=ok"), {
+ note: "hello, world",
+ status: "ok",
+ });
+});
diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts
new file mode 100644
index 0000000..78a0f13
--- /dev/null
+++ b/src/lib/metadata.ts
@@ -0,0 +1,170 @@
+import type { Metadata, MetadataInput } from "../api/types";
+import type { Flags } from "./args";
+
+type MetadataValue = string | number | boolean;
+type MetadataInputValue = MetadataValue | null;
+
+const KEY_PATTERN = /^[A-Za-z0-9_]{1,40}$/;
+const NUMBER_PATTERN = /^-?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function parseScalar(raw: string): MetadataInputValue {
+ const value = raw.trim();
+ if (value === "true") return true;
+ if (value === "false") return false;
+ if (value === "null") return null;
+ if (NUMBER_PATTERN.test(value)) {
+ const numberValue = Number(value);
+ // Only coerce to number when the value round-trips losslessly. This
+ // preserves strings like "00123", "1.10", or "1e2" that a user clearly
+ // wants to keep as strings (e.g. SKUs, phone numbers, zip codes).
+ if (Number.isFinite(numberValue) && String(numberValue) === value) {
+ return numberValue;
+ }
+ }
+ return value;
+}
+
+/**
+ * Split a key=value list on commas while respecting backslash escapes.
+ * `\,` inserts a literal comma; `\\` inserts a literal backslash. This lets
+ * users embed commas in values (e.g. `note=hello\,world`) without dropping
+ * to JSON form.
+ */
+function splitEntries(raw: string): string[] {
+ const entries: string[] = [];
+ let current = "";
+ for (let i = 0; i < raw.length; i++) {
+ const char = raw[i]!;
+ if (char === "\\" && i + 1 < raw.length) {
+ current += raw[i + 1]!;
+ i++;
+ continue;
+ }
+ if (char === ",") {
+ entries.push(current);
+ current = "";
+ continue;
+ }
+ current += char;
+ }
+ entries.push(current);
+ return entries.filter((part) => part.trim());
+}
+
+function validateMetadataValue(
+ key: string,
+ value: unknown,
+ allowNull: boolean,
+): MetadataInputValue {
+ if (!KEY_PATTERN.test(key)) {
+ throw new Error(
+ `Invalid metadata key: ${key}. Use 1-40 alphanumeric or underscore characters.`,
+ );
+ }
+
+ if (value === null) {
+ if (allowNull) return null;
+ throw new Error(`Metadata value for ${key} cannot be null here.`);
+ }
+
+ if (typeof value === "string" || typeof value === "boolean") return value;
+
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+
+ throw new Error(
+ `Invalid metadata value for ${key}. Expected string, number, boolean${
+ allowNull ? ", or null" : ""
+ }.`,
+ );
+}
+
+function parseMetadataObject(raw: string): Record {
+ const trimmed = raw.trim();
+ if (trimmed.startsWith("{")) {
+ const parsed = JSON.parse(trimmed) as unknown;
+ if (!isRecord(parsed)) throw new Error("--metadata JSON must be an object");
+ return parsed;
+ }
+
+ return Object.fromEntries(
+ splitEntries(trimmed).map((part) => {
+ const separator = part.indexOf("=");
+ if (separator <= 0) {
+ throw new Error(
+ `Invalid metadata entry: ${part}. Use key=value pairs or a JSON object.`,
+ );
+ }
+ const key = part.slice(0, separator).trim();
+ const value = parseScalar(part.slice(separator + 1));
+ return [key, value];
+ }),
+ );
+}
+
+export function parseMetadata(
+ raw: string,
+ options: { allowNull?: boolean } = {},
+): Metadata | MetadataInput {
+ const allowNull = options.allowNull ?? false;
+ const parsed = parseMetadataObject(raw);
+ const entries = Object.entries(parsed);
+
+ if (entries.length > 50) {
+ throw new Error("Metadata supports at most 50 keys.");
+ }
+
+ const metadata: Record = {};
+ for (const [key, value] of entries) {
+ metadata[key] = validateMetadataValue(key, value, allowNull);
+ }
+
+ return metadata as Metadata | MetadataInput;
+}
+
+export function metadataFlag(
+ flags: Flags,
+ key = "metadata",
+ options: { allowNull?: boolean } = {},
+): Metadata | MetadataInput | undefined {
+ const value = flags[key];
+ if (value === undefined) return undefined;
+ if (typeof value !== "string") throw new Error(`--${key} requires a value`);
+ return parseMetadata(value, options);
+}
+
+export function entityMetadataFlag(
+ flags: Flags,
+ key = "metadata",
+): Metadata | undefined {
+ return metadataFlag(flags, key, { allowNull: false }) as Metadata | undefined;
+}
+
+export function metadataInputFlag(
+ flags: Flags,
+ key = "metadata",
+): MetadataInput | undefined {
+ return metadataFlag(flags, key, { allowNull: true }) as
+ | MetadataInput
+ | undefined;
+}
+
+export function requireMetadataInputFlag(
+ flags: Flags,
+ key = "metadata",
+): MetadataInput {
+ const metadata = metadataInputFlag(flags, key);
+ if (!metadata) throw new Error(`Missing required flag: --${key}`);
+ return metadata;
+}
+
+export function formatMetadata(metadata: Metadata | null | undefined): string {
+ if (!metadata || Object.keys(metadata).length === 0) return "";
+ return Object.entries(metadata)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, value]) => `${key}=${String(value)}`)
+ .join(", ");
+}
diff --git a/src/lib/registry.tsx b/src/lib/registry.tsx
index d5f718d..160dc9f 100644
--- a/src/lib/registry.tsx
+++ b/src/lib/registry.tsx
@@ -8,6 +8,7 @@ import type {
ContentTypeFilter,
FileExtension,
FollowableType,
+ GroupSort,
Movement,
SearchScope,
SearchSort,
@@ -48,6 +49,7 @@ import {
ConnectionGetCommand,
ConnectionDeleteCommand,
ConnectionMoveCommand,
+ ConnectionUpdateCommand,
BlockConnectionsCommand,
ChannelConnectionsCommand,
ChannelFollowersCommand,
@@ -72,6 +74,7 @@ import {
UserContentsCommand,
UserFollowersCommand,
UserFollowingCommand,
+ UserGroupsCommand,
UserViewCommand,
} from "../commands/user";
import { VersionCommand } from "../commands/version";
@@ -79,6 +82,11 @@ import { WhoamiCommand } from "../commands/whoami";
import { config } from "./config";
import type { DestructiveCommandConfig } from "./destructive-confirmation";
import { uploadLocalFile } from "./upload";
+import {
+ entityMetadataFlag,
+ metadataInputFlag,
+ requireMetadataInputFlag,
+} from "./metadata";
import { CLI_PACKAGE_NAME, getCliVersion } from "./version";
interface HelpLine {
@@ -146,17 +154,17 @@ export const commands: CommandDefinition[] = [
},
{
usage:
- "channel create [--visibility ] [--description ] [--group-id ]",
+ "channel create [--visibility ] [--description ] [--group-id ] [--metadata ]",
description: "Options",
},
{
usage:
- 'channel create "My Research" --visibility private --group-id 123',
+ 'channel create "My Research" --visibility private --metadata status=draft',
description: "Example",
},
{
usage:
- "channel update [--title ] [--description ] [--visibility ]",
+ "channel update [--title ] [--description ] [--visibility ] [--metadata ]",
description: "Options",
},
{
@@ -201,6 +209,7 @@ export const commands: CommandDefinition[] = [
visibility={visibility}
description={flag(flags, "description")}
groupId={intFlag(flags, "group-id")}
+ metadata={entityMetadataFlag(flags)}
/>
);
case "update":
@@ -210,6 +219,7 @@ export const commands: CommandDefinition[] = [
title={flag(flags, "title")}
visibility={visibility}
description={flag(flags, "description")}
+ metadata={metadataInputFlag(flags)}
/>
);
case "delete":
@@ -266,6 +276,7 @@ export const commands: CommandDefinition[] = [
visibility,
description: flag(flags, "description"),
group_id: intFlag(flags, "group-id"),
+ metadata: entityMetadataFlag(flags),
},
}),
);
@@ -277,6 +288,7 @@ export const commands: CommandDefinition[] = [
title: flag(flags, "title"),
visibility,
description: flag(flags, "description"),
+ metadata: metadataInputFlag(flags),
},
}),
);
@@ -346,7 +358,7 @@ export const commands: CommandDefinition[] = [
{ usage: "block 12345", description: "Example" },
{
usage:
- "block update [--title ] [--description ] [--content ] [--alt-text ]",
+ "block update [--title ] [--description ] [--content ] [--alt-text ] [--metadata ]",
description: "Options",
},
{
@@ -383,6 +395,7 @@ export const commands: CommandDefinition[] = [
description={flag(flags, "description")}
content={flag(flags, "content")}
altText={flag(flags, "alt-text")}
+ metadata={metadataInputFlag(flags)}
/>
);
case "comments":
@@ -420,6 +433,7 @@ export const commands: CommandDefinition[] = [
description: flag(flags, "description"),
content: flag(flags, "content"),
alt_text: flag(flags, "alt-text"),
+ metadata: metadataInputFlag(flags),
},
}),
);
@@ -576,6 +590,14 @@ export const commands: CommandDefinition[] = [
usage: "add --insert-at 1",
description: "Example",
},
+ {
+ usage: "add --metadata status=reviewed",
+ description: "Example",
+ },
+ {
+ usage: "add --connection-metadata placement=homepage",
+ description: "Example",
+ },
],
render(args, flags) {
const argValue = args.slice(1).join(" ").trim() || undefined;
@@ -589,6 +611,8 @@ export const commands: CommandDefinition[] = [
originalSourceUrl={flag(flags, "original-source-url")}
originalSourceTitle={flag(flags, "original-source-title")}
insertAt={intFlag(flags, "insert-at")}
+ metadata={entityMetadataFlag(flags)}
+ connectionMetadata={entityMetadataFlag(flags, "connection-metadata")}
/>
);
},
@@ -603,18 +627,29 @@ export const commands: CommandDefinition[] = [
const stdin = argValue ? undefined : await readStdin();
const value = argValue ?? stdin;
if (!value) throw new Error("Missing required argument: value");
+ const metadata = entityMetadataFlag(flags);
+ const connectionMetadata = entityMetadataFlag(
+ flags,
+ "connection-metadata",
+ );
return getData(
client.POST("/v3/blocks", {
body: {
value,
- channel_ids: [ch.id],
+ channels: [
+ {
+ id: ch.id,
+ position: intFlag(flags, "insert-at"),
+ metadata: connectionMetadata,
+ },
+ ],
title: flag(flags, "title"),
description: flag(flags, "description"),
alt_text: flag(flags, "alt-text"),
original_source_url: flag(flags, "original-source-url"),
original_source_title: flag(flags, "original-source-title"),
- insert_at: intFlag(flags, "insert-at"),
+ metadata,
},
}),
);
@@ -765,11 +800,12 @@ export const commands: CommandDefinition[] = [
help: [
{
usage:
- "connect [--type ] [--position ]",
+ "connect [--type ] [--position ] [--metadata ]",
description: "Options",
},
{
- usage: "connect --type Channel --position 1",
+ usage:
+ "connect --type Channel --position 1 --metadata status=reviewed",
description: "Example",
},
],
@@ -780,20 +816,28 @@ export const commands: CommandDefinition[] = [
channel={requireArg(args, 1, "channel")}
connectableType={flagAs<"Block" | "Channel">(flags, "type")}
position={intFlag(flags, "position")}
+ metadata={entityMetadataFlag(flags)}
/>
);
},
async json(args, flags) {
- await client.POST("/v3/connections", {
- body: {
- connectable_id: idArg(args, 0, "block id"),
- channel_ids: [requireArg(args, 1, "channel")],
- connectable_type:
- flagAs<"Block" | "Channel">(flags, "type") || "Block",
- position: intFlag(flags, "position"),
- },
- });
- return { connected: true };
+ const response = await getData(
+ client.POST("/v3/connections", {
+ body: {
+ connectable_id: idArg(args, 0, "block id"),
+ connectable_type:
+ flagAs<"Block" | "Channel">(flags, "type") || "Block",
+ channels: [
+ {
+ id: requireArg(args, 1, "channel"),
+ position: intFlag(flags, "position"),
+ metadata: entityMetadataFlag(flags),
+ },
+ ],
+ },
+ }),
+ );
+ return { connected: true, ...response };
},
},
@@ -806,6 +850,14 @@ export const commands: CommandDefinition[] = [
{ usage: "connection 67890", description: "Example" },
{ usage: "connection delete ", description: "Options" },
{ usage: "connection delete 67890", description: "Example" },
+ {
+ usage: "connection update --metadata ",
+ description: "Options",
+ },
+ {
+ usage: "connection update 67890 --metadata status=reviewed,score=1",
+ description: "Example",
+ },
{
usage:
"connection move [--movement ] [--position ]",
@@ -836,6 +888,13 @@ export const commands: CommandDefinition[] = [
position={intFlag(flags, "position")}
/>
);
+ case "update":
+ return (
+
+ );
default:
return ;
}
@@ -858,6 +917,13 @@ export const commands: CommandDefinition[] = [
},
}),
);
+ case "update":
+ return getData(
+ client.PUT("/v3/connections/{id}", {
+ params: { path: { id: idArg(args, 1, "connection id") } },
+ body: { metadata: requireMetadataInputFlag(flags) },
+ }),
+ );
default:
return getData(
client.GET("/v3/connections/{id}", {
@@ -950,6 +1016,14 @@ export const commands: CommandDefinition[] = [
usage: "user following --type User --sort connected_at_desc",
description: "Example",
},
+ {
+ usage: "user groups [--page ] [--per ] [--sort ]",
+ description: "Options",
+ },
+ {
+ usage: "user groups --sort updated_at_desc",
+ description: "Example",
+ },
],
session: { args: "", desc: "View a user profile" },
render(args, flags) {
@@ -984,6 +1058,15 @@ export const commands: CommandDefinition[] = [
sort={flagAs(flags, "sort")}
/>
);
+ case "groups":
+ return (
+ (flags, "sort")}
+ />
+ );
default:
return ;
}
@@ -1032,6 +1115,19 @@ export const commands: CommandDefinition[] = [
},
}),
);
+ case "groups":
+ return getData(
+ client.GET("/v3/users/{id}/groups", {
+ params: {
+ path: { id: requireArg(args, 1, "slug") },
+ query: {
+ page: page(flags),
+ per: per(flags),
+ sort: flagAs(flags, "sort"),
+ },
+ },
+ }),
+ );
default:
return getData(
client.GET("/v3/users/{id}", {
@@ -1386,8 +1482,14 @@ export const commandHelpDocs: Record = {
flag: "--group-id ",
description: "Create channel under a group",
},
+ {
+ flag: "--metadata ",
+ description: "Custom channel metadata",
+ },
+ ],
+ examples: [
+ 'arena channel create "Team Notes" --group-id 123 --metadata status=draft',
],
- examples: ['arena channel create "Team Notes" --group-id 123'],
},
update: {
summary: "Update a channel.",
@@ -1399,8 +1501,14 @@ export const commandHelpDocs: Record = {
flag: "--visibility ",
description: "New visibility",
},
+ {
+ flag: "--metadata ",
+ description: "Merge channel metadata; use null to remove keys",
+ },
+ ],
+ examples: [
+ 'arena channel update my-research --title "New Title" --metadata status=published',
],
- examples: ['arena channel update my-research --title "New Title"'],
},
delete: {
summary: "Delete a channel.",
@@ -1450,9 +1558,13 @@ export const commandHelpDocs: Record = {
{ flag: "--description ", description: "New description" },
{ flag: "--content ", description: "Text content" },
{ flag: "--alt-text ", description: "Image alt text" },
+ {
+ flag: "--metadata ",
+ description: "Merge block metadata; use null to remove keys",
+ },
],
examples: [
- 'arena block update 12345 --title "New Title" --description "Updated"',
+ 'arena block update 12345 --title "New Title" --metadata status=reviewed',
],
},
comments: {
@@ -1506,10 +1618,18 @@ export const commandHelpDocs: Record = {
flag: "--insert-at ",
description: "Insert position within the channel",
},
+ {
+ flag: "--metadata ",
+ description: "Custom block metadata",
+ },
+ {
+ flag: "--connection-metadata ",
+ description: "Metadata for the channel connection",
+ },
],
examples: [
'arena add my-channel "Hello world"',
- 'arena add my-channel https://example.com --alt-text "Cover image" --insert-at 1',
+ 'arena add my-channel https://example.com --alt-text "Cover image" --insert-at 1 --metadata status=reviewed',
'echo "piped text" | arena add my-channel',
],
seeAlso: ["upload", "batch", "channel"],
@@ -1582,14 +1702,21 @@ export const commandHelpDocs: Record = {
options: [
{ flag: "--type ", description: "Connectable type" },
{ flag: "--position ", description: "Insertion position" },
+ {
+ flag: "--metadata ",
+ description: "Metadata for the created connection",
+ },
+ ],
+ examples: [
+ "arena connect 12345 my-channel --type Channel --position 1 --metadata status=reviewed",
],
- examples: ["arena connect 12345 my-channel --type Channel --position 1"],
seeAlso: ["connection", "block", "channel"],
},
connection: {
- summary: "Inspect, move, or delete a connection.",
+ summary: "Inspect, update, move, or delete a connection.",
usage: [
"arena connection ",
+ "arena connection update --metadata ",
"arena connection delete ",
"arena connection move [flags]",
],
@@ -1602,9 +1729,14 @@ export const commandHelpDocs: Record = {
flag: "--position ",
description: "Target position (for move subcommand)",
},
+ {
+ flag: "--metadata ",
+ description: "Merge metadata for update; use null to remove keys",
+ },
],
examples: [
"arena connection 67890",
+ "arena connection update 67890 --metadata status=reviewed",
"arena connection move 67890 --movement insert_at --position 1",
],
seeAlso: ["connect"],
@@ -1661,6 +1793,16 @@ export const commandHelpDocs: Record = {
"arena user following damon-zucconi --type User --sort connected_at_desc",
],
},
+ groups: {
+ summary: "List groups a user belongs to.",
+ usage: ["arena user groups [flags]"],
+ options: [
+ { flag: "--page ", description: "Page number" },
+ { flag: "--per ", description: "Items per page" },
+ { flag: "--sort ", description: "Sort order" },
+ ],
+ examples: ["arena user groups damon-zucconi --sort updated_at_desc"],
+ },
},
seeAlso: ["group", "search"],
},