Skip to content
Merged
11 changes: 11 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: kaiten-cli CodeQL config

# `rust/cleartext-logging` treats `println!` as a log file and any identifier
# matching user(name|id) / uid / account as sensitive, so every `card view`,
# `card member` and `auth status` line that names a user is an alert — printing
# them on stdout is what a CLI does. The only real log sinks here are the
# `tracing` calls, which print host, path and status only. The query is
# therefore excluded; everything else in the default suite stays on.
query-filters:
- exclude:
id: rust/cleartext-logging
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ jobs:
- uses: Swatinem/rust-cache@v2
- run: cargo test --workspace

# Line coverage of the whole workspace test suite, including the `kaiten`
# binary the integration tests spawn (cargo-llvm-cov instruments child
# processes too). Uploaded to Codecov for the README badge and the PR
# coverage comment. CODECOV_TOKEN is an Actions secret and a Dependabot
# secret; PRs from forks have neither, so an upload failure only fails the
# job for pushes and same-repo PRs.
coverage:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: taiki-e/install-action@cargo-llvm-cov
- uses: Swatinem/rust-cache@v2
- run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- uses: codecov/codecov-action@v5
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}

# Tests use unix-only permission APIs; on Windows we verify the build only.
build-windows:
runs-on: windows-latest
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
- uses: actions/checkout@v7
- uses: github/codeql-action/init@v4
with:
config-file: ./.github/codeql/codeql-config.yml
languages: rust
build-mode: none
- uses: github/codeql-action/analyze@v4
Expand Down
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[![CI](https://github.com/dsociative/kaiten-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/dsociative/kaiten-cli/actions/workflows/ci.yml)
[![Security](https://github.com/dsociative/kaiten-cli/actions/workflows/security.yml/badge.svg)](https://github.com/dsociative/kaiten-cli/actions/workflows/security.yml)
[![CodeQL](https://github.com/dsociative/kaiten-cli/actions/workflows/codeql.yml/badge.svg)](https://github.com/dsociative/kaiten-cli/actions/workflows/codeql.yml)
[![Coverage](https://codecov.io/gh/dsociative/kaiten-cli/graph/badge.svg)](https://codecov.io/gh/dsociative/kaiten-cli)
[![Release](https://img.shields.io/github/v/release/dsociative/kaiten-cli)](https://github.com/dsociative/kaiten-cli/releases)
[![Crates.io](https://img.shields.io/crates/v/kaiten-cli.svg)](https://crates.io/crates/kaiten-cli)
[![Downloads](https://img.shields.io/crates/d/kaiten-cli.svg)](https://crates.io/crates/kaiten-cli)
Expand All @@ -12,7 +13,7 @@ Command-line client and MCP server for the [Kaiten](https://kaiten.ru) tracker,
in the spirit of `gh` / `glab`.

- Browse spaces, boards and cards from the terminal
- Create, edit, move and archive cards; manage members, tags, comments and checklists
- Create, edit, move and archive cards; manage members, tags, comments, checklists and external links
- `--json` output on every command for scripting
- Built-in MCP server (`kaiten mcp serve`) so coding agents can work with the tracker
- Raw API escape hatch: `kaiten api GET /users/current`
Expand Down Expand Up @@ -88,7 +89,7 @@ kaiten board view 456 # columns and lanes (ids for `card move

kaiten card list --mine
kaiten card list --board 456 --query "deploy" --limit 20
kaiten card view 67089469 --comments # a full card URL works too
kaiten card view 67089469 --include external_links,comments # a full card URL works too
kaiten card create --board 456 --title "Fix the flaky test" --description "..."
kaiten card edit 67089469 --title "New title" --asap true
kaiten card move 67089469 --column 6308511
Expand All @@ -97,6 +98,8 @@ kaiten card archive 67089469
kaiten card member add 67089469 user@example.com # user id or email
kaiten card member responsible 67089469 user@example.com
kaiten card comment add 67089469 --body "Done, please review"
kaiten card external-link add 67089469 --url https://example.com/spec --description "Spec"
kaiten card external-link list 67089469 # also edit / rm
kaiten card checklist add 67089469 --name "Release steps"
kaiten card checklist item add 67089469 91011 --text "Bump version"
kaiten card checklist item check 67089469 91011 121314
Expand All @@ -118,6 +121,10 @@ kaiten api POST /cards --data '{"board_id":456,"title":"Raw"}'

Add `--json` to any command to print the raw JSON of the API response.

`card view --include external_links,comments` fetches extra sections with the card
(one request each; the names match the MCP `get_card` `include` values).
`card view --comments` still works but is deprecated — use `--include comments`.

## Shell completion

```sh
Expand All @@ -133,9 +140,11 @@ kaiten completion fish > ~/.config/fish/completions/kaiten.fish

## MCP server

The same binary is an MCP server (stdio transport, 36 tools mirroring the CLI,
The same binary is an MCP server (stdio transport, 40 tools mirroring the CLI,
including compact card projections and a cursor-based `poll_updates` for
event-like agent workflows).
event-like agent workflows). `get_card` takes an optional
`include: ["external_links", "comments"]` to return those sections in the same
call (one extra request each).

Claude Code:

Expand Down Expand Up @@ -183,7 +192,7 @@ by area (✅ covered, ◐ partial, — not covered):
| Users list (id lookup) | ✅ | ✅ |
| Card links: children / parents / blockers | ✅ `card link/unlink/unblock` | ✅ `link_cards` etc. |
| Files: attach / detach / list / download | ✅ (uploads get a PUBLIC url!) | ✅ (`download_file` saves locally) |
| External links | — | — |
| External links (Links (common links)): list / add / edit / remove | ✅ `card external-link`, `card view --include external_links` | ✅ four tools + `get_card` `include` |
| Custom properties: reference + set values | ✅ `property list/values`, `--properties-json` | ✅ two tools + `properties` (a JSON object — a wrong shape is rejected before the API call; mutations echo the resulting `properties`) |
| Time logs | ✅ `card time add/list` | ✅ |
| Events: polling for changes | — | ✅ `poll_updates` (cursor-based) |
Expand Down
87 changes: 87 additions & 0 deletions crates/kaiten-client/src/api/external_links.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use crate::client::KaitenClient;
use crate::error::Result;
use crate::models::ExternalLink;

/// Card external links facade (`Links (common links)` in Kaiten). Construct
/// via [`KaitenClient::external_links`].
///
/// The API itself neither validates `url` nor rejects duplicates — this
/// client does not second-guess it.
pub struct ExternalLinks<'a> {
pub(crate) client: &'a KaitenClient,
}

impl ExternalLinks<'_> {
/// GET /cards/{card_id}/external-links
pub async fn list(&self, card_id: u64) -> Result<Vec<ExternalLink>> {
self.client
.request(
reqwest::Method::GET,
&format!("/cards/{card_id}/external-links"),
None,
None,
)
.await
}

/// POST /cards/{card_id}/external-links — `description` is sent only when given.
pub async fn add(
&self,
card_id: u64,
url: &str,
description: Option<&str>,
) -> Result<ExternalLink> {
let mut body = serde_json::json!({ "url": url });
if let Some(description) = description {
body["description"] = serde_json::Value::String(description.to_owned());
}
self.client
.request(
reqwest::Method::POST,
&format!("/cards/{card_id}/external-links"),
None,
Some(body),
)
.await
}

/// PATCH /cards/{card_id}/external-links/{link_id} — only the given fields
/// are sent (the API answers 404 to PUT). Pass at least one of them: with
/// both `None` the request body is `{}`.
pub async fn update(
&self,
card_id: u64,
link_id: u64,
url: Option<&str>,
description: Option<&str>,
) -> Result<ExternalLink> {
let mut body = serde_json::Map::new();
if let Some(url) = url {
body.insert("url".into(), serde_json::Value::String(url.to_owned()));
}
if let Some(description) = description {
body.insert(
"description".into(),
serde_json::Value::String(description.to_owned()),
);
}
self.client
.request(
reqwest::Method::PATCH,
&format!("/cards/{card_id}/external-links/{link_id}"),
None,
Some(serde_json::Value::Object(body)),
)
.await
}

/// DELETE /cards/{card_id}/external-links/{link_id}
pub async fn remove(&self, card_id: u64, link_id: u64) -> Result<()> {
self.client
.request_empty(
reqwest::Method::DELETE,
&format!("/cards/{card_id}/external-links/{link_id}"),
)
.await
}
}
1 change: 1 addition & 0 deletions crates/kaiten-client/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod boards;
pub mod cards;
pub mod checklists;
pub mod comments;
pub mod external_links;
pub mod files;
pub mod links;
pub mod members;
Expand Down
5 changes: 5 additions & 0 deletions crates/kaiten-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ impl KaitenClient {
crate::api::checklists::Checklists { client: self }
}

/// Card external links facade.
pub fn external_links(&self) -> crate::api::external_links::ExternalLinks<'_> {
crate::api::external_links::ExternalLinks { client: self }
}

/// Card file attachments facade.
pub fn files(&self) -> crate::api::files::Files<'_> {
crate::api::files::Files { client: self }
Expand Down
19 changes: 19 additions & 0 deletions crates/kaiten-client/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,25 @@ pub struct SelectValue {
pub sort_order: Option<f64>,
}

/// An external link of a card (`Links (common links)` in Kaiten): a URL
/// with an optional description. `GET /cards/{id}` embeds them under
/// `external_links` as well, but [`Card`] does not model that field yet
/// (adding one would be a breaking change), so they are read through
/// [`crate::api::external_links::ExternalLinks::list`].
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExternalLink {
pub id: u64,
#[serde(default)]
pub uid: Option<String>,
pub url: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub updated: Option<String>,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading