Compare commits
13
Commits
b69e70e0a1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2440188f1f | ||
|
|
67f8056e0b | ||
|
|
ecffb4e598 | ||
|
|
1957dcbc7e | ||
|
|
7dfce930b6 | ||
|
|
11ec4cf4a3 | ||
|
|
e2d4bb333c | ||
|
|
d4e9fd693f | ||
|
|
9d6764be11 | ||
|
|
4ad44bbe9d | ||
|
|
b595e26d62 | ||
|
|
b3a810ba07 | ||
|
|
237f345582 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 . .
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,4 +1,4 @@
|
|||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}" \
|
||||||
|
|||||||
@@ -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:-}" \
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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` |
|
||||||
Executable
+48
@@ -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" "$@"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Secrets - never commit
|
||||||
|
.env
|
||||||
|
|
||||||
|
# OS / editor cruft
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
requests>=2.31.0
|
||||||
@@ -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())
|
||||||
Executable
+210
@@ -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 ]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Secrets - never commit
|
||||||
|
.env
|
||||||
|
|
||||||
|
# OS / editor cruft
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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. |
|
||||||
Executable
+125
@@ -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
|
||||||
Executable
+81
@@ -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 ]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Secrets - never commit
|
||||||
|
.env
|
||||||
|
|
||||||
|
# OS / editor cruft
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
@@ -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`). |
|
|
||||||
Reference in New Issue
Block a user