Compare commits

...
7 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
35 changed files with 1438 additions and 86 deletions
+23 -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
@@ -159,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/`. |
+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
+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} "$@"
+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:-}" \
+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" "$@"
+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.
@@ -13,20 +13,22 @@ the Discourse REST API. A small Python CLI built on `requests`.
## Configuration ## Configuration
Copy the env template and fill in real values (the `.env` is gitignored): Credentials live in the **centralized credential store** at
`~/.creds/discourse.env` (see `tooling-cli/KNELCredsManager`). It holds:
```bash
cp .env.example .env
# then edit .env:
# DISCOURSE_URL=https://discourse.example.com
# DISCOURSE_API_KEY=abc123...
# DISCOURSE_API_USERNAME=your-username
``` ```
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 ## Build
```bash ```bash
docker build -t kneldevstack-aimiddleware-discourse-cli . docker build -t kneldevstack-aimiddleware-discourse-cli:latest .
``` ```
A prebuilt image is also available in the Gitea registry: A prebuilt image is also available in the Gitea registry:
@@ -37,16 +39,29 @@ git.knownelement.com/reachableceo/discourse-cli:latest
## Usage ## Usage
### Via docker directly (from this directory) Invoke the container directly with `docker run`. Pass credentials from the
centralized store via `--env-file`:
```bash ```bash
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli whoami docker run --rm --env-file ~/.creds/discourse.env \
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli ls git.knownelement.com/reachableceo/discourse-cli:latest whoami
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli show 42
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli create -c 5 -t "Hello" -b "Body text" docker run --rm --env-file ~/.creds/discourse.env \
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli reply 42 -b "Nice point" 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 ## Commands
| Command | Description | | Command | Description |
@@ -59,7 +74,7 @@ docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli reply 42
| `create` | Create a topic (`--category`, `--title`, `--body`, `--tags`). | | `create` | Create a topic (`--category`, `--title`, `--body`, `--tags`). |
| `reply <topic_id>` | Reply to a topic (`--body`, optional `--reply-to`). | | `reply <topic_id>` | Reply to a topic (`--body`, optional `--reply-to`). |
| `update <post_id>` | Update a post (`--body`). | | `update <post_id>` | Update a post (`--body`). |
| `delete <post_id>` | Delete a post. | | `delete <post_id>` | Delete a post. |
| `search <query>` | Search the forum. | | `search <query>` | Search the forum. |
| `notifications` | List your notifications. | | `notifications` | List your notifications. |
@@ -89,3 +104,14 @@ docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli reply 42
| `DISCOURSE_URL` | yes | Base URL of the Discourse instance. | | `DISCOURSE_URL` | yes | Base URL of the Discourse instance. |
| `DISCOURSE_API_KEY` | yes | API key ("All users" or "Single user"). | | `DISCOURSE_API_KEY` | yes | API key ("All users" or "Single user"). |
| `DISCOURSE_API_USERNAME`| yes | Username the API key acts as. | | `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`.
@@ -121,17 +121,22 @@ def cmd_whoami(args):
def cmd_categories(args): def cmd_categories(args):
api = _client() api = _client()
data = api.get("/categories.json") data = api.get("/categories.json", params={"include_subcategories": "true"})
cats = data.get("category_list", {}).get("categories", []) cats = data.get("category_list", {}).get("categories", [])
print(f"{'ID':<6} {'SLUG':<28} {'TOPICS':>7} NAME") print(f"{'ID':<6} {'SLUG':<36} {'TOPICS':>7} NAME")
print("-" * 80) print("-" * 90)
count = 0
for c in cats: for c in cats:
topic_count = c.get("topic_count", 0) topic_count = c.get("topic_count", 0)
print(f"{c.get('id','?'):<6} {str(c.get('slug','')):<28} {topic_count:>6} {c.get('name','')}") print(f"{c.get('id','?'):<6} {str(c.get('slug','')):<36} {topic_count:>6} {c.get('name','')}")
subcats = [] count += 1
for c in cats: for s in c.get("subcategory_list") or []:
subcats.extend(c.get("subcategory_ids", []) or []) sub_count = s.get("topic_count", 0)
print(f"\n{len(cats)} category(ies)") 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 return 0
@@ -314,7 +319,7 @@ def _resolve_category(api, ref):
"""Resolve a category id, slug, or name to a numeric id.""" """Resolve a category id, slug, or name to a numeric id."""
if str(ref).isdigit(): if str(ref).isdigit():
return int(ref) return int(ref)
data = api.get("/categories.json") data = api.get("/categories.json", params={"include_subcategories": "true"})
cats = data.get("category_list", {}).get("categories", []) cats = data.get("category_list", {}).get("categories", [])
needle = str(ref).strip().lower() needle = str(ref).strip().lower()
for c in cats: for c in cats:
@@ -322,6 +327,11 @@ def _resolve_category(api, ref):
return c["id"] return c["id"]
if str(c.get("name", "")).lower() == needle: if str(c.get("name", "")).lower() == needle:
return c["id"] 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.") _err(f"unknown category '{ref}'. Run 'categories' to list valid slugs/names.")
@@ -2,7 +2,7 @@
# discourse-cli validation script. # discourse-cli validation script.
# Runs everything inside containers so no host tooling (curl, etc.) is needed. # Runs everything inside containers so no host tooling (curl, etc.) is needed.
# #
# Usage: ./validate.sh (reads .env from the discourse-cli directory) # Usage: ./validate.sh
# #
# Exit codes: 0 = all checks passed, 1 = one or more checks failed # Exit codes: 0 = all checks passed, 1 = one or more checks failed
set -eu set -eu
@@ -10,13 +10,18 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
# Ensure .env exists # Credentials live in the centralized ~/.creds/ store (see KNELCredsManager).
if [ ! -f .env ]; then # Override with DISCOURSE_ENV_FILE if you need a different location.
echo "FAIL: .env not found. Copy .env.example to .env and fill in real values." 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 exit 1
fi fi
IMAGE="${DISCOURSE_CLI_IMAGE:-kneldevstack-aimiddleware-discourse-cli}" IMAGE="${DISCOURSE_CLI_IMAGE:-git.knownelement.com/reachableceo/discourse-cli:latest}"
PASS=0 PASS=0
FAIL=0 FAIL=0
TOTAL=0 TOTAL=0
@@ -35,10 +40,10 @@ echo ""
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [0] Credential check (raw HTTP via containerized curl) ---" echo "--- [0] Credential check (raw HTTP via containerized curl) ---"
# Load env vars from .env for the raw test # Load env vars from the creds file for the raw test
DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' .env | cut -d= -f2-) DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' "$ENV_FILE" | cut -d= -f2-)
DISCOURSE_API_KEY=$(grep -E '^DISCOURSE_API_KEY=' .env | 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 | cut -d= -f2-) DISCOURSE_API_USERNAME=$(grep -E '^DISCOURSE_API_USERNAME=' "$ENV_FILE" | cut -d= -f2-)
HTTP_CODE=$(docker run --rm curlimages/curl:latest \ HTTP_CODE=$(docker run --rm curlimages/curl:latest \
-s -o /dev/null -w '%{http_code}' \ -s -o /dev/null -w '%{http_code}' \
@@ -67,7 +72,7 @@ echo ""
# 1. whoami (live connection test) # 1. whoami (live connection test)
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [1] whoami ---" echo "--- [1] whoami ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" whoami 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" whoami 2>&1) || true
if echo "$OUT" | grep -qE '^username:'; then if echo "$OUT" | grep -qE '^username:'; then
ok "whoami returned user info" ok "whoami returned user info"
echo " $OUT" | head -5 echo " $OUT" | head -5
@@ -81,7 +86,7 @@ echo ""
# 2. categories (live read) # 2. categories (live read)
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [2] categories ---" echo "--- [2] categories ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" categories 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" categories 2>&1) || true
if echo "$OUT" | grep -qE '^[0-9]+ category'; then if echo "$OUT" | grep -qE '^[0-9]+ category'; then
ok "categories returned data" ok "categories returned data"
echo " $OUT" | head -6 echo " $OUT" | head -6
@@ -98,7 +103,7 @@ echo ""
# 3. topics / ls (live read) # 3. topics / ls (live read)
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [3] topics (latest) ---" echo "--- [3] topics (latest) ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" ls 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" ls 2>&1) || true
if echo "$OUT" | grep -qE 'topic\(s\)'; then if echo "$OUT" | grep -qE 'topic\(s\)'; then
ok "topics list returned data" ok "topics list returned data"
echo " $OUT" | head -6 echo " $OUT" | head -6
@@ -115,7 +120,7 @@ echo ""
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
if [ -n "${TOPIC_ID:-}" ]; then if [ -n "${TOPIC_ID:-}" ]; then
echo "--- [4] show topic ${TOPIC_ID} ---" echo "--- [4] show topic ${TOPIC_ID} ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" show "${TOPIC_ID}" 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" show "${TOPIC_ID}" 2>&1) || true
if echo "$OUT" | grep -qE '^#'; then if echo "$OUT" | grep -qE '^#'; then
ok "show topic ${TOPIC_ID} returned content" ok "show topic ${TOPIC_ID} returned content"
echo " $OUT" | head -8 echo " $OUT" | head -8
@@ -132,7 +137,7 @@ echo ""
# 5. search (live read) # 5. search (live read)
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [5] search ---" echo "--- [5] search ---"
OUT=$(docker run --rm --env-file .env "${IMAGE}" search "test" 2>&1) || true OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" search "test" 2>&1) || true
if echo "$OUT" | grep -qE 'result\(s\)'; then if echo "$OUT" | grep -qE 'result\(s\)'; then
ok "search returned results" ok "search returned results"
echo " $OUT" | head -5 echo " $OUT" | head -5
@@ -147,7 +152,7 @@ echo ""
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "--- [6] create + reply + update + delete (write cycle) ---" echo "--- [6] create + reply + update + delete (write cycle) ---"
CREATE_OUT=$(docker run --rm --env-file .env "${IMAGE}" create \ CREATE_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" create \
${CAT_ID:+-c "$CAT_ID"} \ ${CAT_ID:+-c "$CAT_ID"} \
-t "discourse-cli validation test $(date +%s)" \ -t "discourse-cli validation test $(date +%s)" \
-b "This is an automated validation test post. It will be cleaned up shortly after creation." \ -b "This is an automated validation test post. It will be cleaned up shortly after creation." \
@@ -159,7 +164,7 @@ if [ -n "${NEW_TOPIC_ID:-}" ]; then
echo " $CREATE_OUT" echo " $CREATE_OUT"
# reply # reply
REPLY_OUT=$(docker run --rm --env-file .env "${IMAGE}" reply "${NEW_TOPIC_ID}" \ REPLY_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" reply "${NEW_TOPIC_ID}" \
-b "Validation reply test." 2>&1) || true -b "Validation reply test." 2>&1) || true
POST_ID=$(echo "$REPLY_OUT" | grep -oE 'id=[0-9]+' | grep -oE '[0-9]+') POST_ID=$(echo "$REPLY_OUT" | grep -oE 'id=[0-9]+' | grep -oE '[0-9]+')
if echo "$REPLY_OUT" | grep -qE 'Posted reply'; then if echo "$REPLY_OUT" | grep -qE 'Posted reply'; then
@@ -172,7 +177,7 @@ if [ -n "${NEW_TOPIC_ID:-}" ]; then
# update # update
if [ -n "${POST_ID:-}" ]; then if [ -n "${POST_ID:-}" ]; then
UPD_OUT=$(docker run --rm --env-file .env "${IMAGE}" update "${POST_ID}" \ UPD_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" update "${POST_ID}" \
-b "Updated validation reply test." 2>&1) || true -b "Updated validation reply test." 2>&1) || true
if echo "$UPD_OUT" | grep -qE 'Updated post'; then if echo "$UPD_OUT" | grep -qE 'Updated post'; then
ok "update post ${POST_ID} succeeded" ok "update post ${POST_ID} succeeded"
@@ -182,7 +187,7 @@ if [ -n "${NEW_TOPIC_ID:-}" ]; then
fi fi
# delete # delete
DEL_OUT=$(docker run --rm --env-file .env "${IMAGE}" delete "${POST_ID}" 2>&1) || true 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 if echo "$DEL_OUT" | grep -qE 'Deleted post'; then
ok "delete post ${POST_ID} succeeded" ok "delete post ${POST_ID} succeeded"
else else
@@ -197,13 +202,9 @@ fi
echo "" echo ""
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
# Summary # Results
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
echo "============================================" echo "============================================"
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed" echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
echo "============================================" echo "============================================"
[ "$FAIL" -eq 0 ]
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
exit 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`). |