Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
24fd49c01c
|
|||
|
5707441e5c
|
|||
|
e9a087fc69
|
|||
|
3314443fb7
|
|||
|
c58be7c902
|
|||
|
2463e83a43
|
|||
|
19c0fa81ab
|
|||
|
2cd4b34347
|
|||
|
e484e279d9
|
|||
|
abffaa8ef9
|
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
*
|
||||||
|
!src/
|
||||||
|
!migrations/
|
||||||
|
!build.rs
|
||||||
|
!Cargo.toml
|
||||||
|
!Cargo.lock
|
||||||
61
.github/workflows/build-docker.yml
vendored
Normal file
61
.github/workflows/build-docker.yml
vendored
Normal 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
35
.github/workflows/release.yml
vendored
Normal 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' }}
|
||||||
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -376,6 +376,16 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
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]]
|
[[package]]
|
||||||
name = "etcetera"
|
name = "etcetera"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1193,6 +1203,16 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
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]]
|
[[package]]
|
||||||
name = "signature"
|
name = "signature"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -1536,8 +1556,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "todo"
|
name = "todo-mcp"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1561,6 +1581,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "todo"
|
name = "todo-mcp"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "simple todo cli with mcp server for ai integration"
|
description = "simple todo cli with mcp server for ai integration"
|
||||||
@@ -19,7 +19,7 @@ dirs = "6"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] }
|
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]
|
[build-dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
9
Dockerfile
Normal file
9
Dockerfile
Normal 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"]
|
||||||
72
README.md
72
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Todo
|
# todo-mcp
|
||||||
|
|
||||||
A todo CLI with a built-in [MCP](https://modelcontextprotocol.io/) server.
|
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
|
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
|
```sh
|
||||||
# add a todo
|
# add a todo
|
||||||
todo add "fix the login bug"
|
todo-mcp add "fix the login bug"
|
||||||
|
|
||||||
# add with priority and tags
|
# 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
|
# add without associating to the current git repo
|
||||||
todo add "something general" --no-project
|
todo-mcp add "something general" --no-project
|
||||||
|
|
||||||
# list open todos
|
# list open todos
|
||||||
todo ls
|
todo-mcp ls
|
||||||
|
|
||||||
# list todos for the current repo only
|
# list todos for the current repo only
|
||||||
todo ls --here
|
todo-mcp ls --here
|
||||||
|
|
||||||
# filter by tag or priority
|
# filter by tag or priority
|
||||||
todo ls -t backend
|
todo-mcp ls -t backend
|
||||||
todo ls -p critical
|
todo-mcp ls -p critical
|
||||||
|
|
||||||
# search
|
# search
|
||||||
todo ls -s "auth"
|
todo-mcp ls -s "auth"
|
||||||
|
|
||||||
# mark as done
|
# mark as done
|
||||||
todo done 3
|
todo-mcp done 3
|
||||||
|
|
||||||
# edit text, priority, or tags
|
# edit text, priority, or tags
|
||||||
todo edit 3 "updated description"
|
todo-mcp edit 3 "updated description"
|
||||||
todo edit 3 -p low
|
todo-mcp edit 3 -p low
|
||||||
todo edit 3 -t new-tag --untag old-tag
|
todo-mcp edit 3 -t new-tag --untag old-tag
|
||||||
|
|
||||||
# remove permanently
|
# remove permanently
|
||||||
todo rm 3
|
todo-mcp rm 3
|
||||||
|
|
||||||
# show all tags in use
|
# show all tags in use
|
||||||
todo tags
|
todo-mcp tags
|
||||||
|
|
||||||
# clean up completed todos
|
# 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
|
## Claude Setup
|
||||||
|
|
||||||
Todo ships with an MCP server that lets Claude manage your todos. Add this to
|
### Native
|
||||||
your Claude MCP config:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"todo": {
|
"todo": {
|
||||||
"command": "todo",
|
"command": "todo-mcp",
|
||||||
"args": ["mcp-serve"]
|
"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,
|
This gives Claude access to all todo operations — adding, listing, completing,
|
||||||
editing, removing, and purging.
|
editing, removing, and purging.
|
||||||
|
|||||||
2
build.rs
2
build.rs
@@ -19,7 +19,7 @@ fn main() -> Result<(), Error> {
|
|||||||
let mut cmd = Cli::command();
|
let mut cmd = Cli::command();
|
||||||
|
|
||||||
for shell in Shell::value_variants() {
|
for shell in Shell::value_variants() {
|
||||||
let path = generate_to(*shell, &mut cmd, "todo", &outdir)?;
|
let path = generate_to(*shell, &mut cmd, env!("CARGO_PKG_NAME"), &outdir)?;
|
||||||
|
|
||||||
println!("cargo:warning=completion file is generated: {path:?}");
|
println!("cargo:warning=completion file is generated: {path:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ pub struct ListArgs {
|
|||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct DoneArgs {
|
pub struct DoneArgs {
|
||||||
/// todo id
|
/// todo id(s)
|
||||||
pub id: i64,
|
#[arg(required = true)]
|
||||||
|
pub id: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|||||||
@@ -65,9 +65,11 @@ async fn list(ctx: &Ctx<'_>, args: ListArgs) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn done(ctx: &Ctx<'_>, args: DoneArgs) -> Result<()> {
|
async fn done(ctx: &Ctx<'_>, args: DoneArgs) -> Result<()> {
|
||||||
let todo = db::complete_todo(ctx.pool, args.id).await?;
|
for id in args.id {
|
||||||
print!("{} ", "done:".green().bold());
|
let todo = db::complete_todo(ctx.pool, id).await?;
|
||||||
print_todo(&todo);
|
print!("{} ", "done:".green().bold());
|
||||||
|
print_todo(&todo);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
src/mcp.rs
41
src/mcp.rs
@@ -1,8 +1,9 @@
|
|||||||
use std::io::{self, BufRead, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
use crate::db::{self, ListFilters, Pool};
|
use crate::db::{self, ListFilters, Pool};
|
||||||
use crate::model::Priority;
|
use crate::model::Priority;
|
||||||
@@ -79,9 +80,9 @@ fn tool_schemas() -> Value {
|
|||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "integer" }
|
"id": { "type": "integer", "description": "single todo id" },
|
||||||
},
|
"ids": { "type": "array", "items": { "type": "integer" }, "description": "multiple todo ids" }
|
||||||
"required": ["id"]
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -182,11 +183,21 @@ async fn dispatch(pool: &Pool, name: &str, args: &Value) -> Value {
|
|||||||
.map(|t| serde_json::to_string(&t).unwrap())
|
.map(|t| serde_json::to_string(&t).unwrap())
|
||||||
}
|
}
|
||||||
"todo_done" => {
|
"todo_done" => {
|
||||||
let id = match args.get("id").and_then(|v| v.as_i64()) {
|
let ids: Vec<i64> = if let Some(arr) = args.get("ids").and_then(|v| v.as_array()) {
|
||||||
Some(id) => id,
|
arr.iter().filter_map(|v| v.as_i64()).collect()
|
||||||
None => return tool_error("missing required arg: id".into()),
|
} else if let Some(id) = args.get("id").and_then(|v| v.as_i64()) {
|
||||||
|
vec![id]
|
||||||
|
} else {
|
||||||
|
return tool_error("missing required arg: id or ids".into());
|
||||||
};
|
};
|
||||||
db::complete_todo(pool, id).await.map(|t| serde_json::to_string(&t).unwrap())
|
let mut todos = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
match db::complete_todo(pool, id).await {
|
||||||
|
Ok(t) => todos.push(t),
|
||||||
|
Err(e) => return tool_error(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(serde_json::to_string(&todos).unwrap())
|
||||||
}
|
}
|
||||||
"todo_edit" => {
|
"todo_edit" => {
|
||||||
let id = match args.get("id").and_then(|v| v.as_i64()) {
|
let id = match args.get("id").and_then(|v| v.as_i64()) {
|
||||||
@@ -223,11 +234,19 @@ async fn dispatch(pool: &Pool, name: &str, args: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serve(pool: &Pool) -> Result<()> {
|
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 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() {
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user