From 6ef50269b5bc0782c9c9ac8e59dac9b693405842 Mon Sep 17 00:00:00 2001 From: sol Date: Sat, 7 Mar 2026 15:41:59 +0000 Subject: [PATCH] resolve: keep workspace versions of skill/SKILL.md and live-status.js --- skill/SKILL.md | 85 +++++++---- src/live-status.js | 341 +++++++++++++++++++++++++++++++++------------ 2 files changed, 311 insertions(+), 115 deletions(-) diff --git a/skill/SKILL.md b/skill/SKILL.md index 0604850..96071d7 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,48 +1,75 @@ # Live Status Skill -**Use this tool to report real-time progress updates to Mattermost.** -It allows you to create a "Live Log" post that you update in-place, reducing chat spam. +**Real-time progress updates in Mattermost via in-place post editing.** +Creates a single "status box" post and updates it repeatedly — no chat spam. ## Usage -### 1. Initialize (Start of Task) - -Create a new status post. It will print the `POST_ID`. -**Required:** Pass the `CHANNEL_ID` if known (otherwise it defaults to system channel). - +### Create a status box ```bash -live-status create "🚀 **Task Started:** Initializing..." +live-status --channel create "🚀 **Task Started:** Initializing..." +``` +Returns the `POST_ID` (26-char string). **Capture it.** + +### Create in a thread +```bash +live-status --channel --reply-to create "🚀 Starting..." ``` -**Output:** `p6...` (The Post ID) - -### 2. Update (During Task) - -Update the post with new log lines. Use a code block for logs. - +### Update the status box ```bash -live-status update "🚀 **Task Started:** Initializing... +live-status update "🚀 **Task Running** \`\`\` -[10:00] Checking files... OK -[10:01] Downloading assets... +[10:00] Step 1... OK +[10:01] Step 2... Working \`\`\`" ``` -### 3. Complete (End of Task) - -Mark as done. - +### Mark complete ```bash -live-status update "✅ **Task Complete.** +live-status update "✅ **Task Complete** \`\`\` -[10:00] Checking files... OK -[10:01] Downloading assets... Done. -[10:05] Verifying... Success. +[10:00] Step 1... OK +[10:01] Step 2... OK +[10:05] Done. \`\`\`" ``` +### Delete a status box +```bash +live-status delete +``` + +## Multi-Agent Support + +When multiple agents share a channel, each creates its **own** status box: +```bash +# Agent A +BOX_A=$(live-status --channel $CH --agent god-agent create "🤖 God Agent working...") +# Agent B +BOX_B=$(live-status --channel $CH --agent nutrition-agent create "🥗 Nutrition Agent working...") +``` +Each agent updates only its own box by ID. No conflicts. + +## Options + +| Flag | Purpose | +|---|---| +| `--channel ID` | Target channel (or set `MM_CHANNEL_ID`) | +| `--reply-to ID` | Post as thread reply (sets `root_id`) | +| `--agent NAME` | Use bot token mapped to this agent in openclaw.json | +| `--token TOKEN` | Explicit bot token (overrides everything) | +| `--host HOST` | Mattermost hostname | + +## Auto-Detection + +The tool reads `openclaw.json` automatically for: +- **Host** — from `mattermost.baseUrl` +- **Token** — from `mattermost.accounts` (mapped via `--agent` or defaults) +- No env vars or manual config needed in most cases. + ## Protocol - -- **Always** capture the `POST_ID` from the `create` command. -- **Always** append to the previous log (maintain history). -- **Use Code Blocks** for technical logs. +1. **Always** capture the `POST_ID` from `create`. +2. **Always** append to previous log (maintain full history in the message). +3. **Use code blocks** for technical logs. +4. Each new task gets a **new** status box — never reuse old IDs across tasks. diff --git a/src/live-status.js b/src/live-status.js index fe7fbe4..f721118 100755 --- a/src/live-status.js +++ b/src/live-status.js @@ -1,114 +1,283 @@ #!/usr/bin/env node -const https = require('http'); // Using http for mattermost:8065 (no ssl inside docker network) -const _fs = require('fs'); +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); -// --- HELPER: PARSE ARGS --- +// --- PARSE ARGS --- const args = process.argv.slice(2); let command = null; -let options = {}; -let otherArgs = []; +const options = {}; +const otherArgs = []; for (let i = 0; i < args.length; i++) { - if (args[i] === '--channel') { - options.channel = args[i + 1]; - i++; // Skip next arg (the channel ID) - } else if (!command && (args[i] === 'create' || args[i] === 'update')) { - command = args[i]; - } else { - otherArgs.push(args[i]); - } + const arg = args[i]; + const next = args[i + 1]; + if (arg === '--channel' && next) { options.channel = next; i++; } + else if (arg === '--reply-to' && next) { options.replyTo = next; i++; } + else if (arg === '--agent' && next) { options.agent = next; i++; } + else if (arg === '--token' && next) { options.token = next; i++; } + else if (arg === '--host' && next) { options.host = next; i++; } + else if (arg === '--rich') { options.rich = true; } + else if (!command && ['create', 'update', 'complete', 'error', 'delete'].includes(arg)) { + command = arg; + } else { + otherArgs.push(arg); + } } -// --- CONFIGURATION (DYNAMIC) --- +// --- LOAD CONFIG --- +function loadConfig() { + const searchPaths = [ + process.env.OPENCLAW_CONFIG_DIR && path.join(process.env.OPENCLAW_CONFIG_DIR, 'openclaw.json'), + process.env.XDG_CONFIG_HOME && path.join(process.env.XDG_CONFIG_HOME, 'openclaw.json'), + path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json'), + '/home/node/.openclaw/openclaw.json' + ].filter(Boolean); + + for (const p of searchPaths) { + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } + catch (_) {} + } + return null; +} + +function resolveToken(config) { + if (options.token) return options.token; + if (process.env.MM_BOT_TOKEN) return process.env.MM_BOT_TOKEN; + if (!config) return null; + + const mm = config.mattermost || (config.channels && config.channels.mattermost) || {}; + const accounts = mm.accounts || {}; + + if (options.agent) { + try { + const mapPath = path.join(__dirname, 'agent-accounts.json'); + const agentMap = JSON.parse(fs.readFileSync(mapPath, 'utf8')); + const accName = agentMap[options.agent]; + if (accName && accounts[accName] && accounts[accName].botToken) { + return accounts[accName].botToken; + } + } catch (_) {} + } + + if (accounts.default && accounts.default.botToken) return accounts.default.botToken; + for (const acc of Object.values(accounts)) { + if (acc.botToken) return acc.botToken; + } + return null; +} + +function resolveHost(config) { + if (options.host) return options.host; + if (process.env.MM_HOST) return process.env.MM_HOST; + if (config) { + const mm = config.mattermost || (config.channels && config.channels.mattermost) || {}; + const baseUrl = mm.baseUrl || ''; + if (baseUrl) { try { return new URL(baseUrl).hostname; } catch (_) {} } + } + return 'localhost'; +} + +function resolvePort(config) { + if (process.env.MM_PORT) return parseInt(process.env.MM_PORT, 10); + if (config) { + const mm = config.mattermost || (config.channels && config.channels.mattermost) || {}; + const baseUrl = mm.baseUrl || ''; + if (baseUrl) { + try { + const url = new URL(baseUrl); + return url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80); + } catch (_) {} + } + } + return 443; +} + +function resolveProtocol(config) { + if (config) { + const mm = config.mattermost || (config.channels && config.channels.mattermost) || {}; + if ((mm.baseUrl || '').startsWith('http://')) return 'http'; + } + return 'https'; +} + +// --- BUILD CONFIG --- +const ocConfig = loadConfig(); const CONFIG = { - host: 'mattermost', - port: 8065, - token: 'DEFAULT_TOKEN_PLACEHOLDER', // Set via install.sh wizard - // Priority: 1. CLI Flag, 2. Env Var, 3. Hardcoded Fallback (Project-0) - channel_id: - options.channel || - process.env.MM_CHANNEL_ID || - process.env.CHANNEL_ID || - 'obzja4hb8pd85xk45xn4p31jye', + host: resolveHost(ocConfig), + port: resolvePort(ocConfig), + protocol: resolveProtocol(ocConfig), + token: resolveToken(ocConfig), + channel_id: options.channel || process.env.MM_CHANNEL_ID || process.env.CHANNEL_ID }; -// --- HELPER: HTTP REQUEST --- -function request(method, path, data) { - return new Promise((resolve, reject) => { - const options = { - hostname: CONFIG.host, - port: CONFIG.port, - path: '/api/v4' + path, - method: method, - headers: { - Authorization: `Bearer ${CONFIG.token}`, - 'Content-Type': 'application/json', - }, - }; +if (!CONFIG.token) { + console.error('Error: No bot token found.'); + console.error(' Set MM_BOT_TOKEN, use --token, or configure openclaw.json'); + process.exit(1); +} - const req = https.request(options, (res) => { - let body = ''; - res.on('data', (chunk) => (body += chunk)); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - try { - resolve(JSON.parse(body)); - } catch (e) { - resolve(body); - } - } else { - reject(new Error(`Request Failed (${res.statusCode}): ${body}`)); - } - }); +// --- HTTP REQUEST --- +function request(method, apiPath, data) { + const transport = CONFIG.protocol === 'https' ? https : http; + return new Promise((resolve, reject) => { + const req = transport.request({ + hostname: CONFIG.host, port: CONFIG.port, + path: '/api/v4' + apiPath, method, + headers: { 'Authorization': `Bearer ${CONFIG.token}`, 'Content-Type': 'application/json' } + }, (res) => { + let body = ''; + res.on('data', (chunk) => body += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(JSON.parse(body)); } catch (_) { resolve(body); } + } else { + let msg = `HTTP ${res.statusCode}`; + try { msg = JSON.parse(body).message || msg; } catch (_) {} + reject(new Error(msg)); + } + }); + }); + req.on('error', (e) => reject(e)); + if (data) req.write(JSON.stringify(data)); + req.end(); }); +} - req.on('error', (e) => reject(e)); - if (data) req.write(JSON.stringify(data)); - req.end(); - }); +// --- RICH ATTACHMENT HELPERS --- +const RICH_STYLES = { + create: { color: '#FFA500', prefix: '⏳', status: '🔄 Running' }, + update: { color: '#FFA500', prefix: '🔄', status: '🔄 Running' }, + complete: { color: '#36A64F', prefix: '✅', status: '✅ Complete' }, + error: { color: '#DC3545', prefix: '❌', status: '❌ Error' } +}; + +function buildAttachment(cmd, text) { + const style = RICH_STYLES[cmd] || RICH_STYLES.update; + const agentName = options.agent || 'unknown'; + + // Split text: first line = title, rest = log body + const lines = text.split('\n'); + const title = `${style.prefix} ${lines[0]}`; + const body = lines.length > 1 ? lines.slice(1).join('\n') : ''; + + return { + color: style.color, + title: title, + text: body || undefined, + fields: [ + { short: true, title: 'Agent', value: agentName }, + { short: true, title: 'Status', value: style.status } + ] + }; } // --- COMMANDS --- -async function createPost(text) { - try { - const result = await request('POST', '/posts', { - channel_id: CONFIG.channel_id, - message: text, - }); - console.log(result.id); - } catch (e) { - console.error(`Error creating post in channel ${CONFIG.channel_id}:`, e.message); - process.exit(1); - } +async function createPost(text, cmd) { + if (!CONFIG.channel_id) { + console.error('Error: Channel ID required. Use --channel or set MM_CHANNEL_ID.'); + process.exit(1); + } + if (!text || !text.trim()) { + console.error('Error: Message text is required for create.'); + process.exit(1); + } + try { + let payload; + if (options.rich) { + payload = { + channel_id: CONFIG.channel_id, + message: '', + props: { attachments: [buildAttachment(cmd || 'create', text)] } + }; + } else { + payload = { channel_id: CONFIG.channel_id, message: text }; + } + if (options.replyTo) payload.root_id = options.replyTo; + const result = await request('POST', '/posts', payload); + console.log(result.id); + } catch (e) { + console.error('Error (create):', e.message); + process.exit(1); + } } -async function updatePost(postId, text) { - try { - const current = await request('GET', `/posts/${postId}`); - await request('PUT', `/posts/${postId}`, { - id: postId, - message: text, - props: current.props, - }); - console.log('updated'); - } catch (e) { - console.error('Error updating post:', e.message); - process.exit(1); - } +async function updatePost(postId, text, cmd) { + if (!postId) { + console.error('Error: Post ID is required for update.'); + process.exit(1); + } + if (!text || !text.trim()) { + console.error('Error: Message text is required for update.'); + process.exit(1); + } + try { + if (options.rich) { + await request('PUT', `/posts/${postId}`, { + id: postId, + message: '', + props: { attachments: [buildAttachment(cmd || 'update', text)] } + }); + } else { + const current = await request('GET', `/posts/${postId}`); + await request('PUT', `/posts/${postId}`, { + id: postId, message: text, props: current.props + }); + } + console.log('updated'); + } catch (e) { + console.error('Error (update):', e.message); + process.exit(1); + } +} + +async function deletePost(postId) { + if (!postId) { + console.error('Error: Post ID is required for delete.'); + process.exit(1); + } + try { + await request('DELETE', `/posts/${postId}`); + console.log('deleted'); + } catch (e) { + console.error('Error (delete):', e.message); + process.exit(1); + } } // --- CLI ROUTER --- if (command === 'create') { - const text = otherArgs.join(' '); - createPost(text); + createPost(otherArgs.join(' '), 'create'); } else if (command === 'update') { - const id = otherArgs[0]; - const text = otherArgs.slice(1).join(' '); - updatePost(id, text); + updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'update'); +} else if (command === 'complete') { + updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'complete'); +} else if (command === 'error') { + updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'error'); +} else if (command === 'delete') { + deletePost(otherArgs[0]); } else { - console.log('Usage: live-status [--channel ID] create '); - console.log(' live-status update '); - process.exit(1); + console.log('Usage:'); + console.log(' live-status [options] create '); + console.log(' live-status [options] update '); + console.log(' live-status [options] complete '); + console.log(' live-status [options] error '); + console.log(' live-status [options] delete '); + console.log(''); + console.log('Options:'); + console.log(' --rich Use rich message attachments (colored cards)'); + console.log(' --channel ID Target channel'); + console.log(' --reply-to ID Post as thread reply'); + console.log(' --agent NAME Use bot token mapped to this agent'); + console.log(' --token TOKEN Explicit bot token (overrides all)'); + console.log(' --host HOST Mattermost hostname'); + console.log(''); + console.log('Rich mode colors:'); + console.log(' create/update → Orange (running)'); + console.log(' complete → Green (done)'); + console.log(' error → Red (failed)'); + process.exit(1); }