Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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_PASSWORD=dummy-password-replace-with-real
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
|
||||
+39
-39
@@ -2,192 +2,192 @@
|
||||
"$schema": "https://charm.land/crush.json",
|
||||
"lsp": {
|
||||
"bash": {
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/lsp-bash-wrapper.sh"
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/lsp-bash-wrapper.sh"
|
||||
},
|
||||
"docker": {
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/lsp-docker-wrapper.sh"
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/lsp-docker-wrapper.sh"
|
||||
},
|
||||
"markdown": {
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/lsp-marksman-wrapper.sh"
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/lsp-marksman-wrapper.sh"
|
||||
},
|
||||
"terraform": {
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/lsp-terraform-wrapper.sh"
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/lsp-terraform-wrapper.sh"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"audiobook": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-audiobook-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-audiobook-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"bitwarden": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-bitwarden-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-bitwarden-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"blender": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-blender-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-blender-wrapper.sh",
|
||||
"timeout": 180
|
||||
},
|
||||
"cloudron": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-cloudron-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-cloudron-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"context7": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-context7-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-context7-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"docker": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-docker-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-docker-wrapper.sh",
|
||||
"timeout": 180
|
||||
},
|
||||
"drawio": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-drawio-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-drawio-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"elasticsearch": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-elasticsearch-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-elasticsearch-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"freecad": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-freecad-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-freecad-wrapper.sh",
|
||||
"timeout": 180
|
||||
},
|
||||
"ghost": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-ghost-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-ghost-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"gimp": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-gimp-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-gimp-wrapper.sh",
|
||||
"timeout": 180
|
||||
},
|
||||
"imap": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-imap-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-imap-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"kubernetes": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-kubernetes-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-kubernetes-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"docspace": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-docspace-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-docspace-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"webserial": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-webserial-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-webserial-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"proxmox": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-proxmox-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-proxmox-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"terraform": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-terraform-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-terraform-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"nextcloud": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-nextcloud-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-nextcloud-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"matomo": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-matomo-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-matomo-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"snipeit": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-snipeit-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-snipeit-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"redmine": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-redmine-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-redmine-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"ansible": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-ansible-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-ansible-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"actual": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-actual-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-actual-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"beszel": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-beszel-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-beszel-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"gitea": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-gitea-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-gitea-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"grafana": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-grafana-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-grafana-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-ha-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-ha-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"limesurvey": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-limesurvey-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-limesurvey-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"linkwarden": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-linkwarden-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-linkwarden-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"superset": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-superset-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-superset-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"postizz": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-postizz-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-postizz-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"discourse": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-discourse-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-discourse-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"penpot": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-penpot-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-penpot-wrapper.sh",
|
||||
"timeout": 60
|
||||
},
|
||||
"firefly-iii": {
|
||||
"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
|
||||
},
|
||||
"paperless": {
|
||||
"type": "stdio",
|
||||
"command": "/home/charles/Projects/KNEL-AIMiddleware/mcp-paperless-wrapper.sh",
|
||||
"command": "/home/reachableceo/projects/KNEL-AIMiddleware/mcp-paperless-wrapper.sh",
|
||||
"timeout": 60
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,91 @@
|
||||
# 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
|
||||
|
||||
Copy the env template and fill in real values (the `.env` is gitignored):
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# then edit .env:
|
||||
# DISCOURSE_URL=https://discourse.example.com
|
||||
# DISCOURSE_API_KEY=abc123...
|
||||
# DISCOURSE_API_USERNAME=your-username
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
docker build -t kneldevstack-aimiddleware-discourse-cli .
|
||||
```
|
||||
|
||||
A prebuilt image is also available in the Gitea registry:
|
||||
|
||||
```
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Via docker directly (from this directory)
|
||||
|
||||
```bash
|
||||
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli whoami
|
||||
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli ls
|
||||
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli show 42
|
||||
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli create -c 5 -t "Hello" -b "Body text"
|
||||
docker run --rm --env-file .env kneldevstack-aimiddleware-discourse-cli reply 42 -b "Nice point"
|
||||
```
|
||||
|
||||
## 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. |
|
||||
@@ -0,0 +1 @@
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,434 @@
|
||||
#!/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")
|
||||
cats = data.get("category_list", {}).get("categories", [])
|
||||
print(f"{'ID':<6} {'SLUG':<28} {'TOPICS':>7} NAME")
|
||||
print("-" * 80)
|
||||
for c in cats:
|
||||
topic_count = c.get("topic_count", 0)
|
||||
print(f"{c.get('id','?'):<6} {str(c.get('slug','')):<28} {topic_count:>6} {c.get('name','')}")
|
||||
subcats = []
|
||||
for c in cats:
|
||||
subcats.extend(c.get("subcategory_ids", []) or [])
|
||||
print(f"\n{len(cats)} 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")
|
||||
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"]
|
||||
_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
+209
@@ -0,0 +1,209 @@
|
||||
#!/bin/sh
|
||||
# discourse-cli validation script.
|
||||
# Runs everything inside containers so no host tooling (curl, etc.) is needed.
|
||||
#
|
||||
# Usage: ./validate.sh (reads .env from the discourse-cli directory)
|
||||
#
|
||||
# Exit codes: 0 = all checks passed, 1 = one or more checks failed
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Ensure .env exists
|
||||
if [ ! -f .env ]; then
|
||||
echo "FAIL: .env not found. Copy .env.example to .env and fill in real values."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="${DISCOURSE_CLI_IMAGE:-kneldevstack-aimiddleware-discourse-cli}"
|
||||
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 .env for the raw test
|
||||
DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' .env | cut -d= -f2-)
|
||||
DISCOURSE_API_KEY=$(grep -E '^DISCOURSE_API_KEY=' .env | cut -d= -f2-)
|
||||
DISCOURSE_API_USERNAME=$(grep -E '^DISCOURSE_API_USERNAME=' .env | 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 "${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 "${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 "${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 "${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 "${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 "${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 "${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 "${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 "${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 ""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Summary
|
||||
# ---------------------------------------------------------------- #
|
||||
echo "============================================"
|
||||
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
|
||||
echo "============================================"
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,13 +1,15 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
# Install pnpm for monorepo
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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 . .
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@ WORKDIR /app
|
||||
|
||||
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 . .
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ COPY . .
|
||||
# Replace mcp git dependency with fastmcp from PyPI
|
||||
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
|
||||
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
|
||||
fi
|
||||
echo -e "${GREEN}=== Building $service ===${NC}"
|
||||
docker compose build "$service"
|
||||
docker compose --profile dev --profile ops build "$service"
|
||||
}
|
||||
|
||||
# Function to clean vendor directory
|
||||
@@ -89,19 +89,19 @@ status() {
|
||||
# Function to validate MCP servers
|
||||
validate() {
|
||||
echo -e "${GREEN}=== Validating MCP Servers ===${NC}"
|
||||
./scripts/validate-mcp.sh
|
||||
./scripts/validate-all.sh mcp
|
||||
}
|
||||
|
||||
# Function to show logs
|
||||
logs() {
|
||||
echo -e "${GREEN}=== Service Logs ===${NC}"
|
||||
docker compose logs -f
|
||||
docker compose --profile dev --profile ops logs -f
|
||||
}
|
||||
|
||||
# Function to show process status
|
||||
ps() {
|
||||
echo -e "${GREEN}=== Service Process Status ===${NC}"
|
||||
docker compose ps
|
||||
docker compose --profile dev --profile ops ps
|
||||
}
|
||||
|
||||
# Function to start services
|
||||
@@ -126,8 +126,8 @@ rebuild() {
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}=== Rebuilding $service ===${NC}"
|
||||
docker compose build --no-cache "$service"
|
||||
docker compose up -d "$service"
|
||||
docker compose --profile dev --profile ops build --no-cache "$service"
|
||||
docker compose --profile dev --profile ops up -d "$service"
|
||||
}
|
||||
|
||||
# Main script logic
|
||||
|
||||
+5
-5
@@ -17,16 +17,16 @@ echo ""
|
||||
echo "Building all services..."
|
||||
echo ""
|
||||
|
||||
# Change to project directory
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
# Change to project root (parent of scripts/)
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
# Get list of all services from docker-compose.yml
|
||||
SERVICES=$(docker compose config --services)
|
||||
# Get list of all services from docker-compose.yml (include all profiles)
|
||||
SERVICES=$(docker compose --profile dev --profile ops config --services | sort)
|
||||
|
||||
# Build each service
|
||||
for SERVICE in $SERVICES; do
|
||||
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"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Failed to build $SERVICE"
|
||||
|
||||
@@ -22,8 +22,9 @@ if [ "$CONFIRM" != "yes" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Vendor directory
|
||||
VENDOR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vendor"
|
||||
# Vendor directory (repo root is parent of scripts/)
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENDOR_DIR="$REPO_ROOT/vendor"
|
||||
|
||||
echo ""
|
||||
echo "Removing vendor directories..."
|
||||
|
||||
@@ -11,8 +11,9 @@ GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Vendor directory
|
||||
VENDOR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vendor"
|
||||
# Vendor directory (repo root is parent of scripts/)
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENDOR_DIR="$REPO_ROOT/vendor"
|
||||
|
||||
echo -e "${GREEN}=== KNEL-AIMiddleware Vendor Repository Clone Script ===${NC}"
|
||||
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 "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 "mcp-server-elasticsearch" "https://github.com/elastic/mcp-server-elasticsearch.git"
|
||||
|
||||
echo -e "${GREEN}=== All repositories cloned successfully! ===${NC}"
|
||||
echo ""
|
||||
|
||||
@@ -15,11 +15,11 @@ NC='\033[0m' # No Color
|
||||
echo -e "${BLUE}=== KNEL-AIMiddleware Service Status Check ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Change to project directory
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
# Change to project root (parent of scripts/)
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
# Get list of all services from docker-compose.yml
|
||||
SERVICES=$(docker compose config --services | sort)
|
||||
# Get list of all services from docker-compose.yml (include all profiles)
|
||||
SERVICES=$(docker compose --profile dev --profile ops config --services | sort)
|
||||
|
||||
# Counters
|
||||
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"
|
||||
|
||||
# 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-mcp-redmine"]="10 REDMINE_URL=https://redmine.example.com REDMINE_API_KEY=dummy"
|
||||
["kneldevstack-aimiddleware-mcp-ansible"]="10"
|
||||
["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
|
||||
["kneldevstack-aimiddleware-penpot-mcp"]="10 PENPOT_URL=https://design.penpot.app PENPOT_TOKEN=dummy"
|
||||
|
||||
Reference in New Issue
Block a user