storymode-cli 1.0.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,65 @@
1
+ # storymode-cli
2
+
3
+ Play AI-animated pixel art characters in your terminal.
4
+
5
+ ```
6
+ npx storymode-cli play 1
7
+ ```
8
+
9
+ ## Commands
10
+
11
+ ### `play <id>`
12
+ Play a gallery animation with interactive controls.
13
+
14
+ ```
15
+ npx storymode-cli play 1
16
+ ```
17
+
18
+ **Controls:**
19
+ - `space` — pause / resume
20
+ - `←` `→` — step frames (when paused)
21
+ - `+` `-` — adjust speed
22
+ - `q` — quit
23
+
24
+ ### `show <id>`
25
+ Print the first frame as a static portrait.
26
+
27
+ ```
28
+ npx storymode-cli show 1
29
+ ```
30
+
31
+ ### `browse`
32
+ Browse the gallery and pick an animation to play.
33
+
34
+ ```
35
+ npx storymode-cli browse
36
+ ```
37
+
38
+ ### `mcp`
39
+ Start a Model Context Protocol server for Claude Code integration.
40
+
41
+ Add to `~/.claude/settings.json`:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "storymode": {
47
+ "command": "npx",
48
+ "args": ["storymode-cli", "mcp"]
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ## Requirements
55
+
56
+ - Node.js 18+
57
+ - A terminal with truecolor support (iTerm2, Terminal.app, Windows Terminal, Claude Code)
58
+
59
+ ## Zero Dependencies
60
+
61
+ Built entirely on Node.js built-ins — no `node_modules` needed.
62
+
63
+ ## Gallery
64
+
65
+ Browse characters at [storymode.fixmy.codes](https://storymode.fixmy.codes)
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.mjs';
3
+ run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "storymode-cli",
3
+ "version": "1.0.0",
4
+ "description": "Play AI-animated pixel art characters in your terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "storymode": "bin/storymode.mjs",
8
+ "storymode-cli": "bin/storymode.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "keywords": [
19
+ "pixel-art",
20
+ "animation",
21
+ "terminal",
22
+ "ansi",
23
+ "cli"
24
+ ],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/Yuncun/storymode-cli"
29
+ },
30
+ "homepage": "https://storymode.fixmy.codes"
31
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,45 @@
1
+ import https from 'node:https';
2
+ import http from 'node:http';
3
+ import { gunzipSync } from 'node:zlib';
4
+
5
+ const BASE = 'https://storymode.fixmy.codes';
6
+
7
+ function request(url) {
8
+ return new Promise((resolve, reject) => {
9
+ const mod = url.startsWith('https') ? https : http;
10
+ mod.get(url, { headers: { 'Accept-Encoding': 'identity' } }, (res) => {
11
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
12
+ return resolve(request(res.headers.location));
13
+ }
14
+ if (res.statusCode !== 200) {
15
+ res.resume();
16
+ return reject(new Error(`HTTP ${res.statusCode}`));
17
+ }
18
+ const chunks = [];
19
+ res.on('data', (c) => chunks.push(c));
20
+ res.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: res.headers['content-type'] || '' }));
21
+ res.on('error', reject);
22
+ }).on('error', reject);
23
+ });
24
+ }
25
+
26
+ export async function fetchGallery() {
27
+ const { buffer } = await request(`${BASE}/gallery`);
28
+ return JSON.parse(buffer.toString('utf-8'));
29
+ }
30
+
31
+ export async function fetchFrames(galleryId) {
32
+ const { buffer, contentType } = await request(`${BASE}/gallery/${galleryId}/frames`);
33
+ let json;
34
+ if (contentType.includes('gzip') || buffer[0] === 0x1f) {
35
+ json = gunzipSync(buffer).toString('utf-8');
36
+ } else {
37
+ json = buffer.toString('utf-8');
38
+ }
39
+ return JSON.parse(json);
40
+ }
41
+
42
+ export async function fetchCharacter(galleryId) {
43
+ const { buffer } = await request(`${BASE}/gallery/${galleryId}/character`);
44
+ return JSON.parse(buffer.toString('utf-8'));
45
+ }
package/src/browse.mjs ADDED
@@ -0,0 +1,70 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { fetchGallery, fetchFrames } from './api.mjs';
3
+ import { playAnimation } from './player.mjs';
4
+
5
+ export async function browse() {
6
+ const spinner = startSpinner('Fetching gallery...');
7
+ let items;
8
+ try {
9
+ items = await fetchGallery();
10
+ } finally {
11
+ stopSpinner(spinner);
12
+ }
13
+
14
+ if (!items || items.length === 0) {
15
+ console.log('Gallery is empty.');
16
+ return;
17
+ }
18
+
19
+ console.log('');
20
+ console.log(' storymode gallery');
21
+ console.log('');
22
+ for (let i = 0; i < items.length; i++) {
23
+ const item = items[i];
24
+ const name = item.name || 'untitled';
25
+ const prompt = item.prompt ? ` — ${item.prompt}` : '';
26
+ const author = item.author_name ? ` (by ${item.author_name})` : '';
27
+ console.log(` ${String(i + 1).padStart(3)}. ${name}${prompt}${author}`);
28
+ }
29
+ console.log('');
30
+
31
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
32
+ try {
33
+ while (true) {
34
+ const answer = await rl.question(' Enter number to play (q to quit): ');
35
+ const trimmed = answer.trim().toLowerCase();
36
+ if (trimmed === 'q' || trimmed === 'quit' || trimmed === '') break;
37
+
38
+ const num = parseInt(trimmed, 10);
39
+ if (isNaN(num) || num < 1 || num > items.length) {
40
+ console.log(` Pick 1-${items.length}`);
41
+ continue;
42
+ }
43
+
44
+ const item = items[num - 1];
45
+ rl.close();
46
+
47
+ console.log(`\n Loading ${item.name || 'animation'}...`);
48
+ const framesData = await fetchFrames(item.id);
49
+ await playAnimation(framesData);
50
+ return;
51
+ }
52
+ } finally {
53
+ rl.close();
54
+ }
55
+ }
56
+
57
+ function startSpinner(msg) {
58
+ const chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
59
+ let i = 0;
60
+ process.stdout.write(` ${chars[0]} ${msg}`);
61
+ return setInterval(() => {
62
+ i = (i + 1) % chars.length;
63
+ process.stdout.write(`\r ${chars[i]} ${msg}`);
64
+ }, 80);
65
+ }
66
+
67
+ function stopSpinner(id) {
68
+ clearInterval(id);
69
+ process.stdout.write('\r\x1b[K');
70
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,92 @@
1
+ import { fetchFrames, fetchCharacter } from './api.mjs';
2
+ import { playAnimation, showFrame } from './player.mjs';
3
+ import { browse } from './browse.mjs';
4
+ import { startMcpServer } from './mcp.mjs';
5
+
6
+ const HELP = `
7
+ storymode-cli — play AI-animated pixel art in your terminal
8
+
9
+ Usage:
10
+ storymode play <gallery_id> Play an animation
11
+ storymode show <gallery_id> Show first frame as static portrait
12
+ storymode browse Browse the gallery interactively
13
+ storymode mcp Start MCP server (for Claude Code)
14
+
15
+ Controls (during playback):
16
+ space pause / resume
17
+ < / > step frames (when paused)
18
+ + / - increase / decrease speed
19
+ q quit
20
+
21
+ https://storymode.fixmy.codes
22
+ `;
23
+
24
+ export async function run(args) {
25
+ const cmd = args[0];
26
+ const id = args[1];
27
+
28
+ switch (cmd) {
29
+ case 'play': {
30
+ if (!id) {
31
+ console.error('Usage: storymode play <gallery_id>');
32
+ process.exit(1);
33
+ }
34
+ try {
35
+ process.stderr.write(' Loading animation...\n');
36
+ const framesData = await fetchFrames(id);
37
+ await playAnimation(framesData);
38
+ } catch (err) {
39
+ console.error(`Error: ${err.message}`);
40
+ process.exit(1);
41
+ }
42
+ break;
43
+ }
44
+
45
+ case 'show': {
46
+ if (!id) {
47
+ console.error('Usage: storymode show <gallery_id>');
48
+ process.exit(1);
49
+ }
50
+ try {
51
+ process.stderr.write(' Loading...\n');
52
+ const [framesData, character] = await Promise.all([
53
+ fetchFrames(id),
54
+ fetchCharacter(id).catch(() => null),
55
+ ]);
56
+ const info = character
57
+ ? { ...character, galleryId: id }
58
+ : { galleryId: id };
59
+ showFrame(framesData, info);
60
+ } catch (err) {
61
+ console.error(`Error: ${err.message}`);
62
+ process.exit(1);
63
+ }
64
+ break;
65
+ }
66
+
67
+ case 'browse':
68
+ try {
69
+ await browse();
70
+ } catch (err) {
71
+ console.error(`Error: ${err.message}`);
72
+ process.exit(1);
73
+ }
74
+ break;
75
+
76
+ case 'mcp':
77
+ startMcpServer();
78
+ break;
79
+
80
+ case '--help':
81
+ case '-h':
82
+ case 'help':
83
+ case undefined:
84
+ console.log(HELP);
85
+ break;
86
+
87
+ default:
88
+ console.error(`Unknown command: ${cmd}`);
89
+ console.log(HELP);
90
+ process.exit(1);
91
+ }
92
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,150 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { fetchGallery, fetchFrames, fetchCharacter } from './api.mjs';
3
+
4
+ const TOOLS = [
5
+ {
6
+ name: 'list_characters',
7
+ description: 'List all characters in the Storymode gallery',
8
+ inputSchema: { type: 'object', properties: {} },
9
+ },
10
+ {
11
+ name: 'show_character',
12
+ description: 'Show character info and portrait (first frame as ANSI art)',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: { gallery_id: { type: 'string', description: 'Gallery item ID' } },
16
+ required: ['gallery_id'],
17
+ },
18
+ },
19
+ {
20
+ name: 'play_animation',
21
+ description: 'Get animation data for a gallery character (returns first frame as ANSI art + metadata)',
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: { gallery_id: { type: 'string', description: 'Gallery item ID' } },
25
+ required: ['gallery_id'],
26
+ },
27
+ },
28
+ ];
29
+
30
+ async function handleToolCall(name, args) {
31
+ switch (name) {
32
+ case 'list_characters': {
33
+ const items = await fetchGallery();
34
+ const lines = items.map((item, i) =>
35
+ `${i + 1}. ${item.name || 'untitled'}${item.prompt ? ' — ' + item.prompt : ''} (id: ${item.id})`
36
+ );
37
+ return lines.join('\n') || 'Gallery is empty.';
38
+ }
39
+ case 'show_character': {
40
+ const id = args.gallery_id;
41
+ const [character, framesData] = await Promise.all([
42
+ fetchCharacter(id).catch(() => null),
43
+ fetchFrames(id),
44
+ ]);
45
+ let text = '';
46
+ if (character) {
47
+ text += `Name: ${character.name || 'untitled'}\n`;
48
+ if (character.backstory) text += `Backstory: ${character.backstory}\n`;
49
+ if (character.animations?.length) {
50
+ text += `Animations: ${character.animations.map(a => a.name || a.prompt).join(', ')}\n`;
51
+ }
52
+ text += '\n';
53
+ }
54
+ // First frame as ANSI
55
+ if (framesData.frames?.length > 0) {
56
+ text += 'Portrait (ANSI):\n';
57
+ text += framesData.frames[0].join('\n');
58
+ }
59
+ return text;
60
+ }
61
+ case 'play_animation': {
62
+ const id = args.gallery_id;
63
+ const framesData = await fetchFrames(id);
64
+ let text = '';
65
+ if (framesData.frames?.length > 0) {
66
+ text += framesData.frames[0].join('\n');
67
+ text += `\n\n${framesData.frames.length} frames @ ${framesData.fps || 16} fps`;
68
+ text += `\n\nTo see the full animation, run:\n npx storymode-cli play ${id}`;
69
+ } else {
70
+ text = 'No frames available.';
71
+ }
72
+ return text;
73
+ }
74
+ default:
75
+ throw new Error(`Unknown tool: ${name}`);
76
+ }
77
+ }
78
+
79
+ function send(obj) {
80
+ process.stdout.write(JSON.stringify(obj) + '\n');
81
+ }
82
+
83
+ function handleMessage(msg) {
84
+ const { id, method, params } = msg;
85
+
86
+ switch (method) {
87
+ case 'initialize':
88
+ send({
89
+ jsonrpc: '2.0',
90
+ id,
91
+ result: {
92
+ protocolVersion: '2024-11-05',
93
+ capabilities: { tools: {} },
94
+ serverInfo: { name: 'storymode', version: '1.0.0' },
95
+ },
96
+ });
97
+ break;
98
+
99
+ case 'notifications/initialized':
100
+ // No response needed
101
+ break;
102
+
103
+ case 'tools/list':
104
+ send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
105
+ break;
106
+
107
+ case 'tools/call':
108
+ handleToolCall(params.name, params.arguments || {})
109
+ .then((text) => {
110
+ send({
111
+ jsonrpc: '2.0',
112
+ id,
113
+ result: { content: [{ type: 'text', text }] },
114
+ });
115
+ })
116
+ .catch((err) => {
117
+ send({
118
+ jsonrpc: '2.0',
119
+ id,
120
+ result: {
121
+ content: [{ type: 'text', text: `Error: ${err.message}` }],
122
+ isError: true,
123
+ },
124
+ });
125
+ });
126
+ break;
127
+
128
+ default:
129
+ if (id) {
130
+ send({
131
+ jsonrpc: '2.0',
132
+ id,
133
+ error: { code: -32601, message: `Method not found: ${method}` },
134
+ });
135
+ }
136
+ }
137
+ }
138
+
139
+ export function startMcpServer() {
140
+ const rl = createInterface({ input: process.stdin });
141
+ rl.on('line', (line) => {
142
+ try {
143
+ const msg = JSON.parse(line);
144
+ handleMessage(msg);
145
+ } catch {
146
+ // Ignore malformed input
147
+ }
148
+ });
149
+ rl.on('close', () => process.exit(0));
150
+ }
package/src/player.mjs ADDED
@@ -0,0 +1,146 @@
1
+ const { stdout, stdin } = process;
2
+
3
+ export function playAnimation(framesData) {
4
+ const frames = framesData.frames;
5
+ const baseFps = framesData.fps || 16;
6
+ const nFrames = frames.length;
7
+
8
+ if (nFrames === 0) {
9
+ console.log('No frames to play.');
10
+ return;
11
+ }
12
+
13
+ const maxLines = Math.max(...frames.map(f => f.length));
14
+ let frameIdx = 0;
15
+ let frameCount = 0;
16
+ let loopCount = 0;
17
+ let paused = false;
18
+ let currentFps = baseFps;
19
+ let interval = 1000 / currentFps;
20
+ let quit = false;
21
+
22
+ // Enter alt screen, hide cursor, clear
23
+ stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H');
24
+
25
+ function drawFrame(idx) {
26
+ let out = '\x1b[H';
27
+ const frameLines = frames[idx];
28
+ for (let i = 0; i < maxLines; i++) {
29
+ if (i < frameLines.length) {
30
+ out += frameLines[i];
31
+ }
32
+ out += '\x1b[K\n';
33
+ }
34
+ const status = paused ? 'PAUSED' : 'playing';
35
+ out += `\x1b[0m\x1b[K [${status}] frame ${idx + 1}/${nFrames} `;
36
+ out += `${currentFps} fps loop ${loopCount + 1} `;
37
+ out += '[space]=pause [</>]=step [+/-]=speed [q]=quit\n';
38
+ stdout.write(out);
39
+ }
40
+
41
+ function cleanup() {
42
+ if (stdin.isTTY) stdin.setRawMode(false);
43
+ stdin.removeAllListeners('data');
44
+ stdin.pause();
45
+ // Exit alt screen, show cursor
46
+ stdout.write('\x1b[?1049l\x1b[?25h');
47
+ stdout.write(
48
+ `\x1b[0m${frameCount} frames displayed (${nFrames} unique, ${loopCount} loops)\n`
49
+ );
50
+ }
51
+
52
+ function handleKey(data) {
53
+ const key = data.toString();
54
+ if (key === 'q' || key === '\x03') {
55
+ quit = true;
56
+ return;
57
+ }
58
+ if (key === ' ') {
59
+ paused = !paused;
60
+ drawFrame(frameIdx);
61
+ return;
62
+ }
63
+ if (key === '+' || key === '=') {
64
+ currentFps = Math.min(currentFps + 2, 60);
65
+ interval = 1000 / currentFps;
66
+ drawFrame(frameIdx);
67
+ return;
68
+ }
69
+ if (key === '-' || key === '_') {
70
+ currentFps = Math.max(currentFps - 2, 1);
71
+ interval = 1000 / currentFps;
72
+ drawFrame(frameIdx);
73
+ return;
74
+ }
75
+ // Arrow keys: ESC [ A/B/C/D
76
+ if (key === '\x1b[C' && paused) {
77
+ // right arrow - next frame
78
+ frameIdx = (frameIdx + 1) % nFrames;
79
+ frameCount++;
80
+ drawFrame(frameIdx);
81
+ } else if (key === '\x1b[D' && paused) {
82
+ // left arrow - prev frame
83
+ frameIdx = (frameIdx - 1 + nFrames) % nFrames;
84
+ frameCount++;
85
+ drawFrame(frameIdx);
86
+ }
87
+ }
88
+
89
+ // Set raw mode for key input
90
+ if (stdin.isTTY) {
91
+ stdin.setRawMode(true);
92
+ stdin.resume();
93
+ stdin.on('data', handleKey);
94
+ }
95
+
96
+ return new Promise((resolve) => {
97
+ function tick() {
98
+ if (quit) {
99
+ cleanup();
100
+ resolve();
101
+ return;
102
+ }
103
+ if (!paused) {
104
+ drawFrame(frameIdx);
105
+ frameCount++;
106
+ frameIdx++;
107
+ if (frameIdx >= nFrames) {
108
+ loopCount++;
109
+ frameIdx = 0;
110
+ }
111
+ }
112
+ setTimeout(tick, paused ? 50 : interval);
113
+ }
114
+ tick();
115
+
116
+ // Handle SIGINT gracefully
117
+ process.on('SIGINT', () => {
118
+ quit = true;
119
+ });
120
+ });
121
+ }
122
+
123
+ export function showFrame(framesData, info) {
124
+ const frames = framesData.frames;
125
+ if (!frames || frames.length === 0) {
126
+ console.log('No frames available.');
127
+ return;
128
+ }
129
+ // Print first frame
130
+ const frame = frames[0];
131
+ for (const line of frame) {
132
+ stdout.write(line + '\x1b[K\n');
133
+ }
134
+ stdout.write('\x1b[0m');
135
+ // Print character info below if provided
136
+ if (info) {
137
+ console.log('');
138
+ if (info.name) console.log(` ${info.name}`);
139
+ if (info.author_name) console.log(` by ${info.author_name}`);
140
+ if (info.backstory) console.log(` ${info.backstory}`);
141
+ console.log('');
142
+ console.log(` ${frames.length} frames @ ${framesData.fps || 16} fps`);
143
+ console.log(` npx storymode-cli play ${info.galleryId || '?'}`);
144
+ }
145
+ console.log('');
146
+ }