feat: add redmine-cli containerized CLI tool

Merge the redmine-cli work from the EnableAI repo into KNEL-AIMiddleware as the
canonical home for AI middleware. This is a standalone CLI (python-redmine 2.5.0)
that lists, shows, creates, updates, and closes Redmine issues via docker run.
Complements the existing mcp-redmine MCP server (protocol-native) with a
direct-invocation tool for interactive use.

Verified end-to-end against projects.knownelement.com: whoami, projects,
statuses, list, show, create, update, and close all tested successfully.

Also records host-cleanliness conventions (docker/tea only on host; custom
images to Gitea registry) in AGENTS.md, and adds a CLI Tools section to
STATUS.md.
This commit is contained in:
2026-07-30 12:09:44 -05:00
parent 280ebc2e67
commit b69e70e0a1
8 changed files with 549 additions and 0 deletions
+8
View File
@@ -45,6 +45,14 @@ ALL work performed on this project MUST be documented in [JOURNAL.md](JOURNAL.md
- `vendor/` directory is gitignored (cloned repos not committed) - `vendor/` directory is gitignored (cloned repos not committed)
- Custom Dockerfiles saved in `dockerfiles/` with subdirectory structure - Custom Dockerfiles saved in `dockerfiles/` with subdirectory structure
- KiCAD MCP is host-only - requires KiCAD installed on host machine - KiCAD MCP is host-only - requires KiCAD installed on host machine
- **Keep the host clean**: only `docker` and `tea` (Gitea CLI) commands run
directly on the host. Everything else lives in containers. No host installs
of runtimes, CLIs, or packages.
- **Custom images**: publish to the Gitea container registry at
`git.knownelement.com/reachableceo/<image>:<tag>` (Docker is pre-authenticated).
- **Standalone CLI tools**: containerized CLI tools (not MCP/LSP servers) live
in their own top-level directory (e.g. `redmine-cli/`). Invoked on-demand via
`docker run --env-file .env <image> <command>`, not as long-running services.
## Crush Configuration ## Crush Configuration
+26
View File
@@ -1898,3 +1898,29 @@ Now has 3 working MCP servers:
When npm package exists and works better than source build, prefer npm package installation for reliability and simplicity. When npm package exists and works better than source build, prefer npm package installation for reliability and simplicity.
--- ---
## 2026-07-30 — Merged redmine-cli from EnableAI repo
**Context**: The EnableAI repo (`/home/reachableceo/projects/EnableAI`) was a
meta-project for enabling AI access to SaaS systems. Its first deliverable was a
containerized Redmine CLI. That work is now merged into KNEL-AIMiddleware, which
is the canonical home for all AI middleware going forward.
**What was merged**:
- `redmine-cli/` — a self-contained Python CLI (python-redmine 2.5.0) that
lists, shows, creates, updates, and closes Redmine issues. Built as a Docker
image (`kneldevstack-aimiddleware-redmine-cli`), verified end-to-end against
the live Redmine at `projects.knownelement.com` (whoami, list, show, create,
update, close all tested successfully).
**Relationship to existing mcp-redmine**:
- `mcp-redmine` (dockerfiles/mcp-redmine) is the upstream runekaagaard MCP
server — a protocol-native tool provider for OpenWebUI/Crush via MCP stdio.
- `redmine-cli` is a complementary standalone CLI for direct invocation from
the shell (useful when working interactively in Crush). Both share the same
`REDMINE_URL` / `REDMINE_API_KEY` environment variables.
**Host-cleanliness rule established**: The host stays clean — only `docker` and
`tea` commands run on the host; everything else is containerized. Custom images
are published to the Gitea container registry at
`git.knownelement.com/reachableceo/<image>`.
+8
View File
@@ -210,3 +210,11 @@ docker compose build --no-cache <service-name>
``` ```
Note: Use lowercase service names from compose file. Container names will be prefixed with kneldevstack-aimiddleware-. Note: Use lowercase service names from compose file. Container names will be prefixed with kneldevstack-aimiddleware-.
## CLI Tools
Standalone containerized CLI tools (not MCP/LSP servers). Invoked on-demand via `docker run` rather than long-running services.
| Tool | Image | Status | Notes |
|------|-------|--------|-------|
| redmine-cli | kneldevstack-aimiddleware-redmine-cli | Working | Custom CLI (python-redmine) to list/show/create/update/close Redmine issues. Verified end-to-end against projects.knownelement.com. Requires REDMINE_URL and REDMINE_API_KEY. Source in `redmine-cli/`. |
+4
View File
@@ -0,0 +1,4 @@
# Redmine connection details
# Copy this file to .env and fill in real values. The .env file is gitignored.
REDMINE_URL=https://redmine.example.com
REDMINE_API_KEY=your-api-key-here
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY src/redmine_cli.py /usr/local/bin/redmine-cli
RUN chmod +x /usr/local/bin/redmine-cli
ENTRYPOINT ["redmine-cli"]
CMD ["--help"]
+97
View File
@@ -0,0 +1,97 @@
# redmine-cli
A Docker container that lets an AI agent (or a human) access, edit, and close
Redmine issues through the Redmine REST API. Built on the
[`python-redmine`](https://python-redmine.com) library with a small command-line
wrapper.
## Requirements
- Docker on the host.
- A Redmine instance with REST web services enabled
(Administration → Settings → API → Enable REST API).
- A Redmine API key for the user the agent will act as
(My account → API access key → Show / Reset).
## Configuration
Copy the env template and fill in real values (the `.env` is gitignored):
```bash
cp .env.example .env
# then edit .env:
# REDMINE_URL=https://your-redmine.example.com
# REDMINE_API_KEY=abc123...
```
## Build
```bash
docker build -t kneldevstack-aimiddleware-redmine-cli .
```
A prebuilt image is also available in the Gitea registry:
```
git.knownelement.com/reachableceo/redmine-cli:latest
```
## Usage
### Via the repo wrapper (loads `.env` automatically)
```bash
bin/redmine whoami
bin/redmine list --assigned-to-me
bin/redmine show 123
bin/redmine close 123 --notes "work complete"
```
### Via docker directly (from the repo root)
```bash
docker run --rm --env-file .env kneldevstack-aimiddleware-redmine-cli whoami
docker run --rm --env-file .env kneldevstack-aimiddleware-redmine-cli list --assigned-to-me
```
## Commands
| Command | Description |
| -------------------------------- | ------------------------------------------------------ |
| `whoami` | Show the authenticated user (also a connection test). |
| `projects` | List projects (id, identifier, name). |
| `statuses` | List issue statuses and which are "closed". |
| `list` (`ls`) | List issues. Filters below. |
| `show <id>` | Show full issue detail incl. note history. |
| `create` | Create an issue (`--project`, `--subject`, ...). |
| `update <id>` | Update an issue (status, notes, assignee, done, ...). |
| `close <id>` | Close an issue (first closed status, done ratio 100%). |
### `list` filters
- `-m, --assigned-to-me` — only issues assigned to the current user
- `-a, --assigned-to <id>` — filter by assignee user id
- `-p, --project <id|identifier>` — filter by project
- `-s, --status <name|id|open|closed>` — filter by status
- `-l, --limit <n>` — max results (default 50)
- `--sort <spec>` — Redmine sort spec
(default `priority:desc,updated_on:desc`)
### `update` / `create` options
- `--status <name|id>` — status name (case-insensitive) or numeric id
- `-n, --notes <text>` — add a journal note
- `-a, --assigned-to <id>` — assignee user id
- `--done-ratio <0-100>` — percent complete
- `--subject <text>` — change the subject (`update`/`create`)
- `--priority <id>` — priority id
- `-d, --description <text>` — description (`create` only)
- `-t, --tracker <id>` — tracker id (`create` only)
## Environment variables
| Variable | Required | Description |
| ----------------- | -------- | ------------------------------------ |
| `REDMINE_URL` | yes | Base URL of the Redmine instance. |
| `REDMINE_API_KEY` | yes | API key of the acting user. |
| `REDMINE_CLI_IMAGE` | no | Override the image tag used by `bin/redmine` (default `git.knownelement.com/reachableceo/redmine-cli:latest`). |
+1
View File
@@ -0,0 +1 @@
python-redmine>=2.5.0
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""Redmine CLI - a thin wrapper around the Redmine REST API.
Connection details come from the environment:
REDMINE_URL base URL of the Redmine instance (e.g. https://redmine.example.com)
REDMINE_API_KEY API key of an authenticated user
Designed to be run inside the redmine-cli Docker container, but works anywhere
these environment variables are set.
"""
import argparse
import os
import sys
from redminelib import Redmine
from redminelib.exceptions import (
AuthError,
ResourceNotFoundError,
ServerError,
ValidationError,
ConflictError,
NoFileError,
)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _client():
"""Build and return an authenticated Redmine client, or exit with help."""
url = os.environ.get("REDMINE_URL", "").strip().rstrip("/")
key = os.environ.get("REDMINE_API_KEY", "").strip()
missing = [n for n, v in (("REDMINE_URL", url), ("REDMINE_API_KEY", key)) if not v]
if missing:
sys.stderr.write(
"ERROR: missing required environment variable(s): "
+ ", ".join(missing)
+ "\n"
)
sys.exit(2)
return Redmine(url, key=key)
def _current_user(rm):
"""Return the Redmine user behind the current API key."""
return rm.user.get("current")
def _err(msg, code=1):
sys.stderr.write(f"ERROR: {msg}\n")
sys.exit(code)
def _kv(label, value):
"""Format a label/value line, omitting falsy values gracefully."""
if value in (None, "", []):
return None
return f"{label:>14}: {value}"
def _name(obj):
"""Best-effort human name for a user object."""
if obj is None:
return "(unassigned)"
# python-redmine user objects expose these attributes
fname = getattr(obj, "firstname", "") or ""
lname = getattr(obj, "lastname", "") or ""
full = f"{fname} {lname}".strip()
login = getattr(obj, "login", "") or ""
mail = getattr(obj, "mail", "") or ""
if full:
return f"{full} ({login})" if login else full
return login or mail or str(obj)
# --------------------------------------------------------------------------- #
# Commands
# --------------------------------------------------------------------------- #
def cmd_whoami(args):
rm = _client()
try:
u = _current_user(rm)
except AuthError:
_err("authentication failed - check REDMINE_API_KEY", 2)
print(f"id: {u.id}")
print(f"login: {getattr(u, 'login', '?')}")
print(f"name: {_name(u)}")
print(f"mail: {getattr(u, 'mail', '?')}")
print(f"admin: {getattr(u, 'admin', False)}")
print(f"redmine: {os.environ['REDMINE_URL']}")
return 0
def cmd_projects(args):
rm = _client()
projects = rm.project.all()
print(f"{'ID':<6} {'IDENTIFIER':<24} {'NAME'}")
print("-" * 60)
for p in projects:
print(f"{p.id:<6} {str(p.identifier):<24} {p.name}")
print(f"\n{len(projects)} project(s)")
return 0
def cmd_statuses(args):
rm = _client()
statuses = rm.issue_status.all()
print(f"{'ID':<6} {'NAME':<24} IS_CLOSED")
print("-" * 40)
for s in statuses:
print(f"{s.id:<6} {str(s.name):<24} {bool(s.is_closed)}")
print(f"\n{len(statuses)} status(es)")
return 0
def _resolve_status_id(rm, status_ref):
"""Resolve a status name (case-insensitive) or id to a numeric id."""
if status_ref is None:
return None
if status_ref.isdigit():
return int(status_ref)
needle = status_ref.strip().lower()
for s in rm.issue_status.all():
if str(s.name).lower() == needle:
return s.id
_err(f"unknown status '{status_ref}'. Run 'statuses' to list valid names.")
def _resolve_project_id(rm, project_ref):
"""Resolve a project id or identifier to a numeric id."""
if project_ref is None:
return None
if project_ref.isdigit():
return int(project_ref)
needle = project_ref.strip().lower()
for p in rm.project.all():
if str(p.identifier).lower() == needle:
return p.id
_err(f"unknown project '{project_ref}'. Run 'projects' to list identifiers.")
def cmd_list(args):
rm = _client()
filters = {}
if args.assigned_to_me:
me = _current_user(rm)
filters["assigned_to_id"] = me.id
elif args.assigned_to is not None:
filters["assigned_to_id"] = args.assigned_to
if args.project is not None:
filters["project_id"] = _resolve_project_id(rm, args.project)
if args.status is not None:
# status can be a name or id
if args.status.lower() in ("open", "open*"):
filters["status_id"] = "open"
elif args.status.lower() in ("closed", "closed*"):
filters["status_id"] = "closed"
else:
filters["status_id"] = _resolve_status_id(rm, args.status)
filters["limit"] = args.limit
filters["sort"] = args.sort
issues = rm.issue.filter(**filters)
print(f"{'ID':<8} {'STATUS':<14} {'PRJ':<14} {'DONE':>5} SUBJECT")
print("-" * 90)
for i in issues:
proj = getattr(i, "project", None)
proj_id = str(getattr(proj, "identifier", getattr(proj, "id", ""))) if proj else ""
status = getattr(i, "status", None)
status_name = str(getattr(status, "name", "")) if status else ""
done = getattr(i, "done_ratio", 0) or 0
print(f"{i.id:<8} {status_name:<14} {proj_id:<14} {done:>4}% {i.subject}")
print(f"\n{len(issues)} issue(s)")
return 0
def cmd_show(args):
rm = _client()
try:
i = rm.issue.get(args.issue_id)
except ResourceNotFoundError:
_err(f"issue #{args.issue_id} not found")
lines = []
lines.append(f"#{i.id}: {i.subject}")
lines.append("=" * 90)
proj = getattr(i, "project", None)
status = getattr(i, "status", None)
tracker = getattr(i, "tracker", None)
priority = getattr(i, "priority", None)
lines.append(_kv("project", str(getattr(proj, "name", "")) if proj else None))
lines.append(_kv("tracker", str(getattr(tracker, "name", "")) if tracker else None))
lines.append(_kv("status", str(getattr(status, "name", "")) if status else None))
lines.append(_kv("priority", str(getattr(priority, "name", "")) if priority else None))
lines.append(_kv("author", _name(getattr(i, "author", None))))
lines.append(_kv("assigned", _name(getattr(i, "assigned_to", None))))
lines.append(_kv("done", f"{getattr(i, 'done_ratio', 0) or 0}%"))
lines.append(_kv("created", getattr(i, "created_on", None)))
lines.append(_kv("updated", getattr(i, "updated_on", None)))
lines.append(_kv("start", getattr(i, "start_date", None)))
lines.append(_kv("due", getattr(i, "due_date", None)))
lines.append(_kv("estimated", getattr(i, "estimated_hours", None)))
lines.append(_kv("spent", getattr(i, "spent_hours", None)))
for ln in (l for l in lines if l):
print(ln)
desc = getattr(i, "description", "") or ""
if desc.strip():
print("\n--- description ---")
print(desc.strip())
if not args.no_journals:
journals = getattr(i, "journals", []) or []
notes = [j for j in journals if getattr(j, "notes", "") and str(j.notes).strip()]
if notes:
print("\n--- history (notes) ---")
for j in notes:
who = _name(getattr(j, "user", None))
when = getattr(j, "created_on", "?")
print(f"\n[{when}] {who}:")
print(str(j.notes).strip())
return 0
def cmd_create(args):
rm = _client()
if not args.project:
_err("--project is required to create an issue")
if not args.subject:
_err("--subject is required to create an issue")
fields = {
"project_id": _resolve_project_id(rm, args.project),
"subject": args.subject,
}
if args.description:
fields["description"] = args.description
if args.assigned_to is not None:
fields["assigned_to_id"] = args.assigned_to
if args.tracker is not None:
fields["tracker_id"] = args.tracker
if args.priority is not None:
fields["priority_id"] = args.priority
if args.status is not None:
sid = _resolve_status_id(rm, args.status)
if sid:
fields["status_id"] = sid
try:
issue = rm.issue.create(**fields)
except ValidationError as e:
_err(f"validation failed: {e}")
print(f"Created issue #{issue.id}: {issue.subject}")
return 0
def cmd_update(args):
rm = _client()
fields = {}
if args.status is not None:
sid = _resolve_status_id(rm, args.status)
if sid:
fields["status_id"] = sid
if args.notes:
fields["notes"] = args.notes
if args.assigned_to is not None:
fields["assigned_to_id"] = args.assigned_to
if args.done_ratio is not None:
fields["done_ratio"] = args.done_ratio
if args.subject is not None:
fields["subject"] = args.subject
if args.priority is not None:
fields["priority_id"] = args.priority
if not fields:
_err("no fields to update; pass at least one of --status/--notes/--assigned-to/--done-ratio/--subject/--priority")
try:
rm.issue.update(args.issue_id, **fields)
except ResourceNotFoundError:
_err(f"issue #{args.issue_id} not found")
except ValidationError as e:
_err(f"validation failed: {e}")
print(f"Updated issue #{args.issue_id}")
return 0
def cmd_close(args):
rm = _client()
close_id = None
for s in rm.issue_status.all():
if getattr(s, "is_closed", False):
close_id = s.id
break
if close_id is None:
_err("no closed status found on this Redmine instance")
fields = {"status_id": close_id}
if args.notes:
fields["notes"] = args.notes
if args.done_ratio is not None:
fields["done_ratio"] = args.done_ratio
else:
fields["done_ratio"] = 100
try:
rm.issue.update(args.issue_id, **fields)
except ResourceNotFoundError:
_err(f"issue #{args.issue_id} not found")
except ValidationError as e:
_err(f"validation failed: {e}")
print(f"Closed issue #{args.issue_id} (status_id={close_id}, done=100%)")
return 0
# --------------------------------------------------------------------------- #
# Argument parsing
# --------------------------------------------------------------------------- #
def build_parser():
p = argparse.ArgumentParser(
prog="redmine-cli",
description="Access, edit, and close Redmine issues via the REST API.",
)
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser("whoami", help="show the authenticated user (connection test)").set_defaults(func=cmd_whoami)
sp = sub.add_parser("projects", help="list projects")
sp.set_defaults(func=cmd_projects)
sp = sub.add_parser("statuses", help="list issue statuses")
sp.set_defaults(func=cmd_statuses)
sp = sub.add_parser("list", aliases=["ls"], help="list issues")
sp.add_argument("-m", "--assigned-to-me", action="store_true", help="only issues assigned to the current user")
sp.add_argument("-a", "--assigned-to", metavar="USER_ID", help="filter by assignee user id")
sp.add_argument("-p", "--project", metavar="ID_OR_IDENTIFIER", help="filter by project")
sp.add_argument("-s", "--status", metavar="NAME_OR_ID", help="filter by status (name/id, or 'open'/'closed')")
sp.add_argument("-l", "--limit", type=int, default=50, help="max issues to return (default 50)")
sp.add_argument("--sort", default="priority:desc,updated_on:desc", help="sort order")
sp.set_defaults(func=cmd_list)
sp = sub.add_parser("show", help="show details of an issue")
sp.add_argument("issue_id", type=int)
sp.add_argument("--no-journals", action="store_true", help="omit note history")
sp.set_defaults(func=cmd_show)
sp = sub.add_parser("create", help="create a new issue")
sp.add_argument("-p", "--project", required=True, metavar="ID_OR_IDENTIFIER")
sp.add_argument("-s", "--subject", required=True)
sp.add_argument("-d", "--description")
sp.add_argument("-a", "--assigned-to", metavar="USER_ID")
sp.add_argument("-t", "--tracker", metavar="TRACKER_ID")
sp.add_argument("--priority", metavar="PRIORITY_ID")
sp.add_argument("--status", metavar="NAME_OR_ID")
sp.set_defaults(func=cmd_create)
sp = sub.add_parser("update", help="update an issue")
sp.add_argument("issue_id", type=int)
sp.add_argument("-s", "--status", metavar="NAME_OR_ID", help="new status")
sp.add_argument("-n", "--notes", help="add a note/journal comment")
sp.add_argument("-a", "--assigned-to", metavar="USER_ID")
sp.add_argument("--done-ratio", type=int, metavar="0-100")
sp.add_argument("--subject")
sp.add_argument("--priority", metavar="PRIORITY_ID")
sp.set_defaults(func=cmd_update)
sp = sub.add_parser("close", help="close an issue (set to first closed status, done ratio 100%%)")
sp.add_argument("issue_id", type=int)
sp.add_argument("-n", "--notes", help="add a note/journal comment")
sp.add_argument("--done-ratio", type=int, metavar="0-100", help="override done ratio (default 100)")
sp.set_defaults(func=cmd_close)
return p
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except AuthError:
_err("authentication failed - check REDMINE_API_KEY", 2)
except ServerError as e:
_err(f"server error: {e}")
except ConflictError as e:
_err(f"conflict: {e}")
except (NoFileError, ResourceNotFoundError) as e:
_err(str(e))
except Exception as e: # noqa: BLE001 - top-level safety net for the CLI
_err(f"{type(e).__name__}: {e}")
if __name__ == "__main__":
sys.exit(main())