storymode-cli 1.0.0 → 1.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/package.json +1 -1
- package/src/api.mjs +6 -2
- package/src/cli.mjs +89 -6
- package/src/mcp.mjs +3 -2
- package/src/player.mjs +81 -0
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -28,8 +28,12 @@ export async function fetchGallery() {
|
|
|
28
28
|
return JSON.parse(buffer.toString('utf-8'));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export async function fetchFrames(galleryId) {
|
|
32
|
-
|
|
31
|
+
export async function fetchFrames(galleryId, { size } = {}) {
|
|
32
|
+
let url = `${BASE}/gallery/${galleryId}/frames`;
|
|
33
|
+
if (size && size !== 'full') {
|
|
34
|
+
url += `?size=${size}`;
|
|
35
|
+
}
|
|
36
|
+
const { buffer, contentType } = await request(url);
|
|
33
37
|
let json;
|
|
34
38
|
if (contentType.includes('gzip') || buffer[0] === 0x1f) {
|
|
35
39
|
json = gunzipSync(buffer).toString('utf-8');
|
package/src/cli.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
1
2
|
import { fetchFrames, fetchCharacter } from './api.mjs';
|
|
2
|
-
import { playAnimation, showFrame } from './player.mjs';
|
|
3
|
+
import { playAnimation, playCompanion, showFrame } from './player.mjs';
|
|
3
4
|
import { browse } from './browse.mjs';
|
|
4
5
|
import { startMcpServer } from './mcp.mjs';
|
|
5
6
|
|
|
@@ -7,10 +8,16 @@ const HELP = `
|
|
|
7
8
|
storymode-cli — play AI-animated pixel art in your terminal
|
|
8
9
|
|
|
9
10
|
Usage:
|
|
10
|
-
storymode play <gallery_id>
|
|
11
|
-
storymode
|
|
12
|
-
storymode
|
|
13
|
-
storymode
|
|
11
|
+
storymode play <gallery_id> Play an animation (full screen)
|
|
12
|
+
storymode companion <gallery_id> Play compact sprite alongside your session
|
|
13
|
+
storymode show <gallery_id> Show first frame as static portrait
|
|
14
|
+
storymode browse Browse the gallery interactively
|
|
15
|
+
storymode mcp Start MCP server (for Claude Code)
|
|
16
|
+
|
|
17
|
+
Companion mode:
|
|
18
|
+
Opens a tmux side pane with a small looping sprite.
|
|
19
|
+
Use --detach to return control immediately.
|
|
20
|
+
Use --size=compact (default) or --size=tiny for smaller sprites.
|
|
14
21
|
|
|
15
22
|
Controls (during playback):
|
|
16
23
|
space pause / resume
|
|
@@ -21,9 +28,24 @@ const HELP = `
|
|
|
21
28
|
https://storymode.fixmy.codes
|
|
22
29
|
`;
|
|
23
30
|
|
|
31
|
+
function parseFlags(args) {
|
|
32
|
+
const flags = {};
|
|
33
|
+
const positional = [];
|
|
34
|
+
for (const arg of args) {
|
|
35
|
+
if (arg.startsWith('--')) {
|
|
36
|
+
const [key, val] = arg.slice(2).split('=');
|
|
37
|
+
flags[key] = val ?? true;
|
|
38
|
+
} else {
|
|
39
|
+
positional.push(arg);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { flags, positional };
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
export async function run(args) {
|
|
25
46
|
const cmd = args[0];
|
|
26
|
-
const
|
|
47
|
+
const { flags, positional } = parseFlags(args.slice(1));
|
|
48
|
+
const id = positional[0];
|
|
27
49
|
|
|
28
50
|
switch (cmd) {
|
|
29
51
|
case 'play': {
|
|
@@ -42,6 +64,67 @@ export async function run(args) {
|
|
|
42
64
|
break;
|
|
43
65
|
}
|
|
44
66
|
|
|
67
|
+
case 'companion': {
|
|
68
|
+
if (!id) {
|
|
69
|
+
console.error('Usage: storymode companion <gallery_id> [--detach] [--size=compact|tiny]');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
const size = flags.size || 'compact';
|
|
73
|
+
const detach = !!flags.detach;
|
|
74
|
+
const paneWidth = size === 'tiny' ? 14 : 22;
|
|
75
|
+
|
|
76
|
+
// If we're already the companion pane (spawned by tmux split), play inline
|
|
77
|
+
if (process.env.STORYMODE_COMPANION === '1') {
|
|
78
|
+
try {
|
|
79
|
+
const framesData = await fetchFrames(id, { size });
|
|
80
|
+
await playCompanion(framesData);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`Error: ${err.message}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Otherwise, open a tmux side pane
|
|
89
|
+
const inTmux = !!process.env.TMUX;
|
|
90
|
+
const companionCmd = `STORYMODE_COMPANION=1 npx storymode-cli companion ${id} --size=${size}`;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (inTmux) {
|
|
94
|
+
// Split current window with a side pane
|
|
95
|
+
execSync(
|
|
96
|
+
`tmux split-window -h -l ${paneWidth} '${companionCmd}'`,
|
|
97
|
+
{ stdio: 'ignore' }
|
|
98
|
+
);
|
|
99
|
+
if (!detach) {
|
|
100
|
+
process.stderr.write(` Companion sprite opened in side pane (${size}).\n`);
|
|
101
|
+
process.stderr.write(' Close the pane or press q in it to stop.\n');
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// Not in tmux — start a new tmux session with the split
|
|
105
|
+
process.stderr.write(' Starting tmux session with companion pane...\n');
|
|
106
|
+
execSync(
|
|
107
|
+
`tmux new-session -d -s storymode -x 100 -y 30 \\; ` +
|
|
108
|
+
`split-window -h -l ${paneWidth} '${companionCmd}' \\; ` +
|
|
109
|
+
`select-pane -L`,
|
|
110
|
+
{ stdio: 'ignore' }
|
|
111
|
+
);
|
|
112
|
+
if (detach) {
|
|
113
|
+
process.stderr.write(' Companion running in tmux session "storymode".\n');
|
|
114
|
+
process.stderr.write(' Attach with: tmux attach -t storymode\n');
|
|
115
|
+
} else {
|
|
116
|
+
// Attach to the session so the user sees it
|
|
117
|
+
execSync('tmux attach -t storymode', { stdio: 'inherit' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(`Error: ${err.message}`);
|
|
122
|
+
console.error(' Make sure tmux is installed: brew install tmux');
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
45
128
|
case 'show': {
|
|
46
129
|
if (!id) {
|
|
47
130
|
console.error('Usage: storymode show <gallery_id>');
|
package/src/mcp.mjs
CHANGED
|
@@ -40,7 +40,7 @@ async function handleToolCall(name, args) {
|
|
|
40
40
|
const id = args.gallery_id;
|
|
41
41
|
const [character, framesData] = await Promise.all([
|
|
42
42
|
fetchCharacter(id).catch(() => null),
|
|
43
|
-
fetchFrames(id),
|
|
43
|
+
fetchFrames(id, { size: 'tiny' }),
|
|
44
44
|
]);
|
|
45
45
|
let text = '';
|
|
46
46
|
if (character) {
|
|
@@ -60,12 +60,13 @@ async function handleToolCall(name, args) {
|
|
|
60
60
|
}
|
|
61
61
|
case 'play_animation': {
|
|
62
62
|
const id = args.gallery_id;
|
|
63
|
-
const framesData = await fetchFrames(id);
|
|
63
|
+
const framesData = await fetchFrames(id, { size: 'tiny' });
|
|
64
64
|
let text = '';
|
|
65
65
|
if (framesData.frames?.length > 0) {
|
|
66
66
|
text += framesData.frames[0].join('\n');
|
|
67
67
|
text += `\n\n${framesData.frames.length} frames @ ${framesData.fps || 16} fps`;
|
|
68
68
|
text += `\n\nTo see the full animation, run:\n npx storymode-cli play ${id}`;
|
|
69
|
+
text += `\n\nTo play as a companion sprite alongside your session:\n npx storymode-cli companion ${id}`;
|
|
69
70
|
} else {
|
|
70
71
|
text = 'No frames available.';
|
|
71
72
|
}
|
package/src/player.mjs
CHANGED
|
@@ -120,6 +120,87 @@ export function playAnimation(framesData) {
|
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
export function playCompanion(framesData) {
|
|
124
|
+
const frames = framesData.frames;
|
|
125
|
+
const baseFps = framesData.fps || 16;
|
|
126
|
+
const nFrames = frames.length;
|
|
127
|
+
|
|
128
|
+
if (nFrames === 0) {
|
|
129
|
+
console.log('No frames to play.');
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const maxLines = Math.max(...frames.map(f => f.length));
|
|
134
|
+
let frameIdx = 0;
|
|
135
|
+
let currentFps = baseFps;
|
|
136
|
+
let interval = 1000 / currentFps;
|
|
137
|
+
let quit = false;
|
|
138
|
+
|
|
139
|
+
// No alt screen — render inline. Hide cursor only.
|
|
140
|
+
stdout.write('\x1b[?25l');
|
|
141
|
+
|
|
142
|
+
// Reserve space by printing blank lines, then move cursor back up
|
|
143
|
+
for (let i = 0; i < maxLines + 1; i++) {
|
|
144
|
+
stdout.write('\n');
|
|
145
|
+
}
|
|
146
|
+
stdout.write(`\x1b[${maxLines + 1}A`);
|
|
147
|
+
|
|
148
|
+
// Save cursor position (we'll return here each frame)
|
|
149
|
+
stdout.write('\x1b7');
|
|
150
|
+
|
|
151
|
+
function drawFrame(idx) {
|
|
152
|
+
// Restore saved cursor position
|
|
153
|
+
let out = '\x1b8';
|
|
154
|
+
const frameLines = frames[idx];
|
|
155
|
+
for (let i = 0; i < maxLines; i++) {
|
|
156
|
+
if (i < frameLines.length) {
|
|
157
|
+
out += frameLines[i];
|
|
158
|
+
}
|
|
159
|
+
out += '\x1b[K\n';
|
|
160
|
+
}
|
|
161
|
+
out += `\x1b[0m\x1b[K [q]=quit\n`;
|
|
162
|
+
stdout.write(out);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cleanup() {
|
|
166
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
167
|
+
stdin.removeAllListeners('data');
|
|
168
|
+
stdin.pause();
|
|
169
|
+
stdout.write('\x1b[?25h');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function handleKey(data) {
|
|
173
|
+
const key = data.toString();
|
|
174
|
+
if (key === 'q' || key === '\x03') {
|
|
175
|
+
quit = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (stdin.isTTY) {
|
|
180
|
+
stdin.setRawMode(true);
|
|
181
|
+
stdin.resume();
|
|
182
|
+
stdin.on('data', handleKey);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
function tick() {
|
|
187
|
+
if (quit) {
|
|
188
|
+
cleanup();
|
|
189
|
+
resolve();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
drawFrame(frameIdx);
|
|
193
|
+
frameIdx = (frameIdx + 1) % nFrames;
|
|
194
|
+
setTimeout(tick, interval);
|
|
195
|
+
}
|
|
196
|
+
tick();
|
|
197
|
+
|
|
198
|
+
process.on('SIGINT', () => {
|
|
199
|
+
quit = true;
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
123
204
|
export function showFrame(framesData, info) {
|
|
124
205
|
const frames = framesData.frames;
|
|
125
206
|
if (!frames || frames.length === 0) {
|