resolve: keep workspace versions of skill/SKILL.md and live-status.js
This commit is contained in:
@@ -1,48 +1,75 @@
|
|||||||
# Live Status Skill
|
# Live Status Skill
|
||||||
|
|
||||||
**Use this tool to report real-time progress updates to Mattermost.**
|
**Real-time progress updates in Mattermost via in-place post editing.**
|
||||||
It allows you to create a "Live Log" post that you update in-place, reducing chat spam.
|
Creates a single "status box" post and updates it repeatedly — no chat spam.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### 1. Initialize (Start of Task)
|
### Create a status box
|
||||||
|
|
||||||
Create a new status post. It will print the `POST_ID`.
|
|
||||||
**Required:** Pass the `CHANNEL_ID` if known (otherwise it defaults to system channel).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
live-status create <CHANNEL_ID> "🚀 **Task Started:** Initializing..."
|
live-status --channel <CHANNEL_ID> create "🚀 **Task Started:** Initializing..."
|
||||||
|
```
|
||||||
|
Returns the `POST_ID` (26-char string). **Capture it.**
|
||||||
|
|
||||||
|
### Create in a thread
|
||||||
|
```bash
|
||||||
|
live-status --channel <CHANNEL_ID> --reply-to <ROOT_POST_ID> create "🚀 Starting..."
|
||||||
```
|
```
|
||||||
|
|
||||||
**Output:** `p6...` (The Post ID)
|
### Update the status box
|
||||||
|
|
||||||
### 2. Update (During Task)
|
|
||||||
|
|
||||||
Update the post with new log lines. Use a code block for logs.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
live-status update <POST_ID> "🚀 **Task Started:** Initializing...
|
live-status update <POST_ID> "🚀 **Task Running**
|
||||||
\`\`\`
|
\`\`\`
|
||||||
[10:00] Checking files... OK
|
[10:00] Step 1... OK
|
||||||
[10:01] Downloading assets...
|
[10:01] Step 2... Working
|
||||||
\`\`\`"
|
\`\`\`"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Complete (End of Task)
|
### Mark complete
|
||||||
|
|
||||||
Mark as done.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
live-status update <POST_ID> "✅ **Task Complete.**
|
live-status update <POST_ID> "✅ **Task Complete**
|
||||||
\`\`\`
|
\`\`\`
|
||||||
[10:00] Checking files... OK
|
[10:00] Step 1... OK
|
||||||
[10:01] Downloading assets... Done.
|
[10:01] Step 2... OK
|
||||||
[10:05] Verifying... Success.
|
[10:05] Done.
|
||||||
\`\`\`"
|
\`\`\`"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Delete a status box
|
||||||
|
```bash
|
||||||
|
live-status delete <POST_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
## Protocol
|
||||||
|
1. **Always** capture the `POST_ID` from `create`.
|
||||||
- **Always** capture the `POST_ID` from the `create` command.
|
2. **Always** append to previous log (maintain full history in the message).
|
||||||
- **Always** append to the previous log (maintain history).
|
3. **Use code blocks** for technical logs.
|
||||||
- **Use Code Blocks** for technical logs.
|
4. Each new task gets a **new** status box — never reuse old IDs across tasks.
|
||||||
|
|||||||
@@ -1,114 +1,283 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const https = require('http'); // Using http for mattermost:8065 (no ssl inside docker network)
|
const https = require('https');
|
||||||
const _fs = require('fs');
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
// --- HELPER: PARSE ARGS ---
|
// --- PARSE ARGS ---
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
let command = null;
|
let command = null;
|
||||||
let options = {};
|
const options = {};
|
||||||
let otherArgs = [];
|
const otherArgs = [];
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
if (args[i] === '--channel') {
|
const arg = args[i];
|
||||||
options.channel = args[i + 1];
|
const next = args[i + 1];
|
||||||
i++; // Skip next arg (the channel ID)
|
if (arg === '--channel' && next) { options.channel = next; i++; }
|
||||||
} else if (!command && (args[i] === 'create' || args[i] === 'update')) {
|
else if (arg === '--reply-to' && next) { options.replyTo = next; i++; }
|
||||||
command = args[i];
|
else if (arg === '--agent' && next) { options.agent = next; i++; }
|
||||||
} else {
|
else if (arg === '--token' && next) { options.token = next; i++; }
|
||||||
otherArgs.push(args[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 = {
|
const CONFIG = {
|
||||||
host: 'mattermost',
|
host: resolveHost(ocConfig),
|
||||||
port: 8065,
|
port: resolvePort(ocConfig),
|
||||||
token: 'DEFAULT_TOKEN_PLACEHOLDER', // Set via install.sh wizard
|
protocol: resolveProtocol(ocConfig),
|
||||||
// Priority: 1. CLI Flag, 2. Env Var, 3. Hardcoded Fallback (Project-0)
|
token: resolveToken(ocConfig),
|
||||||
channel_id:
|
channel_id: options.channel || process.env.MM_CHANNEL_ID || process.env.CHANNEL_ID
|
||||||
options.channel ||
|
|
||||||
process.env.MM_CHANNEL_ID ||
|
|
||||||
process.env.CHANNEL_ID ||
|
|
||||||
'obzja4hb8pd85xk45xn4p31jye',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- HELPER: HTTP REQUEST ---
|
if (!CONFIG.token) {
|
||||||
function request(method, path, data) {
|
console.error('Error: No bot token found.');
|
||||||
return new Promise((resolve, reject) => {
|
console.error(' Set MM_BOT_TOKEN, use --token, or configure openclaw.json');
|
||||||
const options = {
|
process.exit(1);
|
||||||
hostname: CONFIG.host,
|
}
|
||||||
port: CONFIG.port,
|
|
||||||
path: '/api/v4' + path,
|
|
||||||
method: method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${CONFIG.token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
// --- HTTP REQUEST ---
|
||||||
let body = '';
|
function request(method, apiPath, data) {
|
||||||
res.on('data', (chunk) => (body += chunk));
|
const transport = CONFIG.protocol === 'https' ? https : http;
|
||||||
res.on('end', () => {
|
return new Promise((resolve, reject) => {
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
const req = transport.request({
|
||||||
try {
|
hostname: CONFIG.host, port: CONFIG.port,
|
||||||
resolve(JSON.parse(body));
|
path: '/api/v4' + apiPath, method,
|
||||||
} catch (e) {
|
headers: { 'Authorization': `Bearer ${CONFIG.token}`, 'Content-Type': 'application/json' }
|
||||||
resolve(body);
|
}, (res) => {
|
||||||
}
|
let body = '';
|
||||||
} else {
|
res.on('data', (chunk) => body += chunk);
|
||||||
reject(new Error(`Request Failed (${res.statusCode}): ${body}`));
|
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));
|
// --- RICH ATTACHMENT HELPERS ---
|
||||||
if (data) req.write(JSON.stringify(data));
|
const RICH_STYLES = {
|
||||||
req.end();
|
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 ---
|
// --- COMMANDS ---
|
||||||
|
|
||||||
async function createPost(text) {
|
async function createPost(text, cmd) {
|
||||||
try {
|
if (!CONFIG.channel_id) {
|
||||||
const result = await request('POST', '/posts', {
|
console.error('Error: Channel ID required. Use --channel <id> or set MM_CHANNEL_ID.');
|
||||||
channel_id: CONFIG.channel_id,
|
process.exit(1);
|
||||||
message: text,
|
}
|
||||||
});
|
if (!text || !text.trim()) {
|
||||||
console.log(result.id);
|
console.error('Error: Message text is required for create.');
|
||||||
} catch (e) {
|
process.exit(1);
|
||||||
console.error(`Error creating post in channel ${CONFIG.channel_id}:`, e.message);
|
}
|
||||||
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) {
|
async function updatePost(postId, text, cmd) {
|
||||||
try {
|
if (!postId) {
|
||||||
const current = await request('GET', `/posts/${postId}`);
|
console.error('Error: Post ID is required for update.');
|
||||||
await request('PUT', `/posts/${postId}`, {
|
process.exit(1);
|
||||||
id: postId,
|
}
|
||||||
message: text,
|
if (!text || !text.trim()) {
|
||||||
props: current.props,
|
console.error('Error: Message text is required for update.');
|
||||||
});
|
process.exit(1);
|
||||||
console.log('updated');
|
}
|
||||||
} catch (e) {
|
try {
|
||||||
console.error('Error updating post:', e.message);
|
if (options.rich) {
|
||||||
process.exit(1);
|
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 ---
|
// --- CLI ROUTER ---
|
||||||
if (command === 'create') {
|
if (command === 'create') {
|
||||||
const text = otherArgs.join(' ');
|
createPost(otherArgs.join(' '), 'create');
|
||||||
createPost(text);
|
|
||||||
} else if (command === 'update') {
|
} else if (command === 'update') {
|
||||||
const id = otherArgs[0];
|
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'update');
|
||||||
const text = otherArgs.slice(1).join(' ');
|
} else if (command === 'complete') {
|
||||||
updatePost(id, text);
|
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 {
|
} else {
|
||||||
console.log('Usage: live-status [--channel ID] create <text>');
|
console.log('Usage:');
|
||||||
console.log(' live-status update <id> <text>');
|
console.log(' live-status [options] create <text>');
|
||||||
process.exit(1);
|
console.log(' live-status [options] update <id> <text>');
|
||||||
|
console.log(' live-status [options] complete <id> <text>');
|
||||||
|
console.log(' live-status [options] error <id> <text>');
|
||||||
|
console.log(' live-status [options] delete <id>');
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user