Compare commits

...
13 Commits
Author SHA1 Message Date
mrcharles 2440188f1f feat(tooling): containerize dns-cli in tooling-cli/dns
Consolidate the Technitium DNS CLI into tooling-cli/dns/ with a
containerized bash CLI (src/dns_cli.sh), Dockerfile (alpine +
curl/python3/bind-tools/bash), README, AGENTS.md, validate.sh, and
.env.example. Env vars aligned to the centralized ~/.creds/technitium.env
store (TECHNITIUM_URL, TECHNITIUM_DNS_TOKEN, TECHNITIUM_DNS_ZONE).

Image built and pushed to the registry as
git.knownelement.com/reachableceo/dns-cli:latest. Verified live:
zones, list, and search all return data.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-10 10:21:37 -05:00
mrcharles 67f8056e0b refactor(tooling): merge redmine-cli into tooling-cli/redmine
Consolidate the redmine-cli source into tooling-cli/redmine/
(CLI source, Dockerfile, README, AGENTS.md) with all documentation
rewritten to invoke the container via raw docker run and credentials
from the centralized ~/.creds/redmine.env store. No bin/ wrapper, no
system-dependent paths in the docs.

Removes the old redmine-cli/ subdirectory, updates the
KNELCredsManager consumer table, and updates STATUS.md + AGENTS.md
to reference the new location and registry image.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-10 09:53:42 -05:00
mrcharles ecffb4e598 refactor(tooling): merge discourse-cli into tooling-cli/discourse
Consolidate the discourse-cli source into tooling-cli/discourse/
(CLI source, Dockerfile, README, AGENTS.md, validate.sh) with all
documentation rewritten to invoke the container via raw docker run
and credentials from the centralized ~/.creds/discourse.env store.
No bin/ wrapper, no system-dependent paths in the docs.

Removes the old discourse-cli/ subdirectory and updates the
KNELCredsManager consumer table to reference the container directly.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-10 09:49:22 -05:00
mrcharles 1957dcbc7e feat(creds): centralize credentials to ~/.creds/, add KNELCredsManager
Rewire all MCP wrappers, docker-compose services, and validate scripts
to source credentials from ~/.creds/ instead of scattered per-service
.env files. This removes credential duplication and prepares for the
HashiCorp Vault migration.

Add KNELCredsManager under tooling-cli with:
- Containerized Bitwarden CLI (pinned image, host stays clean)
- scripts/bw wrapper with session management and data persistence
- README documenting credential layout, consumer wiring, and roadmap

Also fixes latent bug in MCP wrappers that were silently getting empty
creds from ambient shell env — they now explicitly source ~/.creds/.

Tracked in Redmine #407. Discourse: https://community.turnsys.com/t/308

💘 Generated with Crush

Assisted-by: Crush
2026-08-10 09:14:14 -05:00
mrcharles 7dfce930b6 fix: resolve subcategory slugs in category lookup and listing
The /categories.json endpoint only returns top-level categories by
default, so subcategory slugs (e.g. vp-techops) failed to resolve and
the 'categories' listing hid the entire subcategory tree. Both
_resolve_category and cmd_categories now pass include_subcategories=true
and traverse the nested subcategory_list.
2026-08-06 14:13:13 -05:00
mrcharles 11ec4cf4a3 fix: resolve discourse-mcp TS2345 build error and add env-var auth
The discourse-mcp was BLOCKED with a TypeScript TS2345 error caused by
the Dockerfile forcing SDK ^1.23.0 via `pnpm add`, while the upstream
code requires the lockfile's v1.17.3. Removed the override to use
`pnpm install --frozen-lockfile`.

The upstream server does not read environment variables directly — it
requires CLI args (--auth-pairs, --site) or a --profile JSON file. Added
a docker-entrypoint.sh that converts DISCOURSE_URL, DISCOURSE_API_KEY,
and DISCOURSE_API_USERNAME env vars into a profile JSON with auth_pairs
and site tethering.

Validated end-to-end: MCP handshake passes, site auto-tethers on startup,
live tool calls (discourse_search, discourse_filter_topics) return real
data from community.turnsys.com.

Updates STATUS.md and README.md to reflect discourse-mcp as production
ready. Adds JOURNAL.md entries for discourse-cli, discourse-mcp fix, and
protocol improvements.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-07-30 16:53:12 -05:00
mrcharles e2d4bb333c fix: enforce one-server-at-a-time workflow in agent protocol
Agents were attempting to build/validate multiple MCP servers in
parallel, leading to incomplete work that never finished. This adds
a mandatory rule: work on exactly one MCP/LSP server end-to-end
(build, handshake, live client invocation with real data, status
update, commit) before starting the next.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-07-30 16:42:57 -05:00
mrcharles d4e9fd693f feat: add discourse-cli for Discourse forum integration
A standalone CLI container (following the redmine-cli pattern) that
lets AI agents and humans read, post, reply, search, and discuss on a
Discourse forum via the REST API. Uses three environment variables
(DISCOURSE_URL, DISCOURSE_API_KEY, DISCOURSE_API_USERNAME) for auth.

Commands: whoami, categories, cat-info, topics/ls, show, create, reply,
update, delete, search, notifications.

Fully validated end-to-end (10/10 checks) against a live Discourse
instance: credential auth, whoami, categories, topics, show, search,
and full write cycle (create + reply + update + delete).

Includes validate.sh for repeatable end-to-end testing using
containerized curl + the CLI image.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-07-30 16:39:17 -05:00
mrcharles 9d6764be11 fix: add honest status reporting protocol to prevent false validation claims
AI agents were marking services as "validated" or "working" after only
running structural tests (build + --help) without any live interaction
against the real external system. This adds a mandatory section to
AGENTS.md that:

- Distinguishes "built", "structural test", and "validated" as distinct
  states with concrete examples
- Prohibits using "validated"/"working"/"done"/"complete" for credential-
  dependent services until a live operation succeeds
- Requires obtaining credentials and running real read/write operations
  before claiming completion
- Enforces todo-list honesty: items can only be marked completed when
  their acceptance criterion is actually met

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-07-30 16:39:07 -05:00
mrcharles 4ad44bbe9d fix: update Dockerfiles for upstream changes and build fixes
- gitea-mcp, mcp-grafana: bump Go from 1.24 to 1.26 (upstream go.mod
  now requires >= 1.26.x)
- proxmox-mcp: constrain requires-python to >=3.10 for fastmcp compat
- elasticsearch-mcp: add missing vendor repo to CloneVendorRepos.sh
  (elastic/mcp-server-elasticsearch)
- context7-mcp: add .npmrc to disable pnpm v10 strict built-deps check
- drawio-mcp: exclude dev-proxy package from build (upstream TS errors),
  build with pnpm v10 + .npmrc
- discourse-mcp, ghidra-mcp: remain blocked (upstream issues)
2026-07-30 15:47:30 -05:00
mrcharles b595e26d62 fix: add profile flags to all docker compose commands and complete env vars
All services in docker-compose.yml are behind profiles (dev/ops), but the
maintenance scripts called docker compose without --profile, so no services
were detected or built. Added --profile dev --profile ops to BuildAll.sh,
StatusCheck.sh, and all relevant maintenance.sh commands.

Also:
- Added missing env vars for 8 newer servers to .env.example (beszel, gitea,
  grafana, ha, limesurvey, linkwarden, superset)
- Fixed maintenance.sh validate to use validate-all.sh instead of nonexistent
  validate-mcp.sh
2026-07-30 12:15:49 -05:00
mrcharles b3a810ba07 fix: make all scripts resolve paths relative to repo root
CloneVendorRepos.sh and CleanVendor.sh resolved vendor/ relative to scripts/
instead of the repo root, so vendor repos were cloned to the wrong location
(scripts/vendor) and docker-compose builds (which expect ./vendor at repo
root) would fail. BuildAll.sh and StatusCheck.sh had the same issue, cd-ing
to scripts/ instead of the repo root before running docker compose.

Also updated validate-all.sh with the 10 newer MCP servers that were added
after the script was last maintained (firefly-iii, actual, paperless, beszel,
gitea, grafana, ha, limesurvey, linkwarden, superset) plus added missing
env vars for audiobook-mcp and drawio-mcp.
2026-07-30 12:12:43 -05:00
mrcharles 237f345582 fix: correct crush.json wrapper script paths
All wrapper script paths referenced /home/charles/Projects/ (previous
machine/user) instead of the actual /home/reachableceo/projects/. Every
LSP and MCP entry in crush.json was broken. Fixed 39 path references.
2026-07-30 12:12:17 -05:00
48 changed files with 2295 additions and 105 deletions
+31
View File
@@ -98,3 +98,34 @@ PAPERLESS_TOKEN=dummy-paperless-token-replace-with-real
ACTUAL_SERVER_URL=https://actual.example.com ACTUAL_SERVER_URL=https://actual.example.com
ACTUAL_PASSWORD=dummy-password-replace-with-real ACTUAL_PASSWORD=dummy-password-replace-with-real
ACTUAL_BUDGET_SYNC_ID=dummy-budget-sync-id ACTUAL_BUDGET_SYNC_ID=dummy-budget-sync-id
# Beszel
BESZEL_URL=https://beszel.example.com
BESZEL_USERNAME=dummy-username
BESZEL_PASSWORD=dummy-password-replace-with-real
# Gitea
GITEA_URL=https://gitea.example.com
GITEA_TOKEN=dummy-token-replace-with-real
# Grafana
GRAFANA_URL=https://grafana.example.com
GRAFANA_TOKEN=dummy-token-replace-with-real
# Home Assistant
HOMEASSISTANT_URL=https://ha.example.com
HOMEASSISTANT_TOKEN=dummy-token-replace-with-real
# Limesurvey
LIMESURVEY_URL=https://limesurvey.example.com
LIMESURVEY_USERNAME=admin
LIMESURVEY_PASSWORD=dummy-password-replace-with-real
# Linkwarden
LINKWARDEN_URL=https://linkwarden.example.com
LINKWARDEN_TOKEN=dummy-token-replace-with-real
# Superset
SUPERSET_URL=https://superset.example.com
SUPERSET_USERNAME=admin
SUPERSET_PASSWORD=dummy-password-replace-with-real
+48 -2
View File
@@ -51,8 +51,8 @@ ALL work performed on this project MUST be documented in [JOURNAL.md](JOURNAL.md
- **Custom images**: publish to the Gitea container registry at - **Custom images**: publish to the Gitea container registry at
`git.knownelement.com/reachableceo/<image>:<tag>` (Docker is pre-authenticated). `git.knownelement.com/reachableceo/<image>:<tag>` (Docker is pre-authenticated).
- **Standalone CLI tools**: containerized CLI tools (not MCP/LSP servers) live - **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 under `tooling-cli/` (e.g. `tooling-cli/redmine/`). Invoked on-demand via
`docker run --env-file .env <image> <command>`, not as long-running services. `docker run --env-file ~/.creds/<service>.env <image> <command>`, not as long-running services.
## Crush Configuration ## Crush Configuration
@@ -100,6 +100,31 @@ For each agent, verify in order:
**CRITICAL**: Items 1-3 are NON-NEGOTIABLE. Do not mark a server as "working" without protocol validation. **CRITICAL**: Items 1-3 are NON-NEGOTIABLE. Do not mark a server as "working" without protocol validation.
## Honest Status Reporting (MANDATORY)
AI agents MUST distinguish between these distinct states and never blur them:
| State | Meaning | Example |
|-------|---------|---------|
| **Built** | Image/container compiles or assembles without errors | `docker build` exits 0 |
| **Structural test passed** | Binary runs, `--help` works, error paths return correct codes | CLI prints usage, missing-env exits 2 |
| **Validated / Working** | A **live** interaction against a real external system succeeded | `whoami` returns real user data from the live API |
**A structural test is NOT validation.** Building + `--help` does not make a tool "validated" or "working".
### Rules
1. **Never use the words "validated", "working", "done", or "complete"** for any service that depends on external credentials or a live endpoint until you have:
- **Obtained** the real credentials / endpoint (not placeholder/dummy values).
- **Executed** at least one live read or write operation against the real system.
- **Observed** a successful response with real data.
2. **Never claim validation is impossible then state the tool is done.** If you lack credentials, say *"blocked — needs credentials to validate"* and obtain them. Do not imply completion by omission.
3. **Todo list honesty:** A todo item may only be marked `completed` when its acceptance criterion has actually been met. "Build and test" requires a passing test, not a successful build alone.
4. **When blocked on validation:** Stop, state the exact blocker (e.g. "need DISCOURSE_API_KEY to run live whoami test"), and obtain it before proceeding or declaring the task done.
## MCP Handshake Command ## MCP Handshake Command
```bash ```bash
@@ -134,6 +159,27 @@ git config core.hooksPath .githooks
If you cannot complete an item (e.g., no network access for push), explicitly state which items are blocked and why. If you cannot complete an item (e.g., no network access for push), explicitly state which items are blocked and why.
## One Server At A Time (MANDATORY)
AI agents MUST work on exactly **one MCP/LSP server at a time, end-to-end** before
starting the next one. This includes:
1. **Build** the container
2. **Run** the container and validate the protocol handshake
3. **Spin up a live Crush or bash session** and actually invoke the server through
the real client pipeline
4. **Observe a successful real tool call** (not just a handshake — an actual tool
invocation that returns real data or completes a real action)
5. **Update STATUS.md and JOURNAL.md** for that server
6. **Commit and push** before moving on
**NEVER** attempt to build/validate multiple MCP servers in parallel or batch them.
**NEVER** skip from "built" to the next server without completing steps 3-4 (real
client invocation with real data).
The pattern of "build all, then validate all" leads to incomplete work that never
finishes. One server, fully done, then the next.
## Related Documentation ## Related Documentation
- [README.md](README.md) - Project overview, server inventory, installation, usage - [README.md](README.md) - Project overview, server inventory, installation, usage
+64
View File
@@ -1924,3 +1924,67 @@ is the canonical home for all AI middleware going forward.
`tea` commands run on the host; everything else is containerized. Custom images `tea` commands run on the host; everything else is containerized. Custom images
are published to the Gitea container registry at are published to the Gitea container registry at
`git.knownelement.com/reachableceo/<image>`. `git.knownelement.com/reachableceo/<image>`.
## 2026-07-30
### [ADR] discourse-cli: Standalone CLI for Discourse (following redmine-cli pattern)
**Date/Time**: 2026-07-30 5:50 PM EST
**Type**: Implementation
**Status**: Accepted
**Context**: The existing discourse-mcp was BLOCKED (TS2345 build error), but
the redmine-cli pattern (standalone CLI container, on-demand via docker run)
provides a reliable alternative for Crush (which has Docker/CLI access).
**Decision**: Built discourse-cli/ as a standalone Python CLI with full read/write
coverage: whoami, categories, topics, show, create, reply, update, delete, search,
notifications. Uses requests library with JSON body encoding for POST/PUT/DELETE.
Validated 10/10 against community.turnsys.com.
**Key insight**: Python `requests` with `data={nested}` stringifies dicts instead
of form-encoding them. Must use `json=data` for nested payloads to Discourse API.
The `/session/current.json` endpoint doesn't work with API keys (returns 403);
use `/notifications.json` or `/u/<username>.json` instead for connection tests.
### [ADR] discourse-mcp: Fixed TS2345 build error
**Date/Time**: 2026-07-30 5:50 PM EST
**Type**: Bug Fix
**Status**: Accepted
**Context**: discourse-mcp was BLOCKED with TypeScript TS2345 error in
src/tools/remote/tool_exec_api.ts:58. The Dockerfile was forcing
`@modelcontextprotocol/sdk@^1.23.0` via `pnpm add`, but the upstream code
is only compatible with the lockfile's SDK v1.17.3.
**Decision**: Removed the `pnpm add @modelcontextprotocol/sdk@^1.23.0` line
from the Dockerfile. Using `pnpm install --frozen-lockfile` instead.
**Additional fix**: The discourse-mcp server does NOT read environment variables
directly. It requires CLI args (`--auth-pairs`, `--site`) or a `--profile` JSON
file. Added a docker-entrypoint.sh that converts DISCOURSE_URL/API_KEY/API_USERNAME
env vars into a profile JSON file with auth_pairs and site tethering.
**Key insight**: The upstream server's `buildAuth()` returns `{type: "none"}`
by default. Auth comes from auth_pairs overrides in the profile. The `select_site`
tool is needed when not tethered; when `--site` is provided, the server preselects
the site and hides the select_site tool.
Validated with MCP handshake + live tool calls (search, filter_topics) against
community.turnsys.com.
### Protocol Fix: Honest status reporting + one-server-at-a-time
**Date/Time**: 2026-07-30 5:50 PM EST
**Type**: Process Improvement
**Status**: Accepted
**Context**: AI agents were marking services as "validated" after only running
structural tests (build + --help) without live credentials. Also attempting to
validate multiple MCP servers in parallel, never finishing any.
**Decision**: Added two mandatory rules to AGENTS.md:
1. Honest status reporting: distinguish "built" vs "structural test" vs "validated"
2. One server at a time: build → handshake → live client invocation → status → commit
before starting the next server.
+1 -1
View File
@@ -83,7 +83,7 @@ Legend: ✅ Production Ready | ⚠️ Config Required | ❌ Not Production Ready
#### Communication & Collaboration (3 servers) #### Communication & Collaboration (3 servers)
| Service | Status | Container Name | Description | | Service | Status | Container Name | Description |
|---------|--------|---------------|-------------| |---------|--------|---------------|-------------|
| discourse-mcp | | kneldevstack-aimiddleware-discourse-mcp | Forum (upstream build error) | | discourse-mcp | | kneldevstack-aimiddleware-discourse-mcp | Forum (fixed: SDK version + env-var auth) |
| imap-mcp | ❌ | kneldevstack-aimiddleware-imap-mcp | Email (crashes without live IMAP) | | imap-mcp | ❌ | kneldevstack-aimiddleware-imap-mcp | Email (crashes without live IMAP) |
| postizz-mcp | ❌ | kneldevstack-aimiddleware-postizz-mcp | Social media (HTTP transport, not stdio) | | postizz-mcp | ❌ | kneldevstack-aimiddleware-postizz-mcp | Social media (HTTP transport, not stdio) |
+4 -4
View File
@@ -100,7 +100,7 @@ All 32 vendor repositories have been verified and correctly cloned. CloneVendorR
- ✓ ghidra-mcp: Working (v1.9.4-headless) - 91 REST endpoints for binary analysis - NOT MCP protocol (uses HTTP REST) - ✓ ghidra-mcp: Working (v1.9.4-headless) - 91 REST endpoints for binary analysis - NOT MCP protocol (uses HTTP REST)
**MCP Servers with Build/Runtime Issues:** **MCP Servers with Build/Runtime Issues:**
- discourse-mcp: BLOCKED - TypeScript TS2345 error (upstream SDK incompatibility, cannot fix) - discourse-mcp: Working (@discourse/mcp v0.2.9) - requires DISCOURSE_URL, DISCOURSE_API_KEY, DISCOURSE_API_USERNAME env vars
- ✗ reverse-engineering-assistant: BLOCKED - requires specific gradle version matching Ghidra (complex build) - ✗ reverse-engineering-assistant: BLOCKED - requires specific gradle version matching Ghidra (complex build)
- ✗ penpot-mcp: Build OK, transport mismatch - uses HTTP/WebSocket transport instead of stdio - ✗ penpot-mcp: Build OK, transport mismatch - uses HTTP/WebSocket transport instead of stdio
@@ -122,7 +122,7 @@ All 32 vendor repositories have been verified and correctly cloned. CloneVendorR
| blender-mcp | Built | Container built from source (321MB). Python-based with uv package manager. MCP stdio-based, requires Blender running with addon. Version 1.25.0. | | blender-mcp | Built | Container built from source (321MB). Python-based with uv package manager. MCP stdio-based, requires Blender running with addon. Version 1.25.0. |
| cloudron-mcp | Built | Container built from source (374MB). MCP stdio-based, requires CLOUDRON_URL env var. Version 0.1.0. | | cloudron-mcp | Built | Container built from source (374MB). MCP stdio-based, requires CLOUDRON_URL env var. Version 0.1.0. |
| context7-mcp | Built | Container built from source (224MB). Multi-stage Node.js build with tsc. MCP stdio-based, requires UPSTASH_REDIS_REST_URL and TOKEN env vars. Version 2.1.0. | | context7-mcp | Built | Container built from source (224MB). Multi-stage Node.js build with tsc. MCP stdio-based, requires UPSTASH_REDIS_REST_URL and TOKEN env vars. Version 2.1.0. |
| discourse-mcp | Build Failed | TypeScript TS2345 error in upstream repository. Cannot build locally. | | discourse-mcp | Built | Container built from TypeScript source. Fixed: removed SDK override (pnpm add ^1.23.0) that caused TS2345, added entrypoint to convert env vars to profile JSON. MCP stdio-based, auto-tethers to DISCOURSE_URL. Version 0.2.9. Validated with live tool calls against community.turnsys.com. |
| docker-mcp | Built | Container built from Python source (188MB). Uses uv package manager. MCP stdio-based, requires Docker socket mount (/var/run/docker.sock). Version 0.1.0. | | docker-mcp | Built | Container built from Python source (188MB). Uses uv package manager. MCP stdio-based, requires Docker socket mount (/var/run/docker.sock). Version 0.1.0. |
| docspace-mcp | Built | Container built from official ONLYOFFICE TypeScript source (236MB). Uses pnpm package manager. MCP stdio-based, requires DOCSPACE_HOST and DOCSPACE_TOKEN env vars. Version 3.1.0. | | docspace-mcp | Built | Container built from official ONLYOFFICE TypeScript source (236MB). Uses pnpm package manager. MCP stdio-based, requires DOCSPACE_HOST and DOCSPACE_TOKEN env vars. Version 3.1.0. |
| drawio-mcp | Built | Container built from TypeScript source (302MB). Uses pnpm package manager. MCP stdio-based, requires DRAWIO_URL env var. Version 1.4.0. | | drawio-mcp | Built | Container built from TypeScript source (302MB). Uses pnpm package manager. MCP stdio-based, requires DRAWIO_URL env var. Version 1.4.0. |
@@ -170,7 +170,7 @@ Host-only - requires KiCAD installed on the host machine. The pcbnew Python modu
The WordPress MCP Adapter is a PHP plugin that must be installed within a running WordPress instance. It requires WordPress + Abilities API plugin + MCP Adapter plugin. Not suitable for containerized standalone deployment. The WordPress MCP Adapter is a PHP plugin that must be installed within a running WordPress instance. It requires WordPress + Abilities API plugin + MCP Adapter plugin. Not suitable for containerized standalone deployment.
### discourse-mcp ### discourse-mcp
BLOCKED - Build failed with TypeScript TS2345 error in upstream repository (src/tools/remote/tool_exec_api.ts:58). The code is incompatible with MCP SDK types. Cannot fix locally without upstream changes. FIXED - Build was failing due to Dockerfile forcing `@modelcontextprotocol/sdk@^1.23.0` via `pnpm add`, which is incompatible with upstream code (locks SDK to v1.17.3). Removed the `pnpm add` override, using `pnpm install --frozen-lockfile` instead. Also added a docker-entrypoint.sh that converts env vars (DISCOURSE_URL, DISCOURSE_API_KEY, DISCOURSE_API_USERNAME) into a profile JSON file with auth_pairs and site tethering. The server does not read env vars directly — it requires CLI args or a profile file. Validated with MCP handshake + live tool calls (search, filter_topics) against community.turnsys.com.
### proxmox-mcp ### proxmox-mcp
Fixed by patching pyproject.toml to use 'fastmcp' from PyPI instead of 'mcp @ git+...' and updating imports from 'mcp.server.fastmcp' to 'fastmcp'. Container now builds and runs - requires PROXMOX_MCP_CONFIG env var pointing to a valid config file. Fixed by patching pyproject.toml to use 'fastmcp' from PyPI instead of 'mcp @ git+...' and updating imports from 'mcp.server.fastmcp' to 'fastmcp'. Container now builds and runs - requires PROXMOX_MCP_CONFIG env var pointing to a valid config file.
@@ -217,4 +217,4 @@ Standalone containerized CLI tools (not MCP/LSP servers). Invoked on-demand via
| Tool | Image | Status | Notes | | 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/`. | | redmine-cli | git.knownelement.com/reachableceo/redmine-cli:latest | 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 `tooling-cli/redmine/`. |
+39 -39
View File
@@ -2,192 +2,192 @@
"$schema": "https://charm.land/crush.json", "$schema": "https://charm.land/crush.json",
"lsp": { "lsp": {
"bash": { "bash": {
"command": "/home/charles/Projects/KNEL-AIMiddleware/lsp-bash-wrapper.sh" "command": "/home/reachableceo/projects/KNEL-AIMiddleware/lsp-bash-wrapper.sh"
}, },
"docker": { "docker": {
"command": "/home/charles/Projects/KNEL-AIMiddleware/lsp-docker-wrapper.sh" "command": "/home/reachableceo/projects/KNEL-AIMiddleware/lsp-docker-wrapper.sh"
}, },
"markdown": { "markdown": {
"command": "/home/charles/Projects/KNEL-AIMiddleware/lsp-marksman-wrapper.sh" "command": "/home/reachableceo/projects/KNEL-AIMiddleware/lsp-marksman-wrapper.sh"
}, },
"terraform": { "terraform": {
"command": "/home/charles/Projects/KNEL-AIMiddleware/lsp-terraform-wrapper.sh" "command": "/home/reachableceo/projects/KNEL-AIMiddleware/lsp-terraform-wrapper.sh"
} }
}, },
"mcp": { "mcp": {
"audiobook": { "audiobook": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-audiobook-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-audiobook-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"bitwarden": { "bitwarden": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-bitwarden-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-bitwarden-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"blender": { "blender": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-blender-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-blender-wrapper.sh",
"timeout": 180 "timeout": 180
}, },
"cloudron": { "cloudron": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-cloudron-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-cloudron-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"context7": { "context7": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-context7-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-context7-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"docker": { "docker": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-docker-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-docker-wrapper.sh",
"timeout": 180 "timeout": 180
}, },
"drawio": { "drawio": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-drawio-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-drawio-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"elasticsearch": { "elasticsearch": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-elasticsearch-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-elasticsearch-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"freecad": { "freecad": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-freecad-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-freecad-wrapper.sh",
"timeout": 180 "timeout": 180
}, },
"ghost": { "ghost": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-ghost-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-ghost-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"gimp": { "gimp": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-gimp-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-gimp-wrapper.sh",
"timeout": 180 "timeout": 180
}, },
"imap": { "imap": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-imap-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-imap-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"kubernetes": { "kubernetes": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-kubernetes-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-kubernetes-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"docspace": { "docspace": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-docspace-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-docspace-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"webserial": { "webserial": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-webserial-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-webserial-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"proxmox": { "proxmox": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-proxmox-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-proxmox-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"terraform": { "terraform": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-terraform-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-terraform-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"nextcloud": { "nextcloud": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-nextcloud-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-nextcloud-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"matomo": { "matomo": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-matomo-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-matomo-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"snipeit": { "snipeit": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-snipeit-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-snipeit-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"redmine": { "redmine": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-redmine-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-redmine-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"ansible": { "ansible": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-ansible-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-ansible-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"actual": { "actual": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-actual-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-actual-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"beszel": { "beszel": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-beszel-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-beszel-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"gitea": { "gitea": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-gitea-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-gitea-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"grafana": { "grafana": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-grafana-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-grafana-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"homeassistant": { "homeassistant": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-ha-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-ha-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"limesurvey": { "limesurvey": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-limesurvey-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-limesurvey-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"linkwarden": { "linkwarden": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-linkwarden-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-linkwarden-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"superset": { "superset": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-superset-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-superset-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"postizz": { "postizz": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-postizz-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-postizz-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"discourse": { "discourse": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-discourse-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-discourse-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"penpot": { "penpot": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-penpot-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-penpot-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"firefly-iii": { "firefly-iii": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-firefly-iii-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-firefly-iii-wrapper.sh",
"timeout": 60 "timeout": 60
}, },
"paperless": { "paperless": {
"type": "stdio", "type": "stdio",
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-paperless-wrapper.sh", "command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-paperless-wrapper.sh",
"timeout": 60 "timeout": 60
} }
} }
+6 -9
View File
@@ -282,10 +282,8 @@ services:
dockerfile: ../../dockerfiles/discourse-mcp/Dockerfile dockerfile: ../../dockerfiles/discourse-mcp/Dockerfile
container_name: kneldevstack-aimiddleware-discourse-mcp container_name: kneldevstack-aimiddleware-discourse-mcp
restart: unless-stopped restart: unless-stopped
environment: env_file:
- DISCOURSE_URL=${DISCOURSE_URL} - /home/reachableceo/.creds/discourse.env
- DISCOURSE_API_KEY=${DISCOURSE_API_KEY}
- DISCOURSE_API_USERNAME=${DISCOURSE_API_USERNAME}
profiles: profiles:
- ops - ops
@@ -398,10 +396,10 @@ services:
dockerfile: ../../dockerfiles/mcp-redmine/Dockerfile dockerfile: ../../dockerfiles/mcp-redmine/Dockerfile
container_name: kneldevstack-aimiddleware-mcp-redmine container_name: kneldevstack-aimiddleware-mcp-redmine
restart: "no" restart: "no"
env_file:
- /home/reachableceo/.creds/redmine.env
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- REDMINE_URL=${REDMINE_URL}
- REDMINE_API_KEY=${REDMINE_API_KEY}
profiles: profiles:
- ops - ops
@@ -609,11 +607,10 @@ services:
dockerfile: ../../dockerfiles/beszel-mcp/Dockerfile dockerfile: ../../dockerfiles/beszel-mcp/Dockerfile
container_name: kneldevstack-aimiddleware-beszel-mcp container_name: kneldevstack-aimiddleware-beszel-mcp
restart: "no" restart: "no"
env_file:
- /home/reachableceo/.creds/beszel.env
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- BESZEL_URL=${BESZEL_URL}
- BESZEL_USERNAME=${BESZEL_USERNAME}
- BESZEL_PASSWORD=${BESZEL_PASSWORD}
profiles: profiles:
- ops - ops
+4 -2
View File
@@ -1,13 +1,15 @@
FROM node:22-alpine FROM node:22-alpine
# Install pnpm for monorepo # Install pnpm
RUN npm install -g pnpm RUN npm install -g pnpm
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile # Disable strict built-dependencies check (pnpm v10 exits on ignored builds)
RUN echo "strict-built-dependencies=false" > .npmrc && \
pnpm install --frozen-lockfile
COPY . . COPY . .
+25 -3
View File
@@ -7,12 +7,34 @@ RUN corepack enable && corepack prepare pnpm@10.14.0 --activate
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
# Install dependencies with pinned SDK version # Install dependencies from lockfile (pins SDK to upstream-compatible version)
RUN pnpm add @modelcontextprotocol/sdk@^1.23.0
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm build RUN pnpm build
CMD ["node", "dist/index.js"] # Entrypoint script converts env vars to CLI args (profile JSON file)
RUN cat <<'ENTRYEOF' > /docker-entrypoint.sh
#!/bin/sh
set -e
if [ -n "${DISCOURSE_URL}" ] && [ -n "${DISCOURSE_API_KEY}" ]; then
echo -n '{"auth_pairs":[{"site":"' > /tmp/profile.json
echo -n "${DISCOURSE_URL}" >> /tmp/profile.json
echo -n '","api_key":"' >> /tmp/profile.json
echo -n "${DISCOURSE_API_KEY}" >> /tmp/profile.json
echo -n '"' >> /tmp/profile.json
if [ -n "${DISCOURSE_API_USERNAME}" ]; then
echo -n ',"api_username":"' >> /tmp/profile.json
echo -n "${DISCOURSE_API_USERNAME}" >> /tmp/profile.json
echo -n '"' >> /tmp/profile.json
fi
echo -n '}]}' >> /tmp/profile.json
exec node dist/index.js --profile /tmp/profile.json --site "${DISCOURSE_URL}" --allow-writes --read-only false "$@"
else
exec node dist/index.js "$@"
fi
ENTRYEOF
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
@@ -0,0 +1,32 @@
#!/bin/sh
# Convert env vars to discourse-mcp CLI arguments, then exec the server.
#
# Env vars:
# DISCOURSE_URL - base URL of the Discourse site
# DISCOURSE_API_KEY - API key
# DISCOURSE_API_USERNAME - username for the API key
#
# When DISCOURSE_URL is set, the server is tethered to that site with auth
# pre-configured. Otherwise, the client must call discourse_select_site.
set -e
ARGS=""
if [ -n "${DISCOURSE_URL}" ]; then
ARGS="${ARGS} --site ${DISCOURSE_URL}"
if [ -n "${DISCOURSE_API_KEY}" ]; then
AUTH_PAIR="{\"site\":\"${DISCOURSE_URL}\""
AUTH_PAIR="${AUTH_PAIR},\"api_key\":\"${DISCOURSE_API_KEY}\""
if [ -n "${DISCOURSE_API_USERNAME}" ]; then
AUTH_PAIR="${AUTH_PAIR},\"api_username\":\"${DISCOURSE_API_USERNAME}\""
fi
AUTH_PAIR="${AUTH_PAIR}}"
ARGS="${ARGS} --auth-pairs '[${AUTH_PAIR}]'"
fi
fi
ARGS="${ARGS} --allow-writes --read-only false"
exec eval node dist/index.js ${ARGS} "$@"
+7 -3
View File
@@ -7,10 +7,14 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile # Disable strict built-dependencies check (pnpm v10 exits on ignored builds)
RUN echo "strict-built-dependencies=false" > .npmrc && \
pnpm install --no-frozen-lockfile
COPY . . COPY . .
RUN pnpm run build # Build all packages except dev-proxy (has upstream TS errors).
# Plugin must be built before server (server copies plugin output).
RUN pnpm -r --filter '!drawio-mcp-dev-proxy' build
CMD ["node", "build/index.js"] CMD ["node", "packages/drawio-mcp-server/build/index.js"]
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.24-alpine AS builder FROM golang:1.26-alpine AS builder
WORKDIR /app WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.24-alpine AS builder FROM golang:1.26-alpine AS builder
WORKDIR /app WORKDIR /app
+3
View File
@@ -12,6 +12,9 @@ COPY . .
# Replace mcp git dependency with fastmcp from PyPI # Replace mcp git dependency with fastmcp from PyPI
RUN sed -i 's|"mcp @ git+https://github.com/modelcontextprotocol/python-sdk.git"|"fastmcp"|' pyproject.toml RUN sed -i 's|"mcp @ git+https://github.com/modelcontextprotocol/python-sdk.git"|"fastmcp"|' pyproject.toml
# Fix requires-python for fastmcp compatibility (needs >=3.10)
RUN sed -i 's/requires-python = ">=3.9"/requires-python = ">=3.10"/' pyproject.toml
# Patch imports to use fastmcp instead of mcp.server.fastmcp # Patch imports to use fastmcp instead of mcp.server.fastmcp
RUN find . -name "*.py" -exec sed -i 's/from mcp\.server\.fastmcp/from fastmcp/g' {} \; RUN find . -name "*.py" -exec sed -i 's/from mcp\.server\.fastmcp/from fastmcp/g' {} \;
+6 -6
View File
@@ -65,7 +65,7 @@ build_service() {
exit 1 exit 1
fi fi
echo -e "${GREEN}=== Building $service ===${NC}" echo -e "${GREEN}=== Building $service ===${NC}"
docker compose build "$service" docker compose --profile dev --profile ops build "$service"
} }
# Function to clean vendor directory # Function to clean vendor directory
@@ -89,19 +89,19 @@ status() {
# Function to validate MCP servers # Function to validate MCP servers
validate() { validate() {
echo -e "${GREEN}=== Validating MCP Servers ===${NC}" echo -e "${GREEN}=== Validating MCP Servers ===${NC}"
./scripts/validate-mcp.sh ./scripts/validate-all.sh mcp
} }
# Function to show logs # Function to show logs
logs() { logs() {
echo -e "${GREEN}=== Service Logs ===${NC}" echo -e "${GREEN}=== Service Logs ===${NC}"
docker compose logs -f docker compose --profile dev --profile ops logs -f
} }
# Function to show process status # Function to show process status
ps() { ps() {
echo -e "${GREEN}=== Service Process Status ===${NC}" echo -e "${GREEN}=== Service Process Status ===${NC}"
docker compose ps docker compose --profile dev --profile ops ps
} }
# Function to start services # Function to start services
@@ -126,8 +126,8 @@ rebuild() {
exit 1 exit 1
fi fi
echo -e "${GREEN}=== Rebuilding $service ===${NC}" echo -e "${GREEN}=== Rebuilding $service ===${NC}"
docker compose build --no-cache "$service" docker compose --profile dev --profile ops build --no-cache "$service"
docker compose up -d "$service" docker compose --profile dev --profile ops up -d "$service"
} }
# Main script logic # Main script logic
+8
View File
@@ -14,6 +14,14 @@ if docker ps -a --filter "name=${CONTAINER_NAME}" --format '{{.Names}}' | grep -
done done
fi fi
# Load credentials from central store
CREDS_FILE="/home/reachableceo/.creds/beszel.env"
if [ -f "$CREDS_FILE" ]; then
set -a
. "$CREDS_FILE"
set +a
fi
# Start MCP server with explicit name and environment # Start MCP server with explicit name and environment
exec docker run -i --rm --name "${CONTAINER_NAME}" \ exec docker run -i --rm --name "${CONTAINER_NAME}" \
-e BESZEL_URL="${BESZEL_URL}" \ -e BESZEL_URL="${BESZEL_URL}" \
+8
View File
@@ -14,6 +14,14 @@ if docker ps -a --filter "name=${CONTAINER_NAME}" --format '{{.Names}}' 2>/dev/n
done done
fi fi
# Load credentials from central store
CREDS_FILE="/home/reachableceo/.creds/discourse.env"
if [ -f "$CREDS_FILE" ]; then
set -a
. "$CREDS_FILE"
set +a
fi
# Start MCP server with explicit name and environment variables # Start MCP server with explicit name and environment variables
exec docker run -i --rm --name "${CONTAINER_NAME}" \ exec docker run -i --rm --name "${CONTAINER_NAME}" \
-e "DISCOURSE_URL=${DISCOURSE_URL:-}" \ -e "DISCOURSE_URL=${DISCOURSE_URL:-}" \
+8
View File
@@ -14,6 +14,14 @@ if docker ps -a --filter "name=${CONTAINER_NAME}" --format '{{.Names}}' 2>/dev/n
done done
fi fi
# Load credentials from central store
CREDS_FILE="/home/reachableceo/.creds/redmine.env"
if [ -f "$CREDS_FILE" ]; then
set -a
. "$CREDS_FILE"
set +a
fi
# Start MCP server with explicit name and environment variables # Start MCP server with explicit name and environment variables
exec docker run -i --rm --name "${CONTAINER_NAME}" \ exec docker run -i --rm --name "${CONTAINER_NAME}" \
-e "REDMINE_URL=${REDMINE_URL:-}" \ -e "REDMINE_URL=${REDMINE_URL:-}" \
+5 -5
View File
@@ -17,16 +17,16 @@ echo ""
echo "Building all services..." echo "Building all services..."
echo "" echo ""
# Change to project directory # Change to project root (parent of scripts/)
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")/.."
# Get list of all services from docker-compose.yml # Get list of all services from docker-compose.yml (include all profiles)
SERVICES=$(docker compose config --services) SERVICES=$(docker compose --profile dev --profile ops config --services | sort)
# Build each service # Build each service
for SERVICE in $SERVICES; do for SERVICE in $SERVICES; do
echo -e "${BLUE}Building: ${SERVICE}${NC}" echo -e "${BLUE}Building: ${SERVICE}${NC}"
if docker compose build "$SERVICE" 2>&1 | tail -5; then if docker compose --profile dev --profile ops build "$SERVICE" 2>&1 | tail -5; then
echo -e "${GREEN}${NC} Successfully built $SERVICE" echo -e "${GREEN}${NC} Successfully built $SERVICE"
else else
echo -e "${RED}${NC} Failed to build $SERVICE" echo -e "${RED}${NC} Failed to build $SERVICE"
+3 -2
View File
@@ -22,8 +22,9 @@ if [ "$CONFIRM" != "yes" ]; then
exit 0 exit 0
fi fi
# Vendor directory # Vendor directory (repo root is parent of scripts/)
VENDOR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vendor" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENDOR_DIR="$REPO_ROOT/vendor"
echo "" echo ""
echo "Removing vendor directories..." echo "Removing vendor directories..."
+4 -2
View File
@@ -11,8 +11,9 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# Vendor directory # Vendor directory (repo root is parent of scripts/)
VENDOR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vendor" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENDOR_DIR="$REPO_ROOT/vendor"
echo -e "${GREEN}=== KNEL-AIMiddleware Vendor Repository Clone Script ===${NC}" echo -e "${GREEN}=== KNEL-AIMiddleware Vendor Repository Clone Script ===${NC}"
echo "" echo ""
@@ -90,6 +91,7 @@ clone_repo "gitea-mcp" "https://gitea.com/gitea/gitea-mcp.git"
clone_repo "limesurvey-mcp" "https://github.com/TonisOrmisson/limesurvey-mcp.git" clone_repo "limesurvey-mcp" "https://github.com/TonisOrmisson/limesurvey-mcp.git"
clone_repo "linkwarden-mcp-server" "https://github.com/irfansofyana/linkwarden-mcp-server.git" clone_repo "linkwarden-mcp-server" "https://github.com/irfansofyana/linkwarden-mcp-server.git"
clone_repo "firefly-iii-mcp" "https://github.com/etnperlong/firefly-iii-mcp.git" clone_repo "firefly-iii-mcp" "https://github.com/etnperlong/firefly-iii-mcp.git"
clone_repo "mcp-server-elasticsearch" "https://github.com/elastic/mcp-server-elasticsearch.git"
echo -e "${GREEN}=== All repositories cloned successfully! ===${NC}" echo -e "${GREEN}=== All repositories cloned successfully! ===${NC}"
echo "" echo ""
+4 -4
View File
@@ -15,11 +15,11 @@ NC='\033[0m' # No Color
echo -e "${BLUE}=== KNEL-AIMiddleware Service Status Check ===${NC}" echo -e "${BLUE}=== KNEL-AIMiddleware Service Status Check ===${NC}"
echo "" echo ""
# Change to project directory # Change to project root (parent of scripts/)
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")/.."
# Get list of all services from docker-compose.yml # Get list of all services from docker-compose.yml (include all profiles)
SERVICES=$(docker compose config --services | sort) SERVICES=$(docker compose --profile dev --profile ops config --services | sort)
# Counters # Counters
TOTAL=0 TOTAL=0
+16 -2
View File
@@ -72,12 +72,26 @@ MCP_SERVERS=(
["kneldevstack-aimiddleware-bitwarden-mcp"]="15 BITWARDEN_CLIENT_ID=dummy BITWARDEN_CLIENT_SECRET=dummy BITWARDEN_PASSWORD=dummy BITWARDEN_SERVER_URL=https://vault.bitwarden.com" ["kneldevstack-aimiddleware-bitwarden-mcp"]="15 BITWARDEN_CLIENT_ID=dummy BITWARDEN_CLIENT_SECRET=dummy BITWARDEN_PASSWORD=dummy BITWARDEN_SERVER_URL=https://vault.bitwarden.com"
# Productivity & Automation # Productivity & Automation
["kneldevstack-aimiddleware-audiobook-mcp"]="10" ["kneldevstack-aimiddleware-audiobook-mcp"]="10 AUDIOBOOK_ROOT=/tmp"
["kneldevstack-aimiddleware-snipeit-mcp"]="10 SNIPEIT_URL=https://snipeit.example.com SNIPEIT_TOKEN=dummy" ["kneldevstack-aimiddleware-snipeit-mcp"]="10 SNIPEIT_URL=https://snipeit.example.com SNIPEIT_TOKEN=dummy"
["kneldevstack-aimiddleware-mcp-redmine"]="10 REDMINE_URL=https://redmine.example.com REDMINE_API_KEY=dummy" ["kneldevstack-aimiddleware-mcp-redmine"]="10 REDMINE_URL=https://redmine.example.com REDMINE_API_KEY=dummy"
["kneldevstack-aimiddleware-mcp-ansible"]="10" ["kneldevstack-aimiddleware-mcp-ansible"]="10"
["kneldevstack-aimiddleware-elasticsearch-mcp"]="10 ELASTICSEARCH_URL=http://localhost:9200 ELASTICSEARCH_USERNAME=dummy ELASTICSEARCH_PASSWORD=dummy" ["kneldevstack-aimiddleware-elasticsearch-mcp"]="10 ELASTICSEARCH_URL=http://localhost:9200 ELASTICSEARCH_USERNAME=dummy ELASTICSEARCH_PASSWORD=dummy"
["kneldevstack-aimiddleware-drawio-mcp"]="10" ["kneldevstack-aimiddleware-drawio-mcp"]="10 DRAWIO_URL=https://drawio.example.com"
# Financial & Budgeting
["kneldevstack-aimiddleware-firefly-iii-mcp"]="10 FIREFLY_III_BASE_URL=https://firefly.example.com FIREFLY_III_PAT=dummy"
["kneldevstack-aimiddleware-actual-mcp"]="10 ACTUAL_SERVER_URL=https://actual.example.com ACTUAL_PASSWORD=dummy ACTUAL_BUDGET_SYNC_ID=dummy"
["kneldevstack-aimiddleware-paperless-mcp"]="10"
# Additional Productivity
["kneldevstack-aimiddleware-beszel-mcp"]="10 BESZEL_URL=https://beszel.example.com BESZEL_USERNAME=dummy BESZEL_PASSWORD=dummy"
["kneldevstack-aimiddleware-gitea-mcp"]="10 GITEA_URL=https://gitea.example.com GITEA_TOKEN=dummy"
["kneldevstack-aimiddleware-mcp-grafana"]="10 GRAFANA_URL=https://grafana.example.com GRAFANA_TOKEN=dummy"
["kneldevstack-aimiddleware-ha-mcp"]="10 HOMEASSISTANT_URL=https://ha.example.com HOMEASSISTANT_TOKEN=dummy"
["kneldevstack-aimiddleware-limesurvey-mcp"]="10 LIMESURVEY_URL=https://limesurvey.example.com LIMESURVEY_USERNAME=dummy LIMESURVEY_PASSWORD=dummy"
["kneldevstack-aimiddleware-linkwarden-mcp"]="10 LINKWARDEN_URL=https://linkwarden.example.com LINKWARDEN_TOKEN=dummy"
["kneldevstack-aimiddleware-superset-mcp"]="10 SUPERSET_URL=https://superset.example.com SUPERSET_USERNAME=dummy SUPERSET_PASSWORD=dummy"
# Additional Tools # Additional Tools
["kneldevstack-aimiddleware-penpot-mcp"]="10 PENPOT_URL=https://design.penpot.app PENPOT_TOKEN=dummy" ["kneldevstack-aimiddleware-penpot-mcp"]="10 PENPOT_URL=https://design.penpot.app PENPOT_TOKEN=dummy"
+6
View File
@@ -0,0 +1,6 @@
FROM node:22-slim
ARG BW_CLI_VERSION=2026.7.0
RUN npm install -g @bitwarden/cli@${BW_CLI_VERSION} && npm cache clean --force
ENTRYPOINT ["bw"]
+87
View File
@@ -0,0 +1,87 @@
# KNELCredsManager
Centralized credential management for KNEL infrastructure. Stores service
credentials on disk in `~/.creds/` and provides a containerized Bitwarden CLI
for future migration to a password manager.
## Credential store layout
All credentials live in `~/.creds/` as flat `.env` files, one per service:
```
~/.creds/
├── beszel.env # Beszel monitoring (hub URL set, auth TBD — see #406)
├── discourse.env # Discourse forum API keys + admin key
├── phpipam.env # phpIPAM app_id + app_code
├── redmine.env # Redmine REST API key
├── technitium.env # Technitium DNS API key + admin password
└── uptime-kuma.env # Uptime Kuma push/API key
```
Permissions: directory `700`, files `600` (owner read/write only).
### Consumers
All credential consumers source from `~/.creds/`:
| Service | Wrapper / consumer | Mechanism |
|---|---|---|
| Redmine | `redmine-cli` container (`tooling-cli/redmine/`) | `docker run --env-file ~/.creds/redmine.env` |
| Redmine MCP | `mcp-redmine-wrapper.sh` | `set -a; . ~/.creds/redmine.env; set +a` |
| Discourse | `discourse-cli` container (`tooling-cli/discourse/`) | `docker run --env-file ~/.creds/discourse.env` |
| Discourse MCP | `mcp-discourse-wrapper.sh` | `set -a; . ~/.creds/discourse.env; set +a` |
| Beszel MCP | `mcp-beszel-wrapper.sh` | `set -a; . ~/.creds/beszel.env; set +a` |
| Uptime Kuma | (no active consumer yet) | Direct env reference |
| Technitium | (no active consumer yet) | Direct env reference |
| phpIPAM | (no active consumer yet) | Direct env reference |
## Bitwarden CLI
The Bitwarden CLI runs in a pinned Docker container — no host installation
required. Built from this directory's Dockerfile.
### Build
```bash
docker build -t reachableceo-bw-cli:2026.7.0 .
```
### Wrapper
```bash
# Symlink the wrapper onto PATH
ln -sf ~/projects/KNEL-AIMiddleware/tooling-cli/KNELCredsManager/scripts/bw ~/.local/bin/bw
```
### Usage
```bash
bw login # interactive first-time login
export BW_SESSION=$(bw unlock --raw) # unlock and capture session
bw sync # sync vault
bw list items # list all vault items
bw get item <name> # get a specific item
bw status # check auth/session status
```
Session state persists in `~/.local/share/bw-cli/` across container runs.
### MCP integration (machine-to-machine)
For agent automation without interactive login, the KNEL-AIMiddleware fleet
includes `mcp-bitwarden-wrapper.sh` which uses `BITWARDEN_CLIENT_ID` /
`BITWARDEN_CLIENT_SECRET` / `BITWARDEN_PASSWORD` env vars (machine account
auth). That is a separate integration from this CLI wrapper.
## Build arguments
| Arg | Default | Description |
|---|---|---|
| `BW_CLI_VERSION` | `2026.7.0` | Pinned @bitwarden/cli npm version |
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `BW_CLI_IMAGE` | `reachableceo-bw-cli:2026.7.0` | Override image tag |
| `BW_SESSION` | (unset) | Session key from `bw unlock` |
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# bw — Bitwarden CLI wrapper (Docker containerized, host stays clean).
#
# Usage:
# bw login (interactive — prompts for email/master password/2FA)
# bw unlock (interactive — prints export BW_SESSION=... line)
# bw unlock --raw (prints only the session key, for scripting)
# bw list items
# bw get item <name-or-id>
# bw sync
# bw status
#
# Session management:
# After `bw unlock`, capture the session key:
# export BW_SESSION=$(bw unlock --raw)
# The wrapper passes BW_SESSION through automatically if already set.
#
# Data persistence:
# BW data.json lives at ~/.local/share/bw-cli/ mounted into the container,
# so login state persists across invocations.
set -euo pipefail
IMAGE="${BW_CLI_IMAGE:-reachableceo-bw-cli:2026.7.0}"
DATA_DIR="${HOME}/.local/share/bw-cli"
mkdir -p "$DATA_DIR"
# Detect TTY for interactive commands (login, unlock)
INTERACTIVE=""
if [ -t 0 ] && [ -t 1 ]; then
INTERACTIVE="-it"
fi
# Pass BW_SESSION through if set
SESSION_ARGS=()
if [ -n "${BW_SESSION:-}" ]; then
SESSION_ARGS+=( -e "BW_SESSION=${BW_SESSION}" )
fi
CONTAINER_NAME="reachableceo-bw-cli-$(date +%s)"
exec docker run --rm $INTERACTIVE \
--user "$(id -u):$(id -g)" \
--name "$CONTAINER_NAME" \
-e HOME=/home/bw \
-v "${DATA_DIR}:/home/bw/.config" \
"${SESSION_ARGS[@]}" \
"$IMAGE" "$@"
+5
View File
@@ -0,0 +1,5 @@
# Discourse connection details
# Copy this file to .env and fill in real values. The .env file is gitignored.
DISCOURSE_URL=https://discourse.example.com
DISCOURSE_API_KEY=your-api-key-here
DISCOURSE_API_USERNAME=your-username
+8
View File
@@ -0,0 +1,8 @@
# Secrets - never commit
.env
# OS / editor cruft
.DS_Store
*.swp
*.swo
*~
+346
View File
@@ -0,0 +1,346 @@
# AGENTS.md — Discourse CLI tooling
This directory holds the `discourse-cli` Docker container — a thin Python CLI
over the Discourse REST API. Every Crush session that works on or with this
tool should read this file first: how to invoke it, what commands exist, what
gotchas to avoid, and the patterns to follow for common forum operations.
## Connection & invocation
Invoke the real container with `docker run`. Credentials come from the
centralized credential store (see `tooling-cli/KNELCredsManager`):
```bash
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest <command>
```
There is intentionally **no `bin/` wrapper** — invoke the real container, as
the project-wide rules require. For readability in an interactive session you
may define a throwaway shell alias (do not commit one):
```bash
alias discourse='docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest'
```
The examples below write `discourse <cmd>` for brevity; expand it to the full
`docker run` line (or set the alias) before running.
| Item | Value |
|------|-------|
| Instance | `https://community.turnsys.com` |
| API user | Charles N Wyble (id 2, username `reachableceo`, trust level 4) |
| Admin? | **No** — trust 4 (Leader) but not staff/admin. Admin-only endpoints will 403. |
| Credentials | `~/.creds/discourse.env` (`DISCOURSE_URL`, `DISCOURSE_API_KEY`, `DISCOURSE_API_USERNAME`) |
| CLI image | `git.knownelement.com/reachableceo/discourse-cli:latest` |
| Source | `src/discourse_cli.py` in this directory |
To use a locally built image instead of the registry image, either set
`DISCOURSE_CLI_IMAGE` or swap the tag for `kneldevstack-aimiddleware-discourse-cli:latest`.
## Quick start
```bash
# Connection sanity check (run this first in any session):
discourse whoami
# List categories:
discourse categories
# Latest topics (or in a category):
discourse ls
discourse ls -c general
# Look at a topic:
discourse show 42
```
## Command reference
| Command | Description |
|---------|-------------|
| `whoami` | Authenticated user + connection test. |
| `categories` | List categories (id, slug, topic count, name). |
| `cat-info <id\|slug>` | Show details of a single category. |
| `topics` (`ls`) | List topics (latest, or filtered). Filters below. |
| `show <topic_id>` | Full topic detail incl. all posts (HTML stripped to text). |
| `create` | Create a new topic (first post). |
| `reply <topic_id>` | Reply to a topic. |
| `update <post_id>` | Edit an existing post's body. |
| `delete <post_id>` | Delete a post. |
| `search <query>` | Search the forum. |
| `notifications` | List your notifications. |
### `topics` / `ls` filters
- `-c, --category <id|slug>` — only topics in a category
- `-n, --new` — only new (to you) topics
- `-u, --unread` — only unread topics
- `-p, --page <n>` — page number (default 0)
### `create` options
- `-t, --title` **required**
- `-b, --body` **required** (raw markdown/text — this becomes the first post)
- `-c, --category <id|slug>`
- `--tags` — comma-separated tag list
### `reply` options
- `topic_id` (positional, **required**) — the topic to reply in
- `-b, --body` **required** (raw markdown/text)
- `-r, --reply-to <post_number>` — reply to a specific **post number** (not id)
### `update` / `delete`
- Both operate on a **post id** (positional, required), not a topic id or
post number. Find the post id via `show` or the raw API (see gotcha below).
- `update -b, --body` **required** (new raw body).
### `search`
- `<query>` (positional, required)
- `-p, --page <n>` — page number (default 1)
### `notifications`
- `-l, --limit <n>` — max results (default 20)
## Key categories
| ID | Slug | Name | Notes |
|----|------|------|-------|
| 4 | `general` | General | Open discussion |
| 23 | `reachableceo` | ReachableCEO | Personal |
| 6 | `chiefoperationsandfinanceofficer` | ChiefOperationsOfficer | COO seat — parent for VP subcategories |
| 3 | `staff` | Staff | May be restricted |
| 74 | `vp-techops` | VP TechOps | ChiefOperationsOfficer (6) — 11 wiki topics migrated from PFVCluster |
| 75 | `vp-compliance` | VP Compliance | ChiefOperationsOfficer (6) — awaiting content |
| 76 | `board` | Board | (top-level, future) |
Run `discourse categories` for the full, current list.
### Key topics (VP TechOps — category 74)
Eleven wiki topics migrated from PFVCluster (Discourse is source of truth):
| Topic | Title | Pattern |
|-------|-------|---------|
| 296 | PFVCluster Project Overview | Pinned wiki |
| 297 | Operations Status | Pinned wiki (updated in place) |
| 298 | Infrastructure Audit Log | Wiki index + dated replies |
| 299 | Network Topology | Wiki |
| 300 | Storage Architecture | Wiki |
| 301 | Data Center Infrastructure | Wiki |
| 302 | Automation and Provisioning | Wiki |
| 303 | Security Architecture and Hardening | Wiki |
| 304 | Proxmox Fleet Reference | Wiki + replies |
| 305 | Kubernetes Platform | Wiki + replies |
| 306 | DNS and DHCP Services | Wiki + replies |
## Gotcha: post id vs post number
Discourse distinguishes a **post number** (1, 2, 3... within a topic — the
first post is always #1) from a **post id** (a globally unique integer).
- `reply -r` takes a **post number**.
- `update` / `delete` take a **post id**.
`show` prints headers like `--- #3 [3] author ...` where the value in brackets
is the post number (here they often coincide for simple topics, but they are
**not** the same thing). To reliably get a post's **id**, either read the raw
JSON via the escape hatch below, or note that `create`/`reply` print the id
of the post they just made (`Posted reply #<num> (id=<id>)`).
## Gotcha: not an admin (user key)
The default API user is trust level 4 (Leader) but **not** an admin/staff
member. With the user-level key:
- **Cannot** create categories, set wiki posts, configure site settings, or
access `/admin/...` endpoints (all 403).
- **Can** create topics, reply, edit own posts, search, list notifications.
For admin operations (category creation, wiki flagging, docs plugin config),
an **admin-scoped API key** is needed. Set it via `DISCOURSE_ADMIN_KEY` in the
credential file and pass it explicitly in raw `requests` calls.
Things that will fail or be restricted without admin:
- Deleting other users' posts.
- Creating topics in staff-only or restricted categories.
- Moving/merging/recategorizing topics.
- Setting the wiki flag on posts.
If an operation 403s, that's expected — surface it to the user rather than
retrying.
## Gotcha: bulk operations & raw API
For anything beyond a single `create`/`reply`/`update`, or to read fields the
CLI doesn't print (e.g. exact post ids, category permissions, user lists),
run Python directly inside the container with `requests`. The CLI uses raw
`requests` against the Discourse REST API (no heavyweight SDK).
Pattern (mount a script and run it in the same image):
```bash
cat > /tmp/script.py <<'PY'
import os, requests
URL = os.environ["DISCOURSE_URL"].rstrip("/")
H = {
"Api-Key": os.environ["DISCOURSE_API_KEY"],
"Api-Username": os.environ["DISCOURSE_API_USERNAME"],
"Accept": "application/json",
}
# Example: get post ids for topic 42
r = requests.get(f"{URL}/t/42.json", headers=H, timeout=30)
r.raise_for_status()
for p in r.json()["post_stream"]["posts"]:
print(p["post_number"], "id=", p["id"], "by", p["username"])
PY
docker run --rm --env-file ~/.creds/discourse.env \
--entrypoint python \
-v /tmp/script.py:/tmp/script.py \
git.knownelement.com/reachableceo/discourse-cli:latest \
/tmp/script.py
```
Useful raw endpoints:
- `GET /t/<topic_id>.json` — full topic incl. `post_stream.posts[]` (each has
`id`, `post_number`, `username`, `cooked`, `raw`).
- `GET /categories.json` — all categories.
- `GET /c/<cat_id>.json` — topics in a category.
- `POST /posts.json` — create topic (`title`+`raw`+`category`) or reply
(`topic_id`+`raw`).
- `PUT /posts/<post_id>.json` — edit (`{"post":{"raw":"..."}}`).
- `DELETE /posts/<post_id>.json` — delete.
- `GET /search.json?q=<query>` — search.
- `GET /notifications.json` — your notifications.
## Patterns
### Pattern: review & respond to a topic
1. `discourse show <topic_id>` — read the topic and all replies.
2. Identify the post you're responding to; note its **post number** (for
`-r`) and the overall context.
3. Draft a reply. `create`/`reply` take **raw markdown** — links, lists,
code fences all work.
4. `discourse reply <topic_id> -b "your markdown" [-r <post_number>]`.
5. Verify with `discourse show <topic_id>`.
### Pattern: before you act
- Always `discourse show <topic_id>` before replying/editing — confirm
the current state so you don't duplicate or contradict prior posts.
- **Never delete a post** unless the user explicitly asks.
- Prefer replying over editing someone else's post (and editing others'
posts will likely 403 anyway as a non-admin).
- When unsure which category to post in, `discourse categories` and pick
the closest match, or ask the user.
### Pattern: posting conventions
- Bodies are **raw markdown** — Discourse renders them. Use fenced code
blocks for commands/output, headings, tables, and bullet lists freely.
- Keep titles concise and descriptive.
- Use tags where the category supports them (`--tags a,b,c`).
### Pattern: wiki topics (living documents)
Wiki topics are the core anti-sprawl primitive. A wiki topic's **first post
is editable by anyone** with permission (not just the original author), and
Discourse preserves the full edit history automatically.
When to use a wiki topic:
- **Living references** — inventories, host lists, network topology, storage
maps. Updated in place as facts change.
- **Collaborative documents** — policies, design docs, specs where multiple
agents/humans contribute. First post = the document; replies = discussion.
- **Status/index pages** — operations status, documentation indexes.
When NOT to use a wiki topic:
- **Discussion threads** — regular topics where each reply is a distinct
voice. Wiki-editing the first post would destroy the conversation.
- **Point-in-time reports** — these go as dated replies inside a wiki
"log" topic (see audit pattern below).
To mark a post as wiki via the API (requires admin/moderator):
```python
requests.put(f"{URL}/posts/{post_id}.json", headers=H, json={"wiki": True})
```
### Pattern: post lifecycle (comment vs replace vs new)
The golden rule: **never create a new topic for an update to existing
knowledge.** One wiki topic per subsystem, updated in place.
| Change type | Action | Why |
|-------------|--------|-----|
| Fact update (new VM, IP changed) | Edit the wiki post in place | Edit history preserves old state |
| New snapshot (audit, drift report) | New reply in the existing log topic | One topic accumulates history |
| Question/discussion about content | Reply to the relevant post | Threaded, doesn't mutate the doc |
| Major restructure | Edit wiki + reply noting why | Edit log = what; reply = why |
### Pattern: audit/snapshot logs
Point-in-time reports (audits, drift reports, status snapshots) do NOT each
get their own topic. Instead, create ONE wiki "log" topic per domain and
append each snapshot as a dated reply:
```
[Wiki post #1] Index table (date | scope | link | superseded-by)
+ latest snapshot summary
[Reply #2] Audit 2026-07-29 — full content
[Reply #3] Audit 2026-07-30 — full content (supersedes #2)
[Reply #4] Audit 2026-08-05 — full content (current)
```
When a new snapshot arrives: add a reply with full content, then edit the
wiki first post to point at the latest reply as "current."
### Pattern: tag taxonomy
Tags are cross-cutting classifiers that prevent category multiplication:
| Tag | Meaning |
|-----|---------|
| `reference` | Living reference doc (inventories, topology, host lists) |
| `runbook` | Operational procedure (deploy, recover, configure) |
| `architecture` | Design doc, system architecture, capacity model |
| `policy` | Naming conventions, security policies, standards |
| `decision` | Resolved decision (ADR, distro choice, analysis outcome) |
| `audit` | Point-in-time snapshot |
| `security` | Security/hardening topic |
### Pattern: category taxonomy
Org-based hierarchy mirroring Known Element's VP seats:
```
ChiefOperationsOfficer (id 6)
├── vp-techops — infrastructure, network, compute, security ops
└── vp-compliance — frameworks, evidence, audit response
Board (future)
```
Security operations (SecOps) lives under vp-techops (security is operational).
Compliance covers frameworks (CMMC/STIG), evidence, and audit response.
## Git workflow
### Atomic commits
Each commit is **one logical change** — one feature, one fix, one doc update.
If you're tempted to write "and also..." in a commit message, that's a sign
to split it into two commits. Stage precisely (`git add <file>`, not
`git add -A`) so unrelated changes don't get bundled.
### Conventional commit messages
Use the [Conventional Commits](https://www.conventionalcommits.org/) format:
```
<type>(<optional scope>): <imperative subject>
<optional body — why, not what>
```
Types used in this repo: `feat`, `fix`, `docs`, `refactor`, `chore`, `style`.
Rules:
- Subject line **under 72 chars**, lowercase, imperative mood.
- No period at end of subject.
- Body wrapped at 72 chars, explains **why** the change exists.
### Commit cadence
- **Commit early and often.** Don't accumulate a pile of unrelated changes.
- Commit **without asking** — if you made a coherent change, commit it.
- Every task (topic review, reply, script addition, doc update) ends with
the relevant files committed.
- Run `git status` before committing to stage only what belongs together.
+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/discourse_cli.py /usr/local/bin/discourse-cli
RUN chmod +x /usr/local/bin/discourse-cli
ENTRYPOINT ["discourse-cli"]
CMD ["--help"]
+117
View File
@@ -0,0 +1,117 @@
# discourse-cli
A Docker container that lets an AI agent (or a human) read, post, reply,
search, and discuss on a [Discourse](https://www.discourse.org/) forum through
the Discourse REST API. A small Python CLI built on `requests`.
## Requirements
- Docker on the host.
- A Discourse instance with API access enabled
(Admin → API → "All users" or "Single user" API key).
- An API key and the username the key acts as.
## Configuration
Credentials live in the **centralized credential store** at
`~/.creds/discourse.env` (see `tooling-cli/KNELCredsManager`). It holds:
```
DISCOURSE_URL=https://discourse.example.com
DISCOURSE_API_KEY=abc123...
DISCOURSE_API_USERNAME=your-username
```
Use `.env.example` in this directory as a template if you need to create one.
Permissions: directory `700`, the env file `600` (owner read/write only).
## Build
```bash
docker build -t kneldevstack-aimiddleware-discourse-cli:latest .
```
A prebuilt image is also available in the Gitea registry:
```
git.knownelement.com/reachableceo/discourse-cli:latest
```
## Usage
Invoke the container directly with `docker run`. Pass credentials from the
centralized store via `--env-file`:
```bash
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest whoami
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest ls
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest show 42
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest create -c 5 -t "Hello" -b "Body text"
docker run --rm --env-file ~/.creds/discourse.env \
git.knownelement.com/reachableceo/discourse-cli:latest reply 42 -b "Nice point"
```
To use a locally built image instead of the registry image, either set
`DISCOURSE_CLI_IMAGE` or swap the image tag for `kneldevstack-aimiddleware-discourse-cli:latest`.
## Commands
| Command | Description |
| -------------------------------- | ------------------------------------------------------- |
| `whoami` | Show the authenticated user (also a connection test). |
| `categories` | List categories (id, slug, topic count). |
| `cat-info <cat>` | Show details of a category (id or slug). |
| `topics` (`ls`) | List topics (latest, or filtered by category/new/unread).|
| `show <topic_id>` | Show a topic with all its posts. |
| `create` | Create a topic (`--category`, `--title`, `--body`, `--tags`). |
| `reply <topic_id>` | Reply to a topic (`--body`, optional `--reply-to`). |
| `update <post_id>` | Update a post (`--body`). |
| `delete <post_id>` | Delete a post. |
| `search <query>` | Search the forum. |
| `notifications` | List your notifications. |
### `topics` / `ls` filters
- `-c, --category <id|slug>` — only topics in this category
- `-n, --new` — only new (unread tracking) topics
- `-u, --unread` — only unread topics
- `-p, --page <n>` — page number (default 0)
### `create` options
- `-c, --category <id|slug>` — category to post in
- `-t, --title <text>` — topic title (required)
- `-b, --body <text>` — post body, raw text or markdown (required)
- `--tags <a,b,c>` — comma-separated tags
### `reply` options
- `-b, --body <text>` — reply body, raw text or markdown (required)
- `-r, --reply-to <post_number>` — reply to a specific post number (optional)
## Environment variables
| Variable | Required | Description |
| ----------------------- | -------- | ---------------------------------------- |
| `DISCOURSE_URL` | yes | Base URL of the Discourse instance. |
| `DISCOURSE_API_KEY` | yes | API key ("All users" or "Single user"). |
| `DISCOURSE_API_USERNAME`| yes | Username the API key acts as. |
## Validation
```bash
./validate.sh
```
Runs a full live read/write cycle against the configured instance (credential
check, whoami, categories, topics, show, search, create+reply+update+delete
cleanup). Credentials are read from `~/.creds/discourse.env` by default;
override with `DISCOURSE_ENV_FILE`.
+1
View File
@@ -0,0 +1 @@
requests>=2.31.0
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""Discourse CLI - a thin wrapper around the Discourse REST API.
Connection details come from the environment:
DISCOURSE_URL base URL of the Discourse instance (e.g. https://discourse.example.com)
DISCOURSE_API_KEY API key ("All users" or "Single user" key from Admin > API)
DISCOURSE_API_USERNAME username the API key acts as (e.g. system, or your account)
Designed to be run inside the discourse-cli Docker container, but works anywhere
these environment variables are set.
"""
import argparse
import json
import os
import sys
import requests
# --------------------------------------------------------------------------- #
# HTTP client
# --------------------------------------------------------------------------- #
class DiscourseAPI:
"""Minimal authenticated Discourse REST client."""
def __init__(self, url, api_key, api_username):
self.base_url = url.rstrip("/")
self.headers = {
"Api-Key": api_key,
"Api-Username": api_username,
"Accept": "application/json",
}
self.timeout = 30
def _request(self, method, path, **kwargs):
url = f"{self.base_url}/{path.lstrip('/')}"
resp = requests.request(
method, url, headers=self.headers, timeout=self.timeout, **kwargs
)
if resp.status_code == 429:
_err(f"rate limited by Discourse (HTTP 429). Retry later.")
if not resp.ok:
detail = ""
try:
detail = resp.json().get("errors", resp.text[:200])
except Exception:
detail = resp.text[:200]
_err(f"Discourse API error {resp.status_code} for {method} {path}: {detail}")
if resp.status_code == 204 or not resp.content:
return {}
return resp.json()
def get(self, path, params=None):
return self._request("GET", path, params=params)
def post(self, path, data=None):
return self._request("POST", path, json=data)
def put(self, path, data=None):
return self._request("PUT", path, json=data)
def delete(self, path, data=None):
return self._request("DELETE", path, json=data)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _client():
"""Build and return an authenticated Discourse client, or exit with help."""
url = os.environ.get("DISCOURSE_URL", "").strip()
key = os.environ.get("DISCOURSE_API_KEY", "").strip()
user = os.environ.get("DISCOURSE_API_USERNAME", "").strip()
missing = [
n
for n, v in (
("DISCOURSE_URL", url),
("DISCOURSE_API_KEY", key),
("DISCOURSE_API_USERNAME", user),
)
if not v
]
if missing:
sys.stderr.write(
"ERROR: missing required environment variable(s): "
+ ", ".join(missing)
+ "\n"
)
sys.exit(2)
return DiscourseAPI(url, key, user)
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}"
# --------------------------------------------------------------------------- #
# Commands
# --------------------------------------------------------------------------- #
def cmd_whoami(args):
api = _client()
data = api.get("/session/current.json")
u = data.get("current_user", {})
print(f"id: {u.get('id', '?')}")
print(f"username: {u.get('username', '?')}")
print(f"name: {u.get('name', '?')}")
print(f"admin: {u.get('admin', False)}")
print(f"trust: {u.get('trust_level', '?')}")
print(f"discourse: {os.environ['DISCOURSE_URL']}")
return 0
def cmd_categories(args):
api = _client()
data = api.get("/categories.json", params={"include_subcategories": "true"})
cats = data.get("category_list", {}).get("categories", [])
print(f"{'ID':<6} {'SLUG':<36} {'TOPICS':>7} NAME")
print("-" * 90)
count = 0
for c in cats:
topic_count = c.get("topic_count", 0)
print(f"{c.get('id','?'):<6} {str(c.get('slug','')):<36} {topic_count:>6} {c.get('name','')}")
count += 1
for s in c.get("subcategory_list") or []:
sub_count = s.get("topic_count", 0)
slug = " " + str(s.get("slug", ""))
name = " " + s.get("name", "")
print(f"{s.get('id','?'):<6} {slug:<36} {sub_count:>6} {name}")
count += 1
print(f"\n{count} category(ies)")
return 0
def cmd_topics(args):
api = _client()
if args.category:
cat_id = _resolve_category(api, args.category)
data = api.get(f"/c/{cat_id}.json", params={"page": args.page})
topics = data.get("topic_list", {}).get("topics", [])
else:
path = "/latest.json"
if args.unread:
path = "/unread.json"
elif args.new:
path = "/new.json"
data = api.get(path, params={"page": args.page})
topics = data.get("topic_list", {}).get("topics", [])
print(f"{'ID':<10} {'REPLIES':>8} {'VIEWS':>9} TITLE")
print("-" * 90)
for t in topics:
if t.get("pinned"):
continue
replies = t.get("posts_count", 1) - 1
views = t.get("views", 0)
title = t.get("title", "")
print(f"{t.get('id','?'):<10} {replies:>8} {views:>9} {title}")
print(f"\n{len(topics)} topic(s)")
return 0
def cmd_show(args):
api = _client()
data = api.get(f"/t/{args.topic_id}.json")
print(f"#{data.get('id','?')}: {data.get('title','?')}")
print("=" * 90)
print(_kv("category_id", data.get("category_id")))
print(_kv("views", data.get("views")))
print(_kv("like_count", data.get("like_count")))
print(_kv("posts_count", data.get("posts_count")))
print(_kv("created", data.get("created_at")))
print(_kv("last_posted", data.get("last_posted_at")))
print("")
posts = data.get("post_stream", {}).get("posts", [])
for idx, p in enumerate(posts, start=1):
post_num = p.get("post_number", idx)
author = p.get("username", "?")
created = p.get("created_at", "?")
likes = p.get("actions_summary", [])
like_count = 0
for a in likes:
if a.get("id") == 2:
like_count = a.get("count", 0)
print(f"\n--- #{post_num} [{post_num}] {author} ({created}) likes={like_count} ---")
cooked = p.get("cooked", "")
text = _strip_html(cooked)
if text.strip():
print(text.strip())
else:
print("(no content)")
return 0
def cmd_create(args):
api = _client()
if not args.title:
_err("--title is required to create a topic")
if not args.body:
_err("--body is required to create a topic")
cat_id = _resolve_category(api, args.category) if args.category else None
payload = {
"title": args.title,
"raw": args.body,
}
if cat_id is not None:
payload["category"] = cat_id
if args.tags:
payload["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
data = api.post("/posts.json", data=payload)
topic_id = data.get("topic_id", "?")
post_id = data.get("id", "?")
print(f"Created topic #{topic_id} (post #{post_id}): {args.title}")
if topic_id != "?":
print(f"URL: {os.environ['DISCOURSE_URL']}/t/{topic_id}")
return 0
def cmd_reply(args):
api = _client()
if not args.body:
_err("--body is required to reply")
payload = {"topic_id": args.topic_id, "raw": args.body}
if args.reply_to:
payload["reply_to_post_number"] = args.reply_to
data = api.post("/posts.json", data=payload)
post_id = data.get("id", "?")
post_num = data.get("post_number", "?")
print(f"Posted reply #{post_num} (id={post_id}) in topic #{args.topic_id}")
return 0
def cmd_update(args):
api = _client()
if not args.body:
_err("--body is required to update a post")
data = api.put(f"/posts/{args.post_id}.json", data={"post": {"raw": args.body}})
post_num = data.get("post", {}).get("post_number", "?")
print(f"Updated post #{post_num} (id={args.post_id})")
return 0
def cmd_delete(args):
api = _client()
api.delete(f"/posts/{args.post_id}.json")
print(f"Deleted post #{args.post_id}")
return 0
def cmd_search(args):
api = _client()
params = {"q": args.query, "page": args.page}
data = api.get("/search.json", params=params)
topics = data.get("topics", [])
posts = data.get("posts", [])
topic_map = {t.get("id"): t for t in topics}
print(f"{'TOPIC':<10} {'POST':<8} BLURB")
print("-" * 90)
for p in posts:
tid = p.get("topic_id", "?")
t = topic_map.get(tid, {})
title = t.get("title", "")
blurb = p.get("blurb", "")
pid = p.get("id", "?")
print(f"{tid:<10} {pid:<8} {title}")
if blurb:
print(f"{'':<20}{blurb}")
print(f"\n{len(posts)} result(s)")
return 0
def cmd_notifications(args):
api = _client()
data = api.get("/notifications.json")
notifs = data.get("notifications", [])
if not notifs:
print("(no notifications)")
return 0
print(f"{'ID':<10} {'READ':>5} TYPE SUBJECT")
print("-" * 90)
for n in notifs[: args.limit]:
nid = n.get("id", "?")
read = "yes" if n.get("read") else "no"
ntype = n.get("notification_type", "?")
subject = n.get("slug") or n.get("data", {}).get("topic_title", "")
print(f"{nid:<10} {read:>5} {str(ntype):<20} {subject}")
print(f"\n{len(notifs)} notification(s), showing {min(len(notifs), args.limit)}")
return 0
def cmd_categories_info(args):
api = _client()
cat_id = _resolve_category(api, args.category)
data = api.get(f"/c/{cat_id}/show.json")
c = data.get("category", {})
print(f"#{c.get('id','?')}: {c.get('name','?')}")
print("=" * 60)
print(_kv("slug", c.get("slug")))
print(_kv("color", c.get("color")))
print(_kv("topic_count", c.get("topic_count")))
print(_kv("post_count", c.get("post_count")))
print(_kv("description", _strip_html(c.get("description", "") or "")[:200]))
return 0
# --------------------------------------------------------------------------- #
# Resolution helpers
# --------------------------------------------------------------------------- #
def _resolve_category(api, ref):
"""Resolve a category id, slug, or name to a numeric id."""
if str(ref).isdigit():
return int(ref)
data = api.get("/categories.json", params={"include_subcategories": "true"})
cats = data.get("category_list", {}).get("categories", [])
needle = str(ref).strip().lower()
for c in cats:
if str(c.get("slug", "")).lower() == needle:
return c["id"]
if str(c.get("name", "")).lower() == needle:
return c["id"]
for s in c.get("subcategory_list") or []:
if str(s.get("slug", "")).lower() == needle:
return s["id"]
if str(s.get("name", "")).lower() == needle:
return s["id"]
_err(f"unknown category '{ref}'. Run 'categories' to list valid slugs/names.")
def _strip_html(cooked):
"""Very small HTML-to-text for display of Discourse 'cooked' post bodies."""
import html
import re
if not cooked:
return ""
# Preserve block-level breaks
text = re.sub(r"(?i)</(p|div|li|h[1-6]|tr|blockquote)>", "\n", cooked)
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
text = re.sub(r"(?i)<li[^>]*>", " - ", text)
# Code blocks
text = re.sub(r"(?i)<code[^>]*>", "`", text)
text = re.sub(r"(?i)</code>", "`", text)
# Strip all remaining tags
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
# Collapse excessive blank lines
text = re.sub(r"\n{3,}", "\n\n", text)
return text
# --------------------------------------------------------------------------- #
# Argument parsing
# --------------------------------------------------------------------------- #
def build_parser():
p = argparse.ArgumentParser(
prog="discourse-cli",
description="Read, post, and discuss on a Discourse forum 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("categories", help="list categories")
sp.set_defaults(func=cmd_categories)
sp = sub.add_parser("cat-info", help="show details of a category")
sp.add_argument("category", metavar="ID_OR_SLUG")
sp.set_defaults(func=cmd_categories_info)
sp = sub.add_parser("topics", aliases=["ls"], help="list topics (latest or in a category)")
sp.add_argument("-c", "--category", metavar="ID_OR_SLUG", help="filter by category")
sp.add_argument("-n", "--new", action="store_true", help="only new topics")
sp.add_argument("-u", "--unread", action="store_true", help="only unread topics")
sp.add_argument("-p", "--page", type=int, default=0, help="page number (default 0)")
sp.set_defaults(func=cmd_topics)
sp = sub.add_parser("show", help="show a topic with its posts")
sp.add_argument("topic_id", type=int)
sp.set_defaults(func=cmd_show)
sp = sub.add_parser("create", help="create a new topic")
sp.add_argument("-c", "--category", metavar="ID_OR_SLUG")
sp.add_argument("-t", "--title", required=True, help="topic title")
sp.add_argument("-b", "--body", required=True, help="post body (raw text/markdown)")
sp.add_argument("--tags", help="comma-separated tags")
sp.set_defaults(func=cmd_create)
sp = sub.add_parser("reply", help="reply to a topic")
sp.add_argument("topic_id", type=int)
sp.add_argument("-b", "--body", required=True, help="reply body (raw text/markdown)")
sp.add_argument(
"-r", "--reply-to", type=int, metavar="POST_NUMBER", help="reply to a specific post number"
)
sp.set_defaults(func=cmd_reply)
sp = sub.add_parser("update", help="update an existing post")
sp.add_argument("post_id", type=int)
sp.add_argument("-b", "--body", required=True, help="new post body (raw text/markdown)")
sp.set_defaults(func=cmd_update)
sp = sub.add_parser("delete", help="delete a post")
sp.add_argument("post_id", type=int)
sp.set_defaults(func=cmd_delete)
sp = sub.add_parser("search", help="search the forum")
sp.add_argument("query", help="search query")
sp.add_argument("-p", "--page", type=int, default=1, help="page number (default 1)")
sp.set_defaults(func=cmd_search)
sp = sub.add_parser("notifications", help="list your notifications")
sp.add_argument("-l", "--limit", type=int, default=20, help="max results (default 20)")
sp.set_defaults(func=cmd_notifications)
return p
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except requests.exceptions.ConnectionError:
_err(f"could not connect to Discourse at {os.environ.get('DISCOURSE_URL','?')}", 2)
except requests.exceptions.Timeout:
_err("request timed out talking to Discourse", 2)
except SystemExit:
raise
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())
+210
View File
@@ -0,0 +1,210 @@
#!/bin/sh
# discourse-cli validation script.
# Runs everything inside containers so no host tooling (curl, etc.) is needed.
#
# Usage: ./validate.sh
#
# Exit codes: 0 = all checks passed, 1 = one or more checks failed
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
# Credentials live in the centralized ~/.creds/ store (see KNELCredsManager).
# Override with DISCOURSE_ENV_FILE if you need a different location.
ENV_FILE="${DISCOURSE_ENV_FILE:-$HOME/.creds/discourse.env}"
# Ensure env file exists
if [ ! -f "$ENV_FILE" ]; then
echo "FAIL: env file not found: $ENV_FILE"
echo " Set DISCOURSE_ENV_FILE or create it in ~/.creds/discourse.env"
exit 1
fi
IMAGE="${DISCOURSE_CLI_IMAGE:-git.knownelement.com/reachableceo/discourse-cli:latest}"
PASS=0
FAIL=0
TOTAL=0
ok() { TOTAL=$((TOTAL+1)); echo "PASS: $1"; PASS=$((PASS+1)); }
fail() { TOTAL=$((TOTAL+1)); echo "FAIL: $1"; FAIL=$((FAIL+1)); }
echo "============================================"
echo " discourse-cli validation"
echo " image: ${IMAGE}"
echo "============================================"
echo ""
# ---------------------------------------------------------------- #
# 0. Credential sanity (raw HTTP via containerized curl)
# ---------------------------------------------------------------- #
echo "--- [0] Credential check (raw HTTP via containerized curl) ---"
# Load env vars from the creds file for the raw test
DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' "$ENV_FILE" | cut -d= -f2-)
DISCOURSE_API_KEY=$(grep -E '^DISCOURSE_API_KEY=' "$ENV_FILE" | cut -d= -f2-)
DISCOURSE_API_USERNAME=$(grep -E '^DISCOURSE_API_USERNAME=' "$ENV_FILE" | cut -d= -f2-)
HTTP_CODE=$(docker run --rm curlimages/curl:latest \
-s -o /dev/null -w '%{http_code}' \
-H "Api-Key: ${DISCOURSE_API_KEY}" \
-H "Api-Username: ${DISCOURSE_API_USERNAME}" \
-H "Accept: application/json" \
"${DISCOURSE_URL}/session/current.json" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
ok "raw HTTP credentials accepted (HTTP ${HTTP_CODE})"
else
fail "raw HTTP credentials rejected (HTTP ${HTTP_CODE})"
echo " -> Discourse at ${DISCOURSE_URL} returned ${HTTP_CODE} for /session/current.json"
echo " -> Check: API key active? IP allowlist? Username matches key scope?"
echo ""
echo " Stopping: cannot validate CLI commands without valid credentials."
echo ""
echo "============================================"
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
echo "============================================"
exit 1
fi
echo ""
# ---------------------------------------------------------------- #
# 1. whoami (live connection test)
# ---------------------------------------------------------------- #
echo "--- [1] whoami ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" whoami 2>&1) || true
if echo "$OUT" | grep -qE '^username:'; then
ok "whoami returned user info"
echo " $OUT" | head -5
else
fail "whoami did not return expected output"
echo " $OUT" | head -5
fi
echo ""
# ---------------------------------------------------------------- #
# 2. categories (live read)
# ---------------------------------------------------------------- #
echo "--- [2] categories ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" categories 2>&1) || true
if echo "$OUT" | grep -qE '^[0-9]+ category'; then
ok "categories returned data"
echo " $OUT" | head -6
# Grab first category id for later tests
CAT_ID=$(echo "$OUT" | grep -E '^[0-9]' | head -1 | awk '{print $1}')
else
fail "categories did not return expected output"
echo " $OUT" | head -6
CAT_ID=""
fi
echo ""
# ---------------------------------------------------------------- #
# 3. topics / ls (live read)
# ---------------------------------------------------------------- #
echo "--- [3] topics (latest) ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" ls 2>&1) || true
if echo "$OUT" | grep -qE 'topic\(s\)'; then
ok "topics list returned data"
echo " $OUT" | head -6
TOPIC_ID=$(echo "$OUT" | grep -E '^[0-9]' | head -1 | awk '{print $1}')
else
fail "topics list did not return expected output"
echo " $OUT" | head -6
TOPIC_ID=""
fi
echo ""
# ---------------------------------------------------------------- #
# 4. show topic (live read)
# ---------------------------------------------------------------- #
if [ -n "${TOPIC_ID:-}" ]; then
echo "--- [4] show topic ${TOPIC_ID} ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" show "${TOPIC_ID}" 2>&1) || true
if echo "$OUT" | grep -qE '^#'; then
ok "show topic ${TOPIC_ID} returned content"
echo " $OUT" | head -8
else
fail "show topic ${TOPIC_ID} did not return expected output"
echo " $OUT" | head -8
fi
else
echo "--- [4] show topic (SKIPPED - no topic id found in step 3) ---"
fi
echo ""
# ---------------------------------------------------------------- #
# 5. search (live read)
# ---------------------------------------------------------------- #
echo "--- [5] search ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" search "test" 2>&1) || true
if echo "$OUT" | grep -qE 'result\(s\)'; then
ok "search returned results"
echo " $OUT" | head -5
else
fail "search did not return expected output"
echo " $OUT" | head -5
fi
echo ""
# ---------------------------------------------------------------- #
# 6. create + reply + update + delete (live write cycle)
# ---------------------------------------------------------------- #
echo "--- [6] create + reply + update + delete (write cycle) ---"
CREATE_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" create \
${CAT_ID:+-c "$CAT_ID"} \
-t "discourse-cli validation test $(date +%s)" \
-b "This is an automated validation test post. It will be cleaned up shortly after creation." \
2>&1) || true
NEW_TOPIC_ID=$(echo "$CREATE_OUT" | grep -oE 'topic #[0-9]+' | grep -oE '[0-9]+')
if [ -n "${NEW_TOPIC_ID:-}" ]; then
ok "create topic succeeded (topic #${NEW_TOPIC_ID})"
echo " $CREATE_OUT"
# reply
REPLY_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" reply "${NEW_TOPIC_ID}" \
-b "Validation reply test." 2>&1) || true
POST_ID=$(echo "$REPLY_OUT" | grep -oE 'id=[0-9]+' | grep -oE '[0-9]+')
if echo "$REPLY_OUT" | grep -qE 'Posted reply'; then
ok "reply succeeded"
echo " $REPLY_OUT"
else
fail "reply failed"
echo " $REPLY_OUT"
fi
# update
if [ -n "${POST_ID:-}" ]; then
UPD_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" update "${POST_ID}" \
-b "Updated validation reply test." 2>&1) || true
if echo "$UPD_OUT" | grep -qE 'Updated post'; then
ok "update post ${POST_ID} succeeded"
else
fail "update post ${POST_ID} failed"
echo " $UPD_OUT"
fi
# delete
DEL_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" delete "${POST_ID}" 2>&1) || true
if echo "$DEL_OUT" | grep -qE 'Deleted post'; then
ok "delete post ${POST_ID} succeeded"
else
fail "delete post ${POST_ID} failed"
echo " $DEL_OUT"
fi
fi
else
fail "create topic failed"
echo " $CREATE_OUT"
fi
echo ""
# ---------------------------------------------------------------- #
# Results
# ---------------------------------------------------------------- #
echo "============================================"
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
echo "============================================"
[ "$FAIL" -eq 0 ]
+5
View File
@@ -0,0 +1,5 @@
# Technitium DNS connection details
# Copy this file to .env and fill in real values. The .env file is gitignored.
TECHNITIUM_URL=http://pfv-netinfra-01:5380
TECHNITIUM_DNS_TOKEN=your-dns-api-token-here
TECHNITIUM_DNS_ZONE=knel.net
+8
View File
@@ -0,0 +1,8 @@
# Secrets - never commit
.env
# OS / editor cruft
.DS_Store
*.swp
*.swo
*~
+97
View File
@@ -0,0 +1,97 @@
# AGENTS.md — DNS CLI tooling
This directory holds the `dns-cli` Docker container — a bash CLI over the
Technitium DNS Server REST API. Every Crush session that works on or with
this tool should read this file first.
## Connection & invocation
Invoke the real container with `docker run`. Credentials come from the
centralized credential store (see `tooling-cli/KNELCredsManager`):
```bash
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest <command>
```
There is intentionally **no `bin/` wrapper** — invoke the real container, as
the project-wide rules require. For readability in an interactive session you
may define a throwaway shell alias (do not commit one):
```bash
alias dns='docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest'
```
The examples below write `dns <cmd>` for brevity; expand it to the full
`docker run` line (or set the alias) before running.
| Item | Value |
|------|-------|
| Instance | `http://pfv-netinfra-01.knel.net:5380` |
| Default zone | `knel.net` |
| Credentials | `~/.creds/technitium.env` (`TECHNITIUM_URL`, `TECHNITIUM_DNS_TOKEN`, `TECHNITIUM_DNS_ZONE`) |
| CLI image | `git.knownelement.com/reachableceo/dns-cli:latest` |
| Source | `src/dns_cli.sh` in this directory |
## Quick start
```bash
# List all zones:
dns zones
# List records in the default zone:
dns list
# Search for a record:
dns search ns1
# Resolve a name:
dns get pfv-netinfra-01
```
## Command reference
| Command | Description |
|---------|-------------|
| `zones` | List all zones. |
| `list [zone]` | List records in a zone (default: `TECHNITIUM_DNS_ZONE`). |
| `get <name>` | Resolve a record via `dig` (A/AAAA). |
| `add <name> <ip> [ttl]` | Add an A record with auto-PTR (default TTL 3600). |
| `delete <name> <ip>` | Delete an A record. |
| `flush` | Flush the DNS cache. |
| `search <pattern>` | Search records by name pattern (case-insensitive). |
## Patterns
### Pattern: add a new DNS record
1. `dns search <hostname>` — check if the name already exists.
2. `dns add <hostname> <ip>` — creates the A record with auto-PTR.
3. `dns get <hostname>` — verify resolution.
4. `dns flush` — flush cache so the new record is immediately resolvable.
### Pattern: before you act
- Always `dns search <pattern>` or `dns list` before adding/deleting —
confirm current state so you don't create duplicates or delete the wrong
record.
- **Never delete a record** unless the user explicitly asks.
- PTR records are auto-created on `add` and auto-deleted on `delete`.
## Git workflow
### Atomic commits
Each commit is **one logical change**. Stage precisely (`git add <file>`,
not `git add -A`).
### Conventional commit messages
```
<type>(<optional scope>): <imperative subject>
<optional body — why, not what>
```
Types: `feat`, `fix`, `docs`, `refactor`, `chore`, `style`.
- Subject line under 72 chars, lowercase, imperative mood.
- No period at end of subject.
- Body wrapped at 72 chars, explains **why** the change exists.
+9
View File
@@ -0,0 +1,9 @@
FROM alpine:3.20
RUN apk add --no-cache bash curl python3 bind-tools
COPY src/dns_cli.sh /usr/local/bin/dns-cli
RUN chmod +x /usr/local/bin/dns-cli
ENTRYPOINT ["dns-cli"]
CMD ["--help"]
+82
View File
@@ -0,0 +1,82 @@
# dns-cli
A Docker container that lets an AI agent (or a human) manage DNS records on a
[Technitium DNS Server](https://technitium.com/dns/) instance through its REST
API. A small bash CLI built on `curl` + `python3` + `dig`.
## Requirements
- Docker on the host.
- A Technitium DNS Server instance with the REST API enabled.
- A DNS API token (Technitium → Settings → API Token).
## Configuration
Credentials live in the **centralized credential store** at
`~/.creds/technitium.env` (see `tooling-cli/KNELCredsManager`). It holds:
```
TECHNITIUM_URL=http://pfv-netinfra-01:5380
TECHNITIUM_DNS_TOKEN=your-api-token
TECHNITIUM_DNS_ZONE=knel.net
```
Use `.env.example` in this directory as a template if you need to create one.
Permissions: directory `700`, the env file `600` (owner read/write only).
## Build
```bash
docker build -t git.knownelement.com/reachableceo/dns-cli:latest .
```
The image is also available in the Gitea registry:
```
git.knownelement.com/reachableceo/dns-cli:latest
```
## Usage
Invoke the container directly with `docker run`. Pass credentials from the
centralized store via `--env-file`:
```bash
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest zones
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest list
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest search ns1
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest add myhost 192.168.1.50
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest delete myhost 192.168.1.50
docker run --rm --env-file ~/.creds/technitium.env \
git.knownelement.com/reachableceo/dns-cli:latest flush
```
## Commands
| Command | Description |
|---------|-------------|
| `zones` | List all zones. |
| `list [zone]` | List records in a zone (default: `TECHNITIUM_DNS_ZONE`). |
| `get <name>` | Resolve a record via `dig` (A/AAAA). |
| `add <name> <ip> [ttl]` | Add an A record with auto-PTR (default TTL 3600). |
| `delete <name> <ip>` | Delete an A record. |
| `flush` | Flush the DNS cache. |
| `search <pattern>` | Search records by name pattern (case-insensitive). |
## Environment variables
| Variable | Required | Description |
|----------|----------|-------------|
| `TECHNITIUM_URL` | yes | Base URL of the Technitium instance. |
| `TECHNITIUM_DNS_TOKEN` | yes | API token for the DNS REST API. |
| `TECHNITIUM_DNS_ZONE` | yes | Default zone for add/delete/search operations. |
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# dns-cli - CLI wrapper for Technitium DNS Server API
#
# Usage:
# dns-cli zones List all zones
# dns-cli list [zone] List records in a zone (default: $TECHNITIUM_DNS_ZONE)
# dns-cli get <name> Resolve a record (A/AAAA)
# dns-cli add <name> <ip> [ttl] Add A record (+ auto PTR)
# dns-cli delete <name> <ip> Delete A record
# dns-cli flush Flush DNS cache
# dns-cli search <pattern> Search records by name pattern
#
# Env (provided via --env-file ~/.creds/technitium.env):
# TECHNITIUM_URL Technitium base URL (required)
# TECHNITIUM_DNS_TOKEN API token (required)
# TECHNITIUM_DNS_ZONE Default zone (required)
set -euo pipefail
URL="${TECHNITIUM_URL:?TECHNITIUM_URL is required}"
TOKEN="${TECHNITIUM_DNS_TOKEN:?TECHNITIUM_DNS_TOKEN is required}"
ZONE="${TECHNITIUM_DNS_ZONE:?TECHNITIUM_DNS_ZONE is required}"
# API helper — adds Bearer auth, returns JSON
api() {
local method="$1" endpoint="$2"; shift 2
local args=()
for kv in "$@"; do
args+=(-d "$kv")
done
curl -s -H "Authorization: Bearer $TOKEN" -X "$method" "${args[@]}" \
"$URL/api/$endpoint" 2>/dev/null
}
# Subcommands
cmd_zones() {
api GET "zones/list" | python3 -c "
import json,sys
d=json.load(sys.stdin)
for z in d.get('response',{}).get('zones',[]):
print(z['name'])
"
}
cmd_list() {
local zone="${1:-$ZONE}"
api GET "zones/records/get?domain=$zone&zone=$zone&listZone=true" | python3 -c "
import json,sys
d=json.load(sys.stdin)
recs = d.get('response',{}).get('records',[])
for r in sorted(recs, key=lambda x: (x['name'], x['type'])):
rd = r.get('rData',{})
ip = rd.get('ipAddress','')
val = ip or rd.get('nameServer','') or rd.get('primaryNameServer','') or str(rd)
print(f\"{r['name']:50s} {r['type']:6s} {r.get('ttl',''):6} {val}\")
"
}
cmd_get() {
local name="$1"
dig +short "$name" 2>/dev/null || true
}
cmd_add() {
local name="$1" ip="$2" ttl="${3:-3600}"
local fqdn="$name"
[[ "$fqdn" != *.* ]] && fqdn="$name.$ZONE"
api POST "zones/records/add" \
"zone=$ZONE" "domain=$fqdn" "type=A" "ttl=$ttl" "ipAddress=$ip" "ptr=true" "overwrite=true" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('status','?'), d.get('errorMessage',''))
"
}
cmd_delete() {
local name="$1" ip="$2"
local fqdn="$name"
[[ "$fqdn" != *.* ]] && fqdn="$name.$ZONE"
api POST "zones/records/delete" \
"zone=$ZONE" "domain=$fqdn" "type=A" "ipAddress=$ip" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('status','?'), d.get('errorMessage',''))
"
}
cmd_flush() {
api POST "cache/flush" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('status','?'))
"
}
cmd_search() {
local pattern="$1"
api GET "zones/records/get?domain=$ZONE&zone=$ZONE&listZone=true" | python3 -c "
import json,sys
d=json.load(sys.stdin)
recs = d.get('response',{}).get('records',[])
for r in sorted(recs, key=lambda x: x['name']):
if '$pattern' in r['name'].lower():
rd = r.get('rData',{})
ip = rd.get('ipAddress','')
val = ip or rd.get('nameServer','') or str(rd)
print(f\"{r['name']:50s} {r['type']:6s} {val}\")
"
}
# Main
case "${1:-}" in
zones) cmd_zones ;;
list) shift; cmd_list "${1:-}" ;;
get) shift; cmd_get "$1" ;;
add) shift; cmd_add "$@" ;;
delete) shift; cmd_delete "$@" ;;
flush) cmd_flush ;;
search) shift; cmd_search "$1" ;;
""|-h|--help|help)
sed -n '2,18p' "$0" >&2
exit 0
;;
*) echo "Unknown command: $1" >&2; exit 1 ;;
esac
+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
# dns-cli validation script.
# Runs a live read-only cycle against the configured Technitium instance.
#
# Usage: ./validate.sh
# Exit codes: 0 = all checks passed, 1 = one or more checks failed
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
ENV_FILE="${TECHNITIUM_ENV_FILE:-$HOME/.creds/technitium.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "FAIL: env file not found: $ENV_FILE"
echo " Set TECHNITIUM_ENV_FILE or create it in ~/.creds/technitium.env"
exit 1
fi
IMAGE="${DNS_CLI_IMAGE:-git.knownelement.com/reachableceo/dns-cli:latest}"
PASS=0
FAIL=0
TOTAL=0
ok() { TOTAL=$((TOTAL+1)); echo "PASS: $1"; PASS=$((PASS+1)); }
fail() { TOTAL=$((TOTAL+1)); echo "FAIL: $1"; FAIL=$((FAIL+1)); }
echo "============================================"
echo " dns-cli validation"
echo " image: ${IMAGE}"
echo "============================================"
echo ""
# ---------------------------------------------------------------- #
# 1. zones (live read)
# ---------------------------------------------------------------- #
echo "--- [1] zones ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" zones 2>&1) || true
if echo "$OUT" | grep -qE '\.'; then
ok "zones returned data"
echo "$OUT" | head -5
else
fail "zones did not return expected output"
echo " $OUT" | head -5
fi
echo ""
# ---------------------------------------------------------------- #
# 2. list (live read)
# ---------------------------------------------------------------- #
echo "--- [2] list ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" list 2>&1) || true
if echo "$OUT" | grep -qE 'A\s'; then
ok "list returned records"
echo "$OUT" | head -5
else
fail "list did not return expected output"
echo " $OUT" | head -5
fi
echo ""
# ---------------------------------------------------------------- #
# 3. search (live read)
# ---------------------------------------------------------------- #
echo "--- [3] search ---"
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" search "ns" 2>&1) || true
if [ -n "$OUT" ]; then
ok "search returned results"
echo "$OUT" | head -3
else
fail "search returned no results"
fi
echo ""
# ---------------------------------------------------------------- #
# Results
# ---------------------------------------------------------------- #
echo "============================================"
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
echo "============================================"
[ "$FAIL" -eq 0 ]
+8
View File
@@ -0,0 +1,8 @@
# Secrets - never commit
.env
# OS / editor cruft
.DS_Store
*.swp
*.swo
*~
+240
View File
@@ -0,0 +1,240 @@
# AGENTS.md — Redmine CLI tooling
This directory holds the `redmine-cli` Docker container — a thin Python CLI
over `python-redmine` for Redmine issue tracking. Every Crush session that
works on or with this tool should read this file first: how to invoke it,
what commands exist, what gotchas to avoid, and the patterns to follow for
common ticket operations.
## Connection & invocation
Invoke the real container with `docker run`. Credentials come from the
centralized credential store (see `tooling-cli/KNELCredsManager`):
```bash
docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest <command>
```
There is intentionally **no `bin/` wrapper** — invoke the real container, as
the project-wide rules require. For readability in an interactive session you
may define a throwaway shell alias (do not commit one):
```bash
alias redmine='docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest'
```
The examples below write `redmine <cmd>` for brevity; expand it to the full
`docker run` line (or set the alias) before running.
| Item | Value |
|------|-------|
| Instance | `https://projects.knownelement.com` |
| API user | Charles N (id 5, login `reachableceo`) |
| Credentials | `~/.creds/redmine.env` (`REDMINE_URL`, `REDMINE_API_KEY`) |
| CLI image | `git.knownelement.com/reachableceo/redmine-cli:latest` |
| Source | `src/redmine_cli.py` in this directory |
To use a locally built image instead of the registry image, either set
`REDMINE_CLI_IMAGE` or swap the tag for `kneldevstack-aimiddleware-redmine-cli:latest`.
## Quick start
```bash
# Connection sanity check (run this first in any session):
redmine whoami
# Look at a ticket:
redmine show 314
# Your queue:
redmine list --assigned-to-me
```
## Command reference
| Command | Description |
|---------|-------------|
| `whoami` | Authenticated user + connection test. |
| `projects` | List projects (id, identifier, name). |
| `statuses` | List issue statuses + which are "closed". |
| `list` (`ls`) | List issues. Filters below. |
| `show <id>` | Full issue detail incl. note history. |
| `create` | Create an issue. |
| `update <id>` | Update an issue (status, notes, assignee, done, ...). |
| `close <id>` | Close (first closed status, done ratio 100%). |
### `list` filters
- `-m, --assigned-to-me` — only issues assigned to you
- `-a, --assigned-to <id>` — filter by assignee user id
- `-p, --project <id|slug>` — 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`)
### `create` options
- `-p, --project <id|slug>` **required**
- `-s, --subject` **required**
- `-d, --description`
- `-a, --assigned-to <id>`
- `-t, --tracker <id>`
- `--priority <id>`
- `--status <name|id>`
### `update` options
- `-s, --status <name|id>`
- `-n, --notes <text>`
- `-a, --assigned-to <id>`
- `--done-ratio <0-100>`
- `--subject`
- `--priority <id>`
## Key project / tracker / status IDs
### Most-used projects
| ID | Identifier | Name |
|----|------------|------|
| 55 | `technicaloperations` | Known Element Enterprises - Technology & Facility Services |
| 62 | `business-operations` | Known Element Enterprises - Business Services |
| 77 | `tsys-group` | TSYS Group |
Run `redmine projects` for the full list.
### Statuses
| ID | Name | Closed? |
|----|------|---------|
| 1 | New | no |
| 2 | In Progress | no |
| 3 | Resolved | yes |
| 4 | Feedback | no |
| 5 | Closed | yes |
| 6 | Rejected | no |
### Trackers
Tracker 3 = **Support** (the most common one in project 55). Inspect a
parent issue to inherit its exact tracker.
## Gotcha: creating subtasks
`redmine create` has **no `--parent` flag**. To create a subtask of an
existing issue you must use `python-redmine` directly inside the container
(via `--entrypoint python`).
Pattern (mount a script and run it in the same image):
```bash
cat > /tmp/script.py <<'PY'
import os
from redminelib import Redmine
rm = Redmine(os.environ["REDMINE_URL"].rstrip("/"), key=os.environ["REDMINE_API_KEY"])
issue = rm.issue.create(
project_id=55, # inherit from parent
tracker_id=3, # inherit from parent
priority_id=2, # inherit from parent
status_id=4, # Feedback = freshly created, awaiting work
assigned_to_id=5, # inherit from parent
parent_issue_id=314, # THE KEY FIELD
subject="Your subject",
description="Your description",
)
print(f"Created #{issue.id}")
PY
docker run --rm --env-file ~/.creds/redmine.env \
--entrypoint python \
-v /tmp/script.py:/tmp/script.py \
git.knownelement.com/reachableceo/redmine-cli:latest \
/tmp/script.py
```
**Before creating subtasks**, inspect the parent to inherit its attributes:
```bash
docker run --rm --env-file ~/.creds/redmine.env \
--entrypoint python \
git.knownelement.com/reachableceo/redmine-cli:latest -c "
import os
from redminelib import Redmine
rm = Redmine(os.environ['REDMINE_URL'].rstrip('/'), key=os.environ['REDMINE_API_KEY'])
i = rm.issue.get(PARENT_ID)
print('project_id:', i.project.id)
print('tracker_id:', i.tracker.id, i.tracker.name)
print('priority_id:', i.priority.id, i.priority.name)
print('assigned_to_id:', getattr(i.assigned_to,'id',None))
print('status_id:', i.status.id, i.status.name)
"
```
## Patterns
### Pattern: review & split a ticket
1. `redmine show <id>` — read the full ticket (description + history).
2. Identify the **distinct bodies of work**. Each should be independently
trackable and assignable.
3. Map out **dependencies** between the pieces (what blocks what).
4. Propose a split to the user as a table: proposed subject, scope, and
dependencies. Wait for approval before creating anything.
5. On approval: create subtasks (see "Gotcha: creating subtasks" above),
inheriting project/tracker/priority/assignee from the parent.
6. Update the parent's **description** to an index table of children +
recommended execution order. Add a note explaining the split.
7. Verify with `redmine show <parent_id>`.
### Pattern: ticket conventions
- **Subject prefix:** OAM-pool tickets use `OAM: <topic>`. Match the parent's
naming convention if one exists.
- **Description header:** always start with `Parent/umbrella: #<id> (<subject>).`
- **Scope section:** bullet list of concrete deliverables. Use `- [ ]` for
checklist items within a subtask.
- **Dependencies section:** list what the ticket depends on and what it blocks,
referencing ticket numbers once they exist.
- **Newly created children** go to status **Feedback (4)** so they're visible
but not yet "in progress".
- **Tables** in descriptions render in Redmine's Markdown pipeline (`| a | b |`).
### Pattern: bulk operations
For anything beyond a single `create`/`update`/`close`, write a Python script
and run it inside the container as shown in the subtask gotcha above. This
applies to: batch status changes, bulk ticket creation, inspecting parent
attributes, relationship wiring, etc.
### Pattern: before you act
- Always `redmine show <id>` before updating — confirm current status,
assignee, and existing notes so you don't clobber context.
- Never delete or close a ticket unless the user explicitly asks.
- When in doubt about project/tracker/priority, inherit from the parent or
ask the user.
## Git workflow
### Atomic commits
Each commit is **one logical change** — one feature, one fix, one doc update.
If you're tempted to write "and also..." in a commit message, that's a sign
to split it into two commits. Stage precisely (`git add <file>`, not
`git add -A`) so unrelated changes don't get bundled.
### Conventional commit messages
Use the [Conventional Commits](https://www.conventionalcommits.org/) format:
```
<type>(<optional scope>): <imperative subject>
<optional body — why, not what>
```
Types used in this repo: `feat`, `fix`, `docs`, `refactor`, `chore`, `style`.
Rules:
- Subject line **under 72 chars**, lowercase, imperative mood.
- No period at end of subject.
- Body wrapped at 72 chars, explains **why** the change exists.
### Commit cadence
- **Commit early and often.** Don't accumulate a pile of unrelated changes.
- Commit **without asking** — if you made a coherent change, commit it.
- Every task (ticket review, split, script addition, doc update) ends with
the relevant files committed.
- Run `git status` before committing to stage only what belongs together.
@@ -15,19 +15,21 @@ wrapper.
## Configuration ## Configuration
Copy the env template and fill in real values (the `.env` is gitignored): Credentials live in the **centralized credential store** at
`~/.creds/redmine.env` (see `tooling-cli/KNELCredsManager`). It holds:
```bash
cp .env.example .env
# then edit .env:
# REDMINE_URL=https://your-redmine.example.com
# REDMINE_API_KEY=abc123...
``` ```
REDMINE_URL=https://your-redmine.example.com
REDMINE_API_KEY=abc123...
```
Use `.env.example` in this directory as a template if you need to create one.
Permissions: directory `700`, the env file `600` (owner read/write only).
## Build ## Build
```bash ```bash
docker build -t kneldevstack-aimiddleware-redmine-cli . docker build -t kneldevstack-aimiddleware-redmine-cli:latest .
``` ```
A prebuilt image is also available in the Gitea registry: A prebuilt image is also available in the Gitea registry:
@@ -38,21 +40,25 @@ git.knownelement.com/reachableceo/redmine-cli:latest
## Usage ## Usage
### Via the repo wrapper (loads `.env` automatically) Invoke the container directly with `docker run`. Pass credentials from the
centralized store via `--env-file`:
```bash ```bash
bin/redmine whoami docker run --rm --env-file ~/.creds/redmine.env \
bin/redmine list --assigned-to-me git.knownelement.com/reachableceo/redmine-cli:latest whoami
bin/redmine show 123
bin/redmine close 123 --notes "work complete" docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest list --assigned-to-me
docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest show 123
docker run --rm --env-file ~/.creds/redmine.env \
git.knownelement.com/reachableceo/redmine-cli:latest close 123 --notes "work complete"
``` ```
### Via docker directly (from the repo root) To use a locally built image instead of the registry image, either set
`REDMINE_CLI_IMAGE` or swap the image tag for `kneldevstack-aimiddleware-redmine-cli:latest`.
```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 ## Commands
@@ -94,4 +100,3 @@ docker run --rm --env-file .env kneldevstack-aimiddleware-redmine-cli list --ass
| ----------------- | -------- | ------------------------------------ | | ----------------- | -------- | ------------------------------------ |
| `REDMINE_URL` | yes | Base URL of the Redmine instance. | | `REDMINE_URL` | yes | Base URL of the Redmine instance. |
| `REDMINE_API_KEY` | yes | API key of the acting user. | | `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`). |