4 Commits

Author SHA1 Message Date
24fd49c01c chore: release v0.3.0
Some checks failed
container-images / build-container-image (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-01 23:08:36 +01:00
5707441e5c feat: add github workflow for build and release
Some checks failed
container-images / build-container-image (push) Has been cancelled
2026-03-01 23:07:46 +01:00
e9a087fc69 feat: add docker setup 2026-03-01 23:03:54 +01:00
3314443fb7 chore: update README.md with new cli name 2026-03-01 11:33:43 +01:00
8 changed files with 201 additions and 26 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
*
!src/
!migrations/
!build.rs
!Cargo.toml
!Cargo.lock

61
.github/workflows/build-docker.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: container-images
on:
push:
branches: [ "main" ]
tags: [ "v*" ]
jobs:
build-container-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Set ALL_TAGS
env:
REPOSITORY: '${{ github.repository }}'
run: |
# tag main if main branch
if [[ "${{ github.ref_name }}" == "main" ]]; then
image_tags=("main")
# tag with tag name if tag
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
image_tags=("${{ github.ref_name }}")
# tag with latest if tag is a new major, minor or patch version
if [[ "${{ github.ref_name }}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
image_tags+=("latest")
fi
fi
lc_repo=${REPOSITORY,,}
image_paths=()
for tag in ${image_tags[@]}; do
image_paths+=("ghcr.io/$lc_repo:$tag")
done
# join with ',' and then skip first character
ALL_TAGS=$(printf ',%s' "${image_paths[@]}")
echo "ALL_TAGS=${ALL_TAGS:1}" >>$GITHUB_ENV
- name: Login to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push default image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.ALL_TAGS }}

35
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check if stable release
id: check
run: |
# align with build-docker.yml: stable only for strict semver tags
if [[ "${{ github.ref_name }}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "stable=true" >> "$GITHUB_OUTPUT"
else
echo "stable=false" >> "$GITHUB_OUTPUT"
fi
- name: Create release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
draft: false
prerelease: ${{ steps.check.outputs.stable != 'true' }}
make_latest: ${{ steps.check.outputs.stable == 'true' }}

23
Cargo.lock generated
View File

@@ -376,6 +376,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "etcetera"
version = "0.8.0"
@@ -1193,6 +1203,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
@@ -1537,7 +1557,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "todo-mcp"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"anyhow",
"chrono",
@@ -1561,6 +1581,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",

View File

@@ -1,6 +1,6 @@
[package]
name = "todo-mcp"
version = "0.2.1"
version = "0.3.0"
edition = "2024"
rust-version = "1.85"
description = "simple todo cli with mcp server for ai integration"
@@ -19,7 +19,7 @@ dirs = "6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] }
tokio = { version = "1", features = ["rt", "macros"] }
tokio = { version = "1", features = ["rt", "macros", "io-std", "io-util", "signal"] }
[build-dependencies]
anyhow = "1"

9
Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM rust:1.85 AS build
WORKDIR /src
COPY . .
ENV SQLX_OFFLINE=true
RUN cargo build --release --bin todo-mcp
FROM gcr.io/distroless/cc-debian12
COPY --from=build /src/target/release/todo-mcp /
ENTRYPOINT ["/todo-mcp"]

View File

@@ -1,4 +1,4 @@
# Todo
# todo-mcp
A todo CLI with a built-in [MCP](https://modelcontextprotocol.io/) server.
The main idea is that you can tell an MCP-compatible AI assistant "add this to
@@ -23,60 +23,94 @@ Set `TODO_DB` to override the default database path (`~/.local/share/todo/todos.
```sh
# add a todo
todo add "fix the login bug"
todo-mcp add "fix the login bug"
# add with priority and tags
todo add "refactor auth module" -p high -t backend -t tech-debt
todo-mcp add "refactor auth module" -p high -t backend -t tech-debt
# add without associating to the current git repo
todo add "something general" --no-project
todo-mcp add "something general" --no-project
# list open todos
todo ls
todo-mcp ls
# list todos for the current repo only
todo ls --here
todo-mcp ls --here
# filter by tag or priority
todo ls -t backend
todo ls -p critical
todo-mcp ls -t backend
todo-mcp ls -p critical
# search
todo ls -s "auth"
todo-mcp ls -s "auth"
# mark as done
todo done 3
todo-mcp done 3
# edit text, priority, or tags
todo edit 3 "updated description"
todo edit 3 -p low
todo edit 3 -t new-tag --untag old-tag
todo-mcp edit 3 "updated description"
todo-mcp edit 3 -p low
todo-mcp edit 3 -t new-tag --untag old-tag
# remove permanently
todo rm 3
todo-mcp rm 3
# show all tags in use
todo tags
todo-mcp tags
# clean up completed todos
todo purge
todo-mcp purge
```
## Docker
A pre-built image is available from GitHub Container Registry:
```sh
docker run --rm -v todo-mcp-data:/data -e TODO_DB=/data/todos.db ghcr.io/janezicmatej/todo-mcp --help
```
Or build locally:
```sh
docker build -t local/todo-mcp .
docker run --rm -v todo-mcp-data:/data -e TODO_DB=/data/todos.db local/todo-mcp --help
```
Data is persisted in the `todo-mcp-data` volume. See [Usage](#usage) for more commands.
## Claude Setup
Todo ships with an MCP server that lets Claude manage your todos. Add this to
your Claude MCP config:
### Native
```json
{
"mcpServers": {
"todo": {
"command": "todo",
"command": "todo-mcp",
"args": ["mcp-serve"]
}
}
}
```
### Docker
```json
{
"mcpServers": {
"todo": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-v", "todo-mcp-data:/data",
"-e", "TODO_DB=/data/todos.db",
"ghcr.io/janezicmatej/todo-mcp", "mcp-serve"
]
}
}
}
```
This gives Claude access to all todo operations — adding, listing, completing,
editing, removing, and purging.

View File

@@ -1,8 +1,9 @@
use std::io::{self, BufRead, Write};
use std::io::{self, Write};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, BufReader};
use crate::db::{self, ListFilters, Pool};
use crate::model::Priority;
@@ -233,11 +234,19 @@ async fn dispatch(pool: &Pool, name: &str, args: &Value) -> Value {
}
pub async fn serve(pool: &Pool) -> Result<()> {
let stdin = io::stdin().lock();
let stdin = BufReader::new(tokio::io::stdin());
let mut stdout = io::stdout().lock();
let mut lines = stdin.lines();
loop {
let line: String = tokio::select! {
line = lines.next_line() => match line? {
Some(line) => line,
None => break,
},
_ = tokio::signal::ctrl_c() => break,
};
for line in stdin.lines() {
let line = line?;
if line.is_empty() {
continue;
}