linearstories
v1.0.0
Published
CLI tool that bridges markdown-based user stories and Linear issues, enforcing acceptance criteria discipline for AI agents
Maintainers
Readme
linearstories
A CLI tool that bridges markdown-based user stories and Linear issues, enforcing user story and acceptance criteria discipline for AI agent-driven development.
Why structured acceptance criteria matter for AI agents
AI coding agents -- Claude Code, Cursor, Copilot Workspace, and others -- perform dramatically better when given precise, testable acceptance criteria. Vague tickets like "improve the login flow" lead to ambiguous implementations and wasted iteration cycles. Structured user stories with explicit acceptance criteria give agents the deterministic guardrails they need:
- Clear scope boundaries. Each acceptance criterion is a discrete, verifiable condition. Agents can work through them one at a time and know when they are done.
- Testable by default. Criteria written as checkboxes (
- [ ] ...) map directly to test cases. Agents can generate tests that match the specification. - Markdown as the source of truth. Stories live in your repository alongside the code. Agents can read them directly without API access to your project management tool.
- Two-way sync with Linear. Engineering managers keep their board current; agents keep their specs current. Neither workflow is disrupted.
linearstories closes the gap between how AI agents consume work (structured markdown files in a repo) and how engineering teams manage work (Linear issues on a board).
Quick start
1. Install
Run directly with bunx (no install required):
bunx linearstories import stories/*.mdOr install globally:
bun install -g linearstoriesAlternatively, download a compiled binary for your platform from the releases page, or build from source:
bun install
bun build src/cli/index.ts --compile --outfile linearstories2. Create a config file
Create .linearrc.json in your project root:
{
"apiKey": "lin_api_xxxxxxxxxxxxxxxxxxxx",
"defaultTeam": "Engineering",
"defaultProject": "Q1 2026 Release",
"defaultLabels": ["User Story"]
}Alternatively, set the LINEAR_API_KEY environment variable and skip the apiKey field.
3. Write your first story
Create a file called stories/login.md:
---
project: "Q1 2026 Release"
team: "Engineering"
---
## As a user, I want to log in so that I can access my account
```yaml
linear_id:
linear_url:
priority: 2
labels: [Feature, Auth]
estimate: 3
assignee: [email protected]
status: Backlog
```
User should be able to log in with their email and password.
The system should support rate limiting after 5 failed attempts.
### Acceptance Criteria
- [ ] User can enter email and password on the login page
- [ ] Invalid credentials show a clear error message
- [ ] User is redirected to the dashboard on successful login
- [ ] Account locks after 5 consecutive failed attempts4. Import to Linear
linearstories import stories/login.mdThe CLI creates issues in Linear and writes the linear_id and linear_url back into your markdown file so that subsequent imports update the existing issues rather than creating duplicates.
User story markdown template
Each markdown file can contain one or more user stories. The file structure is:
[YAML frontmatter] -- optional, sets file-level defaults
[Story 1] -- H2 heading + metadata block + body
[Story 2] -- another H2 heading + metadata block + body
...Frontmatter
Optional YAML frontmatter at the top of the file sets defaults for all stories in that file:
---
project: "Q1 2026 Release"
team: "Engineering"
---Both fields are optional. They can be overridden per-story or via CLI flags.
Story heading
Each story starts with an H2 heading (##). The heading text becomes the Linear issue title:
## As a user, I want to reset my password so that I can regain accessYou are free to use any title format, but the "As a [role], I want [goal] so that [benefit]" pattern is recommended for clarity.
Metadata block
Immediately after the heading, include a fenced YAML code block with story metadata:
```yaml
linear_id:
linear_url:
priority: 2
labels: [Feature, Auth]
estimate: 3
assignee: [email protected]
status: Backlog
```All fields are optional. Here is what each field does:
| Field | Type | Description |
|------------- |----------- |---------------------------------------------------------------------------- |
| linear_id | string | Linear issue identifier (e.g., ENG-42). Populated automatically on import. |
| linear_url | string | Linear issue URL. Populated automatically on import. |
| priority | number | Priority level: 0 = None, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low |
| labels | string[] | Label names to apply. Merged with defaultLabels from config. |
| estimate | number | Story point estimate. |
| assignee | string | Assignee email address or display name. |
| status | string | Workflow state name: Backlog, Todo, In Progress, Done, etc. |
Leave linear_id and linear_url empty for new stories. The import command fills them in automatically.
Story body
Everything after the metadata block and before the next H2 heading is the story body. It becomes the Linear issue description. Use standard markdown -- paragraphs, lists, code blocks, and so on.
Acceptance criteria
Include acceptance criteria as a checklist under an H3 heading:
### Acceptance Criteria
- [ ] User can request a password reset from the login page
- [ ] Reset email is sent within 60 seconds
- [ ] Reset link expires after 24 hoursThis section is part of the story body and is included in the Linear issue description.
Complete annotated example
A file with two stories:
---
project: "Q1 2026 Release"
team: "Engineering"
---
## As a user, I want to log in so that I can access my account
```yaml
linear_id: # left empty for new stories
linear_url: # left empty for new stories
priority: 2 # High priority
labels: [Feature, Auth] # merged with defaultLabels from config
estimate: 3 # 3 story points
assignee: [email protected]
status: Backlog
```
User should be able to log in with their email and password.
The system should support rate limiting after 5 failed attempts.
### Acceptance Criteria
- [ ] User can enter email and password on the login page
- [ ] Invalid credentials show a clear error message
- [ ] User is redirected to the dashboard on successful login
- [ ] Account locks after 5 consecutive failed attempts
## As a user, I want to reset my password so that I can regain access
```yaml
linear_id:
linear_url:
priority: 3 # Normal priority
labels: [Feature, Auth]
estimate: 2
```
User should be able to reset their password via email link.
### Acceptance Criteria
- [ ] User can request a password reset from the login page
- [ ] Reset email is sent within 60 seconds
- [ ] Reset link expires after 24 hoursConfiguration
Config file format
The config file is a JSON object with the following fields:
{
"apiKey": "lin_api_xxxxxxxxxxxxxxxxxxxx",
"defaultTeam": "Engineering",
"defaultProject": "Q1 2026 Release",
"defaultLabels": ["User Story"]
}| Field | Type | Required | Description |
|----------------- |--------- |--------- |--------------------------------------------------------- |
| apiKey | string | Yes* | Linear API key. Can also be set via LINEAR_API_KEY env var. |
| defaultTeam | string | No | Default team name for stories that do not specify one. |
| defaultProject | string | No | Default project name for stories that do not specify one.|
| defaultLabels | string[] | No | Labels applied to every imported story. Merged with per-story labels. |
*Required either in the config file or as the LINEAR_API_KEY environment variable.
Config discovery order
The CLI looks for configuration in this order, using the first one found:
- Explicit path -- the
--configflag:linearstories import --config ./my-config.json stories/*.md - Project-level --
.linearrc.jsonin the current working directory - User-level --
~/.config/linearstories/config.json
If no config file is found, the CLI still works as long as LINEAR_API_KEY is set in the environment.
Environment variable
The LINEAR_API_KEY environment variable always takes precedence over the apiKey field in any config file. This is useful for CI pipelines and shared environments where you do not want API keys in committed files:
export LINEAR_API_KEY=lin_api_xxxxxxxxxxxxxxxxxxxx
linearstories import stories/*.mdMulti-context config
If you work across multiple Linear organizations or environments, you can define named contexts in a single config file:
{
"contexts": [
{
"name": "orgA",
"apiKey": "lin_api_orgA_xxxxxxxxxxxx",
"defaultTeam": "Engineering",
"defaultProject": "Q1 2026 Release",
"defaultLabels": ["User Story"]
},
{
"name": "orgB",
"apiKey": "lin_api_orgB_xxxxxxxxxxxx",
"defaultTeam": "Design",
"defaultProject": "Brand Refresh",
"defaultLabels": ["Design Task"]
}
]
}Select a context with the --context flag:
# Use orgA context
linearstories import --context orgA stories/*.md
# Use orgB context
linearstories export --context orgB -o design-stories.mdEach context entry supports the same fields as the flat config (apiKey, defaultTeam, defaultProject, defaultLabels) plus a required name. Only name is required per entry; other fields are optional.
If a multi-context config is detected and no --context flag is provided, the CLI prints the available context names and exits with an error.
The LINEAR_API_KEY environment variable still takes precedence over the selected context's apiKey.
The flat config format continues to work unchanged -- no migration is needed unless you want multi-context support.
Alternatively, you can use separate config files and pass the appropriate one with --config:
linearstories import --config ~/.config/linearstories/org-a.json stories/*.mdCLI reference
linearstories import
Import user stories from markdown files into Linear. Creates new issues or updates existing ones based on whether linear_id is present in the metadata block.
linearstories import <files...> [options]Arguments:
| Argument | Description |
|-------------- |------------------------------------------------- |
| <files...> | One or more markdown file paths or glob patterns |
Options:
| Flag | Description |
|--------------------------- |---------------------------------------------------------------------- |
| -c, --config <path> | Path to a config file |
| --context <name> | Select a named context from a multi-context config |
| -t, --team <name> | Override the default team |
| -p, --project <name> | Override the default project |
| --dry-run | Validate and parse without making any Linear API calls |
| --no-write-back | Skip writing linear_id and linear_url back to the markdown files |
Examples:
# Import a single file
linearstories import stories/login.md
# Import all markdown files in a directory
linearstories import stories/*.md
# Import with team override
linearstories import -t "Platform" stories/infra/*.md
# Dry run to validate without creating issues
linearstories import --dry-run stories/*.md
# Import without modifying the source files
linearstories import --no-write-back stories/*.md
# Import with an explicit config file
linearstories import -c ./team-config.json stories/*.mdlinearstories export
Export Linear issues to a markdown file in the user story format. The exported file can be edited and re-imported.
linearstories export [options]Options:
| Flag | Description | Default |
|--------------------------- |--------------------------------------------------------- |------------------------ |
| -c, --config <path> | Path to a config file | |
| --context <name> | Select a named context from a multi-context config | |
| -t, --team <name> | Override the default team | |
| -o, --output <file> | Output file path | ./exported-stories.md |
| -p, --project <name> | Filter by project name | |
| -i, --issues <ids> | Comma-separated issue identifiers (e.g., ENG-1,ENG-2) | |
| -s, --status <state> | Filter by workflow status | |
| -a, --assignee <email> | Filter by assignee email | |
| --creator <email> | Filter by creator email | |
Examples:
# Export all issues from the default team
linearstories export
# Export to a specific file
linearstories export -o backlog.md
# Export only issues in a specific project
linearstories export -t "Engineering" -p "Q1 2026 Release"
# Export specific issues by ID
linearstories export -i ENG-1,ENG-2,ENG-3
# Export issues with a specific status
linearstories export -s "In Progress"
# Export issues assigned to a specific person
linearstories export -a [email protected]
# Export issues created by a specific person
linearstories export --creator [email protected]
# Combine filters
linearstories export -t "Engineering" -p "Q1 2026 Release" -s "Todo" -o sprint-todo.mdImport workflow
The import command is the primary workflow. Here is what happens step by step.
Step 1: Write stories in markdown
Create a markdown file with one or more stories. Leave linear_id and linear_url empty:
---
project: "Q1 2026 Release"
team: "Engineering"
---
## As a user, I want to log in so that I can access my account
```yaml
linear_id:
linear_url:
priority: 2
labels: [Feature, Auth]
estimate: 3
assignee: [email protected]
status: Backlog
```
User should be able to log in with their email and password.
### Acceptance Criteria
- [ ] User can enter email and password on the login page
- [ ] Invalid credentials show a clear error message
- [ ] User is redirected to the dashboard on successful loginStep 2: Run the import
linearstories import stories/login.mdThe CLI:
- Parses the markdown file and extracts stories.
- Resolves team, project, label, assignee, and status names to Linear UUIDs.
- Creates a new Linear issue for each story (or updates if
linear_idis already set). - Writes the
linear_idandlinear_urlback into the markdown file.
Step 3: Inspect the write-back
After import, the markdown file is updated in place. The before and after difference is in the metadata block:
Before:
linear_id:
linear_url:After:
linear_id: ENG-42
linear_url: https://linear.app/myorg/issue/ENG-42The full file now looks like this:
---
project: "Q1 2026 Release"
team: "Engineering"
---
## As a user, I want to log in so that I can access my account
```yaml
linear_id: ENG-42
linear_url: https://linear.app/myorg/issue/ENG-42
priority: 2
labels: [Feature, Auth]
estimate: 3
assignee: [email protected]
status: Backlog
```
User should be able to log in with their email and password.
### Acceptance Criteria
- [ ] User can enter email and password on the login page
- [ ] Invalid credentials show a clear error message
- [ ] User is redirected to the dashboard on successful loginSubsequent imports of this file will update the existing issue ENG-42 instead of creating a duplicate.
Step 4: Edit and re-import
Make changes to the story -- update acceptance criteria, change the priority, reassign -- and re-run the import. The existing Linear issue is updated in place:
# Edit the file, then re-import
linearstories import stories/login.mdCreate vs. update logic
| linear_id field | Behavior |
|--------------------------- |----------------------------- |
| Empty or missing | Creates a new Linear issue |
| Present (e.g., ENG-42) | Updates the existing issue |
Label merging
Per-story labels and defaultLabels from the config are merged and deduplicated. If your config has "defaultLabels": ["User Story"] and a story specifies labels: [Feature, Auth], the resulting issue gets all three labels: Feature, Auth, and User Story.
Team and project resolution order
For both team and project, the CLI resolves in this order:
- Value specified in the story metadata block
- Value passed via CLI flag (
--team,--project) - Default from config file (
defaultTeam,defaultProject)
Export workflow
The export command pulls issues from Linear and writes them to a markdown file in the standard user story format.
Basic export
linearstories export -t "Engineering" -o stories/exported.mdThis fetches all issues from the Engineering team and writes them to stories/exported.md.
Filtering examples
Export only backlog items for a specific project:
linearstories export -t "Engineering" -p "Q1 2026 Release" -s "Backlog" -o backlog.mdExport a handful of specific issues:
linearstories export -i ENG-1,ENG-5,ENG-12 -o selected.mdExport everything assigned to one person:
linearstories export -a [email protected] -o janes-stories.mdRound-trip workflow
Export, edit, and re-import to update issues from markdown:
# Pull current state from Linear
linearstories export -t "Engineering" -p "Q1 2026 Release" -o stories/current.md
# Edit the file: update acceptance criteria, reprioritize, etc.
# Push changes back to Linear
linearstories import stories/current.mdBecause exported stories include linear_id, the re-import updates existing issues rather than creating new ones.
Building from source
Prerequisites
- Bun v1.0 or later
Install dependencies
bun installRun in development
bun run src/cli/index.ts import stories/*.mdRun tests
bun testLint and format
bun run lint
bun run formatBuild the binary
bun build src/cli/index.ts --compile --outfile linearstoriesThis produces a self-contained linearstories executable that does not require Bun at runtime.
Contributing
Running the test suite
The project has both unit and integration tests:
# Run all tests
bun test
# Run only unit tests
bun test tests/unit
# Run only integration tests
bun test tests/integration
# Run a specific test file
bun test tests/unit/markdown/parser.test.tsTDD expectations
All changes should follow test-driven development:
- Write a failing test that describes the expected behavior.
- Implement the minimal code to make the test pass.
- Refactor while keeping tests green.
New features and bug fixes must include tests. The test suite covers parsing, serialization, config loading, Linear API interactions, resolver logic, and end-to-end import/export flows.
Project structure
src/
cli/
index.ts CLI entry point
commands/
import.ts Import command registration
export.ts Export command registration
config/
loader.ts Config discovery and loading
schema.ts Config validation
linear/
client.ts Linear SDK client factory
issues.ts Issue create/update/fetch operations
filters.ts Issue filter construction
resolvers.ts Name-to-UUID resolution (teams, projects, labels, etc.)
markdown/
parser.ts Markdown-to-UserStory parsing
serializer.ts UserStory-to-markdown serialization
writer.ts Write-back of linear_id/linear_url into existing files
sync/
importer.ts Import orchestration
exporter.ts Export orchestration
types.ts Shared TypeScript interfaces
errors.ts Custom error classes
templates/
user-story.md Example user story template
tests/
unit/ Unit tests
integration/ Integration testsLicense
MIT License. See LICENSE for details.
