Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

- global user config is now merged with project config (`~/.config/linear/linear.toml` on Unix, `%APPDATA%\linear\linear.toml` on Windows); project values override global, env vars override both
- requests now include a User-Agent header (schpet-linear-cli/VERSION)
- file attachment support for issues and comments via `issue attach` command and `--attach` flag on `issue comment add`
- attachments section in `issue view` output with automatic download to local cache
- `attachment_dir` and `auto_download_attachments` config options

## [1.7.0] - 2026-01-09

Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
- import: use dynamic import only when necessary, the static form is preferable
- avoid the typescript `any` type - prefer strict typing, if you can't find a good way to fix a type issue (particularly with graphql data or documents) explain the problem instead of working around it

## permissions

- deno permissions (--allow-env, --allow-net, etc.) are configured in multiple files that must stay in sync
- see [docs/deno-permissions.md](docs/deno-permissions.md) for the full list of files to update when adding new permissions
- key files: `deno.json` (tasks), `dist-workspace.toml` (release builds), test files

## tests

- tests on commands should mirror the directory structure of the src, e.g.
Expand Down
8 changes: 4 additions & 4 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
"exports": "./src/main.ts",
"license": "MIT",
"tasks": {
"dev": "deno task codegen && deno run '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app --allow-sys=hostname --quiet src/main.ts ",
"install": "deno task codegen && deno install -c ./deno.json '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app --allow-sys=hostname --quiet -g -f -n linear ./src/main.ts",
"dev": "deno task codegen && deno run '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --allow-sys=hostname --quiet src/main.ts ",
"install": "deno task codegen && deno install -c ./deno.json '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --allow-sys=hostname --quiet -g -f -n linear ./src/main.ts",
"uninstall": "deno uninstall -g linear",
"sync-schema": "deno task dev schema -o graphql/schema.graphql",
"codegen": "deno run --allow-all npm:@graphql-codegen/cli/graphql-codegen-esm",
"check": "deno check src/main.ts",
"test": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app --allow-sys=hostname --quiet",
"snapshot": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app --allow-sys=hostname -- --update",
"test": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --allow-sys=hostname --quiet",
"snapshot": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --allow-sys=hostname -- --update",
"lefthook-install": "deno run --allow-run --allow-read --allow-write --allow-env npm:lefthook install"
},
"imports": {
Expand Down
2 changes: 1 addition & 1 deletion dist-workspace.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ binaries = ["linear"]
build-command = [
"sh",
"-c",
"deno compile --target=$CARGO_DIST_TARGET -o linear '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP' --allow-read --allow-write --allow-run --allow-net=api.linear.app --quiet src/main.ts",
"deno compile --target=$CARGO_DIST_TARGET -o linear '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --quiet src/main.ts",
]

# Config for 'dist'
Expand Down
90 changes: 90 additions & 0 deletions docs/deno-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Deno Permissions Configuration

This document tracks all locations where Deno permission flags are configured. When adding new permissions (e.g., new network hosts, environment variables), **all files must be updated** to stay in sync.

## Permission Types

| Permission | Purpose |
| --------------- | ------------------------------------------------------------- |
| `--allow-env` | Environment variable access (API keys, config, editor, pager) |
| `--allow-net` | Network access to Linear API and file storage |
| `--allow-read` | File system read access |
| `--allow-write` | File system write access |
| `--allow-run` | Execute subprocesses (git, jj, editor, pager) |
| `--allow-sys` | System info access (hostname) |

## Network Hosts

The following hosts must be allowed for full functionality:

- `api.linear.app` - GraphQL API
- `uploads.linear.app` - Private file uploads/downloads
- `public.linear.app` - Public image downloads

## Files to Update

### Primary Configuration

These files define permissions for production use:

| File | Lines | Purpose |
| --------------------- | ---------- | ------------------------------------------ |
| `deno.json` | 8-9, 14-15 | `dev`, `install`, `test`, `snapshot` tasks |
| `dist-workspace.toml` | 16 | Binary compilation for releases |

### Test Configuration

Test files define their own `denoArgs` arrays. Most use a shared helper:

| File | Lines | Notes |
| -------------------------------------------- | ----- | ----------------------------------------------- |
| `test/utils/test-helpers.ts` | 5-9 | Shared `commonDenoArgs` used by milestone tests |
| `test/commands/issue/issue-view.test.ts` | 10-14 | Local `denoArgs` |
| `test/commands/issue/issue-describe.test.ts` | 7-11 | Local `denoArgs` |
| `test/commands/issue/issue-commits.test.ts` | 6-10 | Local `denoArgs` |
| `test/commands/team/team-list.test.ts` | 8-12 | Local `denoArgs` |
| `test/commands/project/project-view.test.ts` | 7-11 | Local `denoArgs` |

Note: Test files often use broader permissions (e.g., `--allow-net` without host restrictions) since they run against mock servers.

## Environment Variables

### Runtime Variables

Used by the CLI during normal operation:

```
GITHUB_*, GH_* - GitHub integration
LINEAR_* - Linear API key and config
NODE_ENV - Environment detection
EDITOR - Text editor for descriptions
PAGER - Pager for long output
NO_COLOR - Disable color output
TMPDIR, TMP, TEMP - Temp directory for downloads
XDG_CONFIG_HOME - Config file location (Linux)
HOME - Home directory
APPDATA - Config file location (Windows)
```

### Test-Only Variables

Additional variables needed for tests:

```
SNAPSHOT_TEST_NAME - Cliffy snapshot testing
CLIFFY_SNAPSHOT_FAKE_TIME - Time mocking in tests
PATH - Process execution
SystemRoot - Windows compatibility
MOCK_GIT_BRANCH_COMMAND - Git mocking in tests
TEST_CURRENT_TIME - Time mocking
```

## Checklist for Adding Permissions

When adding a new permission:

1. [ ] Update `deno.json` tasks: `dev`, `install`, `test`, `snapshot`
2. [ ] Update `dist-workspace.toml` build command
3. [ ] Update `test/utils/test-helpers.ts` if tests need it
4. [ ] Update individual test files if they have local `denoArgs`
5. [ ] Document the new permission in this file
82 changes: 82 additions & 0 deletions src/commands/issue/issue-attach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Command } from "@cliffy/command"
import { gql } from "../../__codegen__/gql.ts"
import type { AttachmentCreateInput } from "../../__codegen__/graphql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import { getIssueId, getIssueIdentifier } from "../../utils/linear.ts"
import { getNoIssueFoundMessage } from "../../utils/vcs.ts"
import { uploadFile, validateFilePath } from "../../utils/upload.ts"
import { basename } from "@std/path"

export const attachCommand = new Command()
.name("attach")
.description("Attach a file to an issue")
.arguments("<issueId:string> <filepath:string>")
.option("-t, --title <title:string>", "Custom title for the attachment")
.option(
"-c, --comment <body:string>",
"Add a comment body linked to the attachment",
)
.action(async (options, issueId, filepath) => {
const { title, comment } = options

try {
const resolvedIdentifier = await getIssueIdentifier(issueId)
if (!resolvedIdentifier) {
console.error(getNoIssueFoundMessage())
Deno.exit(1)
}

// Validate file exists
await validateFilePath(filepath)

// Get the issue UUID (attachmentCreate needs UUID, not identifier)
const issueUuid = await getIssueId(resolvedIdentifier)
if (!issueUuid) {
console.error(`✗ Issue not found: ${resolvedIdentifier}`)
Deno.exit(1)
}

// Upload the file
const uploadResult = await uploadFile(filepath, {
showProgress: Deno.stdout.isTerminal(),
})
console.log(`✓ Uploaded ${uploadResult.filename}`)

// Create the attachment
const mutation = gql(`
mutation AttachmentCreate($input: AttachmentCreateInput!) {
attachmentCreate(input: $input) {
success
attachment {
id
url
title
}
}
}
`)

const client = getGraphQLClient()
const attachmentTitle = title || basename(filepath)

const input: AttachmentCreateInput = {
issueId: issueUuid,
title: attachmentTitle,
url: uploadResult.assetUrl,
commentBody: comment,
}

const data = await client.request(mutation, { input })

if (!data.attachmentCreate.success) {
throw new Error("Failed to create attachment")
}

const attachment = data.attachmentCreate.attachment
console.log(`✓ Attachment created: ${attachment.title}`)
console.log(attachment.url)
} catch (error) {
console.error("✗ Failed to attach file", error)
Deno.exit(1)
}
})
64 changes: 61 additions & 3 deletions src/commands/issue/issue-comment-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,25 @@ import { gql } from "../../__codegen__/gql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import { getIssueIdentifier } from "../../utils/linear.ts"
import { getNoIssueFoundMessage } from "../../utils/vcs.ts"
import {
formatAsMarkdownLink,
uploadFile,
validateFilePath,
} from "../../utils/upload.ts"

export const commentAddCommand = new Command()
.name("add")
.description("Add a comment to an issue or reply to a comment")
.arguments("[issueId:string]")
.option("-b, --body <text:string>", "Comment body text")
.option("-p, --parent <id:string>", "Parent comment ID for replies")
.option(
"-a, --attach <filepath:string>",
"Attach a file to the comment (can be used multiple times)",
{ collect: true },
)
.action(async (options, issueId) => {
const { body, parent } = options
const { body, parent, attach } = options

try {
const resolvedIdentifier = await getIssueIdentifier(issueId)
Expand All @@ -21,10 +31,38 @@ export const commentAddCommand = new Command()
Deno.exit(1)
}

// Validate and upload attachments first
const attachments = attach || []
const uploadedFiles: {
filename: string
assetUrl: string
isImage: boolean
}[] = []

if (attachments.length > 0) {
// Validate all files exist before uploading
for (const filepath of attachments) {
await validateFilePath(filepath)
}

// Upload files
for (const filepath of attachments) {
const result = await uploadFile(filepath, {
showProgress: Deno.stdout.isTerminal(),
})
uploadedFiles.push({
filename: result.filename,
assetUrl: result.assetUrl,
isImage: result.contentType.startsWith("image/"),
})
console.log(`✓ Uploaded ${result.filename}`)
}
}

let commentBody = body

// If no body provided, prompt for it
if (!commentBody) {
// If no body provided and no attachments, prompt for it
if (!commentBody && uploadedFiles.length === 0) {
commentBody = await Input.prompt({
message: "Comment body",
default: "",
Expand All @@ -36,6 +74,26 @@ export const commentAddCommand = new Command()
}
}

// Append attachment links to comment body
if (uploadedFiles.length > 0) {
const attachmentLinks = uploadedFiles.map((file) => {
return formatAsMarkdownLink({
filename: file.filename,
assetUrl: file.assetUrl,
contentType: file.isImage
? "image/png"
: "application/octet-stream",
size: 0,
})
})

if (commentBody) {
commentBody = `${commentBody}\n\n${attachmentLinks.join("\n")}`
} else {
commentBody = attachmentLinks.join("\n")
}
}

const mutation = gql(`
mutation AddComment($input: CommentCreateInput!) {
commentCreate(input: $input) {
Expand Down
Loading