trmnl-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # trmnl-cli
2
+
3
+ CLI tool for [TRMNL](https://usetrmnl.com) e-ink displays. Send, validate, and track payloads.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g trmnl-cli
9
+ ```
10
+
11
+ Requires Node.js 22.6.0 or later.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # Add your first plugin (webhook)
17
+ trmnl plugin add home "https://trmnl.com/api/custom_plugins/YOUR_UUID"
18
+
19
+ # Send content
20
+ trmnl send --content '<div class="layout">Hello TRMNL!</div>'
21
+
22
+ # Or from a file
23
+ trmnl send --file ./output.html
24
+ ```
25
+
26
+ ## Plugins
27
+
28
+ Manage multiple TRMNL displays with named webhooks:
29
+
30
+ ```bash
31
+ # Add plugins
32
+ trmnl plugin add home "https://trmnl.com/api/custom_plugins/abc123"
33
+ trmnl plugin add office "https://trmnl.com/api/custom_plugins/xyz789"
34
+
35
+ # List plugins
36
+ trmnl plugin
37
+
38
+ # Set default
39
+ trmnl plugin default office
40
+
41
+ # Send to specific plugin
42
+ trmnl send --file output.html --plugin home
43
+
44
+ # Update plugin
45
+ trmnl plugin set home --url "https://new-url..."
46
+
47
+ # Remove plugin
48
+ trmnl plugin rm office
49
+ ```
50
+
51
+ ## Commands
52
+
53
+ ### `trmnl send`
54
+
55
+ Send content to your TRMNL display.
56
+
57
+ ```bash
58
+ # Direct content
59
+ trmnl send --content "<div class=\"layout\">Hello</div>"
60
+
61
+ # From file
62
+ trmnl send --file ./output.html
63
+
64
+ # To specific plugin
65
+ trmnl send --file ./output.html --plugin office
66
+
67
+ # From stdin (piped)
68
+ echo '{"merge_variables":{"content":"..."}}' | trmnl send
69
+ ```
70
+
71
+ Options:
72
+ - `-c, --content <html>` - HTML content to send
73
+ - `-f, --file <path>` - Read content from file
74
+ - `-p, --plugin <name>` - Plugin to use (default: default plugin)
75
+ - `-w, --webhook <url>` - Override webhook URL directly
76
+ - `--skip-validation` - Skip payload validation
77
+ - `--skip-log` - Don't log to history
78
+ - `--json` - Output result as JSON
79
+
80
+ ### `trmnl validate`
81
+
82
+ Validate a payload without sending.
83
+
84
+ ```bash
85
+ trmnl validate --file ./output.html
86
+ trmnl validate --content "..." --tier plus
87
+ ```
88
+
89
+ Options:
90
+ - `-c, --content <html>` - HTML content to validate
91
+ - `-f, --file <path>` - Read content from file
92
+ - `-t, --tier <tier>` - Override tier (`free` or `plus`)
93
+ - `--json` - Output result as JSON
94
+
95
+ ### `trmnl plugin`
96
+
97
+ Manage webhook plugins.
98
+
99
+ ```bash
100
+ # List plugins
101
+ trmnl plugin
102
+
103
+ # Add plugin
104
+ trmnl plugin add <name> <url>
105
+ trmnl plugin add home "https://..." --desc "Living room"
106
+
107
+ # Update plugin
108
+ trmnl plugin set <name> --url "https://..."
109
+
110
+ # Set default
111
+ trmnl plugin default <name>
112
+
113
+ # Remove plugin
114
+ trmnl plugin rm <name>
115
+ ```
116
+
117
+ ### `trmnl config`
118
+
119
+ Show configuration.
120
+
121
+ ```bash
122
+ trmnl config # Show all config
123
+ ```
124
+
125
+ ### `trmnl tier`
126
+
127
+ Get or set the payload size tier.
128
+
129
+ ```bash
130
+ trmnl tier # Show current tier
131
+ trmnl tier plus # Set tier to plus (5KB limit)
132
+ trmnl tier free # Set tier to free (2KB limit)
133
+ ```
134
+
135
+ ### `trmnl history`
136
+
137
+ View send history.
138
+
139
+ ```bash
140
+ trmnl history # Last 10 sends
141
+ trmnl history --last 20 # Last N sends
142
+ trmnl history --today # Today's sends
143
+ trmnl history --failed # Failed sends only
144
+ trmnl history --plugin home # Filter by plugin
145
+ trmnl history stats # Statistics
146
+ trmnl history clear --confirm # Clear history
147
+ ```
148
+
149
+ ## Configuration
150
+
151
+ ### Config File (`~/.trmnl/config.json`)
152
+
153
+ ```json
154
+ {
155
+ "plugins": {
156
+ "home": {
157
+ "url": "https://trmnl.com/api/custom_plugins/...",
158
+ "description": "Living room display"
159
+ },
160
+ "office": {
161
+ "url": "https://trmnl.com/api/custom_plugins/..."
162
+ }
163
+ },
164
+ "defaultPlugin": "home",
165
+ "tier": "free"
166
+ }
167
+ ```
168
+
169
+ ### Environment Variables
170
+
171
+ - `TRMNL_WEBHOOK` - Webhook URL (overrides config, highest priority)
172
+
173
+ ## Tier Limits
174
+
175
+ | Tier | Payload Limit | Rate Limit |
176
+ |------|---------------|------------|
177
+ | Free | 2 KB (2,048 bytes) | 12 requests/hour |
178
+ | Plus | 5 KB (5,120 bytes) | 30 requests/hour |
179
+
180
+ Set your tier globally:
181
+ ```bash
182
+ trmnl tier plus
183
+ ```
184
+
185
+ ## History
186
+
187
+ Sends are logged to `~/.trmnl/history.jsonl`:
188
+
189
+ ```jsonl
190
+ {"timestamp":"2026-02-07T10:00:00Z","plugin":"home","size_bytes":1234,"success":true,...}
191
+ ```
192
+
193
+ ## License
194
+
195
+ MIT
package/bin/trmnl.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node --experimental-strip-types
2
+
3
+ import '../src/index.ts';
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "trmnl-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool for TRMNL e-ink displays - send, validate, and track payloads",
5
+ "author": "peetzweg",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/peetzweg/openclaw-trmnl"
10
+ },
11
+ "homepage": "https://github.com/peetzweg/openclaw-trmnl#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/peetzweg/openclaw-trmnl/issues"
14
+ },
15
+ "keywords": [
16
+ "trmnl",
17
+ "e-ink",
18
+ "cli",
19
+ "display",
20
+ "webhook",
21
+ "dashboard"
22
+ ],
23
+ "type": "module",
24
+ "bin": {
25
+ "trmnl": "./bin/trmnl.mjs"
26
+ },
27
+ "exports": {
28
+ ".": "./src/index.ts"
29
+ },
30
+ "files": [
31
+ "bin",
32
+ "src",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "start": "node --experimental-strip-types ./src/index.ts",
37
+ "typecheck": "tsc --noEmit"
38
+ },
39
+ "dependencies": {
40
+ "cac": "^6.7.14"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.0.0",
44
+ "typescript": "^5.0.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=22.6.0"
48
+ }
49
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * trmnl config - Manage CLI configuration
3
+ * trmnl plugin - Manage webhook plugins
4
+ */
5
+
6
+ import type { CAC } from 'cac';
7
+ import {
8
+ CONFIG_PATH,
9
+ getTier,
10
+ listPlugins,
11
+ loadConfig,
12
+ removePlugin,
13
+ setDefaultPlugin,
14
+ setPlugin,
15
+ setTier,
16
+ } from '../lib/config.ts';
17
+ import { getHistoryPath } from '../lib/logger.ts';
18
+ import type { WebhookTier } from '../types.ts';
19
+
20
+ export function registerConfigCommand(cli: CAC): void {
21
+ // Config show
22
+ cli
23
+ .command('config', 'Show configuration')
24
+ .action(() => {
25
+ const config = loadConfig();
26
+ const plugins = listPlugins();
27
+
28
+ console.log(`Config file: ${CONFIG_PATH}`);
29
+ console.log('');
30
+
31
+ console.log('Plugins:');
32
+ if (plugins.length === 0) {
33
+ console.log(' (none configured)');
34
+ console.log('');
35
+ console.log(' Add a plugin:');
36
+ console.log(' trmnl plugin add <name> <url>');
37
+ } else {
38
+ for (const { name, plugin, isDefault } of plugins) {
39
+ const defaultMark = isDefault ? ' (default)' : '';
40
+ console.log(` ${name}${defaultMark}`);
41
+ console.log(` url: ${plugin.url}`);
42
+ if (plugin.description) {
43
+ console.log(` desc: ${plugin.description}`);
44
+ }
45
+ }
46
+ }
47
+
48
+ console.log('');
49
+ console.log(`Tier: ${config.tier || 'free'}`);
50
+ console.log(` Limit: ${config.tier === 'plus' ? '5 KB' : '2 KB'}`);
51
+
52
+ console.log('');
53
+ console.log('History:');
54
+ console.log(` path: ${getHistoryPath()}`);
55
+
56
+ console.log('');
57
+ console.log('Environment:');
58
+ console.log(` TRMNL_WEBHOOK: ${process.env.TRMNL_WEBHOOK || '(not set)'}`);
59
+ });
60
+
61
+ // Tier command (separate from config)
62
+ cli
63
+ .command('tier [value]', 'Get or set tier (free or plus)')
64
+ .example('trmnl tier # Show current tier')
65
+ .example('trmnl tier plus # Set to plus')
66
+ .example('trmnl tier free # Set to free')
67
+ .action((value?: string) => {
68
+ if (!value) {
69
+ const tier = getTier();
70
+ console.log(`Tier: ${tier}`);
71
+ console.log(`Limit: ${tier === 'plus' ? '5 KB (5,120 bytes)' : '2 KB (2,048 bytes)'}`);
72
+ return;
73
+ }
74
+
75
+ if (value !== 'free' && value !== 'plus') {
76
+ console.error('Invalid tier. Use "free" or "plus".');
77
+ process.exit(1);
78
+ }
79
+
80
+ setTier(value as WebhookTier);
81
+ console.log(`✓ Tier set to: ${value}`);
82
+ });
83
+
84
+ // Plugin command with action as first arg
85
+ cli
86
+ .command('plugin [action] [name] [url]', 'Manage webhook plugins')
87
+ .option('-d, --desc <description>', 'Plugin description')
88
+ .option('-u, --url <url>', 'Webhook URL (for set action)')
89
+ .option('--default', 'Set as default plugin')
90
+ .example('trmnl plugin # List plugins')
91
+ .example('trmnl plugin add home <url> # Add plugin')
92
+ .example('trmnl plugin rm home # Remove plugin')
93
+ .example('trmnl plugin default home # Set default')
94
+ .example('trmnl plugin set home --url ... # Update plugin')
95
+ .action((action?: string, name?: string, url?: string, options?: { desc?: string; url?: string; default?: boolean }) => {
96
+ // No action = list
97
+ if (!action) {
98
+ showPluginList();
99
+ return;
100
+ }
101
+
102
+ // Handle actions
103
+ switch (action) {
104
+ case 'add':
105
+ if (!name || !url) {
106
+ console.error('Usage: trmnl plugin add <name> <url>');
107
+ process.exit(1);
108
+ }
109
+ setPlugin(name, url, options?.desc);
110
+ console.log(`✓ Added plugin: ${name}`);
111
+ if (options?.default) {
112
+ setDefaultPlugin(name);
113
+ console.log(`✓ Set as default`);
114
+ }
115
+ break;
116
+
117
+ case 'rm':
118
+ case 'remove':
119
+ if (!name) {
120
+ console.error('Usage: trmnl plugin rm <name>');
121
+ process.exit(1);
122
+ }
123
+ if (removePlugin(name)) {
124
+ console.log(`✓ Removed plugin: ${name}`);
125
+ } else {
126
+ console.error(`Plugin not found: ${name}`);
127
+ process.exit(1);
128
+ }
129
+ break;
130
+
131
+ case 'default':
132
+ if (!name) {
133
+ console.error('Usage: trmnl plugin default <name>');
134
+ process.exit(1);
135
+ }
136
+ if (setDefaultPlugin(name)) {
137
+ console.log(`✓ Default plugin: ${name}`);
138
+ } else {
139
+ console.error(`Plugin not found: ${name}`);
140
+ process.exit(1);
141
+ }
142
+ break;
143
+
144
+ case 'set':
145
+ case 'update':
146
+ if (!name) {
147
+ console.error('Usage: trmnl plugin set <name> [options]');
148
+ process.exit(1);
149
+ }
150
+ const plugins = listPlugins();
151
+ const existing = plugins.find(p => p.name === name);
152
+ if (!existing) {
153
+ console.error(`Plugin not found: ${name}`);
154
+ process.exit(1);
155
+ }
156
+ const newUrl = options?.url || existing.plugin.url;
157
+ const newDesc = options?.desc !== undefined ? options.desc : existing.plugin.description;
158
+ setPlugin(name, newUrl, newDesc);
159
+ console.log(`✓ Updated plugin: ${name}`);
160
+ break;
161
+
162
+ case 'list':
163
+ showPluginList();
164
+ break;
165
+
166
+ default:
167
+ console.error(`Unknown action: ${action}`);
168
+ console.log('');
169
+ console.log('Available actions:');
170
+ console.log(' add <name> <url> - Add a plugin');
171
+ console.log(' rm <name> - Remove a plugin');
172
+ console.log(' default <name> - Set default plugin');
173
+ console.log(' set <name> - Update a plugin');
174
+ console.log(' list - List all plugins');
175
+ process.exit(1);
176
+ }
177
+ });
178
+
179
+ // Plugins alias for list
180
+ cli
181
+ .command('plugins', 'List all plugins')
182
+ .action(() => {
183
+ showPluginList();
184
+ });
185
+ }
186
+
187
+ function showPluginList(): void {
188
+ const plugins = listPlugins();
189
+
190
+ if (plugins.length === 0) {
191
+ console.log('No plugins configured.');
192
+ console.log('');
193
+ console.log('Add a plugin:');
194
+ console.log(' trmnl plugin add <name> <url>');
195
+ return;
196
+ }
197
+
198
+ console.log('Plugins:');
199
+ for (const { name, plugin, isDefault } of plugins) {
200
+ const defaultMark = isDefault ? ' ★' : '';
201
+ console.log(` ${name}${defaultMark}`);
202
+ console.log(` ${plugin.url}`);
203
+ if (plugin.description) {
204
+ console.log(` ${plugin.description}`);
205
+ }
206
+ }
207
+
208
+ console.log('');
209
+ console.log('★ = default plugin');
210
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * trmnl history - View send history
3
+ */
4
+
5
+ import { unlinkSync } from 'node:fs';
6
+ import type { CAC } from 'cac';
7
+ import { formatEntry, getHistory, getHistoryPath, getHistoryStats, type HistoryFilter } from '../lib/logger.ts';
8
+
9
+ interface HistoryOptions {
10
+ last?: number;
11
+ today?: boolean;
12
+ failed?: boolean;
13
+ success?: boolean;
14
+ plugin?: string;
15
+ json?: boolean;
16
+ verbose?: boolean;
17
+ }
18
+
19
+ export function registerHistoryCommand(cli: CAC): void {
20
+ cli
21
+ .command('history', 'View send history')
22
+ .option('-n, --last <n>', 'Show last N entries', { default: 10 })
23
+ .option('--today', 'Show only today\'s entries')
24
+ .option('--failed', 'Show only failed sends')
25
+ .option('--success', 'Show only successful sends')
26
+ .option('-p, --plugin <name>', 'Filter by plugin name')
27
+ .option('--json', 'Output as JSON')
28
+ .option('-v, --verbose', 'Show content preview')
29
+ .example('trmnl history')
30
+ .example('trmnl history --last 20')
31
+ .example('trmnl history --today --failed')
32
+ .example('trmnl history --plugin office')
33
+ .action((options: HistoryOptions) => {
34
+ const filter: HistoryFilter = {
35
+ last: options.last,
36
+ today: options.today,
37
+ failed: options.failed,
38
+ success: options.success,
39
+ plugin: options.plugin,
40
+ };
41
+
42
+ const entries = getHistory(filter);
43
+
44
+ if (options.json) {
45
+ console.log(JSON.stringify(entries, null, 2));
46
+ return;
47
+ }
48
+
49
+ if (entries.length === 0) {
50
+ console.log('No history entries found.');
51
+ console.log(`History file: ${getHistoryPath()}`);
52
+ return;
53
+ }
54
+
55
+ // Stats header
56
+ const stats = getHistoryStats();
57
+ if (stats) {
58
+ console.log(`History: ${stats.entries} total entries (${stats.sizeMb} MB)`);
59
+ console.log('');
60
+ }
61
+
62
+ // Filter description
63
+ const filterParts: string[] = [];
64
+ if (options.today) filterParts.push('today');
65
+ if (options.failed) filterParts.push('failed');
66
+ if (options.success) filterParts.push('success');
67
+ if (options.plugin) filterParts.push(`plugin: ${options.plugin}`);
68
+ if (filterParts.length > 0) {
69
+ console.log(`Filter: ${filterParts.join(', ')}`);
70
+ console.log('');
71
+ }
72
+
73
+ // Entries
74
+ console.log(`Showing ${entries.length} entries (most recent first):`);
75
+ console.log('');
76
+
77
+ for (const entry of entries) {
78
+ console.log(formatEntry(entry, options.verbose));
79
+ }
80
+ });
81
+
82
+ // History clear
83
+ cli
84
+ .command('history clear', 'Clear send history')
85
+ .option('--confirm', 'Confirm deletion')
86
+ .action((options: { confirm?: boolean }) => {
87
+ const historyPath = getHistoryPath();
88
+
89
+ if (!options.confirm) {
90
+ console.log('This will delete all history. Use --confirm to proceed.');
91
+ console.log(`History file: ${historyPath}`);
92
+ return;
93
+ }
94
+
95
+ try {
96
+ unlinkSync(historyPath);
97
+ console.log('✓ History cleared');
98
+ } catch (err) {
99
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
100
+ console.log('History file does not exist.');
101
+ } else {
102
+ console.error('Error clearing history:', err);
103
+ }
104
+ }
105
+ });
106
+
107
+ // History stats
108
+ cli
109
+ .command('history stats', 'Show history statistics')
110
+ .action(() => {
111
+ const stats = getHistoryStats();
112
+
113
+ if (!stats) {
114
+ console.log('No history file found.');
115
+ return;
116
+ }
117
+
118
+ const entries = getHistory({});
119
+ const successCount = entries.filter(e => e.success).length;
120
+ const failedCount = entries.filter(e => !e.success).length;
121
+ const totalBytes = entries.reduce((sum, e) => sum + e.size_bytes, 0);
122
+ const avgBytes = entries.length > 0 ? Math.round(totalBytes / entries.length) : 0;
123
+ const avgDuration = entries.length > 0
124
+ ? Math.round(entries.reduce((sum, e) => sum + e.duration_ms, 0) / entries.length)
125
+ : 0;
126
+
127
+ // Plugin breakdown
128
+ const byPlugin = new Map<string, number>();
129
+ for (const entry of entries) {
130
+ byPlugin.set(entry.plugin, (byPlugin.get(entry.plugin) || 0) + 1);
131
+ }
132
+
133
+ console.log('History Statistics');
134
+ console.log('');
135
+ console.log(`File: ${getHistoryPath()}`);
136
+ console.log(`Size: ${stats.sizeMb} MB`);
137
+ console.log('');
138
+ console.log(`Total: ${entries.length} sends`);
139
+ console.log(`Success: ${successCount} (${entries.length > 0 ? Math.round(successCount / entries.length * 100) : 0}%)`);
140
+ console.log(`Failed: ${failedCount} (${entries.length > 0 ? Math.round(failedCount / entries.length * 100) : 0}%)`);
141
+ console.log('');
142
+ console.log(`Avg size: ${avgBytes} bytes`);
143
+ console.log(`Avg duration: ${avgDuration}ms`);
144
+
145
+ if (byPlugin.size > 1) {
146
+ console.log('');
147
+ console.log('By plugin:');
148
+ for (const [plugin, count] of byPlugin.entries()) {
149
+ console.log(` ${plugin}: ${count} sends`);
150
+ }
151
+ }
152
+
153
+ // Recent activity
154
+ const today = getHistory({ today: true });
155
+ const thisWeek = getHistory({ since: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) });
156
+ console.log('');
157
+ console.log(`Today: ${today.length} sends`);
158
+ console.log(`This week: ${thisWeek.length} sends`);
159
+ });
160
+ }