From publish-relief-center-news
Use when the user wants to publish news articles from a Relief Center bilingual news .docx file (the ones under examples/content/news/) into the Odoo Latest News blog. Also triggers on slash command invocations of /publish-relief-center-news, on requests to sync the news docx to the CMS, on "publish the unpublished articles", and on any ask to turn white-filled cells in the news docx into live blog posts. Prefer this skill for Relief Center news publishing even if the user doesn't name it — it's the only path in this project that handles the bilingual EN/AR title + body mapping and the docx status recolor together.
How this skill is triggered — by the user, by Claude, or both
Slash command
/publish-relief-center-news:publish-relief-center-newsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Publish the unpublished articles from a Relief Center news `.docx` into the Odoo "Latest News" blog, then mark them published in the docx by recoloring their cells. One invocation handles every unpublished article in the file.
Publish the unpublished articles from a Relief Center news .docx into the Odoo "Latest News" blog, then mark them published in the docx by recoloring their cells. One invocation handles every unpublished article in the file.
The skill runs as a multi-agent orchestration — the main session is the orchestrator; subagents handle docx inspection, per-article HTML formatting, and fuzzy resolution of author + countries against Odoo. This keeps each agent's context minimal and lets per-article work happen in parallel.
All Odoo access goes through XML-RPC via this skill's own scripts (scripts/odoo_client.py, scripts/odoo_search.py, scripts/publish_blog_post.py). The skill does not use the odoo-mcp-nextjs MCP — its createRecords is broken, and its smartSearch silently drops the language context needed for Arabic title translations. Stick to the XML-RPC scripts.
Cross-platform support: The skill includes OS detection (orchestrate_publish.py helper script) for safe temp file handling on Windows, Linux, and macOS. This avoids shell quoting issues that break on apostrophes in article body text.
/publish-relief-center-news <docx-path>/publish-relief-center-news <path-to-docx>
Example:
/publish-relief-center-news "examples/content/news/April 2026.docx"
scripts/odoo_client.py looks up Odoo connection info in this order, first match wins:
ODOO_URL, ODOO_DB, ODOO_UID, ODOO_PASSWORD. These map 1:1 to the plugin's userConfig fields — when the user configured the plugin at install time, the values live there and are surfaced to scripts as env vars. Users can also export them in their shell for ad-hoc overrides. This is the primary and recommended path.odoo-credentials.json file: auto-discovered by ODOO_CREDENTIALS_PATH env var override, or by walking up from cwd to the first directory containing a file named odoo-credentials.json. Used as a dev-time fallback. The file's staging key must contain an object with url, db, uid, credential.If neither resolves, scripts raise CredentialsNotFound with a message listing what was checked. Prefer userConfig for production installs; the file path is a convenience for the skill's original development workflow.
The skill ships with its scripts under scripts/ and reference docs under references/, relative to the skill's base directory. Claude Code shows the absolute base directory at the top of this SKILL.md when the skill loads (look for "Base directory for this skill: …"). In every command below, replace <SKILL_DIR> with that absolute path.
Work from the user's project directory (where the docx lives) so the orchestrator's cwd matches the docx path argument. Credentials are supplied via plugin userConfig — see the README and the Credentials section below.
Before dispatching any agents or touching the docx, verify Odoo credentials are configured and accepted by the server:
python <SKILL_DIR>/scripts/check_credentials.py
The script prints a single line of JSON and exits 0 on success, 1 on failure. Handle the three cases below.
{"ok": true, "user": "...", "uid": N}Credentials work. Proceed to Step 1 silently — no need to mention the check to the user.
{"ok": false, "error": "missing", ...} — collect interactivelyThe plugin has no credentials configured. Collect them from the user one at a time in chat, then save them for this and future runs. Do not dump the whole list in a single message — the user should answer each question before the next is asked.
https://your-instance.odoo.com/)" — wait for the user's reply.uid from your Odoo profile, e.g. 67)" — wait for reply.echo '...' | python save_credentials.py — instead:
a. Use the Write tool to create a temp file ~/.claude-cred-tmp.json with this exact content (fill in the four values verbatim):
json {"url": "<URL>", "db": "<DB>", "uid": "<UID>", "password": "<PASSWORD>"}
b. Pipe that file into the saver and remove it:
bash python <SKILL_DIR>/scripts/save_credentials.py < ~/.claude-cred-tmp.json && rm ~/.claude-cred-tmp.json
The saver writes the values into ~/.claude/odoo-credentials.json in the nested shape odoo_client expects. The temp file is removed immediately so the password doesn't linger on disk in a second location.python <SKILL_DIR>/scripts/check_credentials.py. If the second check returns {"ok": true}, tell the user: "Credentials saved. Authenticated as <user>. Proceeding to publish." — and go to Step 1. If it returns {"ok": false} again, show the error message and ask the user which field to re-enter (repeat the flow for only the affected field, then call save_credentials.py again).Do not echo the password back to the user in your response text. Acknowledge that it was collected and saved — do not include the value in confirmations, summaries, or any other output.
{"ok": false, "error": "auth_failed", ...} — server rejected the loginCredentials exist but Odoo rejected them. Tell the user, include the message from the JSON, and offer to re-collect:
Odoo rejected the login. Server message:
<message>. The credentials on file may be out of date — if you want, I can walk through the four prompts again to replace them.
If the user agrees, run the interactive collection flow from Case B. The saver will overwrite the stale values.
Use the Agent tool with subagent_type: general-purpose. Its job: extract unpublished articles from the docx. The inspect_docx.py script handles filtering automatically — it only outputs articles where status_en == "Unpublished". Pass this prompt verbatim (substitute <docx-path>):
Your task: extract unpublished articles from a Relief Center news .docx file.
- Run:
python <SKILL_DIR>/scripts/inspect_docx.py "<docx-path>"- Parse the JSON output — a list of unpublished article dicts, each with fields:
article_index,title_en,title_ar,author_en,date_en,country_en,body_en,body_ar,status_en,status_en_cell_index,status_ar_cell_index,article_cell_indices(list of every<w:tc>index inside the article's table, in document order — used by the recolor step to tint the whole article).- Return the list as-is — don't strip or rename any fields, downstream steps need them. If the list is empty, return
[].Do NOT publish anything; your only job is extraction.
Parse the returned list. If it's empty, tell the user "No unpublished articles in <docx-path>." and stop — don't dispatch any more agents.
With structured output from Agent A, Agent B's job is much lighter than before: fields are already extracted, so Agent B only formats body HTML, parses the date string, and resolves author + country against Odoo. Launch one Agent tool call per unpublished article in the same message so they run concurrently. Each gets subagent_type: general-purpose. For each article, pass this prompt (substitute the per-article values from Agent A's output):
Your task: format one pre-extracted Relief Center news article for Odoo publishing. Fields are already structured — you just need to build bilingual body HTML, parse the date, and resolve author + country against Odoo.
Pre-extracted fields:
article_index:<article_index>title_en:<title_en>title_ar:<title_ar>author_en:<author_en>(e.g. "Dr. Ola Alkahlout")date_en:<date_en>(e.g. "15 April 2026")country_en:<country_en>(e.g. "International" or "Yemen")body_en:<body_en>(plain text, paragraphs separated by\n)body_ar:<body_ar>(plain text, paragraphs separated by\n)Before generating any HTML, read
<SKILL_DIR>/references/body_html_template.mdin full. That file is the source of truth for body structure (byline format, paragraph shape, lists, alignment, emphasis, special characters). Match it exactly.Produce these derived fields:
body_en_html: wrap each body_en paragraph in<p style="text-align: justify;">…</p>. The first<p>also carriesdata-oe-version="2.0", and its content begins with<strong>{author_en} | </strong><br>followed immediately by the first paragraph's text (the byline renders on its own line, then the body). Example:<p style="text-align: justify;" data-oe-version="2.0"><strong>Dr. Ola Alkahlout | </strong><br>{first body paragraph}</p><p style="text-align: justify;">{second body paragraph}</p>…body_ar_html: same pattern usingauthor_arandbody_ar. Do NOT adddir="rtl"— Odoo's theme handles direction.post_date_iso: the article's date in"YYYY-MM-DD HH:MM:SS"format. Parsedate_en(e.g. "15 April 2026" → "2026-04-15 00:00:00"). Time can be 00:00:00. Ifdate_enis unparseable, fall back todate_arand infer from Arabic month names.Then dispatch one subagent to resolve both author and country against Odoo (subagent_type: general-purpose):
Odoo Resolver (resolves author + country together):
Given author_en =
<author_en>and country_en =<country_en>, resolve both to Odoo record IDs.Author resolution:
- Run:
python <SKILL_DIR>/scripts/odoo_search.py res.partner "<author_en>"- Parse JSON. If the top candidate's
similarity >= 0.60, use its id and name.- Otherwise use author_id: 79 (Shafiq Belaroussi, fallback).
Country resolution:
- Normalize: if the trimmed string (case-insensitive) is
International,Global,Worldwide,دولي, orعالمي, set country_ids to[]and stop.- Otherwise split on commas and
/. For each token:
- Run:
python <SKILL_DIR>/scripts/odoo_search.py res.country "<token>"- If the top candidate has
similarity >= 0.60, include its id.- Return country_ids as a list of matched ids (empty for global).
Return both results in one dict and merge into the final article dict:
{ "article_index": <int>, "title_en": "...", "title_ar": "...", "body_en_html": "...", "body_ar_html": "...", "post_date_iso": "YYYY-MM-DD HH:MM:SS", "author_id": <int>, "country_ids": [<int>, ...] }Do not invoke any publish scripts yourself — the orchestrator will.
For each Agent B result, you hold the article's article_index, status_en_cell_index, status_ar_cell_index, and article_cell_indices from Agent A's output in Step 1. Strip article_index from the Agent B dict (publish_blog_post.py doesn't expect it), then publish by writing the JSON to a temp file and passing it as an argument.
CRITICAL: Detect OS and use appropriate temp directory:
import tempfile
import json
import subprocess
import os
import sys
# Get OS-appropriate temp directory
temp_dir = tempfile.gettempdir() # e.g., C:\Users\...\AppData\Local\Temp on Windows
temp_file = os.path.join(temp_dir, f'rc_article_{article_index}.json')
# Write JSON to temp file using Python (handles UTF-8 encoding properly)
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(field_dict, f)
# Call script with file path as argument (no shell quoting issues)
result = subprocess.run(
[sys.executable, '<SKILL_DIR>/scripts/publish_blog_post.py', temp_file],
capture_output=True,
text=True
)
# Clean up
os.remove(temp_file)
publish_result = json.loads(result.stdout)
# Write JSON to temp file, call script with file path, then clean up
temp_file="/tmp/rc_article_${article_idx}.json"
python -c "import json; json.dump({...}, open('$temp_file', 'w'), ensure_ascii=False)" && \
python <SKILL_DIR>/scripts/publish_blog_post.py "$temp_file" && \
rm "$temp_file"
Why this approach:
tempfile.gettempdir() returns the OS-appropriate temp directory (Windows or POSIX)publish_blog_post.py reads UTF-8 directly from the file, no encoding surprisesParse the JSON on stdout:
status: "created" or status: "partial" (AR translation warning) → success; queue every index in article_cell_indices for recoloring, and queue status_en_cell_index for the Unpublished → Published text rewritestatus: "existing" → the post already exists under this title; still queue article_cell_indices for recolor and status_en_cell_index for the text rewrite (the docx should reflect reality even for duplicates)After all publishes complete, call recolor_docx_cells.py once with every queued cell index. The convention for "article published" is that the entire article table is tinted purple — not just the Status row — so the whole article reads as done at a glance. In the same call, use --set-text to flip the Status cell's text from Unpublished to Published:
python <SKILL_DIR>/scripts/recolor_docx_cells.py "<docx-path>" \
--set-text <status_en_idx_1>=Published \
--set-text <status_en_idx_2>=Published \
<article_cell_idx_1> <article_cell_idx_2> <article_cell_idx_3> ...
Each positional cell index gets its white fill flipped to #B4A7D6 (light purple). Each --set-text <idx>=<text> entry rewrites the first <w:t> inside that cell (any sibling <w:t> runs in the same cell are blanked so the text reads cleanly). If the docx is open in Word, the script will fail to overwrite — warn the user to close Word and rerun; the duplicate-title pre-flight in publish_blog_post.py keeps reruns safe.
Print a concise summary:
N articles published, K failed, M recolored.- <title_en>: <url><cell_index>")Read references/field_mapping.md for:
blog.post fields set (vs. skipped)blog_id=14 (Latest News) and related constants| Condition | What happens |
|---|---|
Agent A returns [] (no articles with unpublished Status) | Orchestrator reports and exits. No other agents spawned. |
publish_blog_post.py returns status: "existing" for a title already in Latest News | Skill still queues that article's Status cells for recoloring — the docx should match reality. |
| AR title translation write fails (EN post created OK) | publish_blog_post.py returns status: "partial" with a warning string. Skill treats it as published and continues. User can fix the AR title in Odoo UI. |
| Agent C (Odoo resolver) finds no author match ≥60% | Returns author_id: 79 (current user's partner); proceeds normally. |
| Agent C (Odoo resolver) finds no confident country matches | Returns country_ids: []; matches the existing convention on most posts. |
recolor_docx_cells.py fails (e.g., file locked by Word) | Posts exist in Odoo but docx unchanged. Warn user to close Word and rerun — the duplicate-title pre-flight keeps a rerun idempotent. |
| Step 3 publish fails (file not found, write error, etc.) | Verify the temp file was created successfully and the path passed to publish_blog_post.py is correct (use absolute paths). Check that the temp directory is writable. If the docx itself has invalid data, Agent B would have failed earlier. |
| Credentials not found | Scripts raise CredentialsNotFound. Tell the user to set the plugin's userConfig (ODOO_URL, ODOO_DB, ODOO_UID, ODOO_PASSWORD) via /plugin config, or export those env vars in their shell. |
User: /publish-relief-center-news "examples/content/news/May 2026.docx"
Skill:
inspect_docx.py, returns 3 articles with status_en = "Unpublished" and all fields pre-extracted (title/author/date/country/body for EN & AR, plus Status cell indices)publish_blog_post.py → ids 130, 131, 132 createdrecolor_docx_cells.py "May 2026.docx" --set-text 21=Published --set-text 44=Published --set-text 67=Published <every index in each article's article_cell_indices> → all three article tables turn purple end-to-end and their Status rows read "Published"User: /publish-relief-center-news "examples/content/news/April 2026.docx" (every article's Status row is already green or purple)
Skill:
[] (no articles where status_en = "Unpublished")User: /publish-relief-center-news ...docx with 2 unpublished articles; one article's Date field in the docx is an unparseable placeholder.
Skill:
date_en and returns a dict missing post_date_isopublish_blog_post.py succeeds on article 1, fails on article 2 with "Missing required fields: ['post_date_iso']"Provides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.
npx claudepluginhub taqatechno/relief-center-claude-plugins --plugin publish-relief-center-news