From dba18c5e66ba7ff671344d00de28df5754f420bd Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Sat, 14 Mar 2026 16:05:52 +0100 Subject: [PATCH] feat: add agent delegate support --- .changeset/soft-bananas-bring.md | 5 + README.md | 60 ++++++++++ src/commands/issue.ts | 180 +++++++++++++++++++++++++++--- src/commands/shared.ts | 182 +++++++++++++++++++++++++++++++ src/commands/user.ts | 33 ++++-- 5 files changed, 436 insertions(+), 24 deletions(-) create mode 100644 .changeset/soft-bananas-bring.md diff --git a/.changeset/soft-bananas-bring.md b/.changeset/soft-bananas-bring.md new file mode 100644 index 0000000..a76f367 --- /dev/null +++ b/.changeset/soft-bananas-bring.md @@ -0,0 +1,5 @@ +--- +"linear-cli": minor +--- + +Add first-class support for Linear issue delegates so the CLI can display and assign agents separately from human assignees. diff --git a/README.md b/README.md index 3d92fbc..8738462 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,21 @@ linear issue get ENG-123 # Create an issue linear issue create --team ENG --title "Fix bug" --priority 2 +# Create an issue with assignee and delegate/agent +linear issue create --team ENG --title "Fix bug" --assignee me --agent "Linear AI" + +# Update an issue to assign a delegate/agent +linear issue update ENG-123 --delegate "agent@example.com" + +# Clear delegate from an issue +linear issue update ENG-123 --delegate none + +# List issues filtered by delegate/agent +linear issue list --agent "Linear AI" + +# List users to discover agents (look for app=true) +linear user list + # Close an issue linear issue close ENG-456 @@ -161,6 +176,51 @@ Place a config file in your project directory to set defaults per-project. Searc Local config overrides global config for `defaultTeamKey` and `outputFormat`. +## Agent Delegation + +Linear supports delegating issues to AI agents (app users). The CLI provides first-class support for this: + +### Discovering Agents + +```sh +# List all users, agents will have app=true +linear user list + +# Get details for a specific user +linear user get +``` + +### Assigning Agents to Issues + +```sh +# Create an issue with an agent +linear issue create --team ENG --title "Research" --agent "Linear AI" + +# Assign an agent to an existing issue +linear issue update ENG-123 --delegate "agent@example.com" + +# Clear the agent from an issue +linear issue update ENG-123 --agent none +``` + +### Filtering by Agent + +```sh +# List issues assigned to a specific agent +linear issue list --agent "Linear AI" + +# List issues with no agent +linear issue list --agent none +``` + +Both `--delegate` and `--agent` flags are aliases and accept: +- UUID of the agent user +- Email address +- Display name or name +- `none` to clear the assignment + +Note: Only users with `app=true` can be assigned as delegates. Regular users cannot be delegates. + ## License MIT diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 9aaffcf..6da455f 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -6,11 +6,20 @@ import { parseArgs, wantsHelp, } from "../args.ts"; -import { pad, printTable, requireToken, useJson } from "./shared.ts"; +import { + pad, + printTable, + requireToken, + resolveAssignee, + resolveDelegate, + useJson, +} from "./shared.ts"; const ISSUE_OPTIONS = { team: { type: "string" as const }, assignee: { type: "string" as const }, + delegate: { type: "string" as const }, + agent: { type: "string" as const, alias: "delegate" }, state: { type: "string" as const }, label: { type: "string" as const }, limit: { type: "string" as const }, @@ -33,7 +42,9 @@ List issues with optional filters. Options: --team Filter by team key - --assignee Filter by assignee ("me" or email) + --assignee Filter by assignee ("me", email, UUID, or name) + --delegate Filter by delegate/agent (UUID, email, or name) + --agent Alias for --delegate --state Filter by state name --label Filter by label name --limit Max results (default: 50) @@ -47,16 +58,55 @@ Options: const json = await useJson(parsed); const team = getString(parsed, "team"); - const assignee = getString(parsed, "assignee"); + const assigneeRaw = getString(parsed, "assignee"); + const delegateRaw = + getString(parsed, "delegate") ?? getString(parsed, "agent"); const state = getString(parsed, "state"); const label = getString(parsed, "label"); const limit = getNumber(parsed, "limit") ?? 50; const filter: Record = {}; if (team) filter.team = { key: { eq: team } }; - if (assignee) { - filter.assignee = - assignee === "me" ? { isMe: { eq: true } } : { email: { eq: assignee } }; + if (assigneeRaw) { + if (assigneeRaw.toLowerCase() === "me") { + filter.assignee = { isMe: { eq: true } }; + } else if (assigneeRaw.toLowerCase() === "none") { + filter.assignee = { null: true }; + } else { + // Resolve the assignee + try { + const assigneeId = await resolveAssignee(token, assigneeRaw); + if (assigneeId) { + filter.assignee = { id: { eq: assigneeId } }; + } else { + filter.assignee = { null: true }; + } + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } + } + if (delegateRaw) { + if (delegateRaw.toLowerCase() === "none") { + filter.delegate = { null: true }; + } else { + try { + const delegateId = await resolveDelegate(token, delegateRaw); + if (delegateId) { + filter.delegate = { id: { eq: delegateId } }; + } else { + filter.delegate = { null: true }; + } + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } } if (state) filter.state = { name: { eqIgnoreCase: state } }; if (label) filter.labels = { name: { eqIgnoreCase: label } }; @@ -72,6 +122,7 @@ Options: priorityLabel state { name type } assignee { name email } + delegate { name email app } team { key name } labels { nodes { name } } createdAt @@ -91,6 +142,7 @@ Options: priorityLabel: string; state: { name: string; type: string }; assignee: { name: string; email: string } | null; + delegate: { name: string; email: string; app: boolean } | null; team: { key: string; name: string }; labels: { nodes: Array<{ name: string }> }; createdAt: string; @@ -111,13 +163,21 @@ Options: return; } - const headers = ["IDENTIFIER", "TITLE", "STATE", "PRIORITY", "ASSIGNEE"]; + const headers = [ + "IDENTIFIER", + "TITLE", + "STATE", + "PRIORITY", + "ASSIGNEE", + "AGENT", + ]; const rows = issues.map((issue) => [ issue.identifier, issue.title, issue.state.name, issue.priorityLabel, issue.assignee?.name ?? "", + issue.delegate?.name ?? "", ]); printTable(headers, rows); @@ -159,6 +219,7 @@ Options: priorityLabel state { name type } assignee { name email } + delegate { name email app } team { key name } project { name } cycle { number name } @@ -182,6 +243,7 @@ Options: priorityLabel: string; state: { name: string; type: string }; assignee: { name: string; email: string } | null; + delegate: { name: string; email: string; app: boolean } | null; team: { key: string; name: string }; project: { name: string } | null; cycle: { number: number; name: string } | null; @@ -217,6 +279,12 @@ Options: "Assignee", issue.assignee ? `${issue.assignee.name} <${issue.assignee.email}>` : "", ], + [ + "Agent", + issue.delegate + ? `${issue.delegate.name} <${issue.delegate.email}>${issue.delegate.app ? " (app)" : ""}` + : "", + ], ["Project", issue.project?.name ?? ""], ["Cycle", issue.cycle ? `#${issue.cycle.number} ${issue.cycle.name}` : ""], ["Labels", issue.labels.nodes.map((l) => l.name).join(", ")], @@ -250,7 +318,9 @@ Options: --title Issue title (required) --description <text> Issue description --priority <0-4> Priority (0=none, 1=urgent, 2=high, 3=medium, 4=low) - --assignee <id> Assignee user ID + --assignee <val> Assignee ("me", email, UUID, or name). Use "none" to clear. + --delegate <val> Delegate/agent (UUID, email, or name of app user). Use "none" to clear. + --agent <val> Alias for --delegate --state <id> State ID --label <ids> Label IDs (comma-separated) --project <id> Project ID @@ -305,8 +375,36 @@ Options: const priority = getNumber(parsed, "priority"); if (priority != null) input.priority = priority; - const assignee = getString(parsed, "assignee"); - if (assignee) input.assigneeId = assignee; + const assigneeRaw = getString(parsed, "assignee"); + if (assigneeRaw) { + try { + const assigneeId = await resolveAssignee(token, assigneeRaw); + if (assigneeId) { + input.assigneeId = assigneeId; + } + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } + + const delegateRaw = + getString(parsed, "delegate") ?? getString(parsed, "agent"); + if (delegateRaw) { + try { + const delegateId = await resolveDelegate(token, delegateRaw); + if (delegateId) { + input.delegateId = delegateId; + } + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } const state = getString(parsed, "state"); if (state) input.stateId = state; @@ -330,6 +428,8 @@ Options: title state { name } team { key } + assignee { name email } + delegate { name email app } url } } @@ -345,6 +445,8 @@ Options: title: string; state: { name: string }; team: { key: string }; + assignee: { name: string; email: string } | null; + delegate: { name: string; email: string; app: boolean } | null; url: string; }; }; @@ -364,6 +466,12 @@ Options: console.log(`Created ${issue.identifier}: ${issue.title}`); console.log(`State: ${issue.state.name}`); + if (issue.assignee) { + console.log(`Assignee: ${issue.assignee.name} <${issue.assignee.email}>`); + } + if (issue.delegate) { + console.log(`Agent: ${issue.delegate.name} <${issue.delegate.email}>`); + } console.log(`URL: ${issue.url}`); } @@ -381,7 +489,9 @@ Options: --description <text> New description --priority <0-4> New priority --state <id> New state ID - --assignee <id> New assignee user ID + --assignee <val> New assignee ("me", email, UUID, or name). Use "none" to clear. + --delegate <val> New delegate/agent (UUID, email, or name of app user). Use "none" to clear. + --agent <val> Alias for --delegate --label <ids> New label IDs (comma-separated) --project <id> New project ID --estimate <n> New estimate value @@ -414,8 +524,42 @@ Options: const state = getString(parsed, "state"); if (state) input.stateId = state; - const assignee = getString(parsed, "assignee"); - if (assignee) input.assigneeId = assignee; + const assigneeRaw = getString(parsed, "assignee"); + if (assigneeRaw) { + try { + const assigneeId = await resolveAssignee(token, assigneeRaw); + if (assigneeId) { + input.assigneeId = assigneeId; + } else { + // Clearing assignee + input.assigneeId = null; + } + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } + + const delegateRaw = + getString(parsed, "delegate") ?? getString(parsed, "agent"); + if (delegateRaw) { + try { + const delegateId = await resolveDelegate(token, delegateRaw); + if (delegateId) { + input.delegateId = delegateId; + } else { + // Clearing delegate + input.delegateId = null; + } + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } const label = getString(parsed, "label"); if (label) input.labelIds = label.split(","); @@ -440,6 +584,8 @@ Options: identifier title state { name } + assignee { name email } + delegate { name email app } url } } @@ -454,6 +600,8 @@ Options: identifier: string; title: string; state: { name: string }; + assignee: { name: string; email: string } | null; + delegate: { name: string; email: string; app: boolean } | null; url: string; }; }; @@ -473,6 +621,12 @@ Options: console.log(`Updated ${issue.identifier}: ${issue.title}`); console.log(`State: ${issue.state.name}`); + if (issue.assignee) { + console.log(`Assignee: ${issue.assignee.name} <${issue.assignee.email}>`); + } + if (issue.delegate) { + console.log(`Agent: ${issue.delegate.name} <${issue.delegate.email}>`); + } console.log(`URL: ${issue.url}`); } diff --git a/src/commands/shared.ts b/src/commands/shared.ts index d23a32d..1a17074 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -1,3 +1,4 @@ +import { graphql } from "../api.ts"; import type { ParsedArgs } from "../args.ts"; import { getBoolean } from "../args.ts"; import { getApiToken, loadConfig } from "../config.ts"; @@ -11,6 +12,187 @@ export async function requireToken(): Promise<string> { return token; } +/** + * User/Agent resolution result + */ +export interface ResolvedUser { + id: string; + name: string; + email: string; + displayName: string; + app: boolean; +} + +/** + * Resolve a user by identifier (UUID, email, displayName, or name). + * If requireApp is true, only returns users where app === true (agents). + * Returns null if not found or throws on ambiguity. + */ +export async function resolveUser( + token: string, + value: string, + options?: { requireApp?: boolean; allowMe?: boolean }, +): Promise<ResolvedUser | null> { + const { requireApp = false, allowMe = false } = options ?? {}; + + // Handle "none" for clearing + if (value.toLowerCase() === "none") { + return null; + } + + // Handle "me" for current user + if (value.toLowerCase() === "me" && allowMe) { + const query = ` + query { + viewer { + id + name + email + displayName + app + } + } + `; + const data = await graphql<{ viewer: ResolvedUser }>(token, query); + if (requireApp && !data.viewer.app) { + throw new Error( + `"me" resolves to a regular user, but an agent/app user is required.`, + ); + } + return data.viewer; + } + + // Check if it's a UUID (8-4-4-4-12 format) + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + value, + ); + + let filter: Record<string, unknown>; + + if (isUuid) { + // Direct UUID lookup + const query = ` + query User($id: String!) { + user(id: $id) { + id + name + email + displayName + app + } + } + `; + const data = await graphql<{ user: ResolvedUser | null }>(token, query, { + id: value, + }); + const user = data.user; + if (!user) { + return null; + } + if (requireApp && !user.app) { + throw new Error( + `User "${user.name}" (${user.email}) is not an agent/app user. Use a user where app=true.`, + ); + } + return user; + } else if (value.includes("@")) { + // Email lookup + filter = { email: { eq: value } }; + } else { + // Name or displayName lookup - try both + filter = { + or: [{ name: { eq: value } }, { displayName: { eq: value } }], + }; + } + + const query = ` + query Users($filter: UserFilter) { + users(filter: $filter, first: 10) { + nodes { + id + name + email + displayName + app + } + } + } + `; + + const data = await graphql<{ users: { nodes: ResolvedUser[] } }>( + token, + query, + { filter }, + ); + + const users = data.users.nodes; + + if (users.length === 0) { + return null; + } + + // Filter to app users if required + const matchingUsers = requireApp ? users.filter((u) => u.app) : users; + + if (matchingUsers.length === 0) { + if (requireApp) { + const userInfo = users.map((u) => `${u.name} (${u.email})`).join(", "); + throw new Error( + `No agent/app user found matching "${value}". Found regular user(s): ${userInfo}`, + ); + } + return null; + } + + if (matchingUsers.length > 1) { + const userInfo = matchingUsers + .map((u) => `${u.name} <${u.email}> (${u.id})`) + .join("\n "); + throw new Error( + `Multiple ${requireApp ? "agents" : "users"} match "${value}":\n ${userInfo}\nPlease use a more specific identifier (email or UUID).`, + ); + } + + return matchingUsers[0] ?? null; +} + +/** + * Resolve an assignee (regular user or "me") + */ +export async function resolveAssignee( + token: string, + value: string, +): Promise<string | null> { + if (value.toLowerCase() === "none") { + return null; + } + const user = await resolveUser(token, value, { allowMe: true }); + if (!user) { + throw new Error(`Assignee "${value}" not found.`); + } + return user.id; +} + +/** + * Resolve a delegate/agent (must be an app user) + */ +export async function resolveDelegate( + token: string, + value: string, +): Promise<string | null> { + if (value.toLowerCase() === "none") { + return null; + } + const user = await resolveUser(token, value, { requireApp: true }); + if (!user) { + throw new Error( + `Agent/delegate "${value}" not found or is not an app user.`, + ); + } + return user.id; +} + export async function useJson(parsed: ParsedArgs): Promise<boolean> { if (getBoolean(parsed, "json")) return true; const config = await loadConfig(); diff --git a/src/commands/user.ts b/src/commands/user.ts index 0b6139a..e6d750f 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,6 +1,6 @@ import { graphql } from "../api.ts"; import { getNumber, getPositional, parseArgs, wantsHelp } from "../args.ts"; -import { printTable, requireToken, useJson } from "./shared.ts"; +import { pad, printTable, requireToken, useJson } from "./shared.ts"; const USER_OPTIONS = { limit: { type: "string" as const }, @@ -14,6 +14,7 @@ interface User { displayName: string; active: boolean; admin: boolean; + app: boolean; avatarUrl?: string; createdAt: string; updatedAt?: string; @@ -50,6 +51,7 @@ Options: displayName active admin + app createdAt } } @@ -72,12 +74,13 @@ Options: return; } - const headers = ["NAME", "EMAIL", "ACTIVE", "ADMIN"]; + const headers = ["NAME", "EMAIL", "ACTIVE", "ADMIN", "APP"]; const rows = users.map((u) => [ u.name, u.email, String(u.active), String(u.admin), + String(u.app), ]); printTable(headers, rows); @@ -121,6 +124,7 @@ Options: displayName active admin + app avatarUrl createdAt updatedAt @@ -167,6 +171,7 @@ Options: displayName active admin + app avatarUrl createdAt updatedAt @@ -186,17 +191,23 @@ Options: } function printUserDetail(user: User): void { - console.log(`Name: ${user.name}`); - console.log(`Display name: ${user.displayName}`); - console.log(`ID: ${user.id}`); - console.log(`Email: ${user.email}`); - console.log(`Active: ${user.active}`); - console.log(`Admin: ${user.admin}`); + const maxKey = Math.max( + "Display name".length, + "Email".length, + "Created".length, + ); + console.log(`${pad("Name", maxKey)} ${user.name}`); + console.log(`${pad("Display name", maxKey)} ${user.displayName}`); + console.log(`${pad("ID", maxKey)} ${user.id}`); + console.log(`${pad("Email", maxKey)} ${user.email}`); + console.log(`${pad("Active", maxKey)} ${String(user.active)}`); + console.log(`${pad("Admin", maxKey)} ${String(user.admin)}`); + console.log(`${pad("App/Agent", maxKey)} ${String(user.app)}`); if (user.avatarUrl) { - console.log(`Avatar: ${user.avatarUrl}`); + console.log(`${pad("Avatar", maxKey)} ${user.avatarUrl}`); } - console.log(`Created: ${user.createdAt}`); + console.log(`${pad("Created", maxKey)} ${user.createdAt}`); if (user.updatedAt) { - console.log(`Updated: ${user.updatedAt}`); + console.log(`${pad("Updated", maxKey)} ${user.updatedAt}`); } } -- 2.51.2