Skill: kissyjpf/openclaw-rtm (version 1.0.9)

--- name: rtm-skill description: Remember The Milk skill for OpenClaw --- # Remember The Milk (RTM) Skill for OpenClaw This skill integrates [Remember The Milk](https://www.rememberthemilk.com/) with OpenClaw, allowing you to manage your daily tasks directly from the command line. ## Installation (CLI Usage) This skill can automatically be loaded by OpenClaw. However, if you would like to use the `rtm` command directly in your Mac's terminal (or allow LLM agents to use it as a shell command), you can install it globally via npm: ```bash npm link ``` ## Obtaining API Credentials Before using this skill, you need to obtain an API Key and a Shared Secret from Remember The Milk: 1. Go to the [Remember The Milk API Keys](https://www.rememberthemilk.com/services/api/keys.rtm) page. 2. Log in with your Remember The Milk account if prompted. 3. Apply for a new API Key by registering your application. 4. Once created, you will receive an **API Key** and a **Shared Secret**. 5. Once you have your credentials, you must save them securely so the bot doesn't lose them. Choose one of the following methods: ### Method A: Using the `config` command (Recommended) Run this command in your terminal: ```bash rtm config <your-api-key> <your-shared-secret> ``` This saves the credentials safely to `~/.rtm-credentials.json` ensuring they persist across terminal restarts. ### Method B: Using a `.env` file Create a file named `.env` in this skill's folder (you can use `.env.example` as a template) and add your keys: ```env RTM_API_KEY="your-api-key" RTM_SHARED_SECRET="your-shared-secret" ``` > **IMPORTANT** > Do NOT edit `index.js` to embed your API key/secret directly into the source files. > Using one of the methods above ensures your credentials remain secure and recognizable by the bot. ## Setup and Authorization Before you can use the commands to manage your tasks, you need to authorize the skill with your Remember The Milk account. > **NOTE** > When you authorize this application, an authentication token will be saved locally to `~/.rtm-token.json` in plaintext. > Keep this file secure on trusted devices, and delete it if you uninstall the skill. 1. **Start Authorization:** ```bash rtm auth ``` This command will provide you with an authorization URL. Open this URL in your web browser and authorize the application. 2. **Save Token:** After authorizing in the browser, run the following command with the `frob` provided. ```bash rtm token <frob> ``` This will save your authentication token locally (`~/.rtm-token.json`) so you only need to do this once. ## Available Commands Once authorized, you can use the following commands to interact with your tasks. ### List Tasks List your tasks. By default, this only fetches **incomplete** tasks to keep the response fast and clean: ```bash rtm list ``` You can explicitly ask for completed tasks (up to 100 recent ones), or all tasks: ```bash rtm list completed # Fetches up to 100 recently completed tasks rtm list all # Fetches absolutely all tasks ``` This will fetch and display the specified tasks along with their properties: - Completion status (✅ or ⬜️) - Persistent Task IDs (`task_id`) - Lists (categories) - Priorities and due dates - Tags and notes *The Task ID (e.g., `8573921`) is a permanent identifier used for completing or modifying tasks.* ### Add a Task Create a new task: ```bash rtm add <task name> ``` Example: `rtm add Buy groceries` ### Add a Note Add a note to an existing task using its `task_id`: ```bash rtm note <task_id> <note text> ``` Example: `rtm note 8573921 Make sure to check the expiry dates` ### Complete a Task Mark a specific task as completed using its `task_id` (obtainable via `rtm list`): ```bash rtm complete <task_id> ``` Example: `rtm complete 8573921` ### Modify Task Properties You can modify task properties using the following commands (all require the `task_id` from `rtm list`): - **Due Date:** `rtm due <task_id> <date string>` (Leave date empty to delete the due date) - Example: `rtm due 8573921 tomorrow` - Delete due: `rtm due 8573921` - **Start Date:** `rtm start <task_id> <date string>` - Example: `rtm start 8573921 next week` - **Priority:** `rtm priority <task_id> <1|2|3|N>` (N is for none) - Example: `rtm priority 8573921 2` - **Postpone:** `rtm postpone <task_id>` (Postpones the task's due date by 1 day) - Example: `rtm postpone 8573921` ### Delete a Task Delete a specific task using its `task_id`: ```bash rtm delete <task_id> ``` Example: `rtm delete 8573921`

path: test.js
content: #!/usr/bin/env node
// Manual test runner for RTM Skill
const skill = require('./index.js');
const fs = require('fs');

const fakeContext = {
    logger: console,
    registerCommand(cmd) {
        console.log('[Test Runner] Registered command:', cmd.name);
        // Export handler so we can invoke it from the command line
        module.exports.invoke = async (args) => {
            const result = await cmd.handler({
                argv: args,
                reply: async (msg) => console.log('\n--- REPLY ---\n' + msg + '\n-------------')
            });
            return result;
        }
    }
};

skill.register(fakeContext);

// Extremely rudimentary CLI for testing
const args = process.argv.slice(2);
if (args.length > 0 && module.exports.invoke) {
    module.exports.invoke(args).catch(console.error);
}


path: openclaw.plugin.json
content: {
    "id": "rtm-skill",
    "name": "rtm-skill",
    "version": "1.0.0",
    "description": "Remember The Milk skill for OpenClaw",
    "main": "index.js",
    "commands": [
        {
            "name": "rtm",
            "description": "Manage Remember The Milk tasks"
        }
    ],
    "requiredEnv": [
        "RTM_API_KEY",
        "RTM_SHARED_SECRET"
    ],
    "configSchema": {
        "type": "object",
        "additionalProperties": false,
        "properties": {}
    }
}

path: index.js
content: const fs = require('fs');
const path = require('path');
const os = require('os');

// Load .env manually to avoid third-party dependencies
const envPath = path.join(__dirname, '.env');
if (fs.existsSync(envPath)) {
    const envFile = fs.readFileSync(envPath, 'utf8');
    envFile.split(/\r?\n/).forEach(line => {
        const match = line.match(/^\s*([^#\s][^=\s]+)\s*=\s*(.*)?\s*$/);
        if (match) {
            const key = match[1];
            let val = match[2] || '';
            if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
            else if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
            if (!process.env[key]) process.env[key] = val;
        }
    });
}

const RTMClient = require('./rtm-client');

const TOKEN_FILE = path.join(os.homedir(), '.rtm-token.json');
const CREDENTIALS_FILE = path.join(os.homedir(), '.rtm-credentials.json');
const ID_CACHE_FILE = path.join(os.homedir(), '.rtm-id-cache.json');

module.exports = {
    name: 'rtm-skill',
    version: '1.0.0',
    register: function (context) {
        const logger = context.logger || console;
        logger.info('[rtm-skill] registering skill');

        let [REDACTED_SECRET] || '';
        let SHARED_SECRET = process.env.RTM_SHARED_SECRET || '';

        if ((!API_KEY || !SHARED_SECRET) && fs.existsSync(CREDENTIALS_FILE)) {
            try {
                const creds = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
                if (creds.API_KEY) [REDACTED_SECRET];
                if (creds.SHARED_SECRET) SHARED_SECRET = creds.SHARED_SECRET;
            } catch (err) {
                logger.error('[rtm-skill] failed to parse credentials file:', err.message);
            }
        }

        if (!API_KEY || !SHARED_SECRET) {
            logger.warn('[rtm-skill] RTM_API_KEY or RTM_SHARED_SECRET are missing. Set env vars or run `rtm config <api_key> <shared_secret>`.');
        }

        const client = new RTMClient(API_KEY, SHARED_SECRET);

        // Try to load token from disk
        try {
            if (fs.existsSync(TOKEN_FILE)) {
                const data = fs.readFileSync(TOKEN_FILE, 'utf8');
                const parsed = JSON.parse(data);
                if (parsed.token) {
                    client.setToken(parsed.token);
                    logger.info('[rtm-skill] loaded auth token from disk');
                }
            }
        } catch (err) {
            logger.error('[rtm-skill] failed to load token:', err.message);
        }

        // Cache is managed via ID_CACHE_FILE now.

        context.registerCommand && context.registerCommand({
            name: 'rtm',
            description: 'Manage Remember The Milk tasks (rtm config, auth, list, add, note, due, start, postpone, priority, complete, delete)',
            async handler({ argv, reply }) {
                const subcmd = argv[0] || 'list';

                const resolveTask = async (targetId) => {
                    let idMap = {};
                    if (fs.existsSync(ID_CACHE_FILE)) {
                        try { idMap = JSON.parse(fs.readFileSync(ID_CACHE_FILE, 'utf8')); } catch(e){}
                    }
                    let task = idMap[targetId];
                    if (!task) {
                        const all = await client.getTasks('');
                        const found = all.find(t => t.task_id === targetId);
                        if (found) {
                            task = { list_id: found.list_id, taskseries_id: found.taskseries_id, task_id: found.task_id, name: found.name };
                            idMap[targetId] = task;
                            fs.writeFileSync(ID_CACHE_FILE, JSON.stringify(idMap), { mode: 0o600 });
                        }
                    }
                    if (!task) throw new Error(`Task ID ${targetId} not found. Please verify valid task_id via 'rtm list'.`);
                    return task;
                };

                try {
                    if (subcmd === 'config') {
                        const newApiKey = argv[1];
                        const newSecret = argv[2];
                        if (!newApiKey || !newSecret) {
                            throw new Error('Provide both API Key and Shared Secret: rtm config <api_key> <shared_secret>');
                        }
                        fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify({ [REDACTED_SECRET], SHARED_SECRET: newSecret }, null, 2), { mode: 0o600 });
                        // Update in-memory variables to immediately allow auth if needed
                        client.[REDACTED_SECRET];
                        client.sharedSecret = newSecret;
                        const msg = `✅ Credentials saved securely to ${CREDENTIALS_FILE}.`;
                        if (reply) await reply(msg);
                        return msg;
                    }

                    if (subcmd === 'auth') {
                        const frob = await client.getFrob();
                        // In a real scenario we might save frob to disk temporarily, but here we'll just ask the user to pass it
                        const url = client.getAuthUrl();
                        const msg = `Please open this URL in your browser to authorize:\n${url}\n\nOnce authorized, run: rtm token ${frob}`;
                        if (reply) await reply(msg);
                        return msg;
                    }

                    if (subcmd === 'token') {
                        const frob = argv[1];
                        if (!frob) throw new Error('You must provide the frob from the auth command: rtm token <frob>');
                        const token = await client.getToken(frob);
                        client.setToken(token);
                        fs.writeFileSync(TOKEN_FILE, JSON.stringify({ token }), { mode: 0o600 });
                        const msg = `Success! RTM is authorized. Token saved securely.`;
                        if (reply) await reply(msg);
                        return msg;
                    }

                    // Require token for the rest
                    if (!client.token) {
                        throw new Error('Not authorized. Run `rtm auth` first.');
                    }

                    if (subcmd === 'list') {
                        const listArg = argv[1] || 'incomplete';
                        let filterStr = '';
                        let limit = null;

                        if (listArg === 'incomplete') {
                            filterStr = 'status:incomplete';
                        } else if (listArg === 'completed') {
                            filterStr = 'status:completed';
                            limit = 100; // 変更: 100件
                        } else if (listArg === 'all') {
                            filterStr = '';
                        } else {
                            // Defaults to incomplete if unknown arg is passed, or we could threat it as a custom filter.
                            filterStr = 'status:incomplete';
                        }

                        let tasks = await client.getTasks(filterStr);
                        
                        if (listArg === 'completed') {
                            // 最新の完了を上位に持ってくるため、完了日時で降順ソート
                            tasks.sort((a, b) => new Date(b.completed || 0) - new Date(a.completed || 0));
                            if (limit && tasks.length > limit) {
                                tasks = tasks.slice(0, limit);
                            }
                        }

                        const listsObj = await client.getLists();

                        // map list ids to names
                        const listMap = {};
                        if (listsObj) {
                            const listsArr = Array.isArray(listsObj) ? listsObj : [listsObj];
                            for (const l of listsArr) {
                                listMap[l.id] = l.name;
                            }
                        }

                        let idMap = {};
                        if (fs.existsSync(ID_CACHE_FILE)) {
                            try { idMap = JSON.parse(fs.readFileSync(ID_CACHE_FILE, 'utf8')); } catch (e) {}
                        }

                        if (tasks.length === 0) {
                            const msg = "No tasks found!";
                            if (reply) await reply(msg);
                            return msg;
                        }

                        let output = `📝 Your Tasks (${listArg === 'completed' ? 'Recent Completed' : listArg === 'all' ? 'All' : 'Incomplete'}):\n`;
                        tasks.forEach((t) => {
                            const tId = t.task_id;
                            idMap[tId] = { list_id: t.list_id, taskseries_id: t.taskseries_id, task_id: t.task_id, name: t.name };

                            const cat = listMap[t.list_id] || 'Inbox';
                            const statusIcon = t.completed ? '✅' : '⬜️';
                            let line = `[${tId}] ${statusIcon} [${cat}] ${t.name}`;

                            const formatTime = (ts) => {
                                if (!ts) return '';
                                const d = new Date(ts);
                                return isNaN(d.getTime()) ? ts : d.toLocaleString();
                            };

                            const extras = [];
                            if (t.priority && t.priority !== 'N') extras.push(`Priority: ${t.priority}`);
                            if (t.due) extras.push(`Due: ${formatTime(t.due)}`);
                            if (t.completed) extras.push(`Completed: ${formatTime(t.completed)}`);
                            if (t.source) extras.push(`Source: ${t.source}`);
                            if (t.tags && t.tags.length > 0) extras.push(`Tags: ${t.tags.join(', ')}`);

                            const validNotes = (t.notes || []).filter(n => n && n.$t).map(n => n.$t).join('; ');
                            if (validNotes) extras.push(`Notes: ${validNotes}`);

                            if (extras.length > 0) {
                                line += ` (${extras.join(' | ')})`;
                            }
                            output += line + '\n';
                        });

                        fs.writeFileSync(ID_CACHE_FILE, JSON.stringify(idMap), { mode: 0o600 });

                        if (reply) await reply(output);
                        return output;
                    }

                    if (subcmd === 'add') {
                        const name = argv.slice(1).join(' ');
                        if (!name) throw new Error('Provide a task name: rtm add <name>');
                        const tl = await client.createTimeline();
                        await client.addTask(tl, name);
                        const msg = `✅ Added task: "${name}"`;
                        if (reply) await reply(msg);
                        return msg;
                    }

                    if (subcmd === 'note') {
                        const targetId = argv[1];
                        const noteText = argv.slice(2).join(' ');
                        if (!targetId || !noteText) throw new Error('Provide a task ID and note text: rtm note <id> <text>');
                        
                        const task = await resolveTask(targetId);
                        const tl = await client.createTimeline();

                        await client.addNote(tl, task.list_id, task.taskseries_id, task.task_id, 'Note', noteText);
                        const msg = `📝 Added note to task "${task.name}": "${noteText}"`;
                        if (reply) await reply(msg);
                        return msg;
                    }

                    if (['due', 'start', 'priority', 'postpone'].includes(subcmd)) {
                        const targetId = argv[1];
                        if (!targetId) throw new Error(`Provide a task ID: rtm ${subcmd} <id> [value]`);
                        
                        const task = await resolveTask(targetId);
                        const tl = await client.createTimeline();
                        let msg = "";

                        if (subcmd === 'due') {
                            const dueStr = argv.slice(2).join(' ') || '';
                            await client.setDueDate(tl, task.list_id, task.taskseries_id, task.task_id, dueStr);
                            msg = dueStr ? `📅 Set due date for "${task.name}" to "${dueStr}"` : `🗑️ Removed due date for "${task.name}"`;
                        } else if (subcmd === 'start') {
                            const startStr = argv.slice(2).join(' ') || '';
                            await client.setStartDate(tl, task.list_id, task.taskseries_id, task.task_id, startStr);
                            msg = startStr ? `⏳ Set start date for "${task.name}" to "${startStr}"` : `🗑️ Removed start date for "${task.name}"`;
                        } else if (subcmd === 'priority') {
                            const p = argv[2];
                            if (!['1', '2', '3', 'N'].includes(p)) throw new Error("Priority must be 1, 2, 3, or N (none).");
                            await client.setPriority(tl, task.list_id, task.taskseries_id, task.task_id, p);
                            msg = `🔥 Set priority for "${task.name}" to ${p}`;
                        } else if (subcmd === 'postpone') {
                            await client.postponeTask(tl, task.list_id, task.taskseries_id, task.task_id);
                            msg = `⏭️ Postponed task: "${task.name}"`;
                        }

                        if (reply) await reply(msg);
                        return msg;
                    }

                    if (subcmd === 'complete' || subcmd === 'delete') {
                        const targetId = argv[1];
                        if (!targetId) throw new Error(`Provide a task ID: rtm ${subcmd} <id>`);
                        
                        const task = await resolveTask(targetId);
                        const tl = await client.createTimeline();

                        if (subcmd === 'complete') {
                            await client.completeTask(tl, task.list_id, task.taskseries_id, task.task_id);
                            const msg = `✔️ Completed task: "${task.name}"`;
                            if (reply) await reply(msg);
                            return msg;
                        } else {
                            await client.deleteTask(tl, task.list_id, task.taskseries_id, task.task_id);
                            const msg = `🗑️ Deleted task: "${task.name}"`;
                            if (reply) await reply(msg);
                            return msg;
                        }
                    }

                    throw new Error(`Unknown subcommand: ${subcmd}`);

                } catch (err) {
                    const msg = `❌ RTM Error: ${err.message}`;
                    logger.error(msg);
                    if (reply) await reply(msg);
                    return msg;
                }
            }
        });
    }
};


path: package.json
content: {
  "name": "rtm-skill",
  "version": "1.0.0",
  "main": "index.js",
  "description": "An OpenClaw skill to manage your tasks on Remember The Milk",
  "bin": {
    "rtm": "./test.js"
  },
  "scripts": {
    "test": "node test.js"
  }
}


path: rtm-client.js
content: const crypto = require('crypto');

class RTMClient {
    constructor(apiKey, sharedSecret) {
        this.[REDACTED_SECRET];
        this.sharedSecret = sharedSecret;
        this.endpoint = 'https://api.rememberthemilk.com/services/rest/';
        this.authEndpoint = 'https://www.rememberthemilk.com/services/auth/';
        this.token = null; // Will be set after auth
    }

    setToken(token) {
        this.token = token;
    }

    _signParams(params) {
        const keys = Object.keys(params).sort();
        let sigString = this.sharedSecret;
        for (const k of keys) {
            sigString += k + params[k];
        }
        return crypto.createHash('md5').update(sigString).digest('hex');
    }

    async callApi(method, params = {}) {
        const fullParams = {
            ...params,
            method,
            [REDACTED_SECRET],
            format: 'json',
        };

        if (this.token && !fullParams.auth_token) {
            fullParams.auth_token = this.token;
        }

        fullParams.api_sig = this._signParams(fullParams);

        const qs = new URLSearchParams(fullParams).toString();
        const url = `${this.endpoint}?${qs}`;

        const res = await fetch(url);
        const data = await res.json();

        if (data.rsp.stat !== 'ok') {
            throw new Error(`RTM API Error: ${data.rsp.err.msg} (Code: ${data.rsp.err.code})`);
        }
        return data.rsp;
    }

    getAuthUrl() {
        const params = {
            [REDACTED_SECRET],
            perms: 'delete' // read, write, or delete
        };
        params.api_sig = this._signParams(params);
        const qs = new URLSearchParams(params).toString();
        return `${this.authEndpoint}?${qs}`;
    }

    async getFrob() {
        const rsp = await this.callApi('rtm.auth.getFrob');
        return rsp.frob;
    }

    async getToken(frob) {
        const rsp = await this.callApi('rtm.auth.getToken', { frob });
        return rsp.auth.token;
    }

    async checkToken() {
        const rsp = await this.callApi('rtm.auth.checkToken');
        return rsp.auth;
    }

    async createTimeline() {
        const rsp = await this.callApi('rtm.timelines.create');
        return rsp.timeline;
    }

    async getLists() {
        const rsp = await this.callApi('rtm.lists.getList');
        return rsp.lists.list; // Array of list objects
    }

    async getTasks(filter = '') {
        const params = {};
        if (filter) params.filter = filter;
        const rsp = await this.callApi('rtm.tasks.getList', params);

        // Normalize return so it's always an array of tasks/series
        if (!rsp.tasks || !rsp.tasks.list) return [];

        let lists = Array.isArray(rsp.tasks.list) ? rsp.tasks.list : [rsp.tasks.list];
        let allTasks = [];

        for (const list of lists) {
            if (!list.taskseries) continue;
            let seriesArr = Array.isArray(list.taskseries) ? list.taskseries : [list.taskseries];
            for (const series of seriesArr) {
                // extract notes
                let notesArr = [];
                if (series.notes && series.notes.note) {
                    notesArr = Array.isArray(series.notes.note) ? series.notes.note : [series.notes.note];
                }

                let tasksArr = Array.isArray(series.task) ? series.task : [series.task];
                for (const task of tasksArr) {
                    allTasks.push({
                        list_id: list.id,
                        taskseries_id: series.id,
                        task_id: task.id,
                        name: series.name,
                        source: series.source,
                        priority: task.priority,
                        due: task.due,
                        completed: task.completed,
                        deleted: task.deleted,
                        tags: series.tags && series.tags.tag ? (Array.isArray(series.tags.tag) ? series.tags.tag : [series.tags.tag]) : [],
                        notes: notesArr
                    });
                }
            }
        }

        return allTasks;
    }

    async addTask(timeline, name, listId = null) {
        const params = { timeline, name };
        if (listId) params.list_id = listId;
        const rsp = await this.callApi('rtm.tasks.add', params);
        return rsp.list;
    }

    async completeTask(timeline, listId, taskseriesId, taskId) {
        const params = {
            timeline,
            list_id: listId,
            taskseries_id: taskseriesId,
            task_id: taskId
        };
        const rsp = await this.callApi('rtm.tasks.complete', params);
        return rsp.list;
    }

    async deleteTask(timeline, listId, taskseriesId, taskId) {
        const params = {
            timeline,
            list_id: listId,
            taskseries_id: taskseriesId,
            task_id: taskId
        };
        const rsp = await this.callApi('rtm.tasks.delete', params);
        return rsp.list;
    }

    async addNote(timeline, listId, taskseriesId, taskId, noteTitle, noteText) {
        const params = {
            timeline,
            list_id: listId,
            taskseries_id: taskseriesId,
            task_id: taskId,
            note_title: noteTitle,
            note_text: noteText
        };
        const rsp = await this.callApi('rtm.tasks.notes.add', params);
        return rsp.note;
    }

    async setDueDate(timeline, listId, taskseriesId, taskId, due) {
        const params = { timeline, list_id: listId, taskseries_id: taskseriesId, task_id: taskId, due, parse: '1' };
        const rsp = await this.callApi('rtm.tasks.setDueDate', params);
        return rsp.list;
    }

    async setStartDate(timeline, listId, taskseriesId, taskId, start) {
        const params = { timeline, list_id: listId, taskseries_id: taskseriesId, task_id: taskId, start, parse: '1' };
        const rsp = await this.callApi('rtm.tasks.setStartDate', params);
        return rsp.list;
    }

    async setPriority(timeline, listId, taskseriesId, taskId, priority) {
        const params = { timeline, list_id: listId, taskseries_id: taskseriesId, task_id: taskId, priority };
        const rsp = await this.callApi('rtm.tasks.setPriority', params);
        return rsp.list;
    }

    async postponeTask(timeline, listId, taskseriesId, taskId) {
        const params = { timeline, list_id: listId, taskseries_id: taskseriesId, task_id: taskId };
        const rsp = await this.callApi('rtm.tasks.postpone', params);
        return rsp.list;
    }
}

module.exports = RTMClient;


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)