@slopperday/cli 0.0.0 → 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 +61 -1
- package/dist/slopper.mjs +672 -0
- package/package.json +32 -1
- package/CHANGELOG.md +0 -3
- package/LICENSE +0 -1
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
1
|
# @slopperday/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Throw slop at the wall from your terminal. `slopper` launches, updates and votes on
|
|
4
|
+
[slopper.day](https://slopper.day) through its API, and prints JSON, so it's built for agents as much as people.
|
|
5
|
+
|
|
6
|
+
## Run it
|
|
7
|
+
|
|
8
|
+
There's nothing to install first: any package runner fetches it and runs the `slopper` command inside.
|
|
9
|
+
|
|
10
|
+
| Runner | Command |
|
|
11
|
+
| --------------- | ------------------------------------------- |
|
|
12
|
+
| npm | `npx -y @slopperday/cli <command>` |
|
|
13
|
+
| pnpm | `pnpm dlx @slopperday/cli <command>` |
|
|
14
|
+
| Bun | `bunx @slopperday/cli <command>` |
|
|
15
|
+
| Yarn 2 or later | `yarn dlx -q @slopperday/cli <command>` |
|
|
16
|
+
| Deno | `deno run -A npm:@slopperday/cli <command>` |
|
|
17
|
+
|
|
18
|
+
Or install it once with `npm i -g @slopperday/cli` and run `slopper <command>`. It needs Node 22 or later, Bun or Deno
|
|
19
|
+
2, and nothing else: the agent skill is built in.
|
|
20
|
+
|
|
21
|
+
- **npx needs `-y` for agents.** Before it installs a package it hasn't cached, npx asks "Ok to proceed?". In a
|
|
22
|
+
terminal it waits for an answer, so an agent running in one hangs; without a terminal (piped, in CI) it goes ahead
|
|
23
|
+
on its own. `-y` makes it go ahead either way.
|
|
24
|
+
- **yarn needs `-q` for agents.** `yarn dlx` prints its install log on stdout, ahead of the JSON. `-q` leaves just the
|
|
25
|
+
JSON. Yarn 4 also holds back releases for a day (`npmMinimalAgeGate`); `YARN_NPM_MINIMAL_AGE_GATE=0` skips the wait.
|
|
26
|
+
- **Deno needs `-A`** so the CLI can read its environment and config and call the site. Without it, Deno asks for each
|
|
27
|
+
permission.
|
|
28
|
+
|
|
29
|
+
## Sign in
|
|
30
|
+
|
|
31
|
+
Make a token at [slopper.day/settings/api](https://slopper.day/settings/api), then:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npx -y @slopperday/cli login --token slp_... # or pipe it in: echo "$TOKEN" | npx -y @slopperday/cli login
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`login` checks the token first, then saves it with its site in `~/.config/slopper/config.json`
|
|
38
|
+
(`$XDG_CONFIG_HOME/slopper`, or `$SLOPPER_CONFIG_DIR`), readable only by you. `logout` deletes it.
|
|
39
|
+
|
|
40
|
+
Agents that shouldn't write a config file can skip `login` and pass the token per call:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
SLOPPER_TOKEN=slp_... npx -y @slopperday/cli whoami
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`--token` beats `SLOPPER_TOKEN`, which beats the saved token. `--url` beats `SLOPPER_URL`, then the saved site, then
|
|
47
|
+
https://slopper.day. An empty variable counts as unset.
|
|
48
|
+
|
|
49
|
+
## Use it
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npx -y @slopperday/cli whoami
|
|
53
|
+
npx -y @slopperday/cli launch --name TiltMaze --tagline "A maze you tilt" --category games \
|
|
54
|
+
--link https://tiltmaze.app --screenshot ./cover.png --autopost
|
|
55
|
+
npx -y @slopperday/cli today
|
|
56
|
+
npx -y @slopperday/cli vote <slop id> up # 5 votes a day; win the day to go slopcessful
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`slopper help` lists every command, and `slopper help <command>` its flags. Results print as JSON on stdout; errors go
|
|
60
|
+
to stderr with exit code 1, or 2 for a usage mistake. A value that starts with a dash, like a Markdown list, needs `=`:
|
|
61
|
+
`--description="- one"`.
|
|
62
|
+
|
|
63
|
+
`slopper skill install` gives your agents the skill that explains it all (also on npm as `@slopperday/skill`).
|
package/dist/slopper.mjs
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { text } from "node:stream/consumers";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
8
|
+
//#region package.json
|
|
9
|
+
var version = "0.1.0";
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/client.ts
|
|
12
|
+
/**
|
|
13
|
+
* Where the CLI's settings come from, and the one HTTP client that talks to
|
|
14
|
+
* slopper.day's REST API (`/api/v1`, documented at /llms.txt).
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_URL = "https://slopper.day";
|
|
17
|
+
/** Something the person (or agent) running the CLI can act on. Exit code 2 is a usage error. */
|
|
18
|
+
var SlopperError = class extends Error {
|
|
19
|
+
details;
|
|
20
|
+
exitCode;
|
|
21
|
+
constructor(message, options = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "SlopperError";
|
|
24
|
+
this.details = options.details ?? null;
|
|
25
|
+
this.exitCode = options.exitCode ?? 1;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
function usageError(message) {
|
|
29
|
+
return new SlopperError(message, { exitCode: 2 });
|
|
30
|
+
}
|
|
31
|
+
/** A file system call that failed, like a config nobody can read. Node, Bun and Deno all give them a code. */
|
|
32
|
+
function isSystemError(error) {
|
|
33
|
+
return error instanceof Error && typeof error.code === "string" && typeof error.syscall === "string";
|
|
34
|
+
}
|
|
35
|
+
/** An environment variable, or undefined when it's unset or empty: `SLOPPER_TOKEN=` turns it off. */
|
|
36
|
+
function envValue(env, name) {
|
|
37
|
+
return env[name] || void 0;
|
|
38
|
+
}
|
|
39
|
+
function configPath(env, home) {
|
|
40
|
+
const dir = envValue(env, "SLOPPER_CONFIG_DIR");
|
|
41
|
+
if (dir) return join(dir, "config.json");
|
|
42
|
+
return join(envValue(env, "XDG_CONFIG_HOME") ?? join(home, ".config"), "slopper", "config.json");
|
|
43
|
+
}
|
|
44
|
+
async function readConfig(path) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = await readFile(path, "utf8");
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (!isSystemError(error)) throw error;
|
|
50
|
+
if (error.code === "ENOENT") return {};
|
|
51
|
+
throw new SlopperError(`Couldn't read ${path}: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(text);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new SlopperError(`${path} isn't valid JSON. Fix it or run \`slopper logout\` to start over.`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new SlopperError(`${path} should hold a JSON object. Run \`slopper logout\` to start over.`);
|
|
60
|
+
const { token, url } = parsed;
|
|
61
|
+
return {
|
|
62
|
+
...typeof token === "string" ? { token } : {},
|
|
63
|
+
...typeof url === "string" ? { url } : {}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Saves the config readable by its owner only: it holds an API token. */
|
|
67
|
+
async function writeConfig(path, config) {
|
|
68
|
+
await mkdir(dirname(path), {
|
|
69
|
+
recursive: true,
|
|
70
|
+
mode: 448
|
|
71
|
+
});
|
|
72
|
+
const file = await open(path, "w", 384);
|
|
73
|
+
try {
|
|
74
|
+
await file.chmod(384);
|
|
75
|
+
await file.writeFile(`${JSON.stringify(config, null, 2)}\n`);
|
|
76
|
+
} finally {
|
|
77
|
+
await file.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function removeConfig(path) {
|
|
81
|
+
await rm(path, { force: true });
|
|
82
|
+
}
|
|
83
|
+
/** The site's origin, from a flag, SLOPPER_URL or the saved config; slopper.day otherwise. */
|
|
84
|
+
function resolveUrl(value) {
|
|
85
|
+
let url;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(value);
|
|
88
|
+
} catch {
|
|
89
|
+
throw usageError(`${value} isn't a URL. Pass the site's address, like ${DEFAULT_URL}.`);
|
|
90
|
+
}
|
|
91
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw usageError(`${value} isn't an http(s) URL.`);
|
|
92
|
+
return url.origin;
|
|
93
|
+
}
|
|
94
|
+
function isErrorBody(value) {
|
|
95
|
+
return typeof value === "object" && value !== null && typeof value.error === "string";
|
|
96
|
+
}
|
|
97
|
+
/** Why a request got no answer. Fetch wraps the cause (refused, DNS, TLS) in a bare "fetch failed". */
|
|
98
|
+
function networkReason(error) {
|
|
99
|
+
if (!(error instanceof Error)) return String(error);
|
|
100
|
+
return error.cause instanceof Error ? `${error.message} (${error.cause.message})` : error.message;
|
|
101
|
+
}
|
|
102
|
+
/** The body as JSON, or undefined when it isn't any: an empty reply, or a proxy's or host's HTML page. */
|
|
103
|
+
function parseJson(text) {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(text);
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function createApi({ url, token, fetch, userAgent }) {
|
|
111
|
+
async function request(method, path, body, contentType) {
|
|
112
|
+
if (!token) throw new SlopperError(`No API token. Make one at ${url}/settings/api, then run \`slopper login --token <token>\` or set SLOPPER_TOKEN.`);
|
|
113
|
+
const headers = {
|
|
114
|
+
authorization: `Bearer ${token}`,
|
|
115
|
+
accept: "application/json",
|
|
116
|
+
"user-agent": userAgent
|
|
117
|
+
};
|
|
118
|
+
if (contentType) headers["content-type"] = contentType;
|
|
119
|
+
let response;
|
|
120
|
+
let text;
|
|
121
|
+
try {
|
|
122
|
+
response = await fetch(`${url}/api/v1${path}`, {
|
|
123
|
+
method,
|
|
124
|
+
headers,
|
|
125
|
+
body
|
|
126
|
+
});
|
|
127
|
+
text = await response.text();
|
|
128
|
+
} catch (error) {
|
|
129
|
+
throw new SlopperError(`Couldn't reach ${url}: ${networkReason(error)}`);
|
|
130
|
+
}
|
|
131
|
+
const parsed = parseJson(text);
|
|
132
|
+
if (response.ok && parsed !== void 0) return parsed;
|
|
133
|
+
if (isErrorBody(parsed)) throw new SlopperError(parsed.error, { details: parsed.details ?? null });
|
|
134
|
+
if (response.ok) throw new SlopperError(`${url} answered ${method} ${path} without JSON. Is it a Slopper site?`);
|
|
135
|
+
throw new SlopperError(`${method} ${path} failed with HTTP ${response.status}.`);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
url,
|
|
139
|
+
get: (path) => request("GET", path),
|
|
140
|
+
post: (path, body) => request("POST", path, JSON.stringify(body), "application/json"),
|
|
141
|
+
patch: (path, body) => request("PATCH", path, JSON.stringify(body), "application/json"),
|
|
142
|
+
delete: (path) => request("DELETE", path),
|
|
143
|
+
upload: (path, bytes, contentType) => request("POST", path, bytes, contentType)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/skill.ts
|
|
148
|
+
async function readSkill() {
|
|
149
|
+
return "---\nname: slopper\ndescription: Ship to slopper.day from the terminal with the `slopper` CLI (npm @slopperday/cli). Use when the user wants to launch a project as today's slop or schedule launches for later days, post an update or field test, set a daily shipping goal, log costs or revenue, check today's slop of the day race, or vote on slops.\n---\n\n# Slopper\n\n[slopper.day](https://slopper.day) is where people throw slop at the wall every day: they ship something small\ndaily (a site, a TestFlight or Play beta, a repo), set a goal like 30 slops in 30 days, and keep slopping until one is\nslopcessful. Every UTC day the slops launched that day race on votes; the leader at midnight UTC wins slop of the day\nand goes slopcessful. Slopcessful is earned, never declared: nobody can mark their own slop slopcessful.\n\n## Setup\n\nRun the CLI without installing it, with the runner the project already uses:\n\n```sh\nnpx -y @slopperday/cli <command> # -y, or npx stops to ask \"Ok to proceed?\" and waits\npnpm dlx @slopperday/cli <command>\nbunx @slopperday/cli <command>\nyarn dlx -q @slopperday/cli <command> # -q, or yarn's install log lands in the JSON\ndeno run -A npm:@slopperday/cli <command>\n```\n\nOr install it once (`npm i -g @slopperday/cli`) and run `slopper`. The examples below say `slopper`: put the runner's\ncommand in its place.\n\nIt needs the user's API token. They make one at https://slopper.day/settings/api. Ask them for it if you don't have one,\nthen either save it or pass it per call:\n\n```sh\nslopper login --token slp_... # checks it, then saves it readable by the user only\nSLOPPER_TOKEN=slp_... slopper whoami # saves nothing: use this when you shouldn't write files\n```\n\n`--url` (or `SLOPPER_URL`) points it at another site, like a local one.\n\nEvery command prints JSON on stdout. Errors go to stderr with exit code 1 (2 for a usage mistake), and validation\nerrors list each field that needs fixing. `slopper help <command>` shows every flag. A value that starts with a dash,\nlike a Markdown list, needs `=`: `--description=\"- one\"`.\n\n## Start with whoami\n\n`slopper whoami` returns the user's username, time zone, today's date in that zone, the open goal and its progress, the\nstreak, and the votes left today. Goal days end at midnight in the user's time zone; the race runs on UTC days.\n\n## Launch a slop\n\n1. Read the project: README, package.json or pubspec.yaml, and any deployed URL, App Store, TestFlight or Play beta\n links.\n2. Take 1 to 3 screenshots of it running. The first is the cover.\n3. Pick a category from `slopper categories`.\n4. Launch it. Screenshot files are uploaded for you:\n\n```sh\nslopper launch \\\n --name \"TiltMaze\" \\\n --tagline \"A marble maze you play by tilting your phone\" \\\n --category games \\\n --description \"Built in a day. Tilt to roll, find the exit before the timer runs out.\" \\\n --link website=https://tiltmaze.app \\\n --link testflight=https://testflight.apple.com/join/abc123 \\\n --screenshot ./shots/cover.png --screenshot ./shots/level-2.png \\\n --made-with Flutter --made-with Claude \\\n --note \"Day 12: a maze you tilt. Try to beat 40 seconds.\" \\\n --autopost\n```\n\nLink kinds: `website`, `app_store`, `testflight`, `play_store`, `play_beta`, `source`, `other`. A bare URL is a\nwebsite. Store links must use their store's domain.\n\nThe result has the slop's `id`, `slug` and `url`, and the content check's verdict. Give the user the URL.\n\n`--autopost` posts the launch to the user's connected X, LinkedIn and Bluesky accounts. It's held (with the reason)\nunless the slop passed the automated content check and nobody has flagged it. Write the `--note` in the user's voice,\ntwo sentences at most.\n\nA new platform or a 2.0 is a new launch with its own shot at slop of the day:\n`slopper launch --relaunch-of <slop> --label \"iOS app\" ...`. A new feature is an update, not a launch.\n\n## Schedule launches\n\nWhen several things are ready at once, ask the user whether to put them all out now or line them up. Every launch\ncounts towards the goal on the day it goes out: three today are three slops today, and three lined up fill three days,\neach with its own shot at slop of the day.\n\n```sh\nslopper launch --at next ... # the next day with nothing out or scheduled, as its race starts\nslopper launch --at 2026-10-15T09:00 ... # the user's time zone, unless it has an offset\nslopper scheduled # what's waiting, soonest first\nslopper release <scheduled> # put it out now instead\nslopper unschedule <scheduled> # drop it\n```\n\nNobody else sees a scheduled launch until it goes out. `--autopost` posts it then.\n\n## Keep it alive\n\n```sh\nslopper post tiltmaze --kind feature --body \"Level editor: build a maze and share the link.\" --autopost\nslopper post tiltmaze --kind field --body \"Showed it to 5 people at the bus stop. 3 got hooked.\"\nslopper edit tiltmaze --tagline \"Now with a level editor\" --screenshot ./shots/editor.png\nslopper money tiltmaze cost 12.99 --category domain --note \"tiltmaze.app\"\n```\n\nUpdate kinds: `update` (fixes, news), `feature` (something new shipped), `field` (a real-world test: ask the user who\ntried it and what they said), `repromote` (bump it back up). `<slop>` is one of the user's slops, by id or slug (see\n`slopper slops`). `edit` changes only the flags passed; `--link` and `--screenshot` replace the whole list.\n\n## Goals\n\nAsk how many days first (7, 14, 30 or 100 are common), then how hard: one slop every five days is safe, every other day\nis getting there, one a day is hardcore, two a day is impossible.\n\n```sh\nslopper goal --days 30 --target 30\n```\n\nOnly one goal can be open at a time.\n\n## The race and votes\n\n`slopper today` ranks today's slops by votes, with each slop's `id`, whether it's the user's (`yours`), how they voted\n(`yourVote`), and `votesLeft`.\n\n```sh\nslopper vote <slop id> up # or down (private: lowers the ranking, nobody sees it), or clear\n```\n\nEveryone gets 5 votes per UTC day. Switching today's vote is free and clearing it gives it back. Nobody can vote on\ntheir own slops. Votes decide slop of the day, so only vote the way the user asks; never spend their votes on your own\njudgement.\n\n## Don't\n\n- Don't launch ideas, waitlists or anything that isn't live: a slop is something people can try today.\n- Don't launch features as new slops; post them as updates.\n- Don't put the token in files you commit. Use `slopper login` or `SLOPPER_TOKEN`.\n";
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/commands.ts
|
|
153
|
+
/**
|
|
154
|
+
* The site's LIMITS.mediaBytes (app/lib/model.ts; the tests check they
|
|
155
|
+
* agree). Bigger screenshots are refused, so they aren't sent.
|
|
156
|
+
*/
|
|
157
|
+
const MEDIA_BYTES = 4194304;
|
|
158
|
+
function text$1(values, key) {
|
|
159
|
+
const value = values[key];
|
|
160
|
+
return typeof value === "string" ? value : void 0;
|
|
161
|
+
}
|
|
162
|
+
function list(values, key) {
|
|
163
|
+
const value = values[key];
|
|
164
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
|
|
165
|
+
}
|
|
166
|
+
function wholeNumber(values, key) {
|
|
167
|
+
const value = text$1(values, key);
|
|
168
|
+
if (value === void 0) return void 0;
|
|
169
|
+
if (!/^\d+$/.test(value)) throw usageError(`--${key} must be a whole number, not “${value}”.`);
|
|
170
|
+
return Number(value);
|
|
171
|
+
}
|
|
172
|
+
function required(value, flag) {
|
|
173
|
+
if (value === void 0) throw usageError(`Missing ${flag}.`);
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
function positional(context, index, name) {
|
|
177
|
+
const value = context.positionals[index];
|
|
178
|
+
if (!value) throw usageError(`Missing <${name}>.`);
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
/** A slop reference as a URL path segment. */
|
|
182
|
+
function slopPath(ref) {
|
|
183
|
+
return `/slops/${encodeURIComponent(ref)}`;
|
|
184
|
+
}
|
|
185
|
+
/** `website=https://…`, or a bare URL for a website. */
|
|
186
|
+
function parseLink(value) {
|
|
187
|
+
const match = /^([a-z_]+)=(.+)$/.exec(value);
|
|
188
|
+
if (match) return {
|
|
189
|
+
kind: match[1],
|
|
190
|
+
url: match[2]
|
|
191
|
+
};
|
|
192
|
+
if (/^https?:\/\//.test(value)) return {
|
|
193
|
+
kind: "website",
|
|
194
|
+
url: value
|
|
195
|
+
};
|
|
196
|
+
throw usageError(`--link takes kind=url (website=https://…, testflight=https://…) or a bare URL, not “${value}”.`);
|
|
197
|
+
}
|
|
198
|
+
const IMAGE_TYPES = {
|
|
199
|
+
".png": "image/png",
|
|
200
|
+
".jpg": "image/jpeg",
|
|
201
|
+
".jpeg": "image/jpeg",
|
|
202
|
+
".webp": "image/webp",
|
|
203
|
+
".gif": "image/gif"
|
|
204
|
+
};
|
|
205
|
+
async function uploadImage(api, file) {
|
|
206
|
+
const contentType = IMAGE_TYPES[extname(file).toLowerCase()];
|
|
207
|
+
if (!contentType) throw usageError(`${file} isn't a PNG, JPEG, WebP or GIF.`);
|
|
208
|
+
let bytes;
|
|
209
|
+
try {
|
|
210
|
+
bytes = new Uint8Array(await readFile(resolve(file)));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (!isSystemError(error)) throw error;
|
|
213
|
+
throw usageError(`Couldn't read ${file} (${error.code}).`);
|
|
214
|
+
}
|
|
215
|
+
if (bytes.byteLength > 4194304) throw usageError(`${file} is bigger than ${MEDIA_BYTES / 1024 / 1024} MB, the most a screenshot can be.`);
|
|
216
|
+
const uploaded = await api.upload("/media", bytes, contentType);
|
|
217
|
+
if (typeof uploaded !== "object" || uploaded === null || typeof uploaded.id !== "string") throw new SlopperError(`Uploading ${file} returned no media id.`);
|
|
218
|
+
return uploaded;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Screenshots are image files, uploaded first, or media ids from
|
|
222
|
+
* `slopper upload`. Anything with an image extension or a path in it is a file.
|
|
223
|
+
*/
|
|
224
|
+
async function screenshotIds(api, values) {
|
|
225
|
+
const ids = [];
|
|
226
|
+
for (const value of values) {
|
|
227
|
+
const isFile = value.includes("/") || value.includes("\\") || extname(value).toLowerCase() in IMAGE_TYPES;
|
|
228
|
+
ids.push(isFile ? (await uploadImage(api, value)).id : value);
|
|
229
|
+
}
|
|
230
|
+
return ids;
|
|
231
|
+
}
|
|
232
|
+
/** The fields `launch` and `edit` share, only those that were passed. */
|
|
233
|
+
async function slopFields(context) {
|
|
234
|
+
const { values, api } = context;
|
|
235
|
+
const links = list(values, "link");
|
|
236
|
+
const screenshots = list(values, "screenshot");
|
|
237
|
+
const madeWith = list(values, "made-with");
|
|
238
|
+
const fields = {
|
|
239
|
+
name: text$1(values, "name"),
|
|
240
|
+
tagline: text$1(values, "tagline"),
|
|
241
|
+
description: text$1(values, "description"),
|
|
242
|
+
category: text$1(values, "category"),
|
|
243
|
+
links: links?.map(parseLink),
|
|
244
|
+
screenshots: screenshots ? await screenshotIds(api, screenshots) : void 0,
|
|
245
|
+
madeWith
|
|
246
|
+
};
|
|
247
|
+
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== void 0));
|
|
248
|
+
}
|
|
249
|
+
const SLOP_OPTIONS = {
|
|
250
|
+
name: { type: "string" },
|
|
251
|
+
tagline: { type: "string" },
|
|
252
|
+
description: { type: "string" },
|
|
253
|
+
category: { type: "string" },
|
|
254
|
+
link: {
|
|
255
|
+
type: "string",
|
|
256
|
+
multiple: true
|
|
257
|
+
},
|
|
258
|
+
screenshot: {
|
|
259
|
+
type: "string",
|
|
260
|
+
multiple: true
|
|
261
|
+
},
|
|
262
|
+
"made-with": {
|
|
263
|
+
type: "string",
|
|
264
|
+
multiple: true
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
const SLOP_FLAGS = {
|
|
268
|
+
"--name <name>": "What it's called.",
|
|
269
|
+
"--tagline <line>": "One line: what does it do?",
|
|
270
|
+
"--description <markdown>": "The longer story, in Markdown.",
|
|
271
|
+
"--category <id>": "One of `slopper categories`.",
|
|
272
|
+
"--link <kind=url>": "Where to try it; repeat for more. Kinds: website, app_store, testflight, play_store, play_beta, source, other. A bare URL is a website.",
|
|
273
|
+
"--screenshot <file|id>": "An image file (uploaded for you) or an id from `slopper upload`; repeat for more. The first is the cover.",
|
|
274
|
+
"--made-with <tool>": "What built it; repeat for more."
|
|
275
|
+
};
|
|
276
|
+
const AUTOPOST_FLAG = { "--autopost": "Also post it to the connected X, LinkedIn and Bluesky accounts. Held unless it passed the content check." };
|
|
277
|
+
const COMMANDS = [
|
|
278
|
+
{
|
|
279
|
+
name: "login",
|
|
280
|
+
summary: "Check an API token (from /settings/api), then save it and its site for the other commands.",
|
|
281
|
+
flags: {
|
|
282
|
+
"--token <token>": "The token. Without it, it's read from stdin: `echo $TOKEN | slopper login`.",
|
|
283
|
+
"--url <url>": "A different site to save, like a local one."
|
|
284
|
+
},
|
|
285
|
+
options: {},
|
|
286
|
+
async run({ values, io, url, configFile, connect }) {
|
|
287
|
+
const token = (text$1(values, "token") ?? await io.readStdin() ?? "").trim();
|
|
288
|
+
if (!token) throw usageError(`Pass --token, or pipe one in. Make a token at ${url}/settings/api.`);
|
|
289
|
+
const me = await connect(token).get("/me");
|
|
290
|
+
const username = typeof me === "object" && me !== null ? me.username : void 0;
|
|
291
|
+
if (typeof username !== "string") throw new SlopperError(`${url} didn't say whose token this is.`);
|
|
292
|
+
await writeConfig(configFile, {
|
|
293
|
+
token,
|
|
294
|
+
url
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
loggedIn: username,
|
|
298
|
+
url,
|
|
299
|
+
config: configFile
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "logout",
|
|
305
|
+
summary: "Forget the saved token. Revoke it at /settings/api to cut it off everywhere.",
|
|
306
|
+
local: true,
|
|
307
|
+
async run({ configFile }) {
|
|
308
|
+
await removeConfig(configFile);
|
|
309
|
+
return {
|
|
310
|
+
loggedOut: true,
|
|
311
|
+
config: configFile
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "whoami",
|
|
317
|
+
summary: "You: profile, time zone, today's date, the open goal, streak and votes left today. Start here.",
|
|
318
|
+
run: ({ api }) => api.get("/me")
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: "today",
|
|
322
|
+
summary: "Today's race (UTC day): slops ranked by votes, how you voted, and your votes left.",
|
|
323
|
+
run: ({ api }) => api.get("/today")
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: "categories",
|
|
327
|
+
summary: "Valid categories, link kinds, currencies and money categories.",
|
|
328
|
+
run: ({ api }) => api.get("/categories")
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: "goal",
|
|
332
|
+
summary: "Start a goal, like 30 slops in 30 days. Only one can be open.",
|
|
333
|
+
flags: {
|
|
334
|
+
"--target <n>": "Required: how many slops to ship.",
|
|
335
|
+
"--days <n>": "Required: how many days. They end at midnight in your time zone.",
|
|
336
|
+
"--start <YYYY-MM-DD>": "Today or later. Defaults to today.",
|
|
337
|
+
"--title <title>": "Defaults to “N slops in M days”."
|
|
338
|
+
},
|
|
339
|
+
options: {
|
|
340
|
+
target: { type: "string" },
|
|
341
|
+
days: { type: "string" },
|
|
342
|
+
start: { type: "string" },
|
|
343
|
+
title: { type: "string" }
|
|
344
|
+
},
|
|
345
|
+
run({ api, values }) {
|
|
346
|
+
return api.post("/goal", {
|
|
347
|
+
target: required(wholeNumber(values, "target"), "--target <n>"),
|
|
348
|
+
days: required(wholeNumber(values, "days"), "--days <n>"),
|
|
349
|
+
startDate: text$1(values, "start"),
|
|
350
|
+
title: text$1(values, "title")
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: "upload",
|
|
356
|
+
args: "<image>",
|
|
357
|
+
summary: "Upload a screenshot (PNG, JPEG, WebP or GIF, up to 4 MB). Prints its id and the content check's verdict.",
|
|
358
|
+
run: (context) => uploadImage(context.api, positional(context, 0, "image"))
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: "slops",
|
|
362
|
+
summary: "Your slops, newest first, with votes, updates, pledges and badges.",
|
|
363
|
+
run: ({ api }) => api.get("/slops")
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: "launch",
|
|
367
|
+
summary: "Throw a new slop at the wall. It counts towards today's goal and enters today's race, or with --at waits until then.",
|
|
368
|
+
flags: {
|
|
369
|
+
...SLOP_FLAGS,
|
|
370
|
+
"--note <text>": "The first update, also the auto-post text. Defaults to the tagline.",
|
|
371
|
+
"--relaunch-of <slop>": "Launch an existing product again (a new platform, a 2.0): its id or slug.",
|
|
372
|
+
"--label <label>": "With --relaunch-of: what's launching, like “iOS app” or “2.0”.",
|
|
373
|
+
"--at <when>": "Schedule it: “next” (the next day with nothing out or scheduled, as its race starts) or a date-time like 2026-10-15T09:00 (your time zone unless it has an offset).",
|
|
374
|
+
...AUTOPOST_FLAG
|
|
375
|
+
},
|
|
376
|
+
options: {
|
|
377
|
+
...SLOP_OPTIONS,
|
|
378
|
+
note: { type: "string" },
|
|
379
|
+
"relaunch-of": { type: "string" },
|
|
380
|
+
label: { type: "string" },
|
|
381
|
+
at: { type: "string" },
|
|
382
|
+
autopost: { type: "boolean" }
|
|
383
|
+
},
|
|
384
|
+
async run(context) {
|
|
385
|
+
const { api, values } = context;
|
|
386
|
+
return api.post("/slops", {
|
|
387
|
+
...await slopFields(context),
|
|
388
|
+
launchNote: text$1(values, "note"),
|
|
389
|
+
relaunchOf: text$1(values, "relaunch-of"),
|
|
390
|
+
launchLabel: text$1(values, "label"),
|
|
391
|
+
releaseAt: text$1(values, "at"),
|
|
392
|
+
autopost: values.autopost === true
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
name: "scheduled",
|
|
398
|
+
summary: "Your scheduled launches, soonest first. Nobody else sees them until they go out.",
|
|
399
|
+
run: ({ api }) => api.get("/scheduled")
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: "release",
|
|
403
|
+
args: "<scheduled>",
|
|
404
|
+
summary: "Put a scheduled launch out now. It counts towards today's goal and enters today's race.",
|
|
405
|
+
run: (context) => context.api.post(`/scheduled/${encodeURIComponent(positional(context, 0, "scheduled"))}/release`, {})
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: "unschedule",
|
|
409
|
+
args: "<scheduled>",
|
|
410
|
+
summary: "Drop a scheduled launch before it goes out.",
|
|
411
|
+
run: (context) => context.api.delete(`/scheduled/${encodeURIComponent(positional(context, 0, "scheduled"))}`)
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
name: "edit",
|
|
415
|
+
args: "<slop>",
|
|
416
|
+
summary: "Change one of your slops. Only the flags you pass change; --link and --screenshot replace the lists.",
|
|
417
|
+
flags: SLOP_FLAGS,
|
|
418
|
+
options: SLOP_OPTIONS,
|
|
419
|
+
async run(context) {
|
|
420
|
+
const slop = positional(context, 0, "slop");
|
|
421
|
+
const fields = await slopFields(context);
|
|
422
|
+
if (Object.keys(fields).length === 0) throw usageError("Nothing to change: pass a flag, like --tagline.");
|
|
423
|
+
return context.api.patch(slopPath(slop), fields);
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
name: "post",
|
|
428
|
+
args: "<slop>",
|
|
429
|
+
summary: "Post an update on one of your slops.",
|
|
430
|
+
flags: {
|
|
431
|
+
"--kind <kind>": "update (fixes, news; the default), feature (something new shipped), field (a real-world test: who tried it, what they said) or repromote.",
|
|
432
|
+
"--body <markdown>": "Required: what happened.",
|
|
433
|
+
...AUTOPOST_FLAG
|
|
434
|
+
},
|
|
435
|
+
options: {
|
|
436
|
+
kind: { type: "string" },
|
|
437
|
+
body: { type: "string" },
|
|
438
|
+
autopost: { type: "boolean" }
|
|
439
|
+
},
|
|
440
|
+
run(context) {
|
|
441
|
+
const { api, values } = context;
|
|
442
|
+
const slop = positional(context, 0, "slop");
|
|
443
|
+
return api.post(`${slopPath(slop)}/updates`, {
|
|
444
|
+
kind: text$1(values, "kind") ?? "update",
|
|
445
|
+
body: required(text$1(values, "body"), "--body <markdown>"),
|
|
446
|
+
autopost: values.autopost === true
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "money",
|
|
452
|
+
args: "<slop> <cost|revenue> <amount>",
|
|
453
|
+
summary: "Log a cost (domain, hosting, AI) or revenue for a product, in major units like 12.99.",
|
|
454
|
+
flags: {
|
|
455
|
+
"--currency <code>": "Defaults to USD.",
|
|
456
|
+
"--category <id>": "See `slopper categories`. Defaults to other.",
|
|
457
|
+
"--note <text>": "What it was.",
|
|
458
|
+
"--date <YYYY-MM-DD>": "Defaults to today."
|
|
459
|
+
},
|
|
460
|
+
options: {
|
|
461
|
+
currency: { type: "string" },
|
|
462
|
+
category: { type: "string" },
|
|
463
|
+
note: { type: "string" },
|
|
464
|
+
date: { type: "string" }
|
|
465
|
+
},
|
|
466
|
+
run(context) {
|
|
467
|
+
const { api, values } = context;
|
|
468
|
+
const slop = positional(context, 0, "slop");
|
|
469
|
+
const kind = positional(context, 1, "cost|revenue");
|
|
470
|
+
if (kind !== "cost" && kind !== "revenue") throw usageError(`Money is a cost or revenue, not “${kind}”.`);
|
|
471
|
+
const amount = positional(context, 2, "amount");
|
|
472
|
+
if (!/^\d+(\.\d+)?$/.test(amount) || Number(amount) <= 0) throw usageError(`<amount> must be a number above zero, like 12.99, not “${amount}”.`);
|
|
473
|
+
return api.post(`${slopPath(slop)}/money`, {
|
|
474
|
+
kind,
|
|
475
|
+
amount: Number(amount),
|
|
476
|
+
currency: text$1(values, "currency"),
|
|
477
|
+
category: text$1(values, "category"),
|
|
478
|
+
note: text$1(values, "note"),
|
|
479
|
+
date: text$1(values, "date")
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
name: "vote",
|
|
485
|
+
args: "<slop> <up|down|clear>",
|
|
486
|
+
summary: "Vote on a slop by its id (from `slopper today`). You get 5 votes a UTC day; downvotes are private, and clearing one gives it back.",
|
|
487
|
+
run(context) {
|
|
488
|
+
const slop = positional(context, 0, "slop");
|
|
489
|
+
const vote = positional(context, 1, "up|down|clear");
|
|
490
|
+
if (vote !== "up" && vote !== "down" && vote !== "clear") throw usageError(`A vote is up, down or clear, not “${vote}”.`);
|
|
491
|
+
return context.api.post(`${slopPath(slop)}/vote`, { vote });
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
name: "skill",
|
|
496
|
+
args: "[install]",
|
|
497
|
+
summary: "Print the agent skill for this CLI, or install it for your agents.",
|
|
498
|
+
flags: { "--dir <dir>": "With install: the skills directory. Defaults to ~/.agents/skills; Claude Code reads ~/.claude/skills." },
|
|
499
|
+
options: { dir: { type: "string" } },
|
|
500
|
+
local: true,
|
|
501
|
+
async run(context) {
|
|
502
|
+
const skill = await readSkill();
|
|
503
|
+
const action = context.positionals[0];
|
|
504
|
+
if (action === void 0) return skill;
|
|
505
|
+
if (action !== "install") throw usageError(`Unknown skill action “${action}”. Try \`slopper skill install\`.`);
|
|
506
|
+
const directory = join(resolve(text$1(context.values, "dir") ?? join(context.io.home, ".agents", "skills")), "slopper");
|
|
507
|
+
await mkdir(directory, { recursive: true });
|
|
508
|
+
const path = join(directory, "SKILL.md");
|
|
509
|
+
await writeFile(path, skill);
|
|
510
|
+
return { installed: path };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
];
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region src/main.ts
|
|
516
|
+
const GLOBAL_OPTIONS = {
|
|
517
|
+
token: { type: "string" },
|
|
518
|
+
url: { type: "string" },
|
|
519
|
+
help: {
|
|
520
|
+
type: "boolean",
|
|
521
|
+
short: "h"
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const GLOBAL_FLAGS = {
|
|
525
|
+
"--token <token>": "API token. Defaults to SLOPPER_TOKEN, then the one `slopper login` saved.",
|
|
526
|
+
"--url <url>": `Site to talk to. Defaults to SLOPPER_URL, then the saved one, then ${DEFAULT_URL}.`,
|
|
527
|
+
"-h, --help": "Help for a command."
|
|
528
|
+
};
|
|
529
|
+
function flagLines(flags) {
|
|
530
|
+
const width = Math.max(...Object.keys(flags).map((flag) => flag.length));
|
|
531
|
+
return Object.entries(flags).map(([flag, description]) => ` ${flag.padEnd(width)} ${description}`);
|
|
532
|
+
}
|
|
533
|
+
function usage(command) {
|
|
534
|
+
return `slopper ${command.name}${command.args ? ` ${command.args}` : ""}${command.flags ? " [flags]" : ""}`;
|
|
535
|
+
}
|
|
536
|
+
function overview() {
|
|
537
|
+
const width = Math.max(...COMMANDS.map((command) => command.name.length));
|
|
538
|
+
return [
|
|
539
|
+
`slopper ${version}: throw slop at the wall, from your terminal. Built for agents.`,
|
|
540
|
+
"",
|
|
541
|
+
"Usage: slopper <command> [flags]",
|
|
542
|
+
"",
|
|
543
|
+
"Commands:",
|
|
544
|
+
...COMMANDS.map((command) => ` ${command.name.padEnd(width)} ${command.summary}`),
|
|
545
|
+
` ${"help".padEnd(width)} Help for one command: slopper help launch`,
|
|
546
|
+
"",
|
|
547
|
+
"Global flags:",
|
|
548
|
+
...flagLines(GLOBAL_FLAGS),
|
|
549
|
+
"",
|
|
550
|
+
"Results print as JSON. Errors go to stderr; the exit code is 1, or 2 for a usage mistake.",
|
|
551
|
+
`Start with \`slopper login\` (tokens live at ${DEFAULT_URL}/settings/api), then \`slopper whoami\`.`,
|
|
552
|
+
"Agents that shouldn't save a token can set SLOPPER_TOKEN instead."
|
|
553
|
+
].join("\n");
|
|
554
|
+
}
|
|
555
|
+
function commandHelp(command) {
|
|
556
|
+
return [
|
|
557
|
+
`Usage: ${usage(command)}`,
|
|
558
|
+
"",
|
|
559
|
+
command.summary,
|
|
560
|
+
...command.flags ? [
|
|
561
|
+
"",
|
|
562
|
+
"Flags:",
|
|
563
|
+
...flagLines(command.flags)
|
|
564
|
+
] : [],
|
|
565
|
+
"",
|
|
566
|
+
"Global flags:",
|
|
567
|
+
...flagLines(GLOBAL_FLAGS)
|
|
568
|
+
].join("\n");
|
|
569
|
+
}
|
|
570
|
+
function findCommand(name) {
|
|
571
|
+
if (name.startsWith("-")) throw usageError(`Put the command first, then its flags: slopper <command> ${name} …`);
|
|
572
|
+
const command = COMMANDS.find((candidate) => candidate.name === name);
|
|
573
|
+
if (!command) throw usageError(`Unknown command “${name}”.`);
|
|
574
|
+
return command;
|
|
575
|
+
}
|
|
576
|
+
/** Where to look after a usage mistake: the command's own help, when there is a command. */
|
|
577
|
+
function helpHint(name) {
|
|
578
|
+
return COMMANDS.some((command) => command.name === name) ? `Run \`slopper help ${name}\`.` : "Run `slopper help` for the commands.";
|
|
579
|
+
}
|
|
580
|
+
function print(io, result) {
|
|
581
|
+
if (result === void 0) return;
|
|
582
|
+
io.stdout(typeof result === "string" ? result : `${JSON.stringify(result, null, 2)}\n`);
|
|
583
|
+
}
|
|
584
|
+
/** Settles which site and token to use: flags, then the environment, then what `slopper login` saved. */
|
|
585
|
+
async function siteContext(local) {
|
|
586
|
+
const { values, io, configFile } = local;
|
|
587
|
+
const saved = await readConfig(configFile);
|
|
588
|
+
const flag = (key) => typeof values[key] === "string" ? values[key] : void 0;
|
|
589
|
+
const url = resolveUrl(flag("url") ?? envValue(io.env, "SLOPPER_URL") ?? saved.url ?? "https://slopper.day");
|
|
590
|
+
const connect = (token) => createApi({
|
|
591
|
+
url,
|
|
592
|
+
token,
|
|
593
|
+
fetch: io.fetch,
|
|
594
|
+
userAgent: `slopper-cli/${version}`
|
|
595
|
+
});
|
|
596
|
+
const token = flag("token") ?? envValue(io.env, "SLOPPER_TOKEN") ?? saved.token;
|
|
597
|
+
return {
|
|
598
|
+
...local,
|
|
599
|
+
url,
|
|
600
|
+
api: connect(token),
|
|
601
|
+
connect
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
async function run(argv, io) {
|
|
605
|
+
const [name, ...rest] = argv;
|
|
606
|
+
if (name === void 0 || name === "help" || name === "--help" || name === "-h") {
|
|
607
|
+
print(io, `${rest[0] ? commandHelp(findCommand(rest[0])) : overview()}\n`);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (name === "--version" || name === "-v") {
|
|
611
|
+
print(io, `${version}\n`);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const command = findCommand(name);
|
|
615
|
+
let parsed;
|
|
616
|
+
try {
|
|
617
|
+
parsed = parseArgs({
|
|
618
|
+
args: rest,
|
|
619
|
+
options: {
|
|
620
|
+
...GLOBAL_OPTIONS,
|
|
621
|
+
...command.options
|
|
622
|
+
},
|
|
623
|
+
allowPositionals: true,
|
|
624
|
+
strict: true
|
|
625
|
+
});
|
|
626
|
+
} catch (error) {
|
|
627
|
+
throw usageError(error instanceof Error ? error.message : String(error));
|
|
628
|
+
}
|
|
629
|
+
const { values, positionals } = parsed;
|
|
630
|
+
if (values.help === true) {
|
|
631
|
+
print(io, `${commandHelp(command)}\n`);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const extra = positionals[command.args?.split(" ").length ?? 0];
|
|
635
|
+
if (extra !== void 0) throw usageError(`Unexpected “${extra}”. Usage: ${usage(command)}`);
|
|
636
|
+
const local = {
|
|
637
|
+
values,
|
|
638
|
+
positionals,
|
|
639
|
+
io,
|
|
640
|
+
configFile: configPath(io.env, io.home)
|
|
641
|
+
};
|
|
642
|
+
print(io, command.local ? await command.run(local) : await command.run(await siteContext(local)));
|
|
643
|
+
}
|
|
644
|
+
/** Runs one command line and returns the exit code: 0, 1 for a failure, 2 for a usage mistake. */
|
|
645
|
+
async function main(argv, io) {
|
|
646
|
+
try {
|
|
647
|
+
await run(argv, io);
|
|
648
|
+
return 0;
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (isSystemError(error)) {
|
|
651
|
+
io.stderr(`slopper: ${error.message}\n`);
|
|
652
|
+
return 1;
|
|
653
|
+
}
|
|
654
|
+
if (!(error instanceof SlopperError)) throw error;
|
|
655
|
+
const details = error.details ? Object.entries(error.details).map(([field, problem]) => `\n ${field}: ${problem}`) : [];
|
|
656
|
+
const hint = error.exitCode === 2 ? `\n${helpHint(argv[0])}` : "";
|
|
657
|
+
io.stderr(`slopper: ${error.message}${details.join("")}${hint}\n`);
|
|
658
|
+
return error.exitCode;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/slopper.ts
|
|
663
|
+
process.exitCode = await main(process.argv.slice(2), {
|
|
664
|
+
env: process.env,
|
|
665
|
+
home: homedir(),
|
|
666
|
+
fetch: globalThis.fetch,
|
|
667
|
+
readStdin: async () => process.stdin.isTTY ? null : await text(process.stdin),
|
|
668
|
+
stdout: (output) => process.stdout.write(output),
|
|
669
|
+
stderr: (output) => process.stderr.write(output)
|
|
670
|
+
});
|
|
671
|
+
//#endregion
|
|
672
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1 +1,32 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@slopperday/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Throw slop at the wall from your terminal: launch, update and vote on slopper.day. Built for agents.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"cli",
|
|
8
|
+
"slopper"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://slopper.day",
|
|
11
|
+
"license": "UNLICENSED",
|
|
12
|
+
"bin": {
|
|
13
|
+
"slopper": "./dist/slopper.mjs"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "vp pack",
|
|
24
|
+
"prepack": "vp pack"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"vite-plus": "1.0.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/CHANGELOG.md
DELETED
package/LICENSE
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Placeholder package published by monochange. See the source repository for license terms.
|