A CLI tool for managing kanban boards where all data lives as plain files.
Kan is a personal project management tool that stores kanban boards as plain files. There is no database, no server, no external dependencies — just files. Works with any VCS (or none).
- File-based: Full VCS history and offline support come free (with any VCS)
- No vendor lock-in: Your data is plain files you control
- Portable and scriptable: Standard file formats, easy to automate
- Data lives where work lives: Boards live alongside the code they track
Personal project management — a kanban board per repo for your various projects, versioned alongside the code.
.kan/
boards/
<board-name>/
config.toml # board-specific configuration
cards/
<flexid>.json # one file per card
The .kan/ directory is the default location. Users MAY specify a custom location via kan init --location <relative-path>, which is persisted in the global user config.
Location: ~/.config/kan/config.toml
# Global defaults
editor = "vim" # Falls back to $EDITOR, then "vim"
# Known projects (lazily populated by any kan command)
[projects]
my-project = "/Users/name/src/my-project"
another = "/Users/name/src/another"
# Per-repo settings
[repos."/Users/name/src/my-project"]
default_board = "features"
data_location = "tools/kanban" # if custom location was usedThe projects registry enables future -p flag functionality for managing boards from anywhere.
Location: .kan/boards/<board-name>/config.toml
# Board identity
id = "k7xQ2m" # flexid, immutable after creation
name = "features"
# Columns (ordered)
[[columns]]
name = "backlog"
color = "#6b7280"
[[columns]]
name = "next"
color = "#3b82f6"
[[columns]]
name = "in-progress"
color = "#f59e0b"
[[columns]]
name = "done"
color = "#10b981"
# Default column for 'kan add'
default_column = "backlog"
# Labels
[[labels]]
name = "bug"
color = "#ef4444"
description = "Something is broken"
[[labels]]
name = "enhancement"
color = "#8b5cf6"
# Custom field schemas
[custom_fields.priority]
type = "enum"
values = ["low", "medium", "high"]
[custom_fields.assignee]
type = "string"
[custom_fields.due_date]
type = "date"Default columns when creating a new board: backlog, next, in-progress, done.
Location: .kan/boards/<board-name>/cards/<flexid>.json
{
"id": "k7xQ2m",
"alias": "fix-login-bug",
"alias_explicit": false,
"title": "Fix login bug",
"description": "Users are getting logged out randomly after 5 minutes.",
"column": "in-progress",
"labels": ["bug"],
"parent": "m3Yp8n",
"creator": "amterp",
"created_at_millis": 1704307200000,
"updated_at_millis": 1704393600000,
"comments": [
{
"id": "c_9kL2x",
"body": "Might be related to the session token changes from last week.",
"author": "amterp",
"created_at_millis": 1704310800000
}
],
"priority": "high"
}| Field | Required | Description |
|---|---|---|
id |
Yes | Unique identifier generated via flexid |
alias |
Yes | Human-friendly slug, auto-generated from title by default |
alias_explicit |
Yes | Boolean. If false, alias updates when title changes. If true, alias is frozen until explicitly changed or cleared. |
title |
Yes | Card title |
description |
No | Detailed description (may contain markdown) |
column |
Yes | Current column (must reference a column defined in board config) |
labels |
No | Array of label names (must reference labels defined in board config) |
parent |
No | ID of parent card (for subtask relationships). MAY reference cards in other boards within the same repo. |
creator |
Yes | Username of card creator |
created_at_millis |
Yes | Creation timestamp in milliseconds since Unix epoch |
updated_at_millis |
Yes | Last update timestamp in milliseconds since Unix epoch |
comments |
No | Array of comment objects |
| (custom fields) | No | Any fields defined in board's custom_fields config |
- Auto-generated by slugifying the title (lowercase, spaces to hyphens, etc.)
- On collision, auto-suffix with incrementing number:
fix-bug,fix-bug-2,fix-bug-3 - Frontends SHOULD allow users to explicitly set an alias, which sets
alias_explicit: true - Frontends SHOULD allow users to clear an explicit alias, reverting to auto-generation
- Card references in data (e.g.,
parentfield) MUST use the card ID, not alias
| Field | Required | Description |
|---|---|---|
id |
Yes | Unique identifier (flexid, prefixed with c_) |
body |
Yes | Comment text |
author |
Yes | Username of comment author |
created_at_millis |
Yes | Creation timestamp in milliseconds since Unix epoch |
Written in Go using ra for command-line parsing.
Initialize Kan in the current directory.
kan init [--location <relative-path>]- Creates
.kan/directory (or custom location if specified) - Creates a default board named
mainwith default columns - Registers the project in global user config
If --location is specified and the path already exists with valid Kan data, registers the existing project without re-initializing.
Manage boards.
kan board create <name> # Create a new board with default columns
kan board list # List all boards in the repoAdd a new card.
kan add <title> [description] [flags]Positional arguments:
title(required): Card titledescription(optional): Card description
Flags:
-b, --board <name>: Target board-c, --column <name>: Target column (defaults to board'sdefault_column)-l, --label <name>: Add label (repeatable)-p, --parent <id|alias>: Set parent card-I, --non-interactive: Fail instead of prompting for missing required fields
Behavior:
- Interactive by default: if any required fields are missing, prompts the user
- With
-I: fails with an error if required fields are missing - Board selection:
- If only one board exists, uses that board
- If multiple boards exist and
-bnot specified, uses configureddefault_board - If multiple boards exist, no
-b, and no default configured, prompts (or fails with-I)
- Column defaults to the board's configured
default_column(first column if not configured) - Creator is automatically set (from
$KAN_USER,git config user.name, or$USER)
Edit an existing card.
kan edit <id|alias> [OPTIONS]Options:
-b, --board <name>— Target board-t, --title <value>— Set card title-d, --description <value>— Set card description-c, --column <name>— Move card to column-l, --label <name>— Set labels (repeatable, replaces existing labels)-p, --parent <id|alias>— Set parent card-a, --alias <value>— Set explicit alias-f, --field <key=value>— Set custom field (repeatable)
Behavior:
- If any flags are provided, applies the changes and exits (non-interactive).
- If no flags are provided, opens an interactive menu to select which field to edit, then opens
$EDITORfor that field. - Use
-l ""to clear all labels.
Editor resolution order (for interactive mode):
editorfrom global user config$EDITORenvironment variablevim
Display card details.
kan show <id|alias>Displays all card fields in a readable format.
List cards.
kan list [flags]Flags:
-b, --board <name>: Filter by board-c, --column <name>: Filter by column
The following are planned but NOT part of the first take:
kan move <id|alias> <column>: Move card to a different columnkan archive <id|alias>: Delete card from working tree (recoverable via VCS history)kan comment <id|alias> [text]: Add comment (opens$EDITORif text not provided)kan config: Manage board configuration (columns, labels, custom fields)-p, --project <fuzzy-name>: Global flag to operate on a different registered project from anywhere
- "Related to" links between cards (bidirectional)
- "Blocked by" dependencies (directional)
kan archive search <query>: Search archived cards via VCS history
Phase 1: Kan's file-based architecture means anyone can build frontends that read/write the .kan/ directory. The file format is the interface.
Phase 2: A web frontend will be embedded in the Go CLI binary and served via a kan command (e.g., kan web or kan serve). This provides a localhost browser interface out of the box.
Future: The same pattern could support additional embedded frontends (TUI, etc.) launched via CLI commands.
Decision: Each card is stored as a separate JSON file.
Rationale: VCS tools handle merges at the file level. With one file per card:
- Adding cards rarely conflicts (different files)
- Conflicts only occur when two people edit the same card — a real conflict worth surfacing
- The alternative (JSONL with one card per line) causes spurious conflicts on concurrent additions
Trade-off: Directory with many files (hundreds to low thousands). Acceptable for tooling; ls becomes unwieldy but that's not the primary interface.
Decision: Card files are JSON. Configuration files are TOML.
Rationale:
- JSON is structured and tooling-friendly for data that's primarily machine-read/written
- TOML is more human-readable for configuration that users may hand-edit
- Both are still editable in a pinch if needed
Decision: Use flexid for generating card IDs.
Rationale: Short, low-collision identifiers that are reasonable to type in CLI commands. Not fully human-memorable, hence the alias system.
Decision: Aliases are auto-generated from titles but can be explicitly overridden.
Rationale: Provides human-friendly card references (fix-login-bug) without coupling filesystem structure to mutable data. The ID remains stable; the alias is a convenience layer.
Decision: Comments are stored as an array within the card JSON, not as separate files.
Rationale: Simpler model. A card is the atomic unit of work. Comment volume per card is expected to be low (personal project management, not team collaboration).
Decision: Each board is a subdirectory with its own config and cards. Boards have a flexid (stored in config) and a name (used as directory name).
Rationale: Clean isolation. Board-specific configuration lives with the board. Deleting a board is deleting a directory. Cross-board references are still possible via card IDs. The flexid provides a stable internal identifier if needed for future features.
Decision: Kan maintains a registry of known projects in ~/.config/kan/config.toml, lazily populated by any kan command run in a Kan-enabled project.
Rationale: Enables future -p flag for operating on projects from anywhere. No explicit registration step needed.
Decision: Frontends are not plugins. They're independent tools that read/write Kan's file format.
Rationale: The file format is the API. Anyone can build a TUI, web UI, or integration by reading .kan/ files. No need for Kan to define a plugin interface.
This section documents a planned architectural change to how card fields work. The current implementation (described above) treats labels as a first-class field. This revamp generalizes the approach.
The trigger: Kan's default labels (feature, bug, enhancement, chore) are mutually exclusive—a card is a bug OR a feature, not both. But labels are multi-select by design. This revealed a category error:
- Type (bug/feature/chore): "What kind of thing is this?" → single-select, categorical
- Labels (blocked/urgent/needs-review): "What attributes does this have?" → multi-select, combinable
The defaults were steering users to misuse labels as types.
The deeper issue: we conflated "commonly used" with "architecturally special." Labels are common but not fundamentally different from any other multi-select enum field. Making labels first-class (dedicated CLI flag, special config section, hardcoded frontend rendering) means every future "commonly used" field faces the same question: "Is this special enough to be first-class?"
The solution: Shrink first-class fields to truly structural/system things. Everything else—including what labels currently is—becomes a custom field with a consistent schema. Board configuration controls how custom fields render on cards.
After the revamp, fields fall into two categories:
These are structural or system fields that exist on every card:
| Category | Fields | Description |
|---|---|---|
| Identity | id, alias, alias_explicit |
Immutable identifiers and human-friendly references |
| Content | title, description |
User-provided content, always present |
| Workflow | column |
Structural—determines board position |
| Hierarchy | parent |
Structural—enables subtask relationships |
| Discussion | comments |
Nested objects, not a simple field type |
| System | creator, created_at_millis, updated_at_millis, _v |
Auto-managed metadata |
Core fields have dedicated schema, validation, and (where applicable) CLI flags. They are not configurable per-board.
Everything else is a custom field defined in the board's configuration:
- What the current
labelsfield does becomes a custom field with typeenum-set - Type distinction (bug/feature/task) becomes a custom field with type
enum - Any user-defined fields (priority, assignee, due date, etc.) use the same system
Custom fields are:
- Defined per-board in
[custom_fields.*]config sections - Stored flattened at the top level of card JSON (not nested)
- Validated against the board schema (undefined fields rejected, enum values checked)
- Rendered according to
[card_display]configuration
| Type | Description | Example Values |
|---|---|---|
string |
Free-form text | "John Doe", "https://..." |
enum |
Single-select from defined options | "bug", "feature" |
enum-set |
Multi-select from defined options | ["blocked", "urgent"] |
free-set |
Multi-value freeform text | ["backend", "auth"] |
date |
Date value | "2024-03-15" |
The enum-set type replaces what labels currently does. The naming convention (enum-set / free-set) makes the constrained vs unconstrained distinction explicit and uses "set" to convey no-duplicates semantics.
Custom fields are defined in board config with type, options (for enum/enum-set), and per-value colors:
[custom_fields.type]
type = "enum"
options = [
{ value = "feature", color = "#16a34a" },
{ value = "bug", color = "#dc2626" },
{ value = "task", color = "#4b5563" },
{ value = "chore", color = "#8b5cf6" },
]
[custom_fields.labels]
type = "enum-set"
options = [
{ value = "blocked", color = "#dc2626" },
{ value = "needs-review", color = "#f59e0b" },
{ value = "urgent", color = "#f97316" },
]
[custom_fields.assignee]
type = "string"
[custom_fields.due_date]
type = "date"Schema structure:
type CustomFieldSchema struct {
Type string `toml:"type"` // "string", "enum", "enum-set", "free-set", "date"
Options []CustomFieldOption `toml:"options,omitempty"` // for enum/enum-set types
}
type CustomFieldOption struct {
Value string `toml:"value"`
Color string `toml:"color,omitempty"` // hex color, e.g. "#ef4444"
// Icon string `toml:"icon,omitempty"` // future: icon identifier
}Validation rules:
enumandenum-setfields requireoptionsto be defined- Values set on cards must exist in the field's
optionslist - Field names cannot start with
_(reserved for internal use) orkan_(reserved for Kan) - The
x_prefix is available as an escape hatch for user-defined fields that would otherwise conflict
How cards render in the board view is configured at the board level, not per-field. This provides:
- Separation of concerns: Field schema defines what the field is. Card display defines how the board renders cards.
- No conflicts: Each display slot maps to specific field(s). Impossible for multiple fields to claim the same slot.
- Explicit at a glance: Reading
[card_display]shows exactly how cards will appear. - Easy reassignment: Change which field shows as the type indicator by editing one line.
[card_display]
type_indicator = "type" # Single enum field shown as colored badge
tint = "priority" # Single enum field for card background color
badges = ["labels"] # Array of set fields shown as colored chips
metadata = ["assignee"] # Array of fields shown as small textSchema structure:
type CardDisplayConfig struct {
TypeIndicator string `toml:"type_indicator,omitempty"` // single enum field
Tint string `toml:"tint,omitempty"` // single enum field -> card background color
Badges []string `toml:"badges,omitempty"` // array of set fields (enum-set, free-set)
Metadata []string `toml:"metadata,omitempty"` // array of any fields
}Card display slots determine where custom field values appear on cards in the board view. Each slot has specific visual treatment.
| Slot | Cardinality | Field Type | Rendering |
|---|---|---|---|
type_indicator |
Single field | enum |
Small colored badge (e.g., "bug" pill) + left border accent |
tint |
Single field | enum |
Subtle background color wash on the entire card |
badges |
Array of fields | enum-set, free-set |
Colored chips, displayed in config order |
metadata |
Array of fields | Any | Small text in card footer |
Fields not assigned to any display slot are only visible in the card detail/edit view.
Type indicator rendering: For v1, type_indicator renders as a badge (similar visual treatment to badges, just single-value). Icons are deferred to a future version.
Badges ordering: When multiple fields are assigned to the badges slot, their values are displayed in the order the fields appear in the config array. Within each field, values appear in the order stored on the card.
Some display elements are non-configurable and always present:
| Indicator | Source | Rendering |
|---|---|---|
| Has description | description field non-empty |
📝 icon on card |
| Has comments | comments array non-empty |
💬 icon on card |
These are hardcoded to core fields and cannot be reassigned. They indicate presence, not values.
Frontend rendering logic:
1. SYSTEM INDICATORS (always, non-configurable):
- If card.description is non-empty → show description icon
- If card.comments is non-empty → show comments icon
2. CONFIGURABLE DISPLAY SLOTS (from [card_display]):
- If type_indicator configured → render field value as badge + left border accent
- If tint configured → render card with subtle background color from field's option color
- If badges configured → render each field's values as chips (in order)
- If metadata configured → render each field's value as small text
┌────────────────────────────────────┐
│ Fix login timeout │ ← title (core field)
│ ┌─────────┐ ┌─────────┐ ┌────────┐ │
│ │ bug │ │ blocked │ │ urgent │ │ ← type_indicator + badges
│ └─────────┘ └─────────┘ └────────┘ │
│ assignee: sarah 📝 💬 │ ← metadata + system indicators
└────────────────────────────────────┘
Board config validation:
type_indicatormust reference an existingenumcustom fieldtintmust reference an existingenumcustom field- Each entry in
badgesmust reference an existingenum-setorfree-setcustom field - Each entry in
metadatamust reference an existing custom field (any type) - References to non-existent fields produce a validation error on board load
Card validation:
- Custom field values must be defined in board schema (unknown fields rejected)
- Enum/enum-set values must exist in the field's
optionslist - Field names cannot use reserved prefixes (
_,kan_)
New boards are created with sensible defaults that demonstrate the pattern:
# Columns (unchanged from current)
[[columns]]
name = "backlog"
color = "#6b7280"
[[columns]]
name = "in-progress"
color = "#f59e0b"
[[columns]]
name = "done"
color = "#10b981"
default_column = "backlog"
# Custom fields
[custom_fields.type]
type = "enum"
options = [
{ value = "feature", color = "#16a34a" },
{ value = "bug", color = "#dc2626" },
{ value = "task", color = "#4b5563" },
]
[custom_fields.labels]
type = "enum-set"
options = [
{ value = "blocked", color = "#dc2626" },
{ value = "needs-review", color = "#f59e0b" },
]
# Card display configuration
[card_display]
type_indicator = "type"
badges = ["labels"]Users see the pattern immediately and can modify, remove, or add fields as needed.
Existing boards with [[labels]] sections require migration:
- Board config:
[[labels]]entries convert to[custom_fields.labels]withtype = "enum-set"and correspondingoptionsarray - Card data: No changes needed—
labelsarray on cards remains the same, just sourced from a custom field now - Card display: Auto-generated
[card_display]withbadges = ["labels"]to preserve current rendering
The migration is handled by kan migrate and schema version bumping (board schema v2).
Removed:
-l, --labelflag onkan addandkan edit
Unchanged:
-f, --field <key=value>for setting custom fields
Usage after revamp:
# Setting labels (now a custom field)
kan add "Fix bug" -f labels=blocked
# Setting type
kan add "New feature" -f type=feature
# Setting multiple on edit
kan edit abc123 -f type=bug -f labels=blocked,urgentThe CLI is primarily for scripts and agents; ergonomics of dedicated flags is less important than consistency. All custom fields use the same -f mechanism.
Deferred: CLI shortcuts configuration (e.g., [cli.shortcuts] to map -l to -f labels=...) may be added in a future version if demand exists.
The architecture supports future enhancements without structural changes:
- Icons on type_indicator: Add
iconfield toCustomFieldOption - Multiple badge rows: Add
badges_top,badges_bottomdisplay slots - Required fields with defaults: Add
requiredanddefaulttoCustomFieldSchema - CLI shortcuts: Add
[cli.shortcuts]for board-specific flag aliases - Additional field types:
number,url,string-list(freeform multi-value)
None currently blocking. The following are explicitly deferred:
- GitHub/GitLab issues sync: Likely a frontend/integration concern, not core Kan
- Multi-repo boards: Explicitly out of scope. Boards are per-repo.