@soulspacex/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,69 @@
1
+ # @soulspacex/cli
2
+
3
+ 让 AI Agent 直接在 SoulSpaceX 的画布上创作:建节点、连线、生成图片和视频,产物自动落回画布。
4
+
5
+ ```bash
6
+ npx @soulspacex/cli login
7
+ ```
8
+
9
+ 不用先安装——`npx` 直接跑。要常驻就 `npm i -g @soulspacex/cli`,命令名是 `ssx`。
10
+
11
+ ## 五分钟跑通
12
+
13
+ ```bash
14
+ ssx login # 浏览器授权这台设备
15
+ ssx workflow create "短剧第一集" # 建画布,自动绑定到当前目录
16
+
17
+ ssx node create "剧本" -t textNode \
18
+ --prompt "写一段赛博朋克短剧的开场" \
19
+ --set llmModelId=3 --run # 建完立刻生成,阻塞到出结果
20
+
21
+ ssx node create "主视觉" -t imageNode \
22
+ --left 剧本 --set imageModelId=4 --run # --left 接上游,剧本的输出成为它的提示词
23
+
24
+ ssx node list # 看看都生成完了没
25
+ ```
26
+
27
+ 最后一步之后,画布链接就在输出里,用户在网页上打开能继续编辑。
28
+
29
+ ## 给 Agent 的几条硬约定
30
+
31
+ - **结果走 stdout(JSON),进度和提示走 stderr。** 可以放心 `ssx node list | jq`。
32
+ - **`status` / `result` / `assetId` 这类字段由生成结果回写,不要自己设**,服务端会拒。拼 `--set` 之前先跑 `ssx schema` 看有哪些字段可以设。
33
+ - **生成默认阻塞到出结果**(最长 55 秒)。不要自己写 while 循环轮询——每轮一次都是一次模型调用,token 是用户在付。要立刻返回就 `--wait 0`。
34
+ - **节点可以用名字指代**,重名时才需要用 id。
35
+ - **命令行创作按积分计费,不使用无限模式额度。**
36
+
37
+ ## 命令
38
+
39
+ 跑 `ssx --help` 看全部。常用的:
40
+
41
+ | 命令 | 作用 |
42
+ | --- | --- |
43
+ | `ssx login` / `logout` / `whoami` | 授权、登出(连带吊销服务端授权)、看余额 |
44
+ | `ssx workflow create/list/use/show` | 画布 |
45
+ | `ssx node create/list/update/run/delete` | 节点 |
46
+ | `ssx node connect/disconnect`、`update --left` | 连线(`--left` 是「入边就是这些」,重跑幂等) |
47
+ | `ssx generate <kind> --model <名> --prompt <描述>` | 不走画布的一次性生成 |
48
+ | `ssx model list` / `ssx model search <词>` | 可用模型与计价 |
49
+ | `ssx schema` | 节点字段契约 |
50
+ | `ssx upload <文件>` / `ssx download <url>` | 素材进出 |
51
+
52
+ ## 环境变量
53
+
54
+ | 变量 | 说明 |
55
+ | --- | --- |
56
+ | `SOULSPACEX_API_KEY` | 直接给凭据。CI 与容器里没法交互登录,用它代替 `ssx login` |
57
+ | `SOULSPACEX_BASE_URL` | 服务地址,默认 `https://soulspacex.com` |
58
+ | `SOULSPACEX_CONFIG_DIR` | 凭据目录,默认 `~/.soulspacex` |
59
+
60
+ ## 本地状态
61
+
62
+ - `~/.soulspacex/credentials`(0600)——凭据,跟人走。
63
+ - `<项目>/.soulspacex/workflow.json`——画布绑定,跟目录走,会像 git 那样逐级向上找。
64
+
65
+ ## 谁能用
66
+
67
+ 订阅卡、会员、无限模式用户。成员(子)账号不支持。会员过期后凭据即失效,续费后同一把凭据自动恢复,不用重新授权。
68
+
69
+ 一个账号同时只保留一台授权设备,新设备授权会让旧设备失效。
package/dist/api.js ADDED
@@ -0,0 +1,75 @@
1
+ import { baseUrl, readApiKey } from './config.js';
2
+ import { fail } from './output.js';
3
+ export async function request(method, path, init = {}) {
4
+ const { body, auth = true, formData } = init;
5
+ const headers = {};
6
+ if (auth) {
7
+ const key = readApiKey();
8
+ if (!key) {
9
+ fail('尚未登录', '执行 ssx login 完成授权,或设置环境变量 SOULSPACEX_API_KEY');
10
+ }
11
+ headers.Authorization = `Bearer ${key}`;
12
+ }
13
+ if (body !== undefined && !formData) {
14
+ headers['Content-Type'] = 'application/json';
15
+ }
16
+ let res;
17
+ try {
18
+ res = await fetch(baseUrl() + path, {
19
+ method,
20
+ headers,
21
+ body: formData ?? (body === undefined ? undefined : JSON.stringify(body)),
22
+ });
23
+ }
24
+ catch (e) {
25
+ return fail(`连不上 ${baseUrl()}:${e.message}`, '检查网络,或用 SOULSPACEX_BASE_URL 指定服务地址');
26
+ }
27
+ const text = await res.text();
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(text);
31
+ }
32
+ catch {
33
+ // 最常见的原因是 Nginx 没配 /openapi/ 的 location,请求落到了前端,返回一坨 HTML。
34
+ // 直接把「JSON 解析失败」抛给用户完全指不到这个原因,所以在这里点破。
35
+ return fail(`服务端返回的不是 JSON(HTTP ${res.status})`, `请确认 ${baseUrl()} 是 SoulSpaceX 的服务地址,且 /openapi/ 已正确反代`);
36
+ }
37
+ if (!parsed.success) {
38
+ return fail(parsed.message || `请求失败(${parsed.code})`);
39
+ }
40
+ return parsed.data;
41
+ }
42
+ /**
43
+ * 失败不退出的变体,返回 null。
44
+ *
45
+ * {@link request} 遇到业务错误会直接 `process.exit(1)`——那是对的,命令失败就该
46
+ * 用非零退出码告诉 agent。但有一种情况相反:`ssx logout` 里吊销服务端 key 失败了,
47
+ * **本地凭据仍然必须删掉**,否则用户敲了登出、钥匙却还留在这台机器上。
48
+ * 那种「尽力而为」的调用走这个。
49
+ */
50
+ export async function requestSoft(method, path) {
51
+ const key = readApiKey();
52
+ if (!key)
53
+ return false;
54
+ try {
55
+ const res = await fetch(baseUrl() + path, {
56
+ method,
57
+ headers: { Authorization: `Bearer ${key}` },
58
+ });
59
+ if (!res.ok)
60
+ return false;
61
+ // 只看 success 标志,不看 data:吊销这类接口成功时 data 本来就是 null,
62
+ // 拿 data 判成败会把每一次成功都读成失败
63
+ const parsed = JSON.parse(await res.text());
64
+ return parsed.success === true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ export const api = {
71
+ get: (path) => request('GET', path),
72
+ post: (path, body) => request('POST', path, { body }),
73
+ postPublic: (path, body) => request('POST', path, { body, auth: false }),
74
+ upload: (path, formData) => request('POST', path, { formData }),
75
+ };
package/dist/args.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 极小的参数解析。没用 commander 之类的库是为了保持零运行时依赖——
3
+ * `npx @soulspacex/cli` 每次冷启动都要下载整棵依赖树,少一个包就快一点,
4
+ * 而 CLI 要解析的东西就这么多。
5
+ */
6
+ /** 允许重复出现、需要保留顺序的选项。其余重复出现时后者覆盖前者。 */
7
+ const REPEATABLE = new Set(['set', 'update', 'left', 'left-add', 'left-rm', 'right', 'ref']);
8
+ export function parseArgs(argv) {
9
+ const positional = [];
10
+ const options = {};
11
+ const repeated = {};
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const arg = argv[i];
14
+ if (!arg.startsWith('-')) {
15
+ positional.push(arg);
16
+ continue;
17
+ }
18
+ const raw = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
19
+ // 支持 --key=value 与 --key value 两种写法:agent 两种都会写
20
+ const eq = raw.indexOf('=');
21
+ let key = eq >= 0 ? raw.slice(0, eq) : raw;
22
+ let value = eq >= 0 ? raw.slice(eq + 1) : true;
23
+ if (value === true) {
24
+ const next = argv[i + 1];
25
+ // 下一个不是选项就当成值。`--wait` 这种纯开关后面往往直接跟位置参数,
26
+ // 所以只在下一项不以 - 开头、且当前 key 不在已知开关表里时才吃掉它
27
+ if (next !== undefined && !next.startsWith('-') && !SWITCHES.has(key)) {
28
+ value = next;
29
+ i++;
30
+ }
31
+ }
32
+ key = ALIASES[key] ?? key;
33
+ if (REPEATABLE.has(key)) {
34
+ ;
35
+ (repeated[key] ??= []).push(String(value));
36
+ }
37
+ else {
38
+ options[key] = value;
39
+ }
40
+ }
41
+ return { positional, options, repeated };
42
+ }
43
+ /**
44
+ * 纯开关,后面跟的东西不属于它。
45
+ *
46
+ * `wait` 刻意不在这里:`--wait 0`(立刻返回,不阻塞)是文档里写明的用法,
47
+ * 归进开关会把 0 解析成 true,等于永远按默认时长阻塞。
48
+ */
49
+ const SWITCHES = new Set(['run', 'json', 'help', 'version', 'open', 'force']);
50
+ const ALIASES = {
51
+ t: 'type',
52
+ s: 'set',
53
+ u: 'update',
54
+ f: 'file',
55
+ n: 'name',
56
+ p: 'project',
57
+ w: 'workflow',
58
+ m: 'model',
59
+ o: 'output',
60
+ h: 'help',
61
+ v: 'version',
62
+ };
63
+ /** `k=v` 拆成键值对,值按 JSON 猜类型(数字/布尔/数组保持原类型,其余当字符串)。 */
64
+ export function parseKeyValues(pairs) {
65
+ const out = {};
66
+ for (const pair of pairs ?? []) {
67
+ const eq = pair.indexOf('=');
68
+ if (eq < 0)
69
+ continue;
70
+ const key = pair.slice(0, eq);
71
+ const raw = pair.slice(eq + 1);
72
+ out[key] = coerce(raw);
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * 把命令行上的字符串还原成合适的类型。
78
+ * `count=2` 要变成数字 2 而不是字符串 "2"——后端的严格档会按 schema 校验类型,
79
+ * 传字符串会被指名道姓地拒掉。
80
+ */
81
+ function coerce(raw) {
82
+ if (raw === 'true')
83
+ return true;
84
+ if (raw === 'false')
85
+ return false;
86
+ if (raw !== '' && !Number.isNaN(Number(raw)))
87
+ return Number(raw);
88
+ if (raw.startsWith('[') || raw.startsWith('{')) {
89
+ try {
90
+ return JSON.parse(raw);
91
+ }
92
+ catch {
93
+ return raw;
94
+ }
95
+ }
96
+ return raw;
97
+ }
@@ -0,0 +1,84 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { api, requestSoft } from '../api.js';
3
+ import { baseUrl, clearApiKey, credentialsPath, readApiKey, writeApiKey } from '../config.js';
4
+ import { emit, fail, log } from '../output.js';
5
+ /**
6
+ * 设备码登录。
7
+ *
8
+ * 为什么不是「浏览器登录后回调 localhost」:agent 经常跑在 SSH 会话、Docker、CI 里,
9
+ * 既没有浏览器也没有可绑的回环端口。设备码把两端解耦,这边只需要能发 HTTP 出去。
10
+ */
11
+ export async function login(parsed) {
12
+ const code = await api.postPublic('/openapi/device/code', {
13
+ clientName: clientName(),
14
+ });
15
+ const url = `${code.verificationUri}?code=${encodeURIComponent(code.userCode)}`;
16
+ log(`请在浏览器打开:${url}`);
17
+ log(`验证码:${code.userCode}`);
18
+ if (parsed.options.open !== false) {
19
+ openBrowser(url);
20
+ }
21
+ log('等待网页确认授权…');
22
+ const deadline = Date.now() + code.expiresIn * 1000;
23
+ let interval = Math.max(code.interval, 1) * 1000;
24
+ while (Date.now() < deadline) {
25
+ await sleep(interval);
26
+ const res = await api.postPublic('/openapi/device/token', { deviceCode: code.deviceCode });
27
+ if (res.status === 'approved' && res.apiKey) {
28
+ writeApiKey(res.apiKey);
29
+ const balance = await api.get('/openapi/balance');
30
+ log(`已登录,凭据写入 ${credentialsPath()}`);
31
+ emit({ loggedIn: true, remainingQuota: balance.remainingQuota });
32
+ return;
33
+ }
34
+ if (res.status === 'expired') {
35
+ fail('验证码已过期', '重新执行 ssx login');
36
+ }
37
+ // slow_down 说明轮太快了,退一步再问
38
+ if (res.status === 'slow_down')
39
+ interval += 1000;
40
+ }
41
+ fail('等待授权超时', '重新执行 ssx login');
42
+ }
43
+ /**
44
+ * 登出 = 吊销服务端那把 key + 删本地凭据。
45
+ *
46
+ * **顺序不能反,而且服务端失败也要继续删本地。** 用户敲 logout 想要的首先是
47
+ * 「这台机器上别留着我的钥匙」——网络不通就把文件留在那儿,等于没登出。
48
+ */
49
+ export async function logout() {
50
+ const had = readApiKey() !== null;
51
+ if (!had) {
52
+ log('本来就没有登录');
53
+ emit({ loggedOut: true, revoked: false });
54
+ return;
55
+ }
56
+ // 这里必须用 requestSoft:普通的 request 遇到业务错误会 process.exit,
57
+ // 那样本地凭据就删不掉了——而删本地恰恰是登出最要紧的那一半
58
+ const revoked = await requestSoft('DELETE', '/openapi/key');
59
+ clearApiKey();
60
+ log(revoked ? '已登出,这台设备的授权已吊销,本地凭据已删除' : '本地凭据已删除;服务端吊销没成功,可以去个人中心的「开发者」里补一次');
61
+ emit({ loggedOut: true, revoked });
62
+ }
63
+ export async function whoami() {
64
+ const balance = await api.get('/openapi/balance');
65
+ log(`服务地址:${baseUrl()}`);
66
+ if (balance.unlimitedActive) {
67
+ // 买了无限模式的人一定会问这件事,先说在前面
68
+ log('注意:命令行创作按积分计费,不使用无限模式额度');
69
+ }
70
+ emit(balance);
71
+ }
72
+ function clientName() {
73
+ // 让用户在「已授权设备」列表里认得出是哪台机器、哪个 agent
74
+ const agent = process.env.CLAUDE_CODE ? 'Claude Code' : process.env.TERM_PROGRAM || 'CLI';
75
+ return `${agent} on ${process.env.HOSTNAME || process.env.HOST || 'unknown'}`;
76
+ }
77
+ function openBrowser(url) {
78
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
79
+ // 打不开浏览器不算失败:上面已经把链接打出来了,用户自己复制也行
80
+ execFile(cmd, [url], () => { });
81
+ }
82
+ function sleep(ms) {
83
+ return new Promise((r) => setTimeout(r, ms));
84
+ }
@@ -0,0 +1,133 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { basename, resolve } from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { api } from '../api.js';
7
+ import { emit, fail, log } from '../output.js';
8
+ import { parseKeyValues } from '../args.js';
9
+ export async function model(parsed) {
10
+ // 类型只认 --type。位置参数留给关键词:`ssx model search nano` 里的 nano 是要搜的词,
11
+ // 不是模型类型——把它当成 type 会让后端过滤出空列表,而 agent 看到空列表会以为没有可用模型
12
+ const type = parsed.options.type;
13
+ const query = type && type !== true ? `?type=${encodeURIComponent(String(type))}` : '';
14
+ const models = await api.get(`/openapi/models${query}`);
15
+ const keyword = parsed.positional[1] === 'search' ? parsed.positional[2] : undefined;
16
+ const filtered = keyword
17
+ ? models.filter((m) => m.name.toLowerCase().includes(String(keyword).toLowerCase()))
18
+ : models;
19
+ emit(filtered.map((m) => ({ name: m.name, type: m.type, pricing: m.pricing })));
20
+ }
21
+ /** 节点契约。agent 拼参数之前该先看这个,而不是猜字段名。 */
22
+ export async function schema() {
23
+ emit(await api.get('/openapi/schema'));
24
+ }
25
+ export async function balance() {
26
+ emit(await api.get('/openapi/balance'));
27
+ }
28
+ /** 不走画布的单步生成。适合「就要一张图」这种一次性需求。 */
29
+ export async function generate(parsed) {
30
+ const kind = parsed.positional[1];
31
+ if (!kind) {
32
+ fail('要指定生成类型', '用法:ssx generate text2img --model "模型名" --prompt "描述"');
33
+ }
34
+ const modelName = parsed.options.model;
35
+ if (!modelName || modelName === true) {
36
+ fail('要指定模型', '可用模型见 ssx model list');
37
+ }
38
+ const prompt = parsed.options.prompt;
39
+ const params = parseKeyValues(parsed.repeated.set);
40
+ log('已提交,等待生成…');
41
+ const g = await api.post('/openapi/generations', {
42
+ kind,
43
+ model: String(modelName),
44
+ prompt: prompt === true ? undefined : prompt,
45
+ params,
46
+ });
47
+ const terminal = await poll(g);
48
+ const url = terminal.result?.assets?.[0]?.url;
49
+ if (terminal.status === 'done') {
50
+ log(`完成,扣了 ${terminal.cost} 积分`);
51
+ // 出图就顺手存到本地:agent 拿到本地路径才能接着做别的事,
52
+ // 只给一个 URL 它还得自己再写一段下载代码
53
+ if (url && parsed.options.output !== false) {
54
+ const file = await download(url, parsed.options.output === true ? undefined : String(parsed.options.output ?? ''));
55
+ emit({ generationId: terminal.id, status: terminal.status, cost: terminal.cost, url, file });
56
+ return;
57
+ }
58
+ }
59
+ else if (terminal.status === 'failed') {
60
+ log(`失败:${terminal.error ?? '未知原因'}`);
61
+ }
62
+ emit({
63
+ generationId: terminal.id,
64
+ status: terminal.status,
65
+ cost: terminal.cost,
66
+ result: url ?? terminal.result?.text,
67
+ });
68
+ }
69
+ export async function upload(parsed) {
70
+ const path = parsed.options.file !== true && parsed.options.file ? String(parsed.options.file) : parsed.positional[1];
71
+ if (!path) {
72
+ fail('要指定文件', '用法:ssx upload ./ref.png');
73
+ }
74
+ const abs = resolve(path);
75
+ const bytes = await readFile(abs).catch(() => fail(`读不到文件 ${abs}`));
76
+ const form = new FormData();
77
+ // Blob 不带 type 的话 multipart 的 Content-Type 就是 application/octet-stream,
78
+ // 服务端按 MIME 判类型,会直接拒掉——本地按扩展名推一个出来
79
+ form.append('file', new Blob([bytes], { type: mimeOf(abs) }), basename(abs));
80
+ const asset = await api.upload('/openapi/assets/upload', form);
81
+ emit({ assetId: asset.id, url: asset.url });
82
+ }
83
+ export async function downloadCommand(parsed) {
84
+ const url = parsed.positional[1];
85
+ if (!url) {
86
+ fail('要指定要下载的 URL', '用法:ssx download <url> [-o 文件名]');
87
+ }
88
+ const out = parsed.options.output;
89
+ const file = await download(url, out === true || !out ? undefined : String(out));
90
+ emit({ file });
91
+ }
92
+ const MIME = {
93
+ png: 'image/png',
94
+ jpg: 'image/jpeg',
95
+ jpeg: 'image/jpeg',
96
+ webp: 'image/webp',
97
+ gif: 'image/gif',
98
+ mp4: 'video/mp4',
99
+ mov: 'video/quicktime',
100
+ webm: 'video/webm',
101
+ mp3: 'audio/mpeg',
102
+ wav: 'audio/wav',
103
+ m4a: 'audio/mp4',
104
+ };
105
+ function mimeOf(path) {
106
+ const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
107
+ return MIME[ext] ?? 'application/octet-stream';
108
+ }
109
+ async function download(url, out) {
110
+ const name = out || basename(new URL(url).pathname) || 'output';
111
+ const target = resolve(name);
112
+ const res = await fetch(url);
113
+ if (!res.ok || !res.body) {
114
+ return fail(`下载失败(HTTP ${res.status}):${url}`);
115
+ }
116
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(target));
117
+ log(`已保存到 ${target}`);
118
+ return target;
119
+ }
120
+ /** 轮到终态。间隔 3 秒,与后端的实际出图节奏匹配,太密只是白发请求。 */
121
+ async function poll(g) {
122
+ let current = g;
123
+ const deadline = Date.now() + 10 * 60 * 1000;
124
+ while (Date.now() < deadline) {
125
+ if (current.status === 'done' || current.status === 'failed' || current.status === 'canceled') {
126
+ return current;
127
+ }
128
+ await new Promise((r) => setTimeout(r, 3000));
129
+ current = await api.get(`/openapi/generations/${g.id}`);
130
+ }
131
+ log('等了 10 分钟还没结束,任务仍在后台跑');
132
+ return current;
133
+ }
@@ -0,0 +1,271 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { api } from '../api.js';
3
+ import { emit, fail, log } from '../output.js';
4
+ import { parseKeyValues } from '../args.js';
5
+ import { canvasUrl, resolveWorkflowId } from './workflow.js';
6
+ const RUNNABLE = new Set(['textNode', 'imageNode', 'storyVideo']);
7
+ export async function node(parsed) {
8
+ const sub = parsed.positional[1] ?? 'list';
9
+ switch (sub) {
10
+ case 'list':
11
+ return list(parsed);
12
+ case 'create':
13
+ return create(parsed);
14
+ case 'update':
15
+ return update(parsed);
16
+ case 'run':
17
+ return run(parsed);
18
+ case 'delete':
19
+ return remove(parsed);
20
+ case 'connect':
21
+ return connect(parsed);
22
+ case 'disconnect':
23
+ return disconnect(parsed);
24
+ default:
25
+ fail(`未知的子命令 ${sub}`, '可用:list / create / update / run / delete / connect / disconnect');
26
+ }
27
+ }
28
+ async function list(parsed) {
29
+ const id = resolveWorkflowId(parsed);
30
+ const canvas = await api.get(`/openapi/workflows/${id}`);
31
+ emit(canvas.nodes.map((n) => ({
32
+ id: n.id,
33
+ type: n.type,
34
+ name: n.data?.title ?? undefined,
35
+ status: n.data?.status ?? 'idle',
36
+ hasResult: Boolean(n.data?.result),
37
+ })));
38
+ }
39
+ async function create(parsed) {
40
+ const workflowId = resolveWorkflowId(parsed);
41
+ const type = String(parsed.options.type ?? '');
42
+ if (!type) {
43
+ fail('建节点要指定类型', '用法:ssx node create <名字> -t imageNode,类型清单见 ssx schema');
44
+ }
45
+ const name = parsed.positional[2];
46
+ const data = parseKeyValues(parsed.repeated.set);
47
+ Object.assign(data, parseKeyValues(parsed.repeated.update));
48
+ if (name)
49
+ data.title = name;
50
+ if (parsed.options.prompt && parsed.options.prompt !== true)
51
+ data.prompt = parsed.options.prompt;
52
+ const nodeId = randomUUID();
53
+ await api.post(`/openapi/workflows/${workflowId}/nodes:batch`, {
54
+ action: 'create',
55
+ creates: [{ id: nodeId, type, position: position(parsed), data }],
56
+ });
57
+ // 连上游:--left 可以给多个,值是上游节点的名字或 id
58
+ const lefts = parsed.repeated.left ?? [];
59
+ if (lefts.length) {
60
+ const canvas = await api.get(`/openapi/workflows/${workflowId}`);
61
+ // 去重:同一个上游写两次 --left 不该连出两条线
62
+ const sources = new Set(lefts.map((ref) => locate(canvas.nodes, ref).id));
63
+ for (const source of sources) {
64
+ await api.post(`/openapi/workflows/${workflowId}/nodes:batch`, {
65
+ action: 'createEdge',
66
+ edge: { id: randomUUID(), source, target: nodeId },
67
+ });
68
+ }
69
+ log(`已连接 ${sources.size} 个上游`);
70
+ }
71
+ if (parsed.options.run) {
72
+ return runNode(workflowId, nodeId, waitSeconds(parsed));
73
+ }
74
+ emit({ id: nodeId, type, name, workflowId, url: canvasUrl(workflowId) });
75
+ }
76
+ async function update(parsed) {
77
+ const workflowId = resolveWorkflowId(parsed);
78
+ const target = await find(workflowId, parsed.positional[2]);
79
+ const data = parseKeyValues(parsed.repeated.set);
80
+ Object.assign(data, parseKeyValues(parsed.repeated.update));
81
+ if (parsed.options.prompt && parsed.options.prompt !== true)
82
+ data.prompt = parsed.options.prompt;
83
+ if (parsed.options.name && parsed.options.name !== true)
84
+ data.title = parsed.options.name;
85
+ const edgeChanges = await applyEdges(workflowId, target.id, parsed);
86
+ if (!Object.keys(data).length && !edgeChanges) {
87
+ fail('没有要改的字段', '用法:ssx node update <节点> --set prompt="新的提示词",或 --left 改连线');
88
+ }
89
+ if (Object.keys(data).length) {
90
+ await api.post(`/openapi/workflows/${workflowId}/nodes:batch`, {
91
+ action: 'update',
92
+ id: target.id,
93
+ data,
94
+ });
95
+ }
96
+ if (parsed.options.run) {
97
+ return runNode(workflowId, target.id, waitSeconds(parsed));
98
+ }
99
+ emit({ id: target.id, updated: Object.keys(data) });
100
+ }
101
+ /**
102
+ * 改入边。三种模式,一次只能用一种:
103
+ *
104
+ * - `--left A --left B`:**确保**入边正好是 A 和 B——缺的补上、多的剪掉。
105
+ * agent 描述目标状态比描述增量容易,重跑同一条命令也不会叠出重复的边。
106
+ * - `--left-add X`:只加
107
+ * - `--left-rm X`:只删
108
+ *
109
+ * @return 是否动过连线
110
+ */
111
+ async function applyEdges(workflowId, nodeId, parsed) {
112
+ const ensure = parsed.repeated.left;
113
+ const add = parsed.repeated['left-add'];
114
+ const remove = parsed.repeated['left-rm'];
115
+ if (!ensure && !add && !remove)
116
+ return false;
117
+ if (ensure && (add || remove)) {
118
+ fail('--left 与 --left-add / --left-rm 不能一起用', '--left 是「入边就是这些」,另两个是增量,选一种');
119
+ }
120
+ const canvas = await api.get(`/openapi/workflows/${workflowId}`);
121
+ const incoming = canvas.edges.filter((e) => e.target === nodeId);
122
+ const idOf = (ref) => locate(canvas.nodes, ref).id;
123
+ const toAdd = [];
124
+ const toRemove = [];
125
+ if (ensure) {
126
+ const want = new Set(ensure.map(idOf));
127
+ for (const source of want) {
128
+ if (!incoming.some((e) => e.source === source))
129
+ toAdd.push(source);
130
+ }
131
+ for (const edge of incoming) {
132
+ if (!want.has(edge.source))
133
+ toRemove.push(edge.id);
134
+ }
135
+ }
136
+ for (const ref of add ?? []) {
137
+ const source = idOf(ref);
138
+ if (!incoming.some((e) => e.source === source))
139
+ toAdd.push(source);
140
+ }
141
+ for (const ref of remove ?? []) {
142
+ const source = idOf(ref);
143
+ for (const edge of incoming) {
144
+ if (edge.source === source)
145
+ toRemove.push(edge.id);
146
+ }
147
+ }
148
+ for (const source of toAdd) {
149
+ await api.post(`/openapi/workflows/${workflowId}/nodes:batch`, {
150
+ action: 'createEdge',
151
+ edge: { id: randomUUID(), source, target: nodeId },
152
+ });
153
+ }
154
+ if (toRemove.length) {
155
+ await api.post(`/openapi/workflows/${workflowId}/nodes:batch`, { action: 'deleteEdge', ids: toRemove });
156
+ }
157
+ if (toAdd.length || toRemove.length) {
158
+ log(`连线:+${toAdd.length} -${toRemove.length}`);
159
+ }
160
+ return true;
161
+ }
162
+ /** 断开两个节点之间的连线。agent 手里只有节点名,不知道边的 id。 */
163
+ async function disconnect(parsed) {
164
+ const workflowId = resolveWorkflowId(parsed);
165
+ const canvas = await api.get(`/openapi/workflows/${workflowId}`);
166
+ const source = locate(canvas.nodes, parsed.positional[2]);
167
+ const target = locate(canvas.nodes, parsed.positional[3]);
168
+ const hit = canvas.edges.filter((e) => e.source === source.id && e.target === target.id);
169
+ if (!hit.length) {
170
+ fail(`「${parsed.positional[2]}」和「${parsed.positional[3]}」之间没有连线`, '现有连线见 ssx workflow show');
171
+ }
172
+ await api.post(`/openapi/workflows/${workflowId}/nodes:batch`, {
173
+ action: 'deleteEdge',
174
+ ids: hit.map((e) => e.id),
175
+ });
176
+ emit({ disconnected: [source.id, target.id], removed: hit.length });
177
+ }
178
+ async function run(parsed) {
179
+ const workflowId = resolveWorkflowId(parsed);
180
+ const target = await find(workflowId, parsed.positional[2]);
181
+ if (!RUNNABLE.has(target.type)) {
182
+ fail(`${target.type} 不支持直接触发生成`, `目前支持:${[...RUNNABLE].join(' / ')}`);
183
+ }
184
+ await runNode(workflowId, target.id, waitSeconds(parsed));
185
+ }
186
+ async function remove(parsed) {
187
+ const workflowId = resolveWorkflowId(parsed);
188
+ const target = await find(workflowId, parsed.positional[2]);
189
+ await api.post(`/openapi/workflows/${workflowId}/nodes:batch`, { action: 'delete', ids: [target.id] });
190
+ emit({ deleted: target.id });
191
+ }
192
+ async function connect(parsed) {
193
+ const workflowId = resolveWorkflowId(parsed);
194
+ const canvas = await api.get(`/openapi/workflows/${workflowId}`);
195
+ const source = locate(canvas.nodes, parsed.positional[2]);
196
+ const target = locate(canvas.nodes, parsed.positional[3]);
197
+ // 幂等:服务端的 createEdge 是直接 add,不去重。agent 重试很常见,
198
+ // 不挡一下的话同一对节点之间会叠出好几条线
199
+ const existing = canvas.edges.find((e) => e.source === source.id && e.target === target.id);
200
+ if (existing) {
201
+ emit({ connected: [source.id, target.id], created: false });
202
+ return;
203
+ }
204
+ await api.post(`/openapi/workflows/${workflowId}/nodes:batch`, {
205
+ action: 'createEdge',
206
+ edge: { id: randomUUID(), source: source.id, target: target.id },
207
+ });
208
+ emit({ connected: [source.id, target.id], created: true });
209
+ }
210
+ /**
211
+ * 触发生成。默认阻塞等终态——agent 每轮询一次都是一次模型调用,token 是用户在付,
212
+ * 让它自己写 while 循环等于让它一直烧钱。要立刻返回就传 --wait 0。
213
+ */
214
+ async function runNode(workflowId, nodeId, wait) {
215
+ log(wait > 0 ? `已提交,最多等 ${wait} 秒…` : '已提交');
216
+ const g = await api.post(`/openapi/workflows/${workflowId}/nodes/${nodeId}:run?waitSeconds=${wait}`);
217
+ const url = g.result?.assets?.[0]?.url;
218
+ const text = g.result?.text;
219
+ if (g.status === 'done') {
220
+ log(`完成,扣了 ${g.cost} 积分`);
221
+ }
222
+ else if (g.status === 'failed') {
223
+ log(`失败:${g.error ?? '未知原因'}`);
224
+ }
225
+ else {
226
+ log(`还在生成中,稍后用 ssx node list 看状态,或去画布看:${canvasUrl(workflowId)}`);
227
+ }
228
+ emit({
229
+ generationId: g.id,
230
+ status: g.status,
231
+ cost: g.cost,
232
+ result: url ?? text,
233
+ nodeId,
234
+ url: canvasUrl(workflowId),
235
+ });
236
+ }
237
+ /** 支持按名字或 id 定位。agent 记名字比记 uuid 容易,这一点对它的可用性影响很大。 */
238
+ async function find(workflowId, ref) {
239
+ const canvas = await api.get(`/openapi/workflows/${workflowId}`);
240
+ return locate(canvas.nodes, ref);
241
+ }
242
+ function locate(nodes, ref) {
243
+ if (!ref) {
244
+ fail('要指定节点', '用节点名或 id,列表见 ssx node list');
245
+ }
246
+ const byId = nodes.find((n) => n.id === ref);
247
+ if (byId)
248
+ return byId;
249
+ const byName = nodes.filter((n) => n.data?.title === ref);
250
+ if (byName.length === 1)
251
+ return byName[0];
252
+ if (byName.length > 1) {
253
+ fail(`有 ${byName.length} 个节点都叫「${ref}」`, `用 id 指定:${byName.map((n) => n.id).join(' / ')}`);
254
+ }
255
+ return fail(`找不到节点「${ref}」`, '列表见 ssx node list');
256
+ }
257
+ function position(parsed) {
258
+ return {
259
+ x: Number(parsed.options.x ?? 0) || 0,
260
+ y: Number(parsed.options.y ?? 0) || 0,
261
+ };
262
+ }
263
+ function waitSeconds(parsed) {
264
+ const raw = parsed.options.wait;
265
+ if (raw === undefined)
266
+ return 55;
267
+ if (raw === true)
268
+ return 55;
269
+ const n = Number(raw);
270
+ return Number.isNaN(n) ? 55 : Math.max(0, Math.min(n, 55));
271
+ }
@@ -0,0 +1,80 @@
1
+ import { api } from '../api.js';
2
+ import { clearBinding, readBinding, writeBinding } from '../config.js';
3
+ import { emit, fail, log } from '../output.js';
4
+ export async function workflow(parsed) {
5
+ const sub = parsed.positional[1] ?? 'show';
6
+ switch (sub) {
7
+ case 'list':
8
+ return list();
9
+ case 'create':
10
+ return create(parsed);
11
+ case 'use':
12
+ return use(parsed);
13
+ case 'unuse':
14
+ return unuse();
15
+ case 'show':
16
+ return show(parsed);
17
+ default:
18
+ fail(`未知的子命令 ${sub}`, '可用:list / create / use / unuse / show');
19
+ }
20
+ }
21
+ async function list() {
22
+ const page = await api.get('/openapi/workflows?limit=50');
23
+ emit(page.items.map((w) => ({ id: w.id, title: w.title, updatedAt: w.updatedAt })));
24
+ }
25
+ async function create(parsed) {
26
+ const title = parsed.positional[2] ?? String(parsed.options.title ?? '');
27
+ const created = await api.post('/openapi/workflows', { title: title || undefined });
28
+ // 新建即绑定:agent 建完画布下一句多半就是建节点,让它不用再传一次 id
29
+ const file = writeBinding({ workflowId: created.id, title: created.title });
30
+ log(`已创建并绑定到当前目录(${file})`);
31
+ emit({ id: created.id, title: created.title, url: canvasUrl(created.id) });
32
+ }
33
+ function use(parsed) {
34
+ const raw = parsed.positional[2];
35
+ const id = Number(raw);
36
+ if (!raw || Number.isNaN(id)) {
37
+ fail('需要画布 id', '用法:ssx workflow use <画布id>,画布列表见 ssx workflow list');
38
+ }
39
+ const file = writeBinding({ workflowId: id });
40
+ log(`已绑定画布 ${id}(${file})`);
41
+ emit({ workflowId: id, url: canvasUrl(id) });
42
+ }
43
+ function unuse() {
44
+ const removed = clearBinding();
45
+ log(removed ? '已解除当前目录的画布绑定' : '当前目录本来就没有绑定');
46
+ emit({ unbound: removed });
47
+ }
48
+ async function show(parsed) {
49
+ const id = resolveWorkflowId(parsed);
50
+ const canvas = await api.get(`/openapi/workflows/${id}`);
51
+ emit({
52
+ workflowId: id,
53
+ url: canvasUrl(id),
54
+ nodes: canvas.nodes.map((n) => ({
55
+ id: n.id,
56
+ type: n.type,
57
+ name: n.data?.title ?? undefined,
58
+ status: n.data?.status ?? 'idle',
59
+ result: n.data?.result,
60
+ })),
61
+ edges: canvas.edges,
62
+ });
63
+ }
64
+ /** 命令行显式给的 > 当前目录绑定的。两者都没有就报错并指路。 */
65
+ export function resolveWorkflowId(parsed) {
66
+ const explicit = parsed.options.workflow ?? parsed.options.project;
67
+ if (explicit && explicit !== true) {
68
+ const id = Number(explicit);
69
+ if (!Number.isNaN(id))
70
+ return id;
71
+ }
72
+ const bound = readBinding();
73
+ if (bound)
74
+ return bound.workflowId;
75
+ return fail('没有指定画布', '先 ssx workflow use <画布id> 绑定,或加 --workflow <画布id>');
76
+ }
77
+ export function canvasUrl(id) {
78
+ const base = (process.env.SOULSPACEX_BASE_URL ?? 'https://soulspacex.com').replace(/\/+$/, '');
79
+ return `${base}/editor/${id}`;
80
+ }
package/dist/config.js ADDED
@@ -0,0 +1,75 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ /**
5
+ * 两份本地状态,作用域刻意不同:
6
+ *
7
+ * - **凭据**在 `~/.soulspacex/credentials`,跟人走,一台机器一份。
8
+ * - **画布绑定**在当前目录的 `.soulspacex/workflow.json`,跟项目走。
9
+ * agent 在一个项目目录里连续操作时不用反复传画布 id——这是抄 liblib 的做法,
10
+ * 也是 git 的做法。
11
+ */
12
+ const CONFIG_DIR = process.env.SOULSPACEX_CONFIG_DIR ?? join(homedir(), '.soulspacex');
13
+ const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials');
14
+ const PROJECT_DIR = '.soulspacex';
15
+ const PROJECT_FILE = 'workflow.json';
16
+ export const DEFAULT_BASE_URL = 'https://soulspacex.com';
17
+ export function baseUrl() {
18
+ return (process.env.SOULSPACEX_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
19
+ }
20
+ // ---- 凭据 ----
21
+ export function readApiKey() {
22
+ // 环境变量优先:CI 和容器里没法交互登录,直接注入 key 是唯一可行的路
23
+ const fromEnv = process.env.SOULSPACEX_API_KEY;
24
+ if (fromEnv && fromEnv.trim())
25
+ return fromEnv.trim();
26
+ if (!existsSync(CREDENTIALS_FILE))
27
+ return null;
28
+ const content = readFileSync(CREDENTIALS_FILE, 'utf8').trim();
29
+ return content || null;
30
+ }
31
+ export function writeApiKey(key) {
32
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
33
+ // 0600:这把 key 能花用户的积分,不该让同机器的其他用户读到
34
+ writeFileSync(CREDENTIALS_FILE, key + '\n', { mode: 0o600 });
35
+ }
36
+ export function clearApiKey() {
37
+ if (existsSync(CREDENTIALS_FILE))
38
+ rmSync(CREDENTIALS_FILE);
39
+ }
40
+ export function credentialsPath() {
41
+ return CREDENTIALS_FILE;
42
+ }
43
+ /** 从当前目录逐级向上找,与 git 找 .git 的方式一致——在子目录里也能用。 */
44
+ export function readBinding(from = process.cwd()) {
45
+ let dir = resolve(from);
46
+ for (;;) {
47
+ const file = join(dir, PROJECT_DIR, PROJECT_FILE);
48
+ if (existsSync(file)) {
49
+ try {
50
+ return JSON.parse(readFileSync(file, 'utf8'));
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ const parent = dirname(dir);
57
+ if (parent === dir)
58
+ return null;
59
+ dir = parent;
60
+ }
61
+ }
62
+ export function writeBinding(binding, at = process.cwd()) {
63
+ const dir = join(at, PROJECT_DIR);
64
+ mkdirSync(dir, { recursive: true });
65
+ const file = join(dir, PROJECT_FILE);
66
+ writeFileSync(file, JSON.stringify(binding, null, 2) + '\n');
67
+ return file;
68
+ }
69
+ export function clearBinding(at = process.cwd()) {
70
+ const file = join(at, PROJECT_DIR, PROJECT_FILE);
71
+ if (!existsSync(file))
72
+ return false;
73
+ rmSync(file);
74
+ return true;
75
+ }
package/dist/index.js ADDED
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from './args.js';
3
+ import { fail, log } from './output.js';
4
+ import { login, logout, whoami } from './commands/login.js';
5
+ import { workflow } from './commands/workflow.js';
6
+ import { node } from './commands/node.js';
7
+ import { balance, downloadCommand, generate, model, schema, upload } from './commands/misc.js';
8
+ const VERSION = '0.1.0';
9
+ /**
10
+ * 这段 help 的第一读者是 AI Agent,不是人。
11
+ *
12
+ * Agent 遇到不会用的命令会自己跑 `--help`,然后照着上面写的拼参数——所以这里的措辞
13
+ * 直接决定它用得对不对。示例要能照抄,约束要写死(哪些字段不能自己设、上游怎么接),
14
+ * 别指望它去猜。
15
+ */
16
+ const HELP = `ssx — SoulSpaceX 命令行工具
17
+
18
+ 在画布上创作:文本、图片、视频节点互相连线,上游的产物自动成为下游的输入。
19
+
20
+ 用法
21
+ ssx <命令> [子命令] [参数]
22
+
23
+ 开始
24
+ ssx login 浏览器授权这台设备(需要订阅卡 / 会员 / 无限模式)
25
+ ssx whoami 看当前身份与积分余额
26
+ ssx logout 吊销这台设备的授权并删除本地凭据
27
+
28
+ 画布
29
+ ssx workflow create "短剧第一集" 新建画布并绑定到当前目录
30
+ ssx workflow list 我的画布
31
+ ssx workflow use <画布id> 把当前目录绑到某张画布
32
+ ssx workflow show 看当前画布的节点与连线
33
+
34
+ 节点
35
+ ssx node create "剧本" -t textNode --prompt "写一段赛博朋克开场" --run
36
+ ssx node create "主视觉" -t imageNode --left 剧本 --set count=1 --run
37
+ ssx node list 列出节点(名字、状态、有没有产物)
38
+ ssx node update 剧本 --set prompt="改后的提示词" --run
39
+ ssx node run 主视觉 触发生成,默认阻塞到出结果
40
+ ssx node connect 剧本 主视觉 连一条线(上游 → 下游),重复连不会叠出多条
41
+ ssx node disconnect 剧本 主视觉 断开
42
+ ssx node update 主视觉 --left 剧本 --left 参考图
43
+ 改入边:结果就是这两个,缺的补、多的剪
44
+ ssx node update 主视觉 --left-add 配乐 只加
45
+ ssx node update 主视觉 --left-rm 参考图 只删
46
+ ssx node delete 主视觉
47
+
48
+ 节点类型:textNode(文本)imageNode(图片)storyVideo(视频)
49
+ audioNode / agentNode / director3dNode 可以建,但暂不支持 run
50
+ -t 指定类型,--set 写生成参数(模型、比例、时长…),--left 接上游,--run 建完立刻生成
51
+ 节点可以用名字指代,重名时才需要用 id
52
+
53
+ 不走画布的一次性生成
54
+ ssx generate text2img --model "模型名" --prompt "一只赛博朋克的猫"
55
+ ssx model list 可用模型(带计价)
56
+ ssx model search nano 按名字搜
57
+
58
+ 素材
59
+ ssx upload ./ref.png 上传参考图,拿到可用的 URL
60
+ ssx download <url> -o out.png 下载产物到本地
61
+
62
+ 其他
63
+ ssx schema 节点字段契约。拼 --set 之前先看这个,别猜字段名
64
+ ssx balance 积分余额
65
+
66
+ 约定
67
+ · 结果走 stdout(JSON),进度和提示走 stderr——可以放心 ssx ... | jq
68
+ · status / result / assetId 这类字段由生成结果回写,不要自己设,会被拒
69
+ · 命令行创作按积分计费,不使用无限模式额度
70
+ · 生成默认阻塞到出结果(最长 55 秒),--wait 0 可以立刻返回
71
+
72
+ 环境变量
73
+ SOULSPACEX_API_KEY 直接给凭据,CI 与容器里用它代替 ssx login
74
+ SOULSPACEX_BASE_URL 服务地址,默认 https://soulspacex.com
75
+ `;
76
+ async function main() {
77
+ const parsed = parseArgs(process.argv.slice(2));
78
+ const command = parsed.positional[0];
79
+ if (parsed.options.version) {
80
+ log(VERSION);
81
+ return;
82
+ }
83
+ if (!command || parsed.options.help) {
84
+ process.stderr.write(HELP);
85
+ return;
86
+ }
87
+ switch (command) {
88
+ case 'login':
89
+ return login(parsed);
90
+ case 'logout':
91
+ return logout();
92
+ case 'whoami':
93
+ case 'account':
94
+ return whoami();
95
+ case 'workflow':
96
+ case 'canvas':
97
+ return workflow(parsed);
98
+ case 'node':
99
+ return node(parsed);
100
+ case 'model':
101
+ return model(parsed);
102
+ case 'schema':
103
+ return schema();
104
+ case 'balance':
105
+ return balance();
106
+ case 'generate':
107
+ return generate(parsed);
108
+ case 'upload':
109
+ return upload(parsed);
110
+ case 'download':
111
+ return downloadCommand(parsed);
112
+ case 'help':
113
+ process.stderr.write(HELP);
114
+ return;
115
+ default:
116
+ fail(`未知命令 ${command}`, '可用命令见 ssx --help');
117
+ }
118
+ }
119
+ main().catch((e) => {
120
+ fail(e instanceof Error ? e.message : String(e));
121
+ });
package/dist/output.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 输出分流:**stdout 只放业务 JSON,进度与提示一律走 stderr**。
3
+ *
4
+ * Agent 会把 stdout 直接喂给下一条命令或自己解析,混进一行「正在生成…」就会让它解析失败。
5
+ * 这条约定和 liblib 的 CLI 一致,是被实践验证过的。
6
+ */
7
+ /** 业务结果。唯一往 stdout 写东西的地方。 */
8
+ export function emit(data) {
9
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
10
+ }
11
+ /** 进度、状态、提示。人和 agent 都会看,但不会被当成结果解析。 */
12
+ export function log(message) {
13
+ process.stderr.write(message + '\n');
14
+ }
15
+ /**
16
+ * 失败。message 会被 agent 原样转述给用户,所以要写清楚下一步做什么,
17
+ * 而不是只说「出错了」。
18
+ */
19
+ export function fail(message, hint) {
20
+ process.stderr.write(`错误:${message}\n`);
21
+ if (hint)
22
+ process.stderr.write(`${hint}\n`);
23
+ process.exit(1);
24
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@soulspacex/cli",
3
+ "version": "0.1.0",
4
+ "description": "SoulSpaceX 命令行工具——让 AI Agent 直接在画布上创作",
5
+ "bin": {
6
+ "ssx": "dist/index.js"
7
+ },
8
+ "type": "module",
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "typecheck": "tsc -p tsconfig.json --noEmit",
18
+ "test": "vitest run",
19
+ "dev": "node --experimental-strip-types src/index.ts",
20
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
21
+ },
22
+ "keywords": [
23
+ "ai",
24
+ "agent",
25
+ "cli",
26
+ "image-generation",
27
+ "video-generation"
28
+ ],
29
+ "license": "MIT",
30
+ "devDependencies": {
31
+ "@types/node": "^22.10.2",
32
+ "typescript": "^5.7.2",
33
+ "vitest": "^2.1.9"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "homepage": "https://soulspacex.com/cli"
39
+ }