A CLI tool that bridges markdown-based user stories and Linear issues, enforcing user story and acceptance criteria discipline for AI agent-driven development.
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).
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 linearstoriesCreate .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.
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: jane@company.com
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 attemptslinearstories 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.
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
...
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.
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.
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: jane@company.com
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.
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.
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.
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: jane@company.com
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 hoursThe 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.
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.
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/*.mdIf 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/*.mdImport 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/*.mdExport 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 jane@company.com
# Export issues created by a specific person
linearstories export --creator alex@company.com
# Combine filters
linearstories export -t "Engineering" -p "Q1 2026 Release" -s "Todo" -o sprint-todo.mdThe import command is the primary workflow. Here is what happens step by step.
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: jane@company.com
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 loginlinearstories 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.
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: jane@company.com
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.
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.mdlinear_id field |
Behavior |
|---|---|
| Empty or missing | Creates a new Linear issue |
Present (e.g., ENG-42) |
Updates the existing issue |
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.
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)
The export command pulls issues from Linear and writes them to a markdown file in the standard user story format.
linearstories export -t "Engineering" -o stories/exported.mdThis fetches all issues from the Engineering team and writes them to stories/exported.md.
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 jane@company.com -o janes-stories.mdExport, 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.
- Bun v1.0 or later
bun installbun run src/cli/index.ts import stories/*.mdbun testbun run lint
bun run formatbun build src/cli/index.ts --compile --outfile linearstoriesThis produces a self-contained linearstories executable that does not require Bun at runtime.
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.tsAll 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.
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 tests
MIT License. See LICENSE for details.