chore: 1.0 release prep

Metadata:
- Bump dew CLI version 0.0.1 → 1.0.0
- Add repository + issue_tracker URLs to all pubspec.yaml files
- Switch inter-package path deps to versioned deps (^1.0.0)
- Remove publish_to: none from all packages
- Add MIT LICENSE to root and all packages
- Confirm all four pub.dev names available (dew, dew_core, dew_kanban, dew_mcp)

Documentation:
- Add CHANGELOG.md (Keep a Changelog format, full 1.0.0 feature history)
- Overhaul README.md (pitch, pub.dev badge, quick-start, feature sections)
- Add TUI section + full keybinding tables to docs/features/kanban.md
- Add CONTRIBUTING.md (setup, test, lint, branch strategy, command guide)

Tests:
- Add packages/cli/test/cli_test.dart (6 smoke tests)
- Add packages/kanban/test/integration_test.dart (6 TicketStore e2e tests)
- Expand packages/mcp/test/mcp_test.dart (5 tool registration tests)
- Add dew_kanban as dev dependency in packages/mcp/pubspec.yaml
- 57/57 tests passing

Code quality:
- dart format applied across all 23 changed source files
- dart analyze: zero errors, zero warnings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-25 15:58:54 -04:00
parent 4193282325
commit 0ad1fae213
51 changed files with 1682 additions and 337 deletions

View file

@ -0,0 +1,20 @@
---
id: DEW-0016
title: Verify pub.dev name availability for all four packages
type: spike
created: 2026-04-25T19:51:54.776993Z
milestones:
- 1.0.0
labels:
- 1.0
- metadata
---
Check that all four package names are available on pub.dev before we invest in publishing prep:
- https://pub.dev/packages/dew
- https://pub.dev/packages/dew_core
- https://pub.dev/packages/dew_kanban
- https://pub.dev/packages/dew_mcp
If any name is taken, we need to decide on alternatives (e.g. `dew_cli`, `dew_board`, etc.) and update package names + imports throughout the codebase.

View file

@ -0,0 +1,23 @@
---
id: DEW-0017
title: Bump CLI version + fill repository URLs in all pubspec.yaml
type: task
created: 2026-04-25T19:52:12.849559Z
milestones:
- 1.0.0
labels:
- 1.0
- metadata
---
Update all pubspec.yaml files for the 1.0 release:
- `packages/cli/pubspec.yaml`: bump version from `0.0.1``1.0.0`
- All four packages: uncomment and set `repository: https://github.com/artificery-dev/dew`
- All four packages: add `issue_tracker: https://github.com/artificery-dev/dew/issues`
Files to edit:
- packages/cli/pubspec.yaml
- packages/core/pubspec.yaml
- packages/kanban/pubspec.yaml
- packages/mcp/pubspec.yaml

View file

@ -0,0 +1,31 @@
---
id: DEW-0018
title: Switch path deps to versioned deps + add dependency_overrides for local dev
type: task
created: 2026-04-25T19:52:18.589466Z
milestones:
- 1.0.0
labels:
- 1.0
- metadata
---
pub.dev rejects path dependencies. Switch all inter-package deps to versioned, and use dependency_overrides in the workspace root for local development.
Changes needed:
- `packages/cli/pubspec.yaml`: change dew_core, dew_kanban, dew_mcp from `path: ../x` to `^1.0.0`
- `packages/kanban/pubspec.yaml`: change dew_core from `path: ../core` to `^1.0.0`
- `packages/mcp/pubspec.yaml`: change dew_core from `path: ../core` to `^1.0.0`
Add to root `pubspec.yaml`:
```yaml
dependency_overrides:
dew_core:
path: packages/core
dew_kanban:
path: packages/kanban
dew_mcp:
path: packages/mcp
```
After changes, run `dart pub get` at root and verify it resolves cleanly.

View file

@ -0,0 +1,21 @@
---
id: DEW-0019
title: "Remove publish_to: none from all packages"
type: task
created: 2026-04-25T19:52:22.448428Z
milestones:
- 1.0.0
labels:
- 1.0
- metadata
---
Remove `publish_to: none` from all four package pubspec.yaml files so they can be published to pub.dev.
Files:
- packages/cli/pubspec.yaml
- packages/core/pubspec.yaml
- packages/kanban/pubspec.yaml
- packages/mcp/pubspec.yaml
After removing, run `dart pub publish --dry-run` on each package to surface any remaining blocking issues.

View file

@ -0,0 +1,24 @@
---
id: DEW-0020
title: Write CHANGELOG.md
type: task
created: 2026-04-25T19:52:30.491758Z
milestones:
- 1.0.0
labels:
- 1.0
- docs
---
Write /CHANGELOG.md at the repo root covering the full 0.x → 1.0.0 history.
Format: Keep a Changelog (https://keepachangelog.com/en/1.0.0/)
The 1.0.0 section should cover all major features shipped:
- dew init command
- Kanban CLI: create, list, get, update, delete, move, search, comment, archive, unarchive, link, unlink, stats, config
- Kanban TUI: interactive board, column nav, ticket detail, editor modal, search/filter, delete, link, type picker, help overlay, auto-refresh, SIGWINCH resize
- MCP server: all kanban tools exposed as MCP tools via DewToolCommand mixin
- File-based ticket storage with YAML frontmatter + Markdown body
Reference `git log --oneline` for the full commit history.

View file

@ -0,0 +1,27 @@
---
id: DEW-0021
title: Overhaul README.md for 1.0 release
type: task
created: 2026-04-25T19:52:37.218209Z
milestones:
- 1.0.0
labels:
- 1.0
- docs
---
Overhaul /README.md for a compelling 1.0 release page.
Must include:
- Elevator pitch (1-2 sentences: what dew is, who it's for)
- Feature highlights with brief descriptions (Kanban CLI, TUI, MCP server)
- Installation: `dart pub global activate dew`
- Quick-start walkthrough:
1. `dew init .`
2. `dew kanban create --title "My first ticket" --type task`
3. `dew kanban tui`
- Configuration overview + link to docs/config.md
- Links to full docs (docs/index.md, docs/features/kanban.md, docs/features/mcp.md)
- Badge for pub.dev version + license
Current README is 37 lines and very thin — needs significant expansion.

View file

@ -0,0 +1,25 @@
---
id: DEW-0022
title: Add TUI section + update command list in docs/features/kanban.md
type: task
created: 2026-04-25T19:52:46.530001Z
milestones:
- 1.0.0
labels:
- 1.0
- docs
---
docs/features/kanban.md currently has zero coverage of `dew kanban tui`. Add a full TUI section.
The section should include:
- What the TUI is and how to launch it (`dew kanban tui`)
- Overview of the three modes: Board, Detail, Editor
- Full keybinding reference table (mirrors the F1 help overlay):
- Board mode: ↑↓ nav, ←→ cols, </> move, Enter detail, n/e/a/D/c/L/? actions, F1, q
- Detail mode: ↑↓ scroll, e edit, b/Esc back, F1, q
- Editor mode: ↑↓ fields, ←→ values, Enter edit, d del item, s save, Esc discard, F1
- Note on auto-refresh (watches .project/kanban/ for file changes)
- Note on external editor for body (respects $VISUAL / $EDITOR)
Also update the command list at the top of the file — it currently lists 12 commands but we have 16 (tui, board, link, unlink are missing or incomplete).

View file

@ -0,0 +1,40 @@
---
id: DEW-0023
title: Write CONTRIBUTING.md
type: task
created: 2026-04-25T19:52:53.733289Z
milestones:
- 1.0.0
labels:
- 1.0
- docs
---
Create /CONTRIBUTING.md at the repo root.
Should cover:
- Prerequisites: Dart SDK ^3.11.4
- Clone & setup:
```bash
git clone https://github.com/artificery-dev/dew.git
cd dew
dart pub get
```
- Running the CLI locally:
```bash
dart run packages/cli/bin/dew.dart kanban list
```
- Running tests:
```bash
dart test packages/core/test/
dart test packages/kanban/test/
dart test packages/mcp/test/
```
- Linting and formatting:
```bash
dart analyze
dart format .
```
- Branch strategy: feature branches off `develop`, PRs into `develop`, releases merge to `main`
- Commit message conventions (reference existing commit style in git log)
- How to add a new kanban command (implement DewToolCommand, register in dew_kanban_base.dart)

View file

@ -0,0 +1,22 @@
---
id: DEW-0024
title: Add CLI package smoke tests
type: task
created: 2026-04-25T19:53:00.484523Z
milestones:
- 1.0.0
labels:
- 1.0
- tests
---
packages/cli has no tests at all. Add packages/cli/test/cli_test.dart with:
1. Smoke test: CommandRunner instantiates without throwing
2. Help output: running with `--help` exits 0 and output contains 'kanban', 'init', 'mcp'
3. Version: running with `--version` exits 0 and output matches version in pubspec.yaml
4. Unknown command: running with an unknown subcommand exits non-zero
Use dart:io Process.run to invoke `dart run packages/cli/bin/dew.dart` with appropriate args, or instantiate the runner directly if that's cleaner.
Also add a `test` script to packages/cli/pubspec.yaml if not present.

View file

@ -0,0 +1,26 @@
---
id: DEW-0025
title: Add integration tests (init → create → list → move → link → delete)
type: task
created: 2026-04-25T19:53:08.295571Z
milestones:
- 1.0.0
labels:
- 1.0
- tests
---
Add end-to-end integration tests that exercise the full CLI against real temp directories.
Create packages/cli/test/integration_test.dart (or packages/kanban/test/integration_test.dart).
Test flows:
1. `dew init <tmpdir>` → .project/dew.yaml and .project/kanban/ are created
2. `dew kanban create --title "Test" --type task` → ticket file appears in backlog
3. `dew kanban list` → returns the created ticket
4. `dew kanban move --id <id> --column doing` → ticket moves
5. `dew kanban link --id <id> --target <id2> --type relates_to` → link appears in both tickets
6. `dew kanban unlink --id <id> --target <id2>` → link removed from both
7. `dew kanban delete --id <id>` → ticket file removed
Use setUp/tearDown with dart:io Directory.systemTemp for isolation. Invoke commands via the Dart API (instantiate CommandRunner directly with a MemoryFileSystem or real temp dir), not via subprocess, for speed.

View file

@ -0,0 +1,19 @@
---
id: DEW-0026
title: Expand MCP server tool registration tests
type: task
created: 2026-04-25T19:53:14.810785Z
milestones:
- 1.0.0
labels:
- 1.0
- tests
---
The MCP serve command and server startup are not covered by tests. Extend packages/mcp/test/mcp_test.dart:
1. McpServer registers all expected tool names (currently partially tested — confirm all 14 kanban tools are present)
2. Tool schemas have correct required fields (e.g. kanban_create_ticket requires `title`)
3. Tool handlers return expected shape (mock TicketStore, call handler, verify output)
Reference packages/mcp/lib/src/dew_mcp_server.dart and the DewToolCommand mixin in dew_core.

View file

@ -0,0 +1,23 @@
---
id: DEW-0027
title: "Final validation: analyze + format + tests + dry-run publish"
type: task
created: 2026-04-25T19:53:20.587728Z
milestones:
- 1.0.0
labels:
- 1.0
---
Final validation gate before publishing. Must pass all of:
1. `dart analyze` — zero errors, zero warnings across all packages
2. `dart format --set-exit-if-changed .` — all files correctly formatted
3. Full test suite green:
- dart test packages/core/test/
- dart test packages/kanban/test/
- dart test packages/mcp/test/
- dart test packages/cli/test/
4. `dart pub publish --dry-run` passes for all four packages
Fix any issues found. Do not proceed to publish until all four checks are clean.

View file

@ -0,0 +1,29 @@
---
id: DEW-0028
title: Publish packages to pub.dev and tag v1.0.0
type: task
created: 2026-04-25T19:53:27.907063Z
milestones:
- 1.0.0
labels:
- 1.0
---
Publish all four packages to pub.dev in dependency order, then tag the release.
Order:
1. `cd packages/core && dart pub publish`
2. `cd packages/kanban && dart pub publish`
3. `cd packages/mcp && dart pub publish`
4. `cd packages/cli && dart pub publish`
Wait for each to appear on pub.dev before publishing the next (pub.dev propagation can take a few minutes).
After all four are published:
1. `git checkout main`
2. `git merge develop`
3. `git tag v1.0.0`
4. `git push origin main --tags`
5. Create GitHub release at https://github.com/artificery-dev/dew/releases with CHANGELOG.md 1.0.0 section as body.
Prerequisite: DEW-0027 (final validation) must be done first.

90
CHANGELOG.md Normal file
View file

@ -0,0 +1,90 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 2026-04-25
### Added
#### `dew init`
- `dew init <path>` scaffolds a new project, generating `.project/dew.yaml` with
sensible defaults and running all module init hooks (kanban directory layout, etc.).
#### Kanban CLI (`dew kanban`)
Full set of kanban subcommands, each also registered as an MCP tool automatically:
| Subcommand | Description |
| ------------ | ------------------------------------------------------------------------ |
| `create` | Create a new ticket with title, type, column, body, labels, and milestones |
| `list` | List tickets filtered by column, type, label, milestone, or archived state |
| `get` | Fetch a single ticket by ID |
| `update` | Update any field on a ticket (title, type, column, body, labels, milestones) |
| `delete` | Permanently delete a ticket |
| `move` | Move a ticket to a different column (validates column transition rules) |
| `search` | Full-text search across ticket titles, bodies, and comments |
| `comment` | Append a comment to a ticket |
| `archive` | Soft-delete a ticket by moving it to the archive column |
| `unarchive` | Restore an archived ticket to a column |
| `link` | Create a typed bidirectional link between two tickets |
| `unlink` | Remove a link between two tickets |
| `stats` | Display ticket counts grouped by column and type |
| `board` | Print an ASCII representation of the board |
| `config` | Print the current kanban configuration |
| `tui` | Launch the interactive terminal UI |
- File-based storage: each ticket is a Markdown file with YAML frontmatter for
metadata and inline `---` separators for comments. Column is derived from the
containing subdirectory — not duplicated in the file.
- Typed, bidirectional ticket links: `blocks`/`is_blocked_by`, `relates_to`,
`duplicates`/`is_duplicated_by`, `parent_of`/`child_of`. Writing one side
automatically writes the inverse on the target ticket.
- Labels and milestones as first-class ticket fields.
- Column transition validation: enforces allowed moves between configured columns.
- `--include-archived` flag on `list` and `search` to include soft-deleted tickets.
#### Interactive TUI (`dew kanban tui`)
- Full Trello-style terminal board rendered with ANSI box-drawing characters.
- **Board mode**: pill/tab column headers, scrollable ticket list per column,
live `?` filter/search overlay, `n` create, `e` edit, `a` archive, `D` delete,
`c` comment, `L` link, `<`/`>` move ticket between columns, Enter open detail.
- **Detail mode**: scrollable ticket detail view with formatted body and comments.
- **Editor modal**: in-terminal overlay for editing all ticket fields — title,
type (picker), column (picker), labels, milestones, and body. Arrow keys cycle
selector values; Enter edits text fields or opens `$VISUAL`/`$EDITOR` for body.
- F1 help overlay in every mode showing context-sensitive keybindings.
- Auto-refresh via filesystem watching: board reloads live when ticket files
change on disk (e.g. from another terminal or AI agent).
- SIGWINCH handling for correct redraws on terminal resize.
#### MCP Server (`dew mcp serve`)
- `dew mcp serve` starts an MCP-compliant stdio server.
- All kanban commands that implement the `DewToolCommand` mixin are automatically
exposed as MCP tools — no separate registration required.
- JSON Schema for each tool's input is derived directly from the command's
`ArgParser`, so argument definitions are written exactly once.
- Compiled CLI binary at `.project/toolchain/bin/dew` used by the MCP server to
avoid stdin conflicts.
#### Core Architecture
- `DewToolCommand` mixin: mix into any `DewCommand` to register it simultaneously
as a CLI subcommand and an MCP tool.
- `CommandRegistry`: central registry collected at startup; MCP server reads from
this registry to enumerate available tools.
- `DewConfig` with typed extension pattern: `core` holds the raw YAML map; feature
packages (`kanban`, `mcp`) add typed accessors via Dart extensions, keeping
feature-specific config out of `core`.
- `ProjectDirs` with injectable filesystem abstraction (`package:file`) for
testable path resolution.
[Unreleased]: https://github.com/artificery-dev/dew/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/artificery-dev/dew/releases/tag/v1.0.0

115
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,115 @@
# Contributing to Dew
Thank you for your interest in contributing! This guide covers everything you need to get set up and start making changes.
## Prerequisites
- **Dart SDK ^3.11.4** — verify with `dart --version`
- **Melos** (optional, for workspace scripts) — `dart pub global activate melos`
## Clone & setup
```bash
git clone https://github.com/artificery-dev/dew.git
cd dew
dart pub get
```
## Running the CLI locally
```bash
dart run packages/cli/bin/dew.dart kanban --help
```
## Running tests
```bash
dart test packages/core/test/
dart test packages/kanban/test/
dart test packages/mcp/test/
```
## Linting and formatting
```bash
dart analyze
dart format .
```
Fix any analysis warnings before opening a PR. The project uses the rules defined in `analysis_options.yaml`.
## Branch strategy
| Branch | Purpose |
| --------- | ---------------------------------------------------------- |
| `main` | Stable, released code. Only merged into from `develop`. |
| `develop` | Integration branch. All PRs target this branch. |
| `feat/*` | Feature branches cut from `develop`. |
| `fix/*` | Bug-fix branches cut from `develop`. |
**Workflow:**
1. Cut a feature branch from `develop`: `git checkout -b feat/my-feature develop`
2. Make your changes, add tests, run `dart analyze && dart format .`
3. Open a PR targeting `develop`
4. Releases are prepared on `develop` and merged to `main`
## Adding a new kanban command
Follow these three steps to add a command that is simultaneously a CLI subcommand and an MCP tool:
### 1. Create the command class
Create `packages/kanban/lib/src/commands/my_command.dart` implementing `DewToolCommand`:
```dart
import 'package:args/command_runner.dart';
import 'package:dew_core/dew_core.dart';
class MyCommand extends DewCommand with DewToolCommand {
@override
final String name = 'my-command';
@override
final String description = 'Does something useful.';
MyCommand() {
argParser.addOption('id', mandatory: true, help: 'Ticket ID.');
}
@override
Future<void> run() async {
final id = argResults!['id'] as String;
// implementation
}
}
```
The `DewToolCommand` mixin automatically derives the MCP tool JSON Schema from
the `ArgParser` — no extra registration work needed.
### 2. Register in the kanban base
Add your command in `packages/kanban/lib/src/dew_kanban_base.dart` alongside the existing commands:
```dart
addSubcommand(MyCommand());
```
### 3. Add tests
Add tests in `packages/kanban/test/my_command_test.dart`. Use the existing command tests as a reference for how to set up a `ProjectContext` with an in-memory filesystem.
```bash
dart test packages/kanban/test/my_command_test.dart
```
## Project structure
```text
packages/
├── cli/ — Entry point; wires all packages together
├── core/ — DewCommand, DewToolCommand, CommandRegistry, DewConfig
├── kanban/ — All kanban commands and storage logic
└── mcp/ — MCP server; reads tools from CommandRegistry
```

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 artificery-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,36 +1,65 @@
# Dew Project Management Tool # Dew
Dew is a project management tool built in Dart, designed to help developers organize and manage their projects efficiently. It provides a simple command-line interface for creating, managing, and tracking projects, tasks, and deadlines. [![pub package](https://img.shields.io/pub/v/dew.svg)](https://pub.dev/packages/dew)
**Dew** is a git-native project management CLI for developers who want a kanban board that lives in their repository and talks to AI agents. Tickets are plain Markdown files with YAML frontmatter — diff-friendly, grep-able, and version-controlled alongside your code.
## Features ## Features
### Kanban CLI
16 subcommands cover the full lifecycle of a ticket:
```
create list get update delete move
search comment archive unarchive link unlink
stats board config tui
```
Tickets are stored as `.project/kanban/<column>/<ID>.md` files. Labels, milestones, typed bidirectional links, and inline comments are all first-class citizens. See the [Kanban documentation](./docs/features/kanban.md) for the full command reference.
### Interactive TUI
`dew kanban tui` opens a full Trello-style terminal board with three modes:
- **Board** — navigate columns and tickets, create/edit/move/archive/delete, live search filter
- **Detail** — scrollable view of a ticket's body and comments
- **Editor** — in-terminal modal for editing all ticket fields; opens `$VISUAL`/`$EDITOR` for the body
The TUI auto-refreshes when ticket files change on disk, so it stays in sync whether you're editing files directly or an AI agent is updating them.
### MCP Server ### MCP Server
Dew includes an MCP server for AI agents to interact with your project. This allows you to integrate AI capabilities into your project management workflow, enabling features like automated task creation, progress tracking, and more. Read more about the MCP server in the [Dew MCP Feature Documentation](./docs/features/mcp.md). `dew mcp serve` starts an MCP-compliant stdio server that exposes every kanban command as an MCP tool. AI agents (GitHub Copilot, Claude, etc.) can create tickets, move cards, search, and comment — using the exact same logic as the CLI. No separate tool definitions needed: every command that mixes in `DewToolCommand` is registered automatically. See the [MCP documentation](./docs/features/mcp.md).
### Kanban Board ## Installation
Dew includes a Kanban board feature that allows you to visualize your tasks and their progress. You can create columns for different stages of your workflow (e.g., To Do, In Progress, Done) and move tasks between them as you work on them. Read more about the Kanban board in the [Dew Kanban Feature Documentation](./docs/features/kanban.md).
## Configuration
Dew's configuration is managed with the [dew.yaml](.project/dew.yaml) file, which allows you to customize various aspects of the tool to fit your workflow. You can specify project settings, task templates, and other preferences in this file. Read more about configuring Dew in the [Dew Configuration Documentation](./docs/config.md).
## Getting Started
To get started with Dew, follow these steps:
```bash ```bash
# Activate the Dew CLI
dart pub global activate dew dart pub global activate dew
``` ```
Once you have the CLI installed, you can create a new project with: Requires Dart SDK ^3.11.4.
## Quick start
```bash ```bash
# Scaffold a new project (creates .project/dew.yaml)
dew init . dew init .
# Create your first ticket
dew kanban create --title "My first ticket" --type task
# Open the interactive board
dew kanban tui
``` ```
This will set up the necessary files and directories for your project. You can then start adding tasks and managing your project using the Dew CLI. ## Configuration
For more detailed instructions and documentation, please refer to the [Dew Documentation](./docs/index.md). Dew reads `.project/dew.yaml` for board columns, ticket types, ID prefix, and MCP server settings. Running `dew init .` generates this file with defaults. See the [Configuration documentation](./docs/config.md) for the full schema reference.
## Documentation
- [Full documentation index](./docs/index.md)
- [Kanban board](./docs/features/kanban.md) — CLI commands, TUI keybindings, ticket format
- [MCP server](./docs/features/mcp.md) — AI agent integration
- [Configuration reference](./docs/config.md)

View file

@ -51,20 +51,24 @@ Second comment.
All commands are available under `dew kanban <subcommand>`. All commands are available under `dew kanban <subcommand>`.
| Subcommand | Description | | Subcommand | Description |
| ---------- | --------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------------------ |
| `create` | Create a new ticket (`--title`, `--type`, `--column`, `--body`) | | `create` | Create a new ticket (`--title`, `--type`, `--column`, `--body`) |
| `list` | List tickets (`--column`, `--type`) | | `list` | List tickets (`--column`, `--type`, `--label`, `--milestone`, `--include-archived`) |
| `get` | Get a ticket by ID (`--id`) | | `get` | Get a ticket by ID (`--id`) |
| `update` | Update fields on a ticket (`--id`, `--title`, `--type`, `--column`, `--body`) | | `update` | Update fields on a ticket (`--id`, `--title`, `--type`, `--column`, `--body`) |
| `delete` | Delete a ticket permanently (`--id`) | | `delete` | Delete a ticket permanently (`--id`) |
| `move` | Move a ticket to a different column (`--id`, `--column`) | | `move` | Move a ticket to a different column (`--id`, `--column`) |
| `search` | Full-text search across all ticket content (`--query`) | | `search` | Full-text search across all ticket content (`--query`, `--include-archived`) |
| `comment` | Append a comment to a ticket (`--id`, `--comment`) | | `comment` | Append a comment to a ticket (`--id`, `--comment`) |
| `config` | Print the current kanban configuration | | `archive` | Soft-delete a ticket by moving it to the archive column (`--id`) |
| `stats` | Show ticket counts by column and type | | `unarchive` | Restore an archived ticket to a column (`--id`, `--column`) |
| `link` | Link two tickets with a typed relationship (`--id`, `--target`, `--type`) | | `link` | Link two tickets with a typed relationship (`--id`, `--target`, `--type`) |
| `unlink` | Remove a link between two tickets (`--id`, `--target`) | | `unlink` | Remove a link between two tickets (`--id`, `--target`) |
| `stats` | Show ticket counts by column and type |
| `board` | Print an ASCII representation of the board |
| `config` | Print the current kanban configuration |
| `tui` | Launch the interactive terminal UI |
## Ticket links ## Ticket links
@ -103,3 +107,74 @@ dew:
``` ```
See the [Configuration documentation](../config.md) for the full schema reference. See the [Configuration documentation](../config.md) for the full schema reference.
## Interactive TUI
Launch the interactive terminal board with:
```bash
dew kanban tui
```
The TUI has three modes. Press **F1** in any mode for a context-sensitive help overlay.
### Modes
| Mode | How to enter | How to leave |
| ---------- | ----------------------------------- | ------------------ |
| **Board** | Default on launch | `q` to quit |
| **Detail** | Press `Enter` on a ticket in Board | `b` or `Esc` |
| **Editor** | Press `e` in Board or Detail mode | `s` save / `Esc` discard |
### Keybinding reference
#### Board mode
| Key | Action |
| --------- | ----------------------------------------------- |
| `↑` / `↓` | Navigate tickets within the current column |
| `←` / `→` | Switch to the previous / next column |
| `<` / `>` | Move the selected ticket to the previous / next column |
| `Enter` | Open Detail view for the selected ticket |
| `n` | Create a new ticket (opens Editor) |
| `e` | Edit the selected ticket (opens Editor) |
| `a` | Archive the selected ticket |
| `D` | Delete the selected ticket (with confirmation) |
| `c` | Append a comment to the selected ticket |
| `L` | Link the selected ticket to another ticket |
| `?` | Open the live filter / search overlay |
| `F1` | Toggle the help overlay |
| `q` | Quit |
#### Detail mode
| Key | Action |
| --------- | ----------------------------- |
| `↑` / `↓` | Scroll the ticket content |
| `e` | Edit the ticket (opens Editor) |
| `b` / `Esc` | Return to Board mode |
| `F1` | Toggle the help overlay |
| `q` | Quit |
#### Editor mode
| Key | Action |
| --------- | ------------------------------------------------------------------------ |
| `↑` / `↓` | Move between fields |
| `←` / `→` | Cycle through selector values (type, column) |
| `Enter` | Edit the focused text field inline, or open `$VISUAL`/`$EDITOR` for body |
| `d` | Delete the current item (e.g. remove a label) |
| `s` | Save changes and return to the previous mode |
| `Esc` | Discard changes and return to the previous mode |
| `F1` | Toggle the help overlay |
> **Body editing:** pressing `Enter` on the body field suspends the TUI and opens
> `$VISUAL` or `$EDITOR` (falling back to `vi`) so you can write long-form content
> in your preferred editor. The TUI resumes once you save and exit.
### Auto-refresh
The board watches the `.project/kanban/` directory for file-system events and
reloads automatically when ticket files are created, modified, or deleted — so
the view stays live whether changes come from another terminal session, a
`git pull`, or an AI agent running `dew mcp serve`.

21
packages/cli/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 artificery-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,8 +1,8 @@
name: dew name: dew
description: Command-line interface for the Dew project management tool. description: Command-line interface for the Dew project management tool.
version: 0.0.1 version: 1.0.0
# repository: https://github.com/my_org/my_repo repository: https://github.com/artificery-dev/dew
publish_to: none issue_tracker: https://github.com/artificery-dev/dew/issues
resolution: workspace resolution: workspace
environment: environment:
@ -10,12 +10,9 @@ environment:
dependencies: dependencies:
args: ^2.7.0 args: ^2.7.0
dew_core: dew_core: ^1.0.0
path: ../core dew_kanban: ^1.0.0
dew_kanban: dew_mcp: ^1.0.0
path: ../kanban
dew_mcp:
path: ../mcp
dev_dependencies: dev_dependencies:
lints: ^6.0.0 lints: ^6.0.0

View file

@ -0,0 +1,55 @@
import 'package:args/command_runner.dart';
import 'package:dew_core/dew_core.dart';
import 'package:dew_kanban/dew_kanban.dart' as kanban;
import 'package:dew_mcp/dew_mcp.dart' as mcp;
import 'package:test/test.dart';
/// Builds the same CommandRunner as bin/dew.dart without actually running it.
CommandRunner<void> buildRunner() {
final commandRegistry = CommandRegistry();
kanban.registerCommands(commandRegistry);
mcp.registerCommands(commandRegistry);
final runner = CommandRunner<void>('dew', 'A project management tool.');
runner.addCommand(InitCommand(commandRegistry.initHooks));
for (final command in commandRegistry.commands) {
runner.addCommand(command);
}
return runner;
}
void main() {
group('CLI CommandRunner', () {
test('builds without throwing', () {
expect(buildRunner, returnsNormally);
});
test('has kanban, init, and mcp commands registered', () {
final runner = buildRunner();
expect(runner.commands.keys, containsAll(['kanban', 'init', 'mcp']));
});
test('--help flag does not throw', () async {
final runner = buildRunner();
await expectLater(runner.run(['--help']), completes);
});
test('unknown command throws UsageException', () async {
final runner = buildRunner();
await expectLater(
runner.run(['totally-unknown-command']),
throwsA(isA<UsageException>()),
);
});
test('kanban subcommand --help does not throw', () async {
final runner = buildRunner();
await expectLater(runner.run(['help', 'kanban']), completes);
});
test('mcp subcommand --help does not throw', () async {
final runner = buildRunner();
await expectLater(runner.run(['help', 'mcp']), completes);
});
});
}

21
packages/core/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 artificery-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -35,7 +35,11 @@ class ProjectContext {
final DewConfig config; final DewConfig config;
final FileSystem fs; final FileSystem fs;
const ProjectContext({required this.root, required this.config, required this.fs}); const ProjectContext({
required this.root,
required this.config,
required this.fs,
});
/// Typed path helpers for this project's well-known directories. /// Typed path helpers for this project's well-known directories.
ProjectDirs get dirs => ProjectDirs(root); ProjectDirs get dirs => ProjectDirs(root);

View file

@ -162,6 +162,7 @@ class CommandRegistry {
collect(sub); collect(sub);
} }
} }
for (final cmd in _commands) { for (final cmd in _commands) {
collect(cmd); collect(cmd);
} }

View file

@ -64,7 +64,8 @@ class InitCommand extends Command<void> {
final List<DewInitHook> _hooks; final List<DewInitHook> _hooks;
final FileSystem _fs; final FileSystem _fs;
InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()}) : _fs = fs { InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()})
: _fs = fs {
argParser argParser
..addOption( ..addOption(
'path', 'path',

View file

@ -1,8 +1,8 @@
name: dew_core name: dew_core
description: Core shared types, interfaces, and configuration for the Dew project management tool. description: Core shared types, interfaces, and configuration for the Dew project management tool.
version: 1.0.0 version: 1.0.0
# repository: https://github.com/my_org/my_repo repository: https://github.com/artificery-dev/dew
publish_to: none issue_tracker: https://github.com/artificery-dev/dew/issues
resolution: workspace resolution: workspace
environment: environment:

21
packages/kanban/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 artificery-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -10,8 +10,18 @@ class AddCommentCommand extends DewCommand with DewToolCommand {
AddCommentCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { AddCommentCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID (e.g. DEW-0001).') ..addOption(
..addOption('comment', abbr: 'm', mandatory: true, help: 'Comment text to append.'); 'id',
abbr: 'i',
mandatory: true,
help: 'Ticket ID (e.g. DEW-0001).',
)
..addOption(
'comment',
abbr: 'm',
mandatory: true,
help: 'Comment text to append.',
);
} }
@override @override

View file

@ -10,14 +10,20 @@ class ArchiveCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
ArchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { ArchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser.addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to archive.'); argParser.addOption(
'id',
abbr: 'i',
mandatory: true,
help: 'Ticket ID to archive.',
);
} }
@override @override
final String name = 'archive'; final String name = 'archive';
@override @override
final String description = 'Archive a ticket (moves it to the archive column).'; final String description =
'Archive a ticket (moves it to the archive column).';
@override @override
final String toolName = 'kanban_archive_ticket'; final String toolName = 'kanban_archive_ticket';
@ -30,7 +36,11 @@ class ArchiveCommand extends DewCommand with DewToolCommand {
final config = context.config.kanban; final config = context.config.kanban;
final kanbanDir = context.dirs.kanban; final kanbanDir = context.dirs.kanban;
final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs); final store = TicketStore(
kanbanDir: kanbanDir,
prefix: config.prefix,
fs: context.fs,
);
final ticket = await store.findById(id); final ticket = await store.findById(id);
if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (ticket == null) throw ArgumentError('Ticket $id not found.');
if (ticket.column == 'archive') return '$id is already archived.'; if (ticket.column == 'archive') return '$id is already archived.';

View file

@ -47,7 +47,9 @@ class BoardCommand extends DewCommand with DewToolCommand {
tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList(); tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList();
} }
if (milestoneFilter != null) { if (milestoneFilter != null) {
tickets = tickets.where((t) => t.milestones.contains(milestoneFilter)).toList(); tickets = tickets
.where((t) => t.milestones.contains(milestoneFilter))
.toList();
} }
// Index by column, preserving config order. // Index by column, preserving config order.
@ -80,8 +82,10 @@ class BoardCommand extends DewCommand with DewToolCommand {
final col = entry.key; final col = entry.key;
final header = ' $col (${entry.value.length}) '; final header = ' $col (${entry.value.length}) ';
final colLines = lines[col]!; final colLines = lines[col]!;
final contentWidth = [header.length, ...colLines.map((l) => l.length + 2)] final contentWidth = [
.reduce((a, b) => a > b ? a : b); header.length,
...colLines.map((l) => l.length + 2),
].reduce((a, b) => a > b ? a : b);
final divider = '' * contentWidth; final divider = '' * contentWidth;
buf.writeln('$divider'); buf.writeln('$divider');
buf.writeln('${header.padRight(contentWidth)}'); buf.writeln('${header.padRight(contentWidth)}');

View file

@ -11,7 +11,8 @@ class GetConfigCommand extends DewCommand with DewToolCommand {
final String name = 'config'; final String name = 'config';
@override @override
final String description = 'Show the kanban configuration (columns and ticket types).'; final String description =
'Show the kanban configuration (columns and ticket types).';
@override @override
final String toolName = 'kanban_get_config'; final String toolName = 'kanban_get_config';
@ -22,7 +23,9 @@ class GetConfigCommand extends DewCommand with DewToolCommand {
final config = context.config.kanban; final config = context.config.kanban;
final columns = config.columns.map((c) => '${c.id} (${c.name})').join(', '); final columns = config.columns.map((c) => '${c.id} (${c.name})').join(', ');
final types = config.ticketTypes.map((t) => '${t.id} (${t.name})').join(', '); final types = config.ticketTypes
.map((t) => '${t.id} (${t.name})')
.join(', ');
return 'Columns: $columns\nTypes: $types'; return 'Columns: $columns\nTypes: $types';
} }

View file

@ -12,7 +12,12 @@ class LinkCommand extends DewCommand with DewToolCommand {
LinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { LinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID.') ..addOption(
'target',
abbr: 't',
mandatory: true,
help: 'Target ticket ID.',
)
..addOption( ..addOption(
'type', 'type',
abbr: 'y', abbr: 'y',
@ -39,7 +44,8 @@ class LinkCommand extends DewCommand with DewToolCommand {
final targetId = (args['target'] as String).toUpperCase(); final targetId = (args['target'] as String).toUpperCase();
final type = args['type'] as String; final type = args['type'] as String;
if (id == targetId) throw ArgumentError('A ticket cannot be linked to itself.'); if (id == targetId)
throw ArgumentError('A ticket cannot be linked to itself.');
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(

View file

@ -11,18 +11,27 @@ class ListCommand extends DewCommand with DewToolCommand {
ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('column', abbr: 'c', help: 'Filter to tickets in this column.') ..addOption(
'column',
abbr: 'c',
help: 'Filter to tickets in this column.',
)
..addOption('type', abbr: 't', help: 'Filter to tickets of this type.') ..addOption('type', abbr: 't', help: 'Filter to tickets of this type.')
..addOption('label', help: 'Filter to tickets with this label.') ..addOption('label', help: 'Filter to tickets with this label.')
..addOption('milestone', help: 'Filter to tickets in this milestone.') ..addOption('milestone', help: 'Filter to tickets in this milestone.')
..addFlag('include-archived', help: 'Include archived tickets.', negatable: false); ..addFlag(
'include-archived',
help: 'Include archived tickets.',
negatable: false,
);
} }
@override @override
final String name = 'list'; final String name = 'list';
@override @override
final String description = 'List kanban tickets, optionally filtered by column or type.'; final String description =
'List kanban tickets, optionally filtered by column or type.';
@override @override
final String toolName = 'kanban_list_tickets'; final String toolName = 'kanban_list_tickets';
@ -53,7 +62,9 @@ class ListCommand extends DewCommand with DewToolCommand {
tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList(); tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList();
} }
if (milestoneFilter != null) { if (milestoneFilter != null) {
tickets = tickets.where((t) => t.milestones.contains(milestoneFilter)).toList(); tickets = tickets
.where((t) => t.milestones.contains(milestoneFilter))
.toList();
} }
if (tickets.isEmpty) return 'No tickets found.'; if (tickets.isEmpty) return 'No tickets found.';

View file

@ -11,14 +11,20 @@ class MoveCommand extends DewCommand with DewToolCommand {
MoveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { MoveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
..addOption('column', abbr: 'c', mandatory: true, help: 'Target column ID.'); ..addOption(
'column',
abbr: 'c',
mandatory: true,
help: 'Target column ID.',
);
} }
@override @override
final String name = 'move'; final String name = 'move';
@override @override
final String description = 'Move a ticket to a different column (validates against config).'; final String description =
'Move a ticket to a different column (validates against config).';
@override @override
final String toolName = 'kanban_move_ticket'; final String toolName = 'kanban_move_ticket';
@ -50,7 +56,8 @@ class MoveCommand extends DewCommand with DewToolCommand {
// Check allowed_transitions if configured on the current column. // Check allowed_transitions if configured on the current column.
final currentColConfig = config.columns.firstWhere( final currentColConfig = config.columns.firstWhere(
(c) => c.id == ticket.column, (c) => c.id == ticket.column,
orElse: () => ColumnConfig(id: ticket.column, name: ticket.column, color: ''), orElse: () =>
ColumnConfig(id: ticket.column, name: ticket.column, color: ''),
); );
if (currentColConfig.allowedTransitions.isNotEmpty && if (currentColConfig.allowedTransitions.isNotEmpty &&
!currentColConfig.allowedTransitions.contains(column)) { !currentColConfig.allowedTransitions.contains(column)) {

View file

@ -17,17 +17,29 @@ class SearchCommand extends DewCommand with DewToolCommand {
help: 'Search query (matches title, body, and comments).', help: 'Search query (matches title, body, and comments).',
) )
..addOption('column', abbr: 'c', help: 'Restrict search to this column.') ..addOption('column', abbr: 'c', help: 'Restrict search to this column.')
..addOption('type', abbr: 't', help: 'Restrict search to this ticket type.') ..addOption(
'type',
abbr: 't',
help: 'Restrict search to this ticket type.',
)
..addOption('label', help: 'Restrict search to tickets with this label.') ..addOption('label', help: 'Restrict search to tickets with this label.')
..addOption('milestone', help: 'Restrict search to tickets in this milestone.') ..addOption(
..addFlag('include-archived', help: 'Include archived tickets.', negatable: false); 'milestone',
help: 'Restrict search to tickets in this milestone.',
)
..addFlag(
'include-archived',
help: 'Include archived tickets.',
negatable: false,
);
} }
@override @override
final String name = 'search'; final String name = 'search';
@override @override
final String description = 'Search tickets by text across title, body, and comments.'; final String description =
'Search tickets by text across title, body, and comments.';
@override @override
final String toolName = 'kanban_search_tickets'; final String toolName = 'kanban_search_tickets';
@ -59,7 +71,9 @@ class SearchCommand extends DewCommand with DewToolCommand {
tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList(); tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList();
} }
if (milestoneFilter != null) { if (milestoneFilter != null) {
tickets = tickets.where((t) => t.milestones.contains(milestoneFilter)).toList(); tickets = tickets
.where((t) => t.milestones.contains(milestoneFilter))
.toList();
} }
final matches = tickets.where((t) { final matches = tickets.where((t) {

View file

@ -30,21 +30,34 @@ final class _TuiRefresh extends _TuiEvent {
// Inline prompt // Inline prompt
enum _PromptKind { newTitle, editTitle, addComment, archiveConfirm, deleteConfirm, linkId, linkType } enum _PromptKind {
newTitle,
editTitle,
addComment,
archiveConfirm,
deleteConfirm,
linkId,
linkType,
}
const _linkRelations = [ const _linkRelations = [
'blocks', 'is_blocked_by', 'relates_to', 'blocks',
'parent_of', 'child_of', 'duplicates', 'is_duplicated_by', 'is_blocked_by',
'relates_to',
'parent_of',
'child_of',
'duplicates',
'is_duplicated_by',
]; ];
class _Prompt { class _Prompt {
final _PromptKind kind; final _PromptKind kind;
final String? ticketId; final String? ticketId;
String input; String input = '';
int typeIdx; // newTitle: selected ticket type index int typeIdx = 0; // newTitle: selected ticket type index
int relationIdx; // linkType: selected relation index int relationIdx = 0; // linkType: selected relation index
String? linkTargetId; // linkType: resolved target ticket id String? linkTargetId; // linkType: resolved target ticket id
_Prompt(this.kind, {this.input = '', this.ticketId, this.typeIdx = 0, this.relationIdx = 0, this.linkTargetId}); _Prompt(this.kind, {this.ticketId, this.linkTargetId});
} }
// Ticket editor // Ticket editor
@ -65,17 +78,17 @@ class _EditorState {
String textInput; String textInput;
_EditorState.from(Ticket t) _EditorState.from(Ticket t)
: ticket = t, : ticket = t,
title = t.title, title = t.title,
type = t.type, type = t.type,
column = t.column, column = t.column,
labels = [...t.labels], labels = [...t.labels],
milestones = [...t.milestones], milestones = [...t.milestones],
body = t.body, body = t.body,
focus = _EditorField.title, focus = _EditorField.title,
itemCursor = 0, itemCursor = 0,
textEditing = false, textEditing = false,
textInput = ''; textInput = '';
bool get isDirty => bool get isDirty =>
title != ticket.title || title != ticket.title ||
@ -259,7 +272,13 @@ class TuiCommand extends DewCommand {
if (mode == _Mode.help) { if (mode == _Mode.help) {
_renderHelp(console: console, w: w, h: h); _renderHelp(console: console, w: w, h: h);
} else if (mode == _Mode.editor && editorState != null) { } else if (mode == _Mode.editor && editorState != null) {
_renderEditor(console: console, config: config, es: editorState, w: w, h: h); _renderEditor(
console: console,
config: config,
es: editorState,
w: w,
h: h,
);
} else if (mode == _Mode.board) { } else if (mode == _Mode.board) {
_renderBoard( _renderBoard(
console: console, console: console,
@ -347,7 +366,8 @@ class TuiCommand extends DewCommand {
// Prompt mode (inline action input) // Prompt mode (inline action input)
if (prompt != null) { if (prompt != null) {
final p = prompt; final p = prompt;
if (p.kind == _PromptKind.archiveConfirm || p.kind == _PromptKind.deleteConfirm) { if (p.kind == _PromptKind.archiveConfirm ||
p.kind == _PromptKind.deleteConfirm) {
if (!key.isControl) { if (!key.isControl) {
if (key.char == 'y' || key.char == 'Y') { if (key.char == 'y' || key.char == 'Y') {
try { try {
@ -361,7 +381,10 @@ class TuiCommand extends DewCommand {
tickets = await store.list(); tickets = await store.list();
byColumn = _groupByColumn(tickets, config); byColumn = _groupByColumn(tickets, config);
final col = config.columns[colIdx]; final col = config.columns[colIdx];
final remaining = _filtered(byColumn[col.id] ?? [], searchQuery); final remaining = _filtered(
byColumn[col.id] ?? [],
searchQuery,
);
ticketIdx = ticketIdx.clamp(0, max(0, remaining.length - 1)); ticketIdx = ticketIdx.clamp(0, max(0, remaining.length - 1));
} on ArgumentError catch (e) { } on ArgumentError catch (e) {
statusMsg = 'Error: ${e.message ?? e}'; statusMsg = 'Error: ${e.message ?? e}';
@ -387,13 +410,19 @@ class TuiCommand extends DewCommand {
case ControlCharacter.arrowLeft: case ControlCharacter.arrowLeft:
if (p.relationIdx > 0) p.relationIdx--; if (p.relationIdx > 0) p.relationIdx--;
case ControlCharacter.arrowRight: case ControlCharacter.arrowRight:
if (p.relationIdx < _linkRelations.length - 1) p.relationIdx++; if (p.relationIdx < _linkRelations.length - 1)
p.relationIdx++;
case ControlCharacter.enter: case ControlCharacter.enter:
try { try {
await store.linkTickets(p.ticketId!, p.linkTargetId!, _linkRelations[p.relationIdx]); await store.linkTickets(
p.ticketId!,
p.linkTargetId!,
_linkRelations[p.relationIdx],
);
tickets = await store.list(); tickets = await store.list();
byColumn = _groupByColumn(tickets, config); byColumn = _groupByColumn(tickets, config);
statusMsg = 'Linked ${p.ticketId}${p.linkTargetId} (${_linkRelations[p.relationIdx]}).'; statusMsg =
'Linked ${p.ticketId}${p.linkTargetId} (${_linkRelations[p.relationIdx]}).';
} on ArgumentError catch (e) { } on ArgumentError catch (e) {
statusMsg = 'Error: ${e.message ?? e}'; statusMsg = 'Error: ${e.message ?? e}';
} }
@ -415,9 +444,12 @@ class TuiCommand extends DewCommand {
p.input = p.input.substring(0, p.input.length - 1); p.input = p.input.substring(0, p.input.length - 1);
} }
case ControlCharacter.arrowLeft: case ControlCharacter.arrowLeft:
if (p.kind == _PromptKind.newTitle && p.typeIdx > 0) p.typeIdx--; if (p.kind == _PromptKind.newTitle && p.typeIdx > 0)
p.typeIdx--;
case ControlCharacter.arrowRight: case ControlCharacter.arrowRight:
if (p.kind == _PromptKind.newTitle && p.typeIdx < config.ticketTypes.length - 1) p.typeIdx++; if (p.kind == _PromptKind.newTitle &&
p.typeIdx < config.ticketTypes.length - 1)
p.typeIdx++;
case ControlCharacter.enter: case ControlCharacter.enter:
final trimmed = p.input.trim(); final trimmed = p.input.trim();
if (p.kind == _PromptKind.linkId) { if (p.kind == _PromptKind.linkId) {
@ -426,7 +458,11 @@ class TuiCommand extends DewCommand {
if (!exists || trimmed.isEmpty) { if (!exists || trimmed.isEmpty) {
statusMsg = 'Ticket "$trimmed" not found.'; statusMsg = 'Ticket "$trimmed" not found.';
} else { } else {
prompt = _Prompt(_PromptKind.linkType, ticketId: p.ticketId, linkTargetId: trimmed); prompt = _Prompt(
_PromptKind.linkType,
ticketId: p.ticketId,
linkTargetId: trimmed,
);
} }
} else if (trimmed.isEmpty) { } else if (trimmed.isEmpty) {
prompt = null; prompt = null;
@ -438,10 +474,17 @@ class TuiCommand extends DewCommand {
final type = config.ticketTypes.isNotEmpty final type = config.ticketTypes.isNotEmpty
? config.ticketTypes[p.typeIdx].id ? config.ticketTypes[p.typeIdx].id
: 'task'; : 'task';
await store.create(title: trimmed, type: type, column: col.id); await store.create(
title: trimmed,
type: type,
column: col.id,
);
tickets = await store.list(); tickets = await store.list();
byColumn = _groupByColumn(tickets, config); byColumn = _groupByColumn(tickets, config);
final created = _filtered(byColumn[col.id] ?? [], searchQuery); final created = _filtered(
byColumn[col.id] ?? [],
searchQuery,
);
ticketIdx = max(0, created.length - 1); ticketIdx = max(0, created.length - 1);
statusMsg = 'Created in ${col.name}.'; statusMsg = 'Created in ${col.name}.';
case _PromptKind.editTitle: case _PromptKind.editTitle:
@ -489,7 +532,10 @@ class TuiCommand extends DewCommand {
ticketIdx = 0; ticketIdx = 0;
case ControlCharacter.backspace: case ControlCharacter.backspace:
if (searchQuery.isNotEmpty) { if (searchQuery.isNotEmpty) {
searchQuery = searchQuery.substring(0, searchQuery.length - 1); searchQuery = searchQuery.substring(
0,
searchQuery.length - 1,
);
} }
default: default:
continue loop; // skip redraw continue loop; // skip redraw
@ -542,7 +588,10 @@ class TuiCommand extends DewCommand {
es.textInput = ''; es.textInput = '';
case ControlCharacter.backspace: case ControlCharacter.backspace:
if (es.textInput.isNotEmpty) { if (es.textInput.isNotEmpty) {
es.textInput = es.textInput.substring(0, es.textInput.length - 1); es.textInput = es.textInput.substring(
0,
es.textInput.length - 1,
);
} }
default: default:
continue loop; continue loop;
@ -564,13 +613,23 @@ class TuiCommand extends DewCommand {
switch (es.focus) { switch (es.focus) {
case _EditorField.labels: case _EditorField.labels:
if (es.labels.isNotEmpty) { if (es.labels.isNotEmpty) {
es.labels.removeAt(es.itemCursor.clamp(0, es.labels.length - 1)); es.labels.removeAt(
es.itemCursor = es.itemCursor.clamp(0, max(0, es.labels.length - 1)); es.itemCursor.clamp(0, es.labels.length - 1),
);
es.itemCursor = es.itemCursor.clamp(
0,
max(0, es.labels.length - 1),
);
} }
case _EditorField.milestones: case _EditorField.milestones:
if (es.milestones.isNotEmpty) { if (es.milestones.isNotEmpty) {
es.milestones.removeAt(es.itemCursor.clamp(0, es.milestones.length - 1)); es.milestones.removeAt(
es.itemCursor = es.itemCursor.clamp(0, max(0, es.milestones.length - 1)); es.itemCursor.clamp(0, es.milestones.length - 1),
);
es.itemCursor = es.itemCursor.clamp(
0,
max(0, es.milestones.length - 1),
);
} }
default: default:
break; break;
@ -593,7 +652,10 @@ class TuiCommand extends DewCommand {
colIdx = config.columns.indexWhere((c) => c.id == es.column); colIdx = config.columns.indexWhere((c) => c.id == es.column);
if (colIdx < 0) colIdx = 0; if (colIdx < 0) colIdx = 0;
final destTickets = byColumn[es.column] ?? []; final destTickets = byColumn[es.column] ?? [];
ticketIdx = max(0, destTickets.indexWhere((x) => x.id == es.ticket.id)); ticketIdx = max(
0,
destTickets.indexWhere((x) => x.id == es.ticket.id),
);
statusMsg = 'Ticket updated.'; statusMsg = 'Ticket updated.';
} on ArgumentError catch (e) { } on ArgumentError catch (e) {
statusMsg = 'Error: ${e.message ?? e}'; statusMsg = 'Error: ${e.message ?? e}';
@ -614,14 +676,15 @@ class TuiCommand extends DewCommand {
editorState = null; editorState = null;
mode = _Mode.board; mode = _Mode.board;
case ControlCharacter.arrowUp: case ControlCharacter.arrowUp:
es.focus = _EditorField.values[ es.focus =
(es.focus.index - 1 + _EditorField.values.length) % _EditorField.values.length _EditorField.values[(es.focus.index -
]; 1 +
_EditorField.values.length) %
_EditorField.values.length];
es.itemCursor = 0; es.itemCursor = 0;
case ControlCharacter.arrowDown: case ControlCharacter.arrowDown:
es.focus = _EditorField.values[ es.focus = _EditorField
(es.focus.index + 1) % _EditorField.values.length .values[(es.focus.index + 1) % _EditorField.values.length];
];
es.itemCursor = 0; es.itemCursor = 0;
case ControlCharacter.arrowLeft: case ControlCharacter.arrowLeft:
switch (es.focus) { switch (es.focus) {
@ -649,7 +712,8 @@ class TuiCommand extends DewCommand {
case _EditorField.labels: case _EditorField.labels:
if (es.itemCursor < es.labels.length - 1) es.itemCursor++; if (es.itemCursor < es.labels.length - 1) es.itemCursor++;
case _EditorField.milestones: case _EditorField.milestones:
if (es.itemCursor < es.milestones.length - 1) es.itemCursor++; if (es.itemCursor < es.milestones.length - 1)
es.itemCursor++;
default: default:
break; break;
} }
@ -664,7 +728,8 @@ class TuiCommand extends DewCommand {
es.textEditing = true; es.textEditing = true;
case _EditorField.body: case _EditorField.body:
// Launch external editor // Launch external editor
final editor = io.Platform.environment['VISUAL'] ?? final editor =
io.Platform.environment['VISUAL'] ??
io.Platform.environment['EDITOR'] ?? io.Platform.environment['EDITOR'] ??
'vi'; 'vi';
final tmpFile = io.File( final tmpFile = io.File(
@ -675,11 +740,9 @@ class TuiCommand extends DewCommand {
console.rawMode = false; console.rawMode = false;
console.showCursor(); console.showCursor();
console.clearScreen(); console.clearScreen();
final proc = await io.Process.start( final proc = await io.Process.start(editor, [
editor, tmpFile.path,
[tmpFile.path], ], mode: io.ProcessStartMode.inheritStdio);
mode: io.ProcessStartMode.inheritStdio,
);
await proc.exitCode; await proc.exitCode;
es.body = await tmpFile.readAsString(); es.body = await tmpFile.readAsString();
await tmpFile.delete(); await tmpFile.delete();
@ -726,13 +789,15 @@ class TuiCommand extends DewCommand {
colIdx--; colIdx--;
final nt = _filtered(byColumn[newColId] ?? [], searchQuery); final nt = _filtered(byColumn[newColId] ?? [], searchQuery);
ticketIdx = max(0, nt.indexWhere((x) => x.id == t.id)); ticketIdx = max(0, nt.indexWhere((x) => x.id == t.id));
statusMsg = 'Moved ${t.id}${config.columns[colIdx].name}'; statusMsg =
'Moved ${t.id}${config.columns[colIdx].name}';
} on ArgumentError catch (e) { } on ArgumentError catch (e) {
statusMsg = 'Error: ${e.message ?? e}'; statusMsg = 'Error: ${e.message ?? e}';
} }
} }
case '>': case '>':
if (colIdx < config.columns.length - 1 && colTickets.isNotEmpty) { if (colIdx < config.columns.length - 1 &&
colTickets.isNotEmpty) {
final t = colTickets[ticketIdx]; final t = colTickets[ticketIdx];
final newColId = config.columns[colIdx + 1].id; final newColId = config.columns[colIdx + 1].id;
try { try {
@ -742,7 +807,8 @@ class TuiCommand extends DewCommand {
colIdx++; colIdx++;
final nt = _filtered(byColumn[newColId] ?? [], searchQuery); final nt = _filtered(byColumn[newColId] ?? [], searchQuery);
ticketIdx = max(0, nt.indexWhere((x) => x.id == t.id)); ticketIdx = max(0, nt.indexWhere((x) => x.id == t.id));
statusMsg = 'Moved ${t.id}${config.columns[colIdx].name}'; statusMsg =
'Moved ${t.id}${config.columns[colIdx].name}';
} on ArgumentError catch (e) { } on ArgumentError catch (e) {
statusMsg = 'Error: ${e.message ?? e}'; statusMsg = 'Error: ${e.message ?? e}';
} }
@ -831,7 +897,10 @@ class TuiCommand extends DewCommand {
detailScroll = 0; detailScroll = 0;
case 'e': case 'e':
final col2 = config.columns[colIdx]; final col2 = config.columns[colIdx];
final colTickets2 = _filtered(byColumn[col2.id] ?? [], searchQuery); final colTickets2 = _filtered(
byColumn[col2.id] ?? [],
searchQuery,
);
if (colTickets2.isNotEmpty) { if (colTickets2.isNotEmpty) {
editorState = _EditorState.from( editorState = _EditorState.from(
colTickets2[ticketIdx.clamp(0, colTickets2.length - 1)], colTickets2[ticketIdx.clamp(0, colTickets2.length - 1)],
@ -898,7 +967,10 @@ class TuiCommand extends DewCommand {
final numCols = config.columns.length; final numCols = config.columns.length;
// How many columns fit side-by-side? // How many columns fit side-by-side?
final numVisible = max(1, min(numCols, (w + _colSep) ~/ (_minColW + _colSep))); final numVisible = max(
1,
min(numCols, (w + _colSep) ~/ (_minColW + _colSep)),
);
final colW = (w - (numVisible - 1) * _colSep) ~/ numVisible; final colW = (w - (numVisible - 1) * _colSep) ~/ numVisible;
// Keep selected column in viewport // Keep selected column in viewport
@ -908,7 +980,10 @@ class TuiCommand extends DewCommand {
if (colIdx >= viewStart + numVisible) viewStart = colIdx - numVisible + 1; if (colIdx >= viewStart + numVisible) viewStart = colIdx - numVisible + 1;
viewStart = viewStart.clamp(0, max(0, numCols - numVisible)); viewStart = viewStart.clamp(0, max(0, numCols - numVisible));
final viewCols = config.columns.sublist(viewStart, min(viewStart + numVisible, numCols)); final viewCols = config.columns.sublist(
viewStart,
min(viewStart + numVisible, numCols),
);
// h - 2 (header+blank) - 2 (footer separator+help) = content height // h - 2 (header+blank) - 2 (footer separator+help) = content height
final colAreaH = h - 4; final colAreaH = h - 4;
@ -978,17 +1053,18 @@ class TuiCommand extends DewCommand {
final nameRaw = isSelected final nameRaw = isSelected
? '${col.name.toUpperCase()} ($count) ▐' ? '${col.name.toUpperCase()} ($count) ▐'
: ' ${col.name} ($count) '; : ' ${col.name} ($count) ';
cells.add(_Cell( cells.add(
_trunc(nameRaw, colW).padRight(colW), _Cell(
fg: isSelected ? color : ConsoleColor.white, _trunc(nameRaw, colW).padRight(colW),
bold: isSelected, fg: isSelected ? color : ConsoleColor.white,
)); bold: isSelected,
),
);
// Top border of the ticket box proper corners so the box closes cleanly // Top border of the ticket box proper corners so the box closes cleanly
cells.add(_Cell( cells.add(
'${'' * innerW}', _Cell('${'' * innerW}', fg: isSelected ? color : ConsoleColor.white),
fg: isSelected ? color : ConsoleColor.white, );
));
// Ticket area // Ticket area
@ -998,25 +1074,38 @@ class TuiCommand extends DewCommand {
// Compute scroll to keep selectedIdx in view // Compute scroll to keep selectedIdx in view
var scroll = 0; var scroll = 0;
if (isSelected && selectedIdx >= 0 && tickets.isNotEmpty) { if (isSelected && selectedIdx >= 0 && tickets.isNotEmpty) {
scroll = (selectedIdx - maxVisible + 1).clamp(0, max(0, tickets.length - maxVisible)); scroll = (selectedIdx - maxVisible + 1).clamp(
0,
max(0, tickets.length - maxVisible),
);
if (selectedIdx < scroll) scroll = selectedIdx; if (selectedIdx < scroll) scroll = selectedIdx;
} }
final showAbove = scroll > 0; final showAbove = scroll > 0;
final showBelow = tickets.isNotEmpty && (scroll + maxVisible) < tickets.length; final showBelow =
tickets.isNotEmpty && (scroll + maxVisible) < tickets.length;
// "More above" indicator // "More above" indicator
if (showAbove) { if (showAbove) {
final msg = _trunc('$scroll above', innerW); final msg = _trunc('$scroll above', innerW);
cells.add(_Cell('${msg.padRight(innerW)}', fg: ConsoleColor.brightYellow)); cells.add(
_Cell('${msg.padRight(innerW)}', fg: ConsoleColor.brightYellow),
);
} else { } else {
cells.add(_Cell('${' ' * innerW}', fg: isSelected ? color : ConsoleColor.white)); cells.add(
_Cell('${' ' * innerW}', fg: isSelected ? color : ConsoleColor.white),
);
} }
// Visible tickets // Visible tickets
final visEnd = min(scroll + maxVisible, tickets.length); final visEnd = min(scroll + maxVisible, tickets.length);
for (int ti = scroll; ti < visEnd; ti++) { for (int ti = scroll; ti < visEnd; ti++) {
_addTicketCells(cells, tickets[ti], innerW, ti == selectedIdx && isSelected); _addTicketCells(
cells,
tickets[ti],
innerW,
ti == selectedIdx && isSelected,
);
} }
// Empty state // Empty state
@ -1024,11 +1113,13 @@ class TuiCommand extends DewCommand {
final borderFg = isSelected ? color : ConsoleColor.white; final borderFg = isSelected ? color : ConsoleColor.white;
cells.add(_Cell('${' ' * innerW}', fg: borderFg)); cells.add(_Cell('${' ' * innerW}', fg: borderFg));
final hint = _trunc(' ··· empty ···', innerW).padRight(innerW); final hint = _trunc(' ··· empty ···', innerW).padRight(innerW);
cells.add(_Cell( cells.add(
'$hint', _Cell(
fg: isSelected ? color : ConsoleColor.white, '$hint',
bold: isSelected, fg: isSelected ? color : ConsoleColor.white,
)); bold: isSelected,
),
);
cells.add(_Cell('${' ' * innerW}', fg: borderFg)); cells.add(_Cell('${' ' * innerW}', fg: borderFg));
} }
@ -1036,22 +1127,30 @@ class TuiCommand extends DewCommand {
if (showBelow) { if (showBelow) {
final remaining = tickets.length - scroll - maxVisible; final remaining = tickets.length - scroll - maxVisible;
final msg = _trunc('$remaining below', innerW); final msg = _trunc('$remaining below', innerW);
cells.add(_Cell('${msg.padRight(innerW)}', fg: ConsoleColor.brightYellow)); cells.add(
_Cell('${msg.padRight(innerW)}', fg: ConsoleColor.brightYellow),
);
} else { } else {
cells.add(_Cell('${' ' * innerW}', fg: isSelected ? color : ConsoleColor.white)); cells.add(
_Cell('${' ' * innerW}', fg: isSelected ? color : ConsoleColor.white),
);
} }
// Fill remaining space before bottom border // Fill remaining space before bottom border
while (cells.length < areaH - 1) { while (cells.length < areaH - 1) {
cells.add(_Cell('${' ' * innerW}', fg: isSelected ? color : ConsoleColor.white)); cells.add(
_Cell('${' ' * innerW}', fg: isSelected ? color : ConsoleColor.white),
);
} }
// Bottom border (always at areaH - 1) // Bottom border (always at areaH - 1)
if (cells.length > areaH - 1) cells.length = areaH - 1; if (cells.length > areaH - 1) cells.length = areaH - 1;
cells.add(_Cell( cells.add(
isSelected ? '${'' * innerW}' : '${'' * innerW}', _Cell(
fg: isSelected ? color : ConsoleColor.white, isSelected ? '${'' * innerW}' : '${'' * innerW}',
)); fg: isSelected ? color : ConsoleColor.white,
),
);
// Pad to exact height // Pad to exact height
while (cells.length < areaH) { while (cells.length < areaH) {
@ -1061,7 +1160,12 @@ class TuiCommand extends DewCommand {
return cells; return cells;
} }
static void _addTicketCells(List<_Cell> cells, Ticket ticket, int innerW, bool isSel) { static void _addTicketCells(
List<_Cell> cells,
Ticket ticket,
int innerW,
bool isSel,
) {
final bg = isSel ? ConsoleColor.blue : null; final bg = isSel ? ConsoleColor.blue : null;
final titleFg = isSel ? ConsoleColor.brightWhite : null; final titleFg = isSel ? ConsoleColor.brightWhite : null;
final bullet = isSel ? '' : ' '; final bullet = isSel ? '' : ' ';
@ -1070,20 +1174,18 @@ class TuiCommand extends DewCommand {
// Row 1: bullet + ID + type badge // Row 1: bullet + ID + type badge
final badge = ' [${_trunc(ticket.type, 7)}]'; final badge = ' [${_trunc(ticket.type, 7)}]';
final idLine = '$bullet ${ticket.id}$badge'; final idLine = '$bullet ${ticket.id}$badge';
cells.add(_Cell( cells.add(
'${_trunc(idLine, innerW).padRight(innerW)}', _Cell(
fg: isSel ? ConsoleColor.brightWhite : typeColor, '${_trunc(idLine, innerW).padRight(innerW)}',
bg: bg, fg: isSel ? ConsoleColor.brightWhite : typeColor,
bold: isSel, bg: bg,
)); bold: isSel,
),
);
// Row 2: title // Row 2: title
final titleLine = ' ${_trunc(ticket.title, innerW - 2)}'; final titleLine = ' ${_trunc(ticket.title, innerW - 2)}';
cells.add(_Cell( cells.add(_Cell('${titleLine.padRight(innerW)}', fg: titleFg, bg: bg));
'${titleLine.padRight(innerW)}',
fg: titleFg,
bg: bg,
));
// Row 3: labels / milestone / blank // Row 3: labels / milestone / blank
final String tagLine; final String tagLine;
@ -1094,11 +1196,13 @@ class TuiCommand extends DewCommand {
} else { } else {
tagLine = ''; tagLine = '';
} }
cells.add(_Cell( cells.add(
'${_trunc(tagLine, innerW).padRight(innerW)}', _Cell(
fg: isSel ? ConsoleColor.brightCyan : ConsoleColor.white, '${_trunc(tagLine, innerW).padRight(innerW)}',
bg: bg, fg: isSel ? ConsoleColor.brightCyan : ConsoleColor.white,
)); bg: bg,
),
);
} }
static void _writeHeaderBar( static void _writeHeaderBar(
@ -1169,7 +1273,8 @@ class TuiCommand extends DewCommand {
line = ' Link ${prompt.ticketId} to ticket ID: ${prompt.input}'; line = ' Link ${prompt.ticketId} to ticket ID: ${prompt.input}';
case _PromptKind.linkType: case _PromptKind.linkType:
final rel = _linkRelations[prompt.relationIdx]; final rel = _linkRelations[prompt.relationIdx];
line = ' ${prompt.ticketId} [◀ $rel ▶] ${prompt.linkTargetId} (Enter to confirm)'; line =
' ${prompt.ticketId} [◀ $rel ▶] ${prompt.linkTargetId} (Enter to confirm)';
} }
console.write(_trunc(line, w).padRight(w)); console.write(_trunc(line, w).padRight(w));
console.resetColorAttributes(); console.resetColorAttributes();
@ -1181,7 +1286,8 @@ class TuiCommand extends DewCommand {
// Column position indicator + help // Column position indicator + help
console.setForegroundColor(ConsoleColor.white); console.setForegroundColor(ConsoleColor.white);
final pos = numCols > numVisible ? ' [${colIdx + 1}/$numCols cols]' : ''; final pos = numCols > numVisible ? ' [${colIdx + 1}/$numCols cols]' : '';
const help = ' [↑↓] nav [←→] col [</>] move [↵] detail [n] new [e] edit [D] del [a] archive [c] comment [L] link [?] filter [F1] help [q] quit'; const help =
' [↑↓] nav [←→] col [</>] move [↵] detail [n] new [e] edit [D] del [a] archive [c] comment [L] link [?] filter [F1] help [q] quit';
console.write(_trunc('$pos$help', w).padRight(w)); console.write(_trunc('$pos$help', w).padRight(w));
console.resetColorAttributes(); console.resetColorAttributes();
} }
@ -1206,7 +1312,8 @@ class TuiCommand extends DewCommand {
console.setBackgroundColor(ConsoleColor.blue); console.setBackgroundColor(ConsoleColor.blue);
console.setForegroundColor(ConsoleColor.brightWhite); console.setForegroundColor(ConsoleColor.brightWhite);
console.setTextStyle(bold: true); console.setTextStyle(bold: true);
final titleBar = ' ${ticket.id} ${_trunc(ticket.title, w - ticket.id.length - 6)}'; final titleBar =
' ${ticket.id} ${_trunc(ticket.title, w - ticket.id.length - 6)}';
console.write(titleBar.padRight(w)); console.write(titleBar.padRight(w));
console.resetColorAttributes(); console.resetColorAttributes();
console.writeLine(); console.writeLine();
@ -1237,7 +1344,10 @@ class TuiCommand extends DewCommand {
final scrollInfo = lines.isNotEmpty final scrollInfo = lines.isNotEmpty
? ' [${s + 1}-${min(s + contentH, lines.length)}/${lines.length}]' ? ' [${s + 1}-${min(s + contentH, lines.length)}/${lines.length}]'
: ''; : '';
console.write(' [↑↓] scroll$scrollInfo [e] edit [b/Esc] back [F1] help [q] quit'.padRight(w)); console.write(
' [↑↓] scroll$scrollInfo [e] edit [b/Esc] back [F1] help [q] quit'
.padRight(w),
);
console.resetColorAttributes(); console.resetColorAttributes();
} }
@ -1248,7 +1358,13 @@ class TuiCommand extends DewCommand {
if (label != null) { if (label != null) {
final tag = ' $label '; final tag = ' $label ';
final dashes = max(0, w - tag.length + 2); final dashes = max(0, w - tag.length + 2);
lines.add(_Cell('$tag${'' * dashes}', fg: ConsoleColor.brightBlue, bold: true)); lines.add(
_Cell(
'$tag${'' * dashes}',
fg: ConsoleColor.brightBlue,
bold: true,
),
);
} else { } else {
lines.add(_Cell(' ${'' * w}', fg: ConsoleColor.white)); lines.add(_Cell(' ${'' * w}', fg: ConsoleColor.white));
} }
@ -1270,8 +1386,10 @@ class TuiCommand extends DewCommand {
kv('Type', ticket.type); kv('Type', ticket.type);
kv('Column', ticket.column); kv('Column', ticket.column);
kv('Created', _fmtDate(ticket.created)); kv('Created', _fmtDate(ticket.created));
if (ticket.milestones.isNotEmpty) kv('Milestones', ticket.milestones.join(', ')); if (ticket.milestones.isNotEmpty)
if (ticket.labels.isNotEmpty) kv('Labels', ticket.labels.map((l) => '#$l').join(' ')); kv('Milestones', ticket.milestones.join(', '));
if (ticket.labels.isNotEmpty)
kv('Labels', ticket.labels.map((l) => '#$l').join(' '));
if (ticket.links.isNotEmpty) { if (ticket.links.isNotEmpty) {
for (final link in ticket.links) { for (final link in ticket.links) {
kv(link.type, link.targetId); kv(link.type, link.targetId);
@ -1398,36 +1516,45 @@ class TuiCommand extends DewCommand {
} }
const sections = [ const sections = [
('Board', [ (
('↑ / ↓', 'Navigate tickets'), 'Board',
('← / →', 'Switch columns'), [
('< / >', 'Move ticket left / right'), ('↑ / ↓', 'Navigate tickets'),
('Enter', 'Open ticket detail'), ('← / →', 'Switch columns'),
('n', 'New ticket'), ('< / >', 'Move ticket left / right'),
('e', 'Edit ticket'), ('Enter', 'Open ticket detail'),
('a', 'Archive ticket'), ('n', 'New ticket'),
('D', 'Delete ticket'), ('e', 'Edit ticket'),
('c', 'Add comment'), ('a', 'Archive ticket'),
('L', 'Link ticket'), ('D', 'Delete ticket'),
('?', 'Filter / search'), ('c', 'Add comment'),
('q / Esc', 'Quit'), ('L', 'Link ticket'),
('F1', 'This help'), ('?', 'Filter / search'),
]), ('q / Esc', 'Quit'),
('Detail', [ ('F1', 'This help'),
('↑ / ↓', 'Scroll'), ],
('e', 'Edit ticket'), ),
('b / Esc', 'Back to board'), (
('q', 'Quit'), 'Detail',
('F1', 'This help'), [
]), ('↑ / ↓', 'Scroll'),
('Editor', [ ('e', 'Edit ticket'),
('↑ / ↓', 'Navigate fields'), ('b / Esc', 'Back to board'),
('← / →', 'Cycle selector values'), ('q', 'Quit'),
('Enter', 'Edit text / open body editor'), ('F1', 'This help'),
('d', 'Delete selected item'), ],
('s', 'Save changes'), ),
('Esc', 'Discard & close'), (
]), 'Editor',
[
('↑ / ↓', 'Navigate fields'),
('← / →', 'Cycle selector values'),
('Enter', 'Edit text / open body editor'),
('d', 'Delete selected item'),
('s', 'Save changes'),
('Esc', 'Discard & close'),
],
),
]; ];
// Compute modal size // Compute modal size
@ -1435,7 +1562,10 @@ class TuiCommand extends DewCommand {
const descW = 36; const descW = 36;
const innerW = labelW + 3 + descW; // "key desc" const innerW = labelW + 3 + descW; // "key desc"
final modalW = min(w - 4, innerW + 4); final modalW = min(w - 4, innerW + 4);
final totalRows = sections.fold(0, (s, sec) => s + sec.$2.length + 2); // +2 per section: header + blank final totalRows = sections.fold(
0,
(s, sec) => s + sec.$2.length + 2,
); // +2 per section: header + blank
final modalH = min(h - 4, totalRows + 4); final modalH = min(h - 4, totalRows + 4);
final modalLeft = max(0, (w - modalW) ~/ 2); final modalLeft = max(0, (w - modalW) ~/ 2);
final modalTop = max(0, (h - modalH) ~/ 2); final modalTop = max(0, (h - modalH) ~/ 2);
@ -1450,7 +1580,9 @@ class TuiCommand extends DewCommand {
console.cursorPosition = Coordinate(modalTop + 1, modalLeft); console.cursorPosition = Coordinate(modalTop + 1, modalLeft);
console.setBackgroundColor(ConsoleColor.cyan); console.setBackgroundColor(ConsoleColor.cyan);
console.setForegroundColor(ConsoleColor.black); console.setForegroundColor(ConsoleColor.black);
console.write('${title.padRight(innerWActual).substring(0, innerWActual)}'); console.write(
'${title.padRight(innerWActual).substring(0, innerWActual)}',
);
console.resetColorAttributes(); console.resetColorAttributes();
// Second header row // Second header row
console.setForegroundColor(ConsoleColor.brightCyan); console.setForegroundColor(ConsoleColor.brightCyan);
@ -1464,7 +1596,9 @@ class TuiCommand extends DewCommand {
console.cursorPosition = Coordinate(row, modalLeft); console.cursorPosition = Coordinate(row, modalLeft);
console.setForegroundColor(ConsoleColor.brightCyan); console.setForegroundColor(ConsoleColor.brightCyan);
final secLine = '$secName'; final secLine = '$secName';
console.write('${secLine.padRight(innerWActual).substring(0, innerWActual)}'); console.write(
'${secLine.padRight(innerWActual).substring(0, innerWActual)}',
);
row++; row++;
for (final (key, desc) in bindings) { for (final (key, desc) in bindings) {
@ -1473,7 +1607,9 @@ class TuiCommand extends DewCommand {
final keyPart = key.padLeft(labelW); final keyPart = key.padLeft(labelW);
final line = ' $keyPart $desc'; final line = ' $keyPart $desc';
console.setForegroundColor(ConsoleColor.white); console.setForegroundColor(ConsoleColor.white);
console.write('${line.padRight(innerWActual).substring(0, innerWActual)}'); console.write(
'${line.padRight(innerWActual).substring(0, innerWActual)}',
);
row++; row++;
} }
@ -1493,7 +1629,10 @@ class TuiCommand extends DewCommand {
// Footer hint // Footer hint
const hint = ' Press any key to close '; const hint = ' Press any key to close ';
console.cursorPosition = Coordinate(modalTop + modalH - 1, modalLeft + (modalW - hint.length) ~/ 2); console.cursorPosition = Coordinate(
modalTop + modalH - 1,
modalLeft + (modalW - hint.length) ~/ 2,
);
console.setForegroundColor(ConsoleColor.white); console.setForegroundColor(ConsoleColor.white);
console.write(hint); console.write(hint);
@ -1576,8 +1715,16 @@ class TuiCommand extends DewCommand {
final textColor = ConsoleColor.white; final textColor = ConsoleColor.white;
void fieldRow(int relRow, _EditorField field, String label, String value, void fieldRow(
{bool isSelector = false, bool isMulti = false, List<String> items = const [], int itemCursor = 0}) { int relRow,
_EditorField field,
String label,
String value, {
bool isSelector = false,
bool isMulti = false,
List<String> items = const [],
int itemCursor = 0,
}) {
final focused = es.focus == field; final focused = es.focus == field;
final prefix = focused ? ' ' : ' '; final prefix = focused ? ' ' : ' ';
at(modalTop + 3 + relRow, modalLeft + 1, () { at(modalTop + 3 + relRow, modalLeft + 1, () {
@ -1628,7 +1775,9 @@ class TuiCommand extends DewCommand {
console.write(' ${items[i]} '); console.write(' ${items[i]} ');
console.resetColorAttributes(); console.resetColorAttributes();
} else { } else {
console.setForegroundColor(focused ? textColor : ConsoleColor.white); console.setForegroundColor(
focused ? textColor : ConsoleColor.white,
);
console.write('${items[i]} '); console.write('${items[i]} ');
} }
} }
@ -1643,7 +1792,9 @@ class TuiCommand extends DewCommand {
console.setForegroundColor(focused ? accentColor : textColor); console.setForegroundColor(focused ? accentColor : textColor);
final disp = value.isNotEmpty ? value : '(empty)'; final disp = value.isNotEmpty ? value : '(empty)';
final hint = focused final hint = focused
? (field == _EditorField.body ? ' [Enter → \$EDITOR]' : ' [Enter to edit]') ? (field == _EditorField.body
? ' [Enter → \$EDITOR]'
: ' [Enter to edit]')
: ''; : '';
final maxLen = innerW - 15 - hint.length; final maxLen = innerW - 15 - hint.length;
console.write(_trunc(disp, maxLen)); console.write(_trunc(disp, maxLen));
@ -1659,18 +1810,33 @@ class TuiCommand extends DewCommand {
fieldRow(0, _EditorField.title, 'Title', es.title); fieldRow(0, _EditorField.title, 'Title', es.title);
fieldRow(1, _EditorField.type, 'Type', es.type, isSelector: true); fieldRow(1, _EditorField.type, 'Type', es.type, isSelector: true);
fieldRow(2, _EditorField.column, 'Column', es.column, isSelector: true); fieldRow(2, _EditorField.column, 'Column', es.column, isSelector: true);
fieldRow(3, _EditorField.labels, 'Labels', '', isMulti: true, items: es.labels, itemCursor: es.itemCursor); fieldRow(
fieldRow(4, _EditorField.milestones, 'Milestones', '', isMulti: true, items: es.milestones, itemCursor: es.itemCursor); 3,
_EditorField.labels,
'Labels',
'',
isMulti: true,
items: es.labels,
itemCursor: es.itemCursor,
);
fieldRow(
4,
_EditorField.milestones,
'Milestones',
'',
isMulti: true,
items: es.milestones,
itemCursor: es.itemCursor,
);
// Body row show first line preview // Body row show first line preview
final bodyPreview = es.body.isNotEmpty final bodyPreview = es.body.isNotEmpty ? es.body.split('\n').first : '';
? es.body.split('\n').first
: '';
fieldRow(5, _EditorField.body, 'Body', bodyPreview); fieldRow(5, _EditorField.body, 'Body', bodyPreview);
// Footer hints // Footer hints
final dirtyMarker = es.isDirty ? ' ● unsaved' : ''; final dirtyMarker = es.isDirty ? ' ● unsaved' : '';
final footerHints = '[↑↓] field [←→] value [Enter] edit [d] del [s] save [Esc] discard [F1] help$dirtyMarker'; final footerHints =
'[↑↓] field [←→] value [Enter] edit [d] del [s] save [Esc] discard [F1] help$dirtyMarker';
at(modalTop + modalH - 2, modalLeft + 1, () { at(modalTop + modalH - 2, modalLeft + 1, () {
console.setForegroundColor(ConsoleColor.white); console.setForegroundColor(ConsoleColor.white);
console.write(_trunc(footerHints, innerW)); console.write(_trunc(footerHints, innerW));
@ -1681,7 +1847,6 @@ class TuiCommand extends DewCommand {
static String _fmtDate(DateTime dt) => static String _fmtDate(DateTime dt) =>
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
static List<String> _wordWrap(String text, int width) { static List<String> _wordWrap(String text, int width) {
if (text.isEmpty) return ['']; if (text.isEmpty) return [''];
if (text.length <= width) return [text]; if (text.length <= width) return [text];

View file

@ -11,7 +11,12 @@ class UnarchiveCommand extends DewCommand with DewToolCommand {
UnarchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { UnarchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to unarchive.') ..addOption(
'id',
abbr: 'i',
mandatory: true,
help: 'Ticket ID to unarchive.',
)
..addOption( ..addOption(
'column', 'column',
abbr: 'c', abbr: 'c',
@ -36,7 +41,11 @@ class UnarchiveCommand extends DewCommand with DewToolCommand {
final config = context.config.kanban; final config = context.config.kanban;
final kanbanDir = context.dirs.kanban; final kanbanDir = context.dirs.kanban;
final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs); final store = TicketStore(
kanbanDir: kanbanDir,
prefix: config.prefix,
fs: context.fs,
);
final ticket = await store.findById(id); final ticket = await store.findById(id);
if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (ticket == null) throw ArgumentError('Ticket $id not found.');
if (ticket.column != 'archive') return '$id is not archived.'; if (ticket.column != 'archive') return '$id is not archived.';

View file

@ -11,7 +11,12 @@ class UnlinkCommand extends DewCommand with DewToolCommand {
UnlinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { UnlinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID to remove link to.'); ..addOption(
'target',
abbr: 't',
mandatory: true,
help: 'Target ticket ID to remove link to.',
);
} }
@override @override

View file

@ -29,7 +29,8 @@ class UpdateCommand extends DewCommand with DewToolCommand {
final String name = 'update'; final String name = 'update';
@override @override
final String description = 'Update one or more fields on an existing kanban ticket.'; final String description =
'Update one or more fields on an existing kanban ticket.';
@override @override
final String toolName = 'kanban_update_ticket'; final String toolName = 'kanban_update_ticket';

View file

@ -43,24 +43,28 @@ extension KanbanDewConfig on DewConfig {
final kanbanYaml = (raw['dew'] as YamlMap)['kanban'] as YamlMap; final kanbanYaml = (raw['dew'] as YamlMap)['kanban'] as YamlMap;
return KanbanConfig( return KanbanConfig(
prefix: kanbanYaml['prefix'] as String, prefix: kanbanYaml['prefix'] as String,
ticketTypes: ticketTypes: (kanbanYaml['ticket_types'] as YamlList)
(kanbanYaml['ticket_types'] as YamlList) .map(
.map((t) => TicketTypeConfig(id: t['id'] as String, name: t['name'] as String)) (t) => TicketTypeConfig(
.toList(), id: t['id'] as String,
columns: name: t['name'] as String,
(kanbanYaml['columns'] as YamlList) ),
.map( )
(c) => ColumnConfig( .toList(),
id: c['id'] as String, columns: (kanbanYaml['columns'] as YamlList)
name: c['name'] as String, .map(
color: c['color'] as String, (c) => ColumnConfig(
allowedTransitions: (c['allowed_transitions'] as YamlList?) id: c['id'] as String,
?.map((t) => t as String) name: c['name'] as String,
.toList() ?? color: c['color'] as String,
const [], allowedTransitions:
), (c['allowed_transitions'] as YamlList?)
) ?.map((t) => t as String)
.toList(), .toList() ??
const [],
),
)
.toList(),
); );
} }
} }

View file

@ -142,26 +142,30 @@ class Ticket {
} }
final fmEnd = content.indexOf('\n---\n', 4); final fmEnd = content.indexOf('\n---\n', 4);
if (fmEnd == -1) { if (fmEnd == -1) {
throw FormatException('Ticket file $id is missing closing frontmatter ---'); throw FormatException(
'Ticket file $id is missing closing frontmatter ---',
);
} }
final fm = loadYaml(content.substring(4, fmEnd)) as YamlMap; final fm = loadYaml(content.substring(4, fmEnd)) as YamlMap;
// Everything after the closing \n---\n, split into body + comments. // Everything after the closing \n---\n, split into body + comments.
final rest = content.substring(fmEnd + 5); final rest = content.substring(fmEnd + 5);
final sections = final sections = rest
rest.split('\n---\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); .split('\n---\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
final rawLinks = fm['links'] as YamlList?; final rawLinks = fm['links'] as YamlList?;
final links = rawLinks final links =
?.map((entry) { rawLinks?.map((entry) {
final map = entry as YamlMap; final map = entry as YamlMap;
return TicketLink( return TicketLink(
targetId: map['id'] as String, targetId: map['id'] as String,
type: map['type'] as String, type: map['type'] as String,
); );
}) }).toList() ??
.toList() ??
const []; const [];
List<String> parseStringList(String key) { List<String> parseStringList(String key) {
@ -186,7 +190,8 @@ class Ticket {
/// Wraps [value] in double quotes if it contains characters that would /// Wraps [value] in double quotes if it contains characters that would
/// confuse a YAML parser (colon-space, leading/trailing whitespace, etc.). /// confuse a YAML parser (colon-space, leading/trailing whitespace, etc.).
static String _yamlQuote(String value) { static String _yamlQuote(String value) {
final needsQuoting = value.contains(': ') || final needsQuoting =
value.contains(': ') ||
value.contains(' #') || value.contains(' #') ||
value.startsWith('"') || value.startsWith('"') ||
value.startsWith("'") || value.startsWith("'") ||
@ -196,4 +201,3 @@ class Ticket {
return '"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"'; return '"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"';
} }
} }

View file

@ -41,14 +41,20 @@ class TicketStore {
milestones: milestones, milestones: milestones,
labels: labels, labels: labels,
); );
await fs.file(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent()); await fs
.file(p.join(columnDir.path, '$id.md'))
.writeAsString(ticket.toFileContent());
return ticket; return ticket;
} }
Future<Ticket?> findById(String id) async { Future<Ticket?> findById(String id) async {
final found = await _findTicketFile(id); final found = await _findTicketFile(id);
if (found == null) return null; if (found == null) return null;
return Ticket.fromFileContent(id, await found.file.readAsString(), found.column); return Ticket.fromFileContent(
id,
await found.file.readAsString(),
found.column,
);
} }
Future<List<Ticket>> list({bool includeArchived = false}) async { Future<List<Ticket>> list({bool includeArchived = false}) async {
@ -76,7 +82,11 @@ class TicketStore {
Future<Ticket> addComment(String id, String comment) async { Future<Ticket> addComment(String id, String comment) async {
final found = await _findTicketFile(id); final found = await _findTicketFile(id);
if (found == null) throw ArgumentError('Ticket $id not found.'); if (found == null) throw ArgumentError('Ticket $id not found.');
final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); final ticket = Ticket.fromFileContent(
id,
await found.file.readAsString(),
found.column,
);
final updated = ticket.copyWith(comments: [...ticket.comments, comment]); final updated = ticket.copyWith(comments: [...ticket.comments, comment]);
await found.file.writeAsString(updated.toFileContent()); await found.file.writeAsString(updated.toFileContent());
return updated; return updated;
@ -97,7 +107,10 @@ class TicketStore {
// Forward link (idempotent skip if already linked to same target). // Forward link (idempotent skip if already linked to same target).
if (!ticket.links.any((l) => l.targetId == targetId)) { if (!ticket.links.any((l) => l.targetId == targetId)) {
final updated = ticket.copyWith( final updated = ticket.copyWith(
links: [...ticket.links, TicketLink(targetId: targetId, type: type)], links: [
...ticket.links,
TicketLink(targetId: targetId, type: type),
],
); );
final found = (await _findTicketFile(id))!; final found = (await _findTicketFile(id))!;
await found.file.writeAsString(updated.toFileContent()); await found.file.writeAsString(updated.toFileContent());
@ -107,7 +120,10 @@ class TicketStore {
final inverseType = linkTypeInverses[type]!; final inverseType = linkTypeInverses[type]!;
if (!target.links.any((l) => l.targetId == id)) { if (!target.links.any((l) => l.targetId == id)) {
final updatedTarget = target.copyWith( final updatedTarget = target.copyWith(
links: [...target.links, TicketLink(targetId: id, type: inverseType)], links: [
...target.links,
TicketLink(targetId: id, type: inverseType),
],
); );
final foundTarget = (await _findTicketFile(targetId))!; final foundTarget = (await _findTicketFile(targetId))!;
await foundTarget.file.writeAsString(updatedTarget.toFileContent()); await foundTarget.file.writeAsString(updatedTarget.toFileContent());
@ -119,7 +135,11 @@ class TicketStore {
Future<Ticket> unlinkTickets(String id, String targetId) async { Future<Ticket> unlinkTickets(String id, String targetId) async {
final found = await _findTicketFile(id); final found = await _findTicketFile(id);
if (found == null) throw ArgumentError('Ticket $id not found.'); if (found == null) throw ArgumentError('Ticket $id not found.');
final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); final ticket = Ticket.fromFileContent(
id,
await found.file.readAsString(),
found.column,
);
final updated = ticket.copyWith( final updated = ticket.copyWith(
links: ticket.links.where((l) => l.targetId != targetId).toList(), links: ticket.links.where((l) => l.targetId != targetId).toList(),
); );
@ -165,7 +185,11 @@ class TicketStore {
}) async { }) async {
final found = await _findTicketFile(id); final found = await _findTicketFile(id);
if (found == null) throw ArgumentError('Ticket $id not found.'); if (found == null) throw ArgumentError('Ticket $id not found.');
final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); final ticket = Ticket.fromFileContent(
id,
await found.file.readAsString(),
found.column,
);
final updated = ticket.copyWith( final updated = ticket.copyWith(
title: title, title: title,
type: type, type: type,
@ -179,7 +203,9 @@ class TicketStore {
await found.file.delete(); await found.file.delete();
final newColDir = fs.directory(p.join(kanbanDir, column)); final newColDir = fs.directory(p.join(kanbanDir, column));
await newColDir.create(recursive: true); await newColDir.create(recursive: true);
await fs.file(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent()); await fs
.file(p.join(newColDir.path, '$id.md'))
.writeAsString(updated.toFileContent());
} else { } else {
await found.file.writeAsString(updated.toFileContent()); await found.file.writeAsString(updated.toFileContent());
} }
@ -192,7 +218,8 @@ class TicketStore {
await found.file.delete(); await found.file.delete();
// Clean up per-ticket attachment directory if present. // Clean up per-ticket attachment directory if present.
final attachmentsDir = fs.directory(p.join(kanbanDir, 'attachments', id)); final attachmentsDir = fs.directory(p.join(kanbanDir, 'attachments', id));
if (await attachmentsDir.exists()) await attachmentsDir.delete(recursive: true); if (await attachmentsDir.exists())
await attachmentsDir.delete(recursive: true);
} }
/// Searches all column subdirectories (one level deep) for a ticket file. /// Searches all column subdirectories (one level deep) for a ticket file.
@ -204,7 +231,8 @@ class TicketStore {
if (entity is! Directory) continue; if (entity is! Directory) continue;
if (p.basename(entity.path) == 'attachments') continue; if (p.basename(entity.path) == 'attachments') continue;
final file = fs.file(p.join(entity.path, '$id.md')); final file = fs.file(p.join(entity.path, '$id.md'));
if (await file.exists()) return (file: file, column: p.basename(entity.path)); if (await file.exists())
return (file: file, column: p.basename(entity.path));
} }
return null; return null;
} }
@ -215,7 +243,8 @@ class TicketStore {
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$'); final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$');
var max = 0; var max = 0;
await for (final entity in dir.list()) { await for (final entity in dir.list()) {
if (entity is! Directory || p.basename(entity.path) == 'attachments') continue; if (entity is! Directory || p.basename(entity.path) == 'attachments')
continue;
await for (final file in entity.list()) { await for (final file in entity.list()) {
final match = pattern.firstMatch(p.basename(file.path)); final match = pattern.firstMatch(p.basename(file.path));
if (match != null) { if (match != null) {

View file

@ -1,8 +1,8 @@
name: dew_kanban name: dew_kanban
description: Kanban board feature for the Dew project management tool. Implements McpToolProvider to expose board tools to the MCP server. description: Kanban board feature for the Dew project management tool. Implements McpToolProvider to expose board tools to the MCP server.
version: 1.0.0 version: 1.0.0
# repository: https://github.com/my_org/my_repo repository: https://github.com/artificery-dev/dew
publish_to: none issue_tracker: https://github.com/artificery-dev/dew/issues
resolution: workspace resolution: workspace
environment: environment:
@ -10,8 +10,7 @@ environment:
# Add regular dependencies here. # Add regular dependencies here.
dependencies: dependencies:
dew_core: dew_core: ^1.0.0
path: ../core
dart_console: ^4.1.2 dart_console: ^4.1.2
file: ^7.0.1 file: ^7.0.1
path: ^1.9.0 path: ^1.9.0

View file

@ -39,8 +39,21 @@ void main() {
expect( expect(
cmd.subcommands.keys, cmd.subcommands.keys,
containsAll([ containsAll([
'create', 'list', 'board', 'get', 'update', 'delete', 'archive', 'unarchive', 'create',
'move', 'search', 'comment', 'config', 'stats', 'link', 'unlink', 'list',
'board',
'get',
'update',
'delete',
'archive',
'unarchive',
'move',
'search',
'comment',
'config',
'stats',
'link',
'unlink',
]), ]),
); );
}); });
@ -82,15 +95,25 @@ void main() {
final registry = CommandRegistry(); final registry = CommandRegistry();
registerCommands(registry); registerCommands(registry);
for (final tool in registry.mcpTools) { for (final tool in registry.mcpTools) {
expect(tool.description, isNotEmpty, reason: '${tool.name} description'); expect(
expect(tool.inputSchema['type'], 'object', reason: '${tool.name} schema type'); tool.description,
isNotEmpty,
reason: '${tool.name} description',
);
expect(
tool.inputSchema['type'],
'object',
reason: '${tool.name} schema type',
);
} }
}); });
test('schema derived from argParser — create tool has required fields', () { test('schema derived from argParser — create tool has required fields', () {
final registry = CommandRegistry(); final registry = CommandRegistry();
registerCommands(registry); registerCommands(registry);
final create = registry.mcpTools.firstWhere((t) => t.name == 'kanban_create_ticket'); final create = registry.mcpTools.firstWhere(
(t) => t.name == 'kanban_create_ticket',
);
final required = create.inputSchema['required'] as List; final required = create.inputSchema['required'] as List;
expect(required, containsAll(['title', 'type'])); expect(required, containsAll(['title', 'type']));
}); });
@ -110,12 +133,19 @@ void main() {
final listResult = await tools['kanban_list_tickets']!.handler({}); final listResult = await tools['kanban_list_tickets']!.handler({});
expect(listResult, contains('T-0001')); expect(listResult, contains('T-0001'));
final searchResult = await tools['kanban_search_tickets']!.handler({'query': 'Hello'}); final searchResult = await tools['kanban_search_tickets']!.handler({
'query': 'Hello',
});
expect(searchResult, contains('T-0001')); expect(searchResult, contains('T-0001'));
await tools['kanban_add_comment']!.handler({'id': 'T-0001', 'comment': 'Nice ticket.'}); await tools['kanban_add_comment']!.handler({
'id': 'T-0001',
'comment': 'Nice ticket.',
});
final getResult = await tools['kanban_get_ticket']!.handler({'id': 'T-0001'}); final getResult = await tools['kanban_get_ticket']!.handler({
'id': 'T-0001',
});
expect(getResult, contains('Nice ticket.')); expect(getResult, contains('Nice ticket.'));
final configResult = await tools['kanban_get_config']!.handler({}); final configResult = await tools['kanban_get_config']!.handler({});
@ -170,22 +200,37 @@ dew:
// Allowed: backlog doing // Allowed: backlog doing
await expectLater( await expectLater(
tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}), tools['kanban_move_ticket']!.handler({
'id': 'T-0001',
'column': 'doing',
}),
completes, completes,
); );
// Reset to backlog first. // Reset to backlog first.
await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'backlog'}); await tools['kanban_move_ticket']!.handler({
'id': 'T-0001',
'column': 'backlog',
});
// backlog done should throw (not in allowed_transitions). // backlog done should throw (not in allowed_transitions).
await expectLater( await expectLater(
tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}), tools['kanban_move_ticket']!.handler({
'id': 'T-0001',
'column': 'done',
}),
throwsA(isA<ArgumentError>()), throwsA(isA<ArgumentError>()),
); );
// Unconstrained column (done) any target is valid. // Unconstrained column (done) any target is valid.
await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}); await tools['kanban_move_ticket']!.handler({
await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}); 'id': 'T-0001',
'column': 'doing',
});
await tools['kanban_move_ticket']!.handler({
'id': 'T-0001',
'column': 'done',
});
// done backlog: done has no constraints, so it's allowed. // done backlog: done has no constraints, so it's allowed.
final result = await tools['kanban_move_ticket']!.handler({ final result = await tools['kanban_move_ticket']!.handler({
'id': 'T-0001', 'id': 'T-0001',
@ -300,17 +345,22 @@ dew:
}); });
group('TicketStore', () { group('TicketStore', () {
TicketStore makeStore(MemoryFileSystem fs) => TicketStore( TicketStore makeStore(MemoryFileSystem fs) =>
kanbanDir: '/kanban', TicketStore(kanbanDir: '/kanban', prefix: 'TEST', fs: fs);
prefix: 'TEST',
fs: fs,
);
test('create assigns incrementing IDs', () async { test('create assigns incrementing IDs', () async {
final fs = MemoryFileSystem(); final fs = MemoryFileSystem();
final store = makeStore(fs); final store = makeStore(fs);
final t1 = await store.create(title: 'First', type: 'task', column: 'todo'); final t1 = await store.create(
final t2 = await store.create(title: 'Second', type: 'bug', column: 'todo'); title: 'First',
type: 'task',
column: 'todo',
);
final t2 = await store.create(
title: 'Second',
type: 'bug',
column: 'todo',
);
expect(t1.id, 'TEST-0001'); expect(t1.id, 'TEST-0001');
expect(t2.id, 'TEST-0002'); expect(t2.id, 'TEST-0002');
}); });
@ -407,35 +457,35 @@ dew:
test('delete throws for missing ticket', () async { test('delete throws for missing ticket', () async {
final fs = MemoryFileSystem(); final fs = MemoryFileSystem();
final store = makeStore(fs); final store = makeStore(fs);
expect( expect(() => store.delete('TEST-0099'), throwsA(isA<ArgumentError>()));
() => store.delete('TEST-0099'),
throwsA(isA<ArgumentError>()),
);
}); });
test('linkTickets adds typed link bidirectionally and is idempotent', () async { test(
final fs = MemoryFileSystem(); 'linkTickets adds typed link bidirectionally and is idempotent',
final store = makeStore(fs); () async {
await store.create(title: 'A', type: 'task', column: 'todo'); final fs = MemoryFileSystem();
await store.create(title: 'B', type: 'task', column: 'todo'); final store = makeStore(fs);
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); await store.create(title: 'A', type: 'task', column: 'todo');
await store.create(title: 'B', type: 'task', column: 'todo');
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
final a = await store.findById('TEST-0001'); final a = await store.findById('TEST-0001');
expect(a!.links, hasLength(1)); expect(a!.links, hasLength(1));
expect(a.links.first.targetId, 'TEST-0002'); expect(a.links.first.targetId, 'TEST-0002');
expect(a.links.first.type, 'blocks'); expect(a.links.first.type, 'blocks');
// Inverse written on target. // Inverse written on target.
final b = await store.findById('TEST-0002'); final b = await store.findById('TEST-0002');
expect(b!.links, hasLength(1)); expect(b!.links, hasLength(1));
expect(b.links.first.targetId, 'TEST-0001'); expect(b.links.first.targetId, 'TEST-0001');
expect(b.links.first.type, 'is_blocked_by'); expect(b.links.first.type, 'is_blocked_by');
// Idempotent calling again doesn't add duplicates. // Idempotent calling again doesn't add duplicates.
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
final a2 = await store.findById('TEST-0001'); final a2 = await store.findById('TEST-0001');
expect(a2!.links, hasLength(1)); expect(a2!.links, hasLength(1));
}); },
);
test('linkTickets relates_to is symmetric', () async { test('linkTickets relates_to is symmetric', () async {
final fs = MemoryFileSystem(); final fs = MemoryFileSystem();

View file

@ -0,0 +1,146 @@
import 'package:dew_kanban/dew_kanban.dart';
import 'package:file/memory.dart';
import 'package:test/test.dart';
const _testConfig = '''
dew:
mcp:
host: localhost
port: 9090
kanban:
prefix: T
ticket_types:
- id: task
name: Task
columns:
- id: todo
name: To Do
color: blue
- id: doing
name: Doing
color: yellow
- id: done
name: Done
color: green
''';
TicketStore _makeStore(MemoryFileSystem fs) {
fs.directory('/.project/kanban').createSync(recursive: true);
fs.file('/.project/dew.yaml').writeAsStringSync(_testConfig);
return TicketStore(kanbanDir: '/.project/kanban', prefix: 'T', fs: fs);
}
void main() {
group('TicketStore integration', () {
late MemoryFileSystem fs;
late TicketStore store;
setUp(() {
fs = MemoryFileSystem();
store = _makeStore(fs);
});
test(
'create() writes file at correct path with YAML frontmatter',
() async {
final ticket = await store.create(
title: 'My ticket',
type: 'task',
column: 'todo',
);
expect(ticket.id, 'T-0001');
final file = fs.file('/.project/kanban/todo/T-0001.md');
expect(await file.exists(), isTrue);
final content = await file.readAsString();
expect(content, contains('title: My ticket'));
expect(content, contains('type: task'));
},
);
test('list() returns ticket created via create()', () async {
await store.create(title: 'Alpha', type: 'task', column: 'todo');
final tickets = await store.list();
expect(tickets, hasLength(1));
expect(tickets.first.id, 'T-0001');
expect(tickets.first.title, 'Alpha');
});
test('update(column:) moves ticket to correct directory', () async {
await store.create(title: 'Move me', type: 'task', column: 'todo');
await store.update('T-0001', column: 'doing');
// File removed from old location.
expect(
await fs.file('/.project/kanban/todo/T-0001.md').exists(),
isFalse,
);
// File present in new location.
expect(
await fs.file('/.project/kanban/doing/T-0001.md').exists(),
isTrue,
);
final ticket = await store.findById('T-0001');
expect(ticket!.column, 'doing');
});
test('linkTickets() adds link to both ticket files', () async {
await store.create(title: 'Source', type: 'task', column: 'todo');
await store.create(title: 'Target', type: 'task', column: 'todo');
await store.linkTickets('T-0001', 'T-0002', 'relates_to');
final sourceContent = await fs
.file('/.project/kanban/todo/T-0001.md')
.readAsString();
final targetContent = await fs
.file('/.project/kanban/todo/T-0002.md')
.readAsString();
expect(sourceContent, contains('T-0002'));
expect(targetContent, contains('T-0001'));
final source = await store.findById('T-0001');
final target = await store.findById('T-0002');
expect(source!.links.any((l) => l.targetId == 'T-0002'), isTrue);
expect(target!.links.any((l) => l.targetId == 'T-0001'), isTrue);
});
test('unlinkTickets() removes link from both ticket files', () async {
await store.create(title: 'Source', type: 'task', column: 'todo');
await store.create(title: 'Target', type: 'task', column: 'todo');
await store.linkTickets('T-0001', 'T-0002', 'relates_to');
await store.unlinkTickets('T-0001', 'T-0002');
final source = await store.findById('T-0001');
final target = await store.findById('T-0002');
expect(source!.links, isEmpty);
expect(target!.links, isEmpty);
final sourceContent = await fs
.file('/.project/kanban/todo/T-0001.md')
.readAsString();
final targetContent = await fs
.file('/.project/kanban/todo/T-0002.md')
.readAsString();
expect(sourceContent, isNot(contains('T-0002')));
expect(targetContent, isNot(contains('T-0001')));
});
test('delete() removes ticket file from disk', () async {
await store.create(title: 'Doomed', type: 'task', column: 'todo');
expect(await fs.file('/.project/kanban/todo/T-0001.md').exists(), isTrue);
await store.delete('T-0001');
expect(
await fs.file('/.project/kanban/todo/T-0001.md').exists(),
isFalse,
);
expect(await store.findById('T-0001'), isNull);
});
});
}

21
packages/mcp/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 artificery-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -29,9 +29,6 @@ class ServeCommand extends DewCommand {
// stdioChannel subscribes to stdin; do not touch stdin after this point. // stdioChannel subscribes to stdin; do not touch stdin after this point.
// The Dart event loop keeps the process alive until the client disconnects. // The Dart event loop keeps the process alive until the client disconnects.
DewMcpServer( DewMcpServer(stdioChannel(input: io.stdin, output: io.stdout), tools);
stdioChannel(input: io.stdin, output: io.stdout),
tools,
);
} }
} }

View file

@ -1,8 +1,8 @@
name: dew_mcp name: dew_mcp
description: MCP server for the Dew project management tool. Collects and serves tools registered by feature packages via the McpToolProvider interface. description: MCP server for the Dew project management tool. Collects and serves tools registered by feature packages via the McpToolProvider interface.
version: 1.0.0 version: 1.0.0
# repository: https://github.com/my_org/my_repo repository: https://github.com/artificery-dev/dew
publish_to: none issue_tracker: https://github.com/artificery-dev/dew/issues
resolution: workspace resolution: workspace
environment: environment:
@ -10,11 +10,11 @@ environment:
# Add regular dependencies here. # Add regular dependencies here.
dependencies: dependencies:
dew_core: dew_core: ^1.0.0
path: ../core
dart_mcp: ^0.5.0 dart_mcp: ^0.5.0
yaml: ^3.1.0 yaml: ^3.1.0
dev_dependencies: dev_dependencies:
lints: ^6.0.0 lints: ^6.0.0
test: ^1.25.6 test: ^1.25.6
dew_kanban: ^1.0.0

View file

@ -1,4 +1,5 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:dew_kanban/dew_kanban.dart' as kanban;
import 'package:dew_mcp/dew_mcp.dart'; import 'package:dew_mcp/dew_mcp.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -34,6 +35,66 @@ void main() {
expect(registry.mcpTools.first.name, 'stub_tool'); expect(registry.mcpTools.first.name, 'stub_tool');
}); });
}); });
group('Kanban MCP tools via CommandRegistry', () {
late List<McpTool> tools;
setUp(() {
final registry = CommandRegistry();
kanban.registerCommands(registry);
tools = registry.mcpTools;
});
test('all 15 expected tool names are registered', () {
expect(tools, hasLength(15));
expect(tools.map((t) => t.name).toSet(), {
'kanban_create_ticket',
'kanban_list_tickets',
'kanban_board',
'kanban_get_ticket',
'kanban_update_ticket',
'kanban_delete_ticket',
'kanban_archive_ticket',
'kanban_unarchive_ticket',
'kanban_move_ticket',
'kanban_search_tickets',
'kanban_add_comment',
'kanban_get_config',
'kanban_stats',
'kanban_link_tickets',
'kanban_unlink_tickets',
});
});
test('kanban_create_ticket schema requires title and type', () {
final create = tools.firstWhere((t) => t.name == 'kanban_create_ticket');
final required = create.inputSchema['required'] as List;
expect(required, containsAll(['title', 'type']));
});
test('kanban_add_comment schema requires id and comment', () {
final comment = tools.firstWhere((t) => t.name == 'kanban_add_comment');
final required = comment.inputSchema['required'] as List;
expect(required, containsAll(['id', 'comment']));
});
test('kanban_search_tickets schema requires query', () {
final search = tools.firstWhere((t) => t.name == 'kanban_search_tickets');
final required = search.inputSchema['required'] as List;
expect(required, contains('query'));
});
test('all tool names follow the snake_case kanban_ prefix pattern', () {
final pattern = RegExp(r'^kanban_[a-z]+(_[a-z]+)*$');
for (final tool in tools) {
expect(
pattern.hasMatch(tool.name),
isTrue,
reason: '${tool.name} does not match snake_case kanban_ pattern',
);
}
});
});
} }
class _StubToolCommand extends DewCommand with DewToolCommand { class _StubToolCommand extends DewCommand with DewToolCommand {
@ -64,4 +125,3 @@ class _StubParentCommand extends DewCommand {
@override @override
Future<void> run() async => printUsage(); Future<void> run() async => printUsage();
} }

View file

@ -18,6 +18,7 @@ dev_dependencies:
lints: ^6.0.0 lints: ^6.0.0
melos: ^7.0.0 melos: ^7.0.0
melos: melos:
scripts: scripts:
analyze: analyze:

View file

@ -55,22 +55,26 @@ void main(List<String> args) async {
protocolLogSink: protocolLog.sink, protocolLogSink: protocolLog.sink,
); );
unawaited(connection.done.then((_) { unawaited(
stderr.writeln('[client] Connection closed.'); connection.done.then((_) {
process.kill(); stderr.writeln('[client] Connection closed.');
})); process.kill();
}),
);
// --- Initialise --- // --- Initialise ---
print('Sending initialize...'); print('Sending initialize...');
late InitializeResult initResult; late InitializeResult initResult;
try { try {
initResult = await connection.initialize( initResult = await connection
InitializeRequest( .initialize(
protocolVersion: ProtocolVersion.latestSupported, InitializeRequest(
capabilities: client.capabilities, protocolVersion: ProtocolVersion.latestSupported,
clientInfo: client.implementation, capabilities: client.capabilities,
), clientInfo: client.implementation,
).timeout(const Duration(seconds: 5)); ),
)
.timeout(const Duration(seconds: 5));
} on TimeoutException { } on TimeoutException {
stderr.writeln('[client] Timed out waiting for initialize response.'); stderr.writeln('[client] Timed out waiting for initialize response.');
process.kill(); process.kill();
@ -81,7 +85,9 @@ void main(List<String> args) async {
exit(1); exit(1);
} }
print('Server: ${initResult.serverInfo.name} ${initResult.serverInfo.version}'); print(
'Server: ${initResult.serverInfo.name} ${initResult.serverInfo.version}',
);
print('Protocol: ${initResult.protocolVersion}'); print('Protocol: ${initResult.protocolVersion}');
print(''); print('');
@ -114,7 +120,9 @@ void main(List<String> args) async {
CallToolRequest(name: 'kanban_list_tickets', arguments: {}), CallToolRequest(name: 'kanban_list_tickets', arguments: {}),
); );
if (result.isError == true) { if (result.isError == true) {
print(' Error: ${result.content.map((c) => (c as TextContent).text).join()}'); print(
' Error: ${result.content.map((c) => (c as TextContent).text).join()}',
);
} else { } else {
print(' Result:'); print(' Result:');
for (final c in result.content) { for (final c in result.content) {