@thesethrose/socialspool-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.
Files changed (4) hide show
  1. package/README.md +169 -0
  2. package/SKILL.md +187 -0
  3. package/dist/spool.js +886 -0
  4. package/package.json +25 -0
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # @thesethrose/socialspool-cli
2
+
3
+ Agent-friendly CLI for the [SocialSpool](https://socialspool.com/) Public API.
4
+
5
+ SocialSpool is a social scheduling tool for creating posts, scheduling them
6
+ across connected publishing accounts, and checking final publish status. This
7
+ CLI is designed for humans and coding agents that need a safe, scriptable way
8
+ to work through the public API without using dashboard session routes.
9
+
10
+ ## What You Can Do
11
+
12
+ - Inspect the authenticated workspace and API key scopes.
13
+ - List connected publishing accounts.
14
+ - Validate post content against account and platform constraints.
15
+ - Create drafts.
16
+ - Schedule posts for future publishing.
17
+ - Publish posts immediately.
18
+ - Wait for terminal publish status.
19
+ - Inspect timelines and failure details.
20
+ - Upload media assets when media support is enabled.
21
+ - Manage public API webhooks when the API key has webhook scopes.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install -g @thesethrose/socialspool-cli
27
+ ```
28
+
29
+ Or run without a global install:
30
+
31
+ ```bash
32
+ npx @thesethrose/socialspool-cli doctor --json
33
+ ```
34
+
35
+ Requires Node.js 20.19 or newer.
36
+
37
+ ## Authentication
38
+
39
+ Create a SocialSpool API key in the dashboard, then set it in your shell or
40
+ agent runtime:
41
+
42
+ ```bash
43
+ export SOCIALSPOOL_API_KEY=ssp_live_...
44
+ ```
45
+
46
+ For scheduling agents, use these scopes:
47
+
48
+ ```text
49
+ posts:read
50
+ posts:write
51
+ accounts:read
52
+ ```
53
+
54
+ Add `webhooks:read` and `webhooks:write` only when the agent should manage
55
+ webhooks.
56
+
57
+ The default API base URL is:
58
+
59
+ ```text
60
+ https://socialspool.com/api/v1
61
+ ```
62
+
63
+ Override it with:
64
+
65
+ ```bash
66
+ export SOCIALSPOOL_API_BASE_URL=https://socialspool.com/api/v1
67
+ ```
68
+
69
+ or pass `--base-url`.
70
+
71
+ Never paste API keys into prompts, logs, tickets, or generated files. Configure
72
+ them through the runtime environment.
73
+
74
+ ## Verify Setup
75
+
76
+ ```bash
77
+ spool me --json
78
+ spool accounts list --json
79
+ spool capabilities --json
80
+ spool doctor --json
81
+ ```
82
+
83
+ `doctor` checks authentication and core API reachability. If it fails, the JSON
84
+ output includes the structured error message and request id when the API
85
+ returned one.
86
+
87
+ ## Common commands
88
+
89
+ List connected accounts:
90
+
91
+ ```bash
92
+ spool accounts list --json
93
+ ```
94
+
95
+ Validate content before scheduling:
96
+
97
+ ```bash
98
+ spool posts validate \
99
+ --content "Hello from SocialSpool" \
100
+ --account acct_123 \
101
+ --json
102
+ ```
103
+
104
+ Create and schedule a post:
105
+
106
+ ```bash
107
+ spool posts create \
108
+ --content "Hello from SocialSpool" \
109
+ --account acct_123 \
110
+ --publish-at 2026-06-12T15:00:00.000Z \
111
+ --idempotency-key schedule-launch-post-20260612 \
112
+ --json
113
+ ```
114
+
115
+ Wait for final status:
116
+
117
+ ```bash
118
+ spool posts wait post_123 --json
119
+ ```
120
+
121
+ Publish an existing draft now:
122
+
123
+ ```bash
124
+ spool posts publish-now post_123 \
125
+ --account acct_123 \
126
+ --idempotency-key publish-now-post-123 \
127
+ --json
128
+ ```
129
+
130
+ Cancel a scheduled post:
131
+
132
+ ```bash
133
+ spool posts cancel post_123 --json
134
+ ```
135
+
136
+ Inspect a post timeline:
137
+
138
+ ```bash
139
+ spool posts timeline post_123 --json
140
+ ```
141
+
142
+ ## Status Semantics
143
+
144
+ Do not treat a successful request as a successful platform publish.
145
+
146
+ - `draft`: saved but not scheduled.
147
+ - `scheduled`: accepted for future publishing.
148
+ - `publishing`: worker is attempting to publish.
149
+ - `published`: confirmed by the platform adapter.
150
+ - `failed`: terminal failure with an error code/message.
151
+ - `canceled`: scheduled publish was canceled.
152
+
153
+ Only report a post as published when SocialSpool returns `published` and
154
+ platform result data such as a platform post id or URL.
155
+
156
+ ## Agent skill
157
+
158
+ This package includes `SKILL.md`, the SocialSpool Public API Agent skill. Give
159
+ that file to compatible coding agents along with an API key configured in their
160
+ runtime environment.
161
+
162
+ The skill intentionally excludes admin/customer-support operations. It uses the
163
+ public `spool` CLI only.
164
+
165
+ ## Links
166
+
167
+ - Website: [https://socialspool.com/](https://socialspool.com/)
168
+ - API base URL: `https://socialspool.com/api/v1`
169
+ - OpenAPI contract: `https://socialspool.com/api/docs/openapi`
package/SKILL.md ADDED
@@ -0,0 +1,187 @@
1
+ ---
2
+ name: socialspool-public-api-agent
3
+ description: Use SocialSpool to inspect a workspace, list connected publishing accounts, create drafts, schedule posts, publish immediately, cancel scheduled posts, and verify post status through the public API and spool CLI.
4
+ version: 2.0.0
5
+ author: SocialSpool
6
+ metadata:
7
+ hermes:
8
+ tags: [socialspool, spool, cli, social-media, api, scheduling, agents]
9
+ ---
10
+
11
+ # Skill: SocialSpool Public API Agent
12
+
13
+ ## Purpose
14
+
15
+ Use SocialSpool to inspect a workspace, list connected publishing accounts, create drafts, schedule posts, publish immediately, cancel scheduled posts, and verify post status.
16
+
17
+ ## When to use
18
+
19
+ Use this skill when a user asks an agent to create, schedule, publish, cancel, or inspect SocialSpool posts through the public API.
20
+
21
+ ## Do not use this skill for
22
+
23
+ - Admin/customer-support operations, audit inspection, operations queues, or billing diagnostics (use `spool-admin` and `src/cli/spool-admin/SKILL.md` instead)
24
+ - User suspension/reactivation
25
+ - Workspace deletion
26
+ - Billing repair
27
+ - API key creation/revocation
28
+ - Direct database access
29
+ - Dashboard session-only routes
30
+ - Social OAuth connection flows
31
+ - Reading or handling social platform tokens
32
+ - Claiming platform publish success before SocialSpool reports confirmed platform result data
33
+
34
+ ## Authentication
35
+
36
+ - Use `SOCIALSPOOL_API_KEY`.
37
+ - Optional override: `SOCIALSPOOL_API_BASE_URL` or `--base-url`.
38
+ - Never print, log, summarize, or echo the API key.
39
+ - If authentication fails, report the structured API error code and request id if present.
40
+
41
+ ## Required first checks
42
+
43
+ Every agent workflow must start with:
44
+
45
+ ```bash
46
+ spool me --json
47
+ spool accounts list --json
48
+ spool capabilities --json
49
+ ```
50
+
51
+ ## Core workflow: schedule a post
52
+
53
+ 1. Inspect workspace and scopes.
54
+ 2. List connected accounts.
55
+ 3. Validate content against selected account/platform constraints.
56
+ 4. Create a draft or schedule directly.
57
+ 5. Use an idempotency key for every write.
58
+ 6. Verify returned post/target status.
59
+ 7. Poll/wait until status is terminal when the user asked for confirmation.
60
+
61
+ Example:
62
+
63
+ ```bash
64
+ spool accounts list --json
65
+
66
+ spool posts validate \
67
+ --content "Post content here" \
68
+ --account acct_123 \
69
+ --publish-at 2026-06-12T15:00:00.000Z \
70
+ --json
71
+
72
+ spool posts create \
73
+ --content "Post content here" \
74
+ --account acct_123 \
75
+ --publish-at 2026-06-12T15:00:00.000Z \
76
+ --idempotency-key schedule-<stable-operation-id> \
77
+ --json
78
+
79
+ spool posts wait post_123 --json
80
+ ```
81
+
82
+ ## Core workflow: publish now
83
+
84
+ ```bash
85
+ spool posts create --content "Post content here" --json
86
+
87
+ spool posts publish-now post_123 \
88
+ --account acct_123 \
89
+ --idempotency-key publish-now-<stable-operation-id> \
90
+ --json
91
+
92
+ spool posts wait post_123 --json
93
+ ```
94
+
95
+ ## Core workflow: cancel scheduled post
96
+
97
+ ```bash
98
+ spool posts cancel post_123 --json
99
+ spool posts get post_123 --json
100
+ ```
101
+
102
+ ## Media workflow
103
+
104
+ When attaching media, upload first, then reference returned media asset ids:
105
+
106
+ ```bash
107
+ spool media config --json
108
+ spool media upload ./image.png --json
109
+ spool posts create --post-style text_media --content "Caption" --media-asset-ids media_123 --json
110
+ ```
111
+
112
+ ## Status semantics
113
+
114
+ - `draft`: not scheduled, not published.
115
+ - `scheduled`: accepted for future publishing.
116
+ - `publishing`: worker is attempting to publish.
117
+ - `published`: confirmed by platform adapter. Only this may be reported as published.
118
+ - `failed`: terminal failure. Report error code/message.
119
+ - `canceled`: no platform publish should occur.
120
+
121
+ ## Hard rules
122
+
123
+ - Never claim publish success from a 200/202 response alone.
124
+ - Never claim success from `scheduled`, `queued`, or `publishing`.
125
+ - A post is published only when SocialSpool returns `published` plus platform result data such as platform post id or URL.
126
+ - Use idempotency keys for every POST/PATCH/DELETE.
127
+ - Retry only through public API/CLI commands.
128
+ - Do not call admin routes.
129
+ - Do not ask the user for social tokens.
130
+ - Do not use dashboard-only endpoints.
131
+ - Prefer JSON output.
132
+
133
+ ## Troubleshooting
134
+
135
+ - Use `spool posts get <postId> --json`.
136
+ - Use `spool posts timeline <postId> --json`.
137
+ - For cross-workspace audit, billing, or operations investigation, use `spool-admin` instead of `spool`.
138
+
139
+ ## Expected agent response
140
+
141
+ Return:
142
+
143
+ - command(s) run
144
+ - post id
145
+ - selected account ids/platforms
146
+ - idempotency key used
147
+ - final status
148
+ - platform URL/id when published
149
+ - error code/message when failed
150
+ - next required action if blocked
151
+
152
+ ## CLI reference
153
+
154
+ ```bash
155
+ spool me --json
156
+ spool capabilities --json
157
+ spool doctor --json
158
+ spool openapi --json
159
+ spool accounts list --json
160
+ spool posts validate --content "..." --account acct_1 --json
161
+ spool posts list --json
162
+ spool posts get post_123 --json
163
+ spool posts create --content "..." --json
164
+ spool posts schedule post_123 --account acct_1 --publish-at 2026-06-12T15:00:00.000Z --json
165
+ spool posts publish-now post_123 --account acct_1 --json
166
+ spool posts cancel post_123 --json
167
+ spool posts wait post_123 --json
168
+ spool posts timeline post_123 --json
169
+ spool media config --json
170
+ spool media upload ./image.png --json
171
+ spool media delete media_123 --json
172
+ spool webhooks list --json
173
+ ```
174
+
175
+ ## API base URL
176
+
177
+ Default production API:
178
+
179
+ ```text
180
+ https://socialspool.com/api/v1
181
+ ```
182
+
183
+ OpenAPI machine contract:
184
+
185
+ ```text
186
+ https://socialspool.com/api/docs/openapi
187
+ ```
package/dist/spool.js ADDED
@@ -0,0 +1,886 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/spool/socialspool.ts
4
+ import { writeFile } from "node:fs/promises";
5
+ import { realpathSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ // src/cli/spool/errors.ts
9
+ class SocialSpoolApiError extends Error {
10
+ status;
11
+ body;
12
+ meta;
13
+ constructor(status, body, meta = {}) {
14
+ super(body.error?.message ?? `SocialSpool API request failed with HTTP ${status}`);
15
+ this.status = status;
16
+ this.body = body;
17
+ this.meta = meta;
18
+ this.name = "SocialSpoolApiError";
19
+ }
20
+ get code() {
21
+ return this.body.error?.code;
22
+ }
23
+ get requestId() {
24
+ return this.body.error?.requestId ?? this.meta.requestId;
25
+ }
26
+ get retryAfter() {
27
+ return this.meta.rateLimit?.retryAfter;
28
+ }
29
+ }
30
+
31
+ class SocialSpoolNetworkError extends Error {
32
+ constructor(message) {
33
+ super(message);
34
+ this.name = "SocialSpoolNetworkError";
35
+ }
36
+ }
37
+
38
+ class UsageError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "UsageError";
42
+ }
43
+ }
44
+ function exitCodeForError(error) {
45
+ if (error instanceof UsageError)
46
+ return 2;
47
+ if (error instanceof SocialSpoolNetworkError)
48
+ return 7;
49
+ if (error instanceof SocialSpoolApiError) {
50
+ if (error.status === 401 || error.status === 403)
51
+ return 4;
52
+ if (error.status === 400)
53
+ return 5;
54
+ if (error.status === 409)
55
+ return 6;
56
+ if (error.status === 429)
57
+ return 8;
58
+ }
59
+ if (error instanceof Error && error.name === "MissingApiKeyError")
60
+ return 3;
61
+ return 1;
62
+ }
63
+
64
+ // src/cli/spool/client.ts
65
+ class SocialSpoolClient {
66
+ config;
67
+ fetchImpl;
68
+ constructor(config, fetchImpl = fetch) {
69
+ this.config = config;
70
+ this.fetchImpl = fetchImpl;
71
+ }
72
+ async request(path, options = {}) {
73
+ const url = new URL(`${this.config.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
74
+ for (const [key, value] of Object.entries(options.query ?? {})) {
75
+ if (value !== undefined && value !== null && value !== "")
76
+ url.searchParams.set(key, String(value));
77
+ }
78
+ const headers = new Headers({
79
+ Authorization: `Bearer ${this.config.apiKey}`
80
+ });
81
+ if (options.body !== undefined)
82
+ headers.set("Content-Type", "application/json");
83
+ if (options.idempotencyKey)
84
+ headers.set("Idempotency-Key", options.idempotencyKey);
85
+ let response;
86
+ try {
87
+ response = await this.fetchImpl(url, {
88
+ method: options.method ?? "GET",
89
+ headers,
90
+ body: options.body === undefined ? undefined : JSON.stringify(options.body)
91
+ });
92
+ } catch (error) {
93
+ throw new SocialSpoolNetworkError(error instanceof Error ? error.message : "Network request failed");
94
+ }
95
+ const meta = readResponseMeta(response);
96
+ const parsed = await parseJson(response);
97
+ if (!response.ok) {
98
+ throw new SocialSpoolApiError(response.status, parsed && typeof parsed === "object" ? parsed : {}, meta);
99
+ }
100
+ return { data: parsed, ...meta };
101
+ }
102
+ }
103
+ async function parseJson(response) {
104
+ const text = await response.text();
105
+ if (!text.trim())
106
+ return null;
107
+ try {
108
+ return JSON.parse(text);
109
+ } catch {
110
+ if (!response.ok) {
111
+ return {
112
+ error: {
113
+ code: "INVALID_JSON",
114
+ message: text,
115
+ statusCode: response.status
116
+ }
117
+ };
118
+ }
119
+ throw new SocialSpoolNetworkError("SocialSpool API returned invalid JSON");
120
+ }
121
+ }
122
+ function readResponseMeta(response) {
123
+ return {
124
+ requestId: response.headers.get("X-Request-Id") ?? undefined,
125
+ rateLimit: {
126
+ limit: response.headers.get("X-RateLimit-Limit") ?? undefined,
127
+ remaining: response.headers.get("X-RateLimit-Remaining") ?? undefined,
128
+ reset: response.headers.get("X-RateLimit-Reset") ?? undefined,
129
+ retryAfter: response.headers.get("Retry-After") ?? undefined
130
+ }
131
+ };
132
+ }
133
+
134
+ // src/cli/spool/config.ts
135
+ var DEFAULT_API_BASE_URL = "https://socialspool.com/api/v1";
136
+ var DEFAULT_API_DOCS_URL = "https://socialspool.com/api/docs/openapi";
137
+
138
+ class MissingApiKeyError extends Error {
139
+ constructor() {
140
+ super("Missing SocialSpool API key. Pass --api-key or set SOCIALSPOOL_API_KEY.");
141
+ this.name = "MissingApiKeyError";
142
+ }
143
+ }
144
+ function resolveConfig(options, env = process.env) {
145
+ const apiKey = firstNonEmpty(options.apiKey, env.SOCIALSPOOL_API_KEY);
146
+ if (!apiKey)
147
+ throw new MissingApiKeyError;
148
+ return {
149
+ apiKey,
150
+ baseUrl: normalizeBaseUrl(firstNonEmpty(options.baseUrl, env.SOCIALSPOOL_API_BASE_URL) ?? DEFAULT_API_BASE_URL)
151
+ };
152
+ }
153
+ function normalizeBaseUrl(value) {
154
+ const trimmed = value.trim();
155
+ if (!trimmed)
156
+ return DEFAULT_API_BASE_URL;
157
+ return trimmed.replace(/\/+$/, "");
158
+ }
159
+ function firstNonEmpty(...values) {
160
+ return values.find((value) => value?.trim())?.trim();
161
+ }
162
+
163
+ // src/cli/spool/commands.ts
164
+ import { readFile, stat } from "node:fs/promises";
165
+ import { basename } from "node:path";
166
+
167
+ // src/cli/spool/idempotency.ts
168
+ import { createHash } from "node:crypto";
169
+ function resolveIdempotencyKey(input) {
170
+ if (input.explicitKey?.trim())
171
+ return input.explicitKey.trim();
172
+ const stable = [input.command, ...input.parts.filter(Boolean)].join("|");
173
+ if (stable === input.command) {
174
+ throw new Error("Idempotency key required");
175
+ }
176
+ return `spool-${createHash("sha256").update(stable).digest("hex").slice(0, 32)}`;
177
+ }
178
+
179
+ // src/cli/spool/commands.ts
180
+ var POST_STYLES = ["text", "text_media", "media_only", "video_only"];
181
+ var TERMINAL_POST_STATUSES = new Set(["published", "failed", "canceled", "draft"]);
182
+ function parseArgs(args) {
183
+ const command = [];
184
+ const options = { accounts: [], mediaAssetIds: [], eventTypes: [] };
185
+ for (let i = 0;i < args.length; i += 1) {
186
+ const arg = args[i];
187
+ if (!arg.startsWith("-")) {
188
+ command.push(arg);
189
+ continue;
190
+ }
191
+ switch (arg) {
192
+ case "--help":
193
+ case "-h":
194
+ options.help = true;
195
+ break;
196
+ case "--version":
197
+ options.version = true;
198
+ break;
199
+ case "--json":
200
+ options.json = true;
201
+ break;
202
+ case "--yes":
203
+ case "-y":
204
+ options.yes = true;
205
+ break;
206
+ case "--api-key":
207
+ options.apiKey = requireValue(args, ++i, arg);
208
+ break;
209
+ case "--base-url":
210
+ options.baseUrl = requireValue(args, ++i, arg);
211
+ break;
212
+ case "--idempotency-key":
213
+ options.idempotencyKey = requireValue(args, ++i, arg);
214
+ break;
215
+ case "--title":
216
+ options.title = requireValue(args, ++i, arg);
217
+ break;
218
+ case "--content":
219
+ options.content = requireValue(args, ++i, arg);
220
+ break;
221
+ case "--status":
222
+ options.status = requireValue(args, ++i, arg);
223
+ break;
224
+ case "--limit":
225
+ options.limit = parsePositiveInt(requireValue(args, ++i, arg), arg);
226
+ break;
227
+ case "--offset":
228
+ options.offset = parseNonNegativeInt(requireValue(args, ++i, arg), arg);
229
+ break;
230
+ case "--account":
231
+ options.accounts.push(requireValue(args, ++i, arg));
232
+ break;
233
+ case "--post-style":
234
+ options.postStyle = parsePostStyle(requireValue(args, ++i, arg), arg);
235
+ break;
236
+ case "--media-asset-id":
237
+ options.mediaAssetIds.push(requireValue(args, ++i, arg));
238
+ break;
239
+ case "--media-asset-ids":
240
+ options.mediaAssetIds.push(...parseCommaSeparatedValues(requireValue(args, ++i, arg), arg));
241
+ break;
242
+ case "--publish-at":
243
+ options.publishAt = requireValue(args, ++i, arg);
244
+ break;
245
+ case "--output":
246
+ case "-o":
247
+ options.output = requireValue(args, ++i, arg);
248
+ break;
249
+ case "--event-type":
250
+ options.eventType = requireValue(args, ++i, arg);
251
+ break;
252
+ case "--event-types":
253
+ options.eventTypes.push(...parseCommaSeparatedValues(requireValue(args, ++i, arg), arg));
254
+ break;
255
+ case "--q":
256
+ options.q = requireValue(args, ++i, arg);
257
+ break;
258
+ case "--timeout":
259
+ options.timeout = parsePositiveInt(requireValue(args, ++i, arg), arg);
260
+ break;
261
+ case "--interval":
262
+ options.interval = parsePositiveInt(requireValue(args, ++i, arg), arg);
263
+ break;
264
+ case "--url":
265
+ options.url = requireValue(args, ++i, arg);
266
+ break;
267
+ default:
268
+ throw new UsageError(`Unknown option: ${arg}`);
269
+ }
270
+ }
271
+ return { command, options };
272
+ }
273
+ async function executeCommand(client, parsed) {
274
+ const [root, action, id] = parsed.command;
275
+ const { options } = parsed;
276
+ if (root === "me") {
277
+ const response = await client.request("/me");
278
+ return { command: "me", response };
279
+ }
280
+ if (root === "capabilities") {
281
+ const response = await client.request("/capabilities");
282
+ return { command: "capabilities", response };
283
+ }
284
+ if (root === "doctor") {
285
+ const response = await runDoctor(client);
286
+ return { command: "doctor", response };
287
+ }
288
+ if (root === "accounts" && action === "list") {
289
+ const response = await client.request("/social-accounts");
290
+ return { command: "accounts.list", response };
291
+ }
292
+ if (root === "media") {
293
+ if (action === "config") {
294
+ const response = await client.request("/media/config");
295
+ return { command: "media.config", response };
296
+ }
297
+ if (action === "upload") {
298
+ const filePath = requireCommandValue(id, "media upload requires <path>");
299
+ const idempotencyKey = resolveIdempotencyKey({
300
+ command: "media.upload",
301
+ explicitKey: options.idempotencyKey,
302
+ parts: [filePath]
303
+ });
304
+ const response = await uploadMediaFile(client, filePath, idempotencyKey);
305
+ return { command: "media.upload", idempotencyKey, response };
306
+ }
307
+ if (action === "delete") {
308
+ const mediaAssetId = requireCommandValue(id, "media delete requires <mediaAssetId>");
309
+ const idempotencyKey = resolveIdempotencyKey({
310
+ command: "media.delete",
311
+ explicitKey: options.idempotencyKey,
312
+ parts: [mediaAssetId]
313
+ });
314
+ const response = await client.request(`/media/${encodeURIComponent(mediaAssetId)}`, {
315
+ method: "DELETE",
316
+ idempotencyKey
317
+ });
318
+ return { command: "media.delete", idempotencyKey, response };
319
+ }
320
+ }
321
+ if (root === "webhooks") {
322
+ if (action === "list") {
323
+ const response = await client.request("/webhooks");
324
+ return { command: "webhooks.list", response };
325
+ }
326
+ if (action === "create") {
327
+ if (!options.url)
328
+ throw new UsageError("webhooks create requires --url");
329
+ const eventTypes = options.eventTypes.length > 0 ? options.eventTypes : options.eventType ? [options.eventType] : [];
330
+ if (eventTypes.length === 0)
331
+ throw new UsageError("webhooks create requires --event-type or --event-types");
332
+ const idempotencyKey = resolveIdempotencyKey({
333
+ command: "webhooks.create",
334
+ explicitKey: options.idempotencyKey,
335
+ parts: [options.url, ...eventTypes]
336
+ });
337
+ const response = await client.request("/webhooks", {
338
+ method: "POST",
339
+ body: { url: options.url, event_types: eventTypes },
340
+ idempotencyKey
341
+ });
342
+ return { command: "webhooks.create", idempotencyKey, response };
343
+ }
344
+ if (action === "test") {
345
+ const webhookId = requireCommandValue(id, "webhooks test requires <webhookId>");
346
+ const idempotencyKey = resolveIdempotencyKey({
347
+ command: "webhooks.test",
348
+ explicitKey: options.idempotencyKey,
349
+ parts: [webhookId]
350
+ });
351
+ const response = await client.request(`/webhooks/${encodeURIComponent(webhookId)}/test`, {
352
+ method: "POST",
353
+ idempotencyKey
354
+ });
355
+ return { command: "webhooks.test", idempotencyKey, response };
356
+ }
357
+ if (action === "rotate-secret") {
358
+ const webhookId = requireCommandValue(id, "webhooks rotate-secret requires <webhookId>");
359
+ const idempotencyKey = resolveIdempotencyKey({
360
+ command: "webhooks.rotate-secret",
361
+ explicitKey: options.idempotencyKey,
362
+ parts: [webhookId]
363
+ });
364
+ const response = await client.request(`/webhooks/${encodeURIComponent(webhookId)}/rotate-secret`, {
365
+ method: "POST",
366
+ idempotencyKey
367
+ });
368
+ return { command: "webhooks.rotate-secret", idempotencyKey, response };
369
+ }
370
+ if (action === "delete") {
371
+ const webhookId = requireCommandValue(id, "webhooks delete requires <webhookId>");
372
+ const idempotencyKey = resolveIdempotencyKey({
373
+ command: "webhooks.delete",
374
+ explicitKey: options.idempotencyKey,
375
+ parts: [webhookId]
376
+ });
377
+ const response = await client.request(`/webhooks/${encodeURIComponent(webhookId)}`, {
378
+ method: "DELETE",
379
+ idempotencyKey
380
+ });
381
+ return { command: "webhooks.delete", idempotencyKey, response };
382
+ }
383
+ }
384
+ if (root === "posts") {
385
+ if (action === "list") {
386
+ const response = await client.request("/posts", {
387
+ query: {
388
+ status: options.status,
389
+ limit: options.limit,
390
+ offset: options.offset
391
+ }
392
+ });
393
+ return { command: "posts.list", response };
394
+ }
395
+ if (action === "get") {
396
+ const postId = requireCommandValue(id, "posts get requires <postId>");
397
+ const response = await client.request(`/posts/${encodeURIComponent(postId)}`);
398
+ return { command: "posts.get", response };
399
+ }
400
+ if (action === "timeline") {
401
+ const postId = requireCommandValue(id, "posts timeline requires <postId>");
402
+ const response = await client.request(`/posts/${encodeURIComponent(postId)}/timeline`);
403
+ return { command: "posts.timeline", response };
404
+ }
405
+ if (action === "wait") {
406
+ const postId = requireCommandValue(id, "posts wait requires <postId>");
407
+ const response = await waitForPost(client, postId, options.timeout ?? 300, options.interval ?? 5);
408
+ return { command: "posts.wait", response };
409
+ }
410
+ if (action === "validate") {
411
+ if (!options.content && options.mediaAssetIds.length === 0) {
412
+ throw new UsageError("posts validate requires --content or --media-asset-ids");
413
+ }
414
+ if (options.accounts.length === 0)
415
+ throw new UsageError("posts validate requires --account");
416
+ const body = {
417
+ content: options.content ?? "",
418
+ social_account_ids: options.accounts
419
+ };
420
+ if (options.postStyle !== undefined)
421
+ body.post_style = options.postStyle;
422
+ if (options.mediaAssetIds.length > 0)
423
+ body.media_asset_ids = options.mediaAssetIds;
424
+ if (options.publishAt !== undefined) {
425
+ rejectReservedPublishAt(options.publishAt);
426
+ body.publish_at = options.publishAt;
427
+ }
428
+ const response = await client.request("/posts/validate", { method: "POST", body });
429
+ return { command: "posts.validate", response };
430
+ }
431
+ if (action === "create") {
432
+ if (!options.content && options.mediaAssetIds.length === 0) {
433
+ throw new UsageError("posts create requires --content or --media-asset-ids");
434
+ }
435
+ const body = { content: options.content ?? "" };
436
+ if (options.title !== undefined)
437
+ body.title = options.title;
438
+ if (options.postStyle !== undefined)
439
+ body.post_style = options.postStyle;
440
+ if (options.mediaAssetIds.length > 0)
441
+ body.media_asset_ids = options.mediaAssetIds;
442
+ if (options.accounts.length > 0)
443
+ body.social_account_ids = options.accounts;
444
+ if (options.publishAt !== undefined) {
445
+ rejectReservedPublishAt(options.publishAt);
446
+ if (options.accounts.length === 0) {
447
+ throw new UsageError("--account is required when --publish-at is provided");
448
+ }
449
+ body.publish_at = options.publishAt;
450
+ }
451
+ const idempotencyKey = resolveIdempotencyKey({
452
+ command: "posts.create",
453
+ explicitKey: options.idempotencyKey,
454
+ parts: [
455
+ String(body.content),
456
+ String(body.post_style ?? ""),
457
+ JSON.stringify(body.social_account_ids ?? []),
458
+ String(body.publish_at ?? ""),
459
+ JSON.stringify(body.media_asset_ids ?? [])
460
+ ]
461
+ });
462
+ const response = await client.request("/posts", {
463
+ method: "POST",
464
+ body,
465
+ idempotencyKey
466
+ });
467
+ return { command: "posts.create", idempotencyKey, response };
468
+ }
469
+ if (action === "update") {
470
+ const postId = requireCommandValue(id, "posts update requires <postId>");
471
+ const body = {};
472
+ if (options.content !== undefined)
473
+ body.content = options.content;
474
+ if (options.title !== undefined)
475
+ body.title = options.title;
476
+ if (options.postStyle !== undefined)
477
+ body.post_style = options.postStyle;
478
+ if (Object.keys(body).length === 0)
479
+ throw new UsageError("posts update requires --content, --title, or --post-style");
480
+ const idempotencyKey = resolveIdempotencyKey({
481
+ command: "posts.update",
482
+ explicitKey: options.idempotencyKey,
483
+ parts: [postId, JSON.stringify(body)]
484
+ });
485
+ const response = await client.request(`/posts/${encodeURIComponent(postId)}`, {
486
+ method: "PATCH",
487
+ body,
488
+ idempotencyKey
489
+ });
490
+ return { command: "posts.update", idempotencyKey, response };
491
+ }
492
+ if (action === "delete") {
493
+ const postId = requireCommandValue(id, "posts delete requires <postId>");
494
+ if (!options.yes)
495
+ throw new UsageError("posts delete requires --yes");
496
+ const idempotencyKey = resolveIdempotencyKey({
497
+ command: "posts.delete",
498
+ explicitKey: options.idempotencyKey,
499
+ parts: [postId]
500
+ });
501
+ const response = await client.request(`/posts/${encodeURIComponent(postId)}`, {
502
+ method: "DELETE",
503
+ idempotencyKey
504
+ });
505
+ return { command: "posts.delete", idempotencyKey, response };
506
+ }
507
+ if (action === "schedule") {
508
+ const postId = requireCommandValue(id, "posts schedule requires <postId>");
509
+ if (options.accounts.length === 0)
510
+ throw new UsageError("posts schedule requires --account");
511
+ if (!options.publishAt)
512
+ throw new UsageError("posts schedule requires --publish-at");
513
+ rejectReservedPublishAt(options.publishAt);
514
+ const idempotencyKey = resolveIdempotencyKey({
515
+ command: "posts.schedule",
516
+ explicitKey: options.idempotencyKey,
517
+ parts: [postId, ...options.accounts, options.publishAt]
518
+ });
519
+ const response = await client.request(`/posts/${encodeURIComponent(postId)}/schedule`, {
520
+ method: "POST",
521
+ body: { social_account_ids: options.accounts, publish_at: options.publishAt },
522
+ idempotencyKey
523
+ });
524
+ return { command: "posts.schedule", idempotencyKey, response };
525
+ }
526
+ if (action === "publish-now") {
527
+ const postId = requireCommandValue(id, "posts publish-now requires <postId>");
528
+ if (options.accounts.length === 0)
529
+ throw new UsageError("posts publish-now requires --account");
530
+ const idempotencyKey = resolveIdempotencyKey({
531
+ command: "posts.publish-now",
532
+ explicitKey: options.idempotencyKey,
533
+ parts: [postId, ...options.accounts]
534
+ });
535
+ const response = await client.request(`/posts/${encodeURIComponent(postId)}/publish-now`, {
536
+ method: "POST",
537
+ body: { social_account_ids: options.accounts },
538
+ idempotencyKey
539
+ });
540
+ return { command: "posts.publish-now", idempotencyKey, response };
541
+ }
542
+ if (action === "cancel") {
543
+ const postId = requireCommandValue(id, "posts cancel requires <postId>");
544
+ const idempotencyKey = resolveIdempotencyKey({
545
+ command: "posts.cancel",
546
+ explicitKey: options.idempotencyKey,
547
+ parts: [postId]
548
+ });
549
+ const response = await client.request(`/posts/${encodeURIComponent(postId)}/cancel`, {
550
+ method: "POST",
551
+ idempotencyKey
552
+ });
553
+ return { command: "posts.cancel", idempotencyKey, response };
554
+ }
555
+ }
556
+ throw new UsageError(parsed.command.length ? `Unknown command: ${parsed.command.join(" ")}` : "Missing command");
557
+ }
558
+ async function runDoctor(client) {
559
+ const checks = [];
560
+ try {
561
+ const me = await client.request("/me");
562
+ checks.push({ name: "auth", ok: true, scopes: me.data.api_key?.scopes ?? [] });
563
+ } catch (error) {
564
+ checks.push({ name: "auth", ok: false, error: error instanceof Error ? error.message : String(error) });
565
+ return { data: { ok: false, checks } };
566
+ }
567
+ try {
568
+ const capabilities = await client.request("/capabilities");
569
+ checks.push({ name: "capabilities", ok: true, features: capabilities.data.features });
570
+ } catch (error) {
571
+ checks.push({ name: "capabilities", ok: false, error: error instanceof Error ? error.message : String(error) });
572
+ }
573
+ return { data: { ok: checks.every((check) => check.ok), checks } };
574
+ }
575
+ async function waitForPost(client, postId, timeoutSeconds, intervalSeconds) {
576
+ const deadline = Date.now() + timeoutSeconds * 1000;
577
+ let latest = await client.request(`/posts/${encodeURIComponent(postId)}`);
578
+ while (Date.now() < deadline) {
579
+ const post = latest.data.post;
580
+ const statuses = [post?.status, ...post?.targets?.map((target) => target.status) ?? []].filter(Boolean);
581
+ if (statuses.every((status) => TERMINAL_POST_STATUSES.has(String(status)))) {
582
+ return latest;
583
+ }
584
+ await sleep(intervalSeconds * 1000);
585
+ latest = await client.request(`/posts/${encodeURIComponent(postId)}`);
586
+ }
587
+ return latest;
588
+ }
589
+ async function uploadMediaFile(client, filePath, idempotencyKey) {
590
+ const fileStats = await stat(filePath);
591
+ if (!fileStats.isFile())
592
+ throw new UsageError(`Not a file: ${filePath}`);
593
+ const fileBytes = await readFile(filePath);
594
+ const fileName = basename(filePath);
595
+ const mimeType = guessMimeType(fileName);
596
+ const created = await client.request("/media/uploads", {
597
+ method: "POST",
598
+ body: {
599
+ file_name: fileName,
600
+ mime_type: mimeType,
601
+ size_bytes: fileStats.size
602
+ },
603
+ idempotencyKey
604
+ });
605
+ const upload = created.data.upload;
606
+ const putResponse = await fetch(upload.url, {
607
+ method: upload.method,
608
+ headers: upload.headers,
609
+ body: new Blob([fileBytes], { type: mimeType })
610
+ });
611
+ if (!putResponse.ok) {
612
+ await client.request(`/media/${encodeURIComponent(created.data.media_asset.id)}/upload-failure`, {
613
+ method: "POST",
614
+ idempotencyKey: `${idempotencyKey}-failure`
615
+ });
616
+ throw new UsageError(`Media upload failed with HTTP ${putResponse.status}`);
617
+ }
618
+ return client.request(`/media/${encodeURIComponent(created.data.media_asset.id)}/confirm-upload`, {
619
+ method: "POST",
620
+ idempotencyKey: `${idempotencyKey}-confirm`
621
+ });
622
+ }
623
+ function guessMimeType(fileName) {
624
+ const lower = fileName.toLowerCase();
625
+ if (lower.endsWith(".png"))
626
+ return "image/png";
627
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
628
+ return "image/jpeg";
629
+ if (lower.endsWith(".gif"))
630
+ return "image/gif";
631
+ if (lower.endsWith(".webp"))
632
+ return "image/webp";
633
+ if (lower.endsWith(".mp4"))
634
+ return "video/mp4";
635
+ if (lower.endsWith(".mov"))
636
+ return "video/quicktime";
637
+ return "application/octet-stream";
638
+ }
639
+ function sleep(ms) {
640
+ return new Promise((resolve) => setTimeout(resolve, ms));
641
+ }
642
+ async function fetchOpenApi(options, fetchImpl = fetch) {
643
+ const docsUrl = options.baseUrl ? `${new URL(normalizeBaseUrl(options.baseUrl)).origin}/api/docs/openapi` : DEFAULT_API_DOCS_URL;
644
+ const response = await fetchImpl(docsUrl);
645
+ return response.json();
646
+ }
647
+ function requireValue(args, index, flag) {
648
+ const value = args[index];
649
+ if (!value || value.startsWith("-"))
650
+ throw new UsageError(`${flag} requires a value`);
651
+ return value;
652
+ }
653
+ function requireCommandValue(value, message) {
654
+ if (!value)
655
+ throw new UsageError(message);
656
+ return value;
657
+ }
658
+ function parsePositiveInt(value, flag) {
659
+ const numberValue = Number(value);
660
+ if (!Number.isInteger(numberValue) || numberValue < 1)
661
+ throw new UsageError(`${flag} must be a positive integer`);
662
+ return numberValue;
663
+ }
664
+ function parseNonNegativeInt(value, flag) {
665
+ const numberValue = Number(value);
666
+ if (!Number.isInteger(numberValue) || numberValue < 0)
667
+ throw new UsageError(`${flag} must be a non-negative integer`);
668
+ return numberValue;
669
+ }
670
+ function parsePostStyle(value, flag) {
671
+ if (POST_STYLES.includes(value))
672
+ return value;
673
+ throw new UsageError(`${flag} must be one of: ${POST_STYLES.join(", ")}`);
674
+ }
675
+ function parseCommaSeparatedValues(value, flag) {
676
+ const values = value.split(",").map((item) => item.trim()).filter(Boolean);
677
+ if (values.length === 0)
678
+ throw new UsageError(`${flag} requires at least one id`);
679
+ return values;
680
+ }
681
+ function rejectReservedPublishAt(value) {
682
+ if (value === "next-free-slot") {
683
+ throw new UsageError("--publish-at next-free-slot is reserved and currently rejected by the API");
684
+ }
685
+ }
686
+
687
+ // src/cli/spool/format.ts
688
+ function formatSuccess(payload, json = false) {
689
+ if (json) {
690
+ return `${JSON.stringify({
691
+ ok: true,
692
+ command: payload.command,
693
+ ...payload.idempotencyKey ? { idempotency_key: payload.idempotencyKey } : {},
694
+ ...payload.requestId ? { request_id: payload.requestId } : {},
695
+ ...payload.rateLimit ? { rate_limit: payload.rateLimit } : {},
696
+ result: payload.result
697
+ }, null, 2)}
698
+ `;
699
+ }
700
+ return `${formatHuman(payload.result)}
701
+ `;
702
+ }
703
+ function formatError(error, status, json = false) {
704
+ if (json) {
705
+ if (error instanceof SocialSpoolApiError) {
706
+ return `${JSON.stringify({
707
+ ok: false,
708
+ status: error.status,
709
+ error: error.body.error,
710
+ ...error.requestId ? { request_id: error.requestId } : {},
711
+ ...error.retryAfter ? { retry_after: error.retryAfter } : {}
712
+ }, null, 2)}
713
+ `;
714
+ }
715
+ return `${JSON.stringify({
716
+ ok: false,
717
+ status,
718
+ error: { message: error instanceof Error ? error.message : String(error) }
719
+ }, null, 2)}
720
+ `;
721
+ }
722
+ if (error instanceof SocialSpoolApiError) {
723
+ const requestId = error.requestId ? ` (${error.requestId})` : "";
724
+ const retryAfter = error.retryAfter ? ` retry-after=${error.retryAfter}s` : "";
725
+ return `${error.body.error?.code ?? "API_ERROR"}: ${error.message}${requestId}${retryAfter}
726
+ `;
727
+ }
728
+ return `${error instanceof Error ? error.message : String(error)}
729
+ `;
730
+ }
731
+ function formatHuman(data) {
732
+ if (data && typeof data === "object") {
733
+ if ("workspace" in data && "api_key" in data) {
734
+ const value = data;
735
+ return [
736
+ `Workspace: ${value.workspace.name} (${value.workspace.plan})`,
737
+ `Workspace ID: ${value.workspace.id}`,
738
+ `API key: ${value.api_key.name} (${value.api_key.id})`,
739
+ `Scopes: ${value.api_key.scopes.join(", ")}`
740
+ ].join(`
741
+ `);
742
+ }
743
+ if ("features" in data && "limits" in data && "workspace" in data) {
744
+ const value = data;
745
+ const enabled = Object.entries(value.features).filter(([, on]) => on).map(([name]) => name).join(", ");
746
+ return [`Workspace: ${value.workspace.name} (${value.workspace.plan})`, `Features: ${enabled}`].join(`
747
+ `);
748
+ }
749
+ if ("results" in data && Array.isArray(data.results)) {
750
+ const value = data;
751
+ if (value.results.length === 0)
752
+ return "No results.";
753
+ return value.results.map((item) => {
754
+ const id = item.id ? ` ${item.id}` : "";
755
+ const label = item.username ?? item.title ?? item.content ?? item.platform ?? "item";
756
+ const status = item.status ? ` [${item.status}]` : "";
757
+ return `- ${String(label)}${id}${status}`;
758
+ }).join(`
759
+ `);
760
+ }
761
+ if ("post" in data) {
762
+ const post = data.post;
763
+ return [
764
+ `Post: ${String(post.id)}`,
765
+ `Status: ${String(post.status)}`,
766
+ post.title ? `Title: ${String(post.title)}` : undefined,
767
+ `Content: ${String(post.content)}`
768
+ ].filter(Boolean).join(`
769
+ `);
770
+ }
771
+ }
772
+ return JSON.stringify(data, null, 2);
773
+ }
774
+
775
+ // src/cli/spool/socialspool.ts
776
+ var VERSION = "1.0.0";
777
+ async function runSpoolCli(args, env = process.env, fetchImpl = fetch) {
778
+ const parsed = parseArgs(args);
779
+ if (parsed.options.help)
780
+ return { exitCode: 0, stdout: helpText() };
781
+ if (parsed.options.version)
782
+ return { exitCode: 0, stdout: `spool ${VERSION}
783
+ ` };
784
+ try {
785
+ if (parsed.command[0] === "openapi") {
786
+ const data = await fetchOpenApi({ baseUrl: parsed.options.baseUrl }, fetchImpl);
787
+ if (parsed.options.output) {
788
+ await writeFile(parsed.options.output, `${JSON.stringify(data, null, 2)}
789
+ `, "utf8");
790
+ return { exitCode: 0, stdout: `Wrote OpenAPI docs to ${parsed.options.output}
791
+ ` };
792
+ }
793
+ return {
794
+ exitCode: 0,
795
+ stdout: formatSuccess({ command: "openapi", result: data }, parsed.options.json)
796
+ };
797
+ }
798
+ const config = resolveConfig(parsed.options, env);
799
+ const client = new SocialSpoolClient(config, fetchImpl);
800
+ const executed = await executeCommand(client, parsed);
801
+ return {
802
+ exitCode: 0,
803
+ stdout: formatSuccess({
804
+ command: executed.command,
805
+ idempotencyKey: executed.idempotencyKey,
806
+ requestId: executed.response.requestId,
807
+ rateLimit: executed.response.rateLimit,
808
+ result: executed.response.data
809
+ }, parsed.options.json)
810
+ };
811
+ } catch (error) {
812
+ return {
813
+ exitCode: exitCodeForError(error),
814
+ stderr: formatError(error, undefined, parsed.options.json)
815
+ };
816
+ }
817
+ }
818
+ function helpText() {
819
+ return `spool ${VERSION}
820
+
821
+ Agent-friendly CLI for the SocialSpool Public API.
822
+
823
+ Usage:
824
+ spool [global options] <command> [command options]
825
+
826
+ Global options:
827
+ --api-key <key> SocialSpool API key. Defaults to SOCIALSPOOL_API_KEY.
828
+ --base-url <url> API base URL. Defaults to https://socialspool.com/api/v1.
829
+ --json Emit machine-readable JSON.
830
+ --idempotency-key <key> Override auto-generated idempotency key for writes.
831
+ --help, -h Show this help.
832
+ --version Show CLI version.
833
+
834
+ Core commands:
835
+ me
836
+ capabilities
837
+ doctor
838
+ openapi [--output openapi.json]
839
+ accounts list
840
+ posts list [--status status] [--limit 20] [--offset 0]
841
+ posts get <postId>
842
+ posts validate --content <text> --account <id> [--publish-at ISO|now] [--post-style style] [--media-asset-ids ids] --json
843
+ posts create [--content <text>] [--title <title>] [--post-style text|text_media|media_only|video_only] [--media-asset-ids <id,id>] [--account <id>...] [--publish-at now|ISO] [--idempotency-key <key>]
844
+ posts update <postId> [--content <text>] [--title <title>] [--post-style style]
845
+ posts delete <postId> --yes
846
+ posts schedule <postId> --account <id>... --publish-at <ISO> [--idempotency-key <key>]
847
+ posts publish-now <postId> --account <id>... [--idempotency-key <key>]
848
+ posts cancel <postId>
849
+ posts wait <postId> [--timeout 300] [--interval 5]
850
+ posts timeline <postId>
851
+ media config
852
+ media upload <path>
853
+ media delete <mediaAssetId>
854
+ webhooks list
855
+ webhooks create --url <url> --event-type <type>|--event-types <a,b>
856
+ webhooks test <webhookId>
857
+ webhooks rotate-secret <webhookId>
858
+ webhooks delete <webhookId>
859
+
860
+ Examples:
861
+ SOCIALSPOOL_API_KEY=ssp_live_xxx spool me --json
862
+ SOCIALSPOOL_API_KEY=ssp_live_xxx spool capabilities --json
863
+ SOCIALSPOOL_API_KEY=ssp_live_xxx spool accounts list --json
864
+ SOCIALSPOOL_API_KEY=ssp_live_xxx spool posts validate --content "Hello" --account acct_1 --json
865
+ SOCIALSPOOL_API_KEY=ssp_live_xxx spool posts create --content "Hello from an agent" --json
866
+ SOCIALSPOOL_API_KEY=ssp_live_xxx spool posts wait post_123 --json
867
+ `;
868
+ }
869
+ function isMainModule(metaUrl) {
870
+ const entrypoint = process.argv[1];
871
+ if (!entrypoint)
872
+ return false;
873
+ return realpathSync(entrypoint) === realpathSync(fileURLToPath(metaUrl));
874
+ }
875
+ if (isMainModule(import.meta.url)) {
876
+ const result = await runSpoolCli(process.argv.slice(2));
877
+ if (result.stdout)
878
+ process.stdout.write(result.stdout);
879
+ if (result.stderr)
880
+ process.stderr.write(result.stderr);
881
+ process.exit(result.exitCode);
882
+ }
883
+ export {
884
+ runSpoolCli,
885
+ helpText
886
+ };
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@thesethrose/socialspool-cli",
3
+ "version": "1.0.0",
4
+ "description": "Agent-friendly CLI for the SocialSpool Public API.",
5
+ "type": "module",
6
+ "bin": {
7
+ "spool": "dist/spool.js"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/spool.js",
11
+ "./package.json": "./package.json",
12
+ "./SKILL.md": "./SKILL.md"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "SKILL.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20.19.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "license": "UNLICENSED"
25
+ }