@inversealtruism/cairn-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 InverseAltruism
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # cairn-cli
2
+
3
+ A command-line client for a Cairn signal board on Compute Substrate. Browse what the community is
4
+ backing, and with a token, propose or support items, all from your terminal.
5
+
6
+ Cairn is a fee-weighted "paid attention" board: people spend CSD to surface what should be built,
7
+ fixed, or funded. `cairn-cli` is a thin HTTP client for a Cairn instance. Browsing needs no `csd`
8
+ binary and no key files.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install -g @inversealtruism/cairn-cli
14
+ ```
15
+
16
+ Or run it without installing:
17
+
18
+ ```bash
19
+ npx @inversealtruism/cairn-cli ls
20
+ ```
21
+
22
+ Or build from source:
23
+
24
+ ```bash
25
+ git clone https://github.com/InverseAltruism/cairn-cli
26
+ cd cairn-cli
27
+ npm install
28
+ npm run build
29
+ npm link
30
+ ```
31
+
32
+ ## Use
33
+
34
+ ```bash
35
+ cairn # help
36
+ cairn domains # list categories + open domains
37
+ cairn ls csd:tools # browse a category (or any open domain, or: cairn ls)
38
+ cairn ls --window trending # trending / 7d / 30d / all
39
+ cairn ls --sort quadratic # lens: totalWeight|quadratic|repWeight|conviction|supporterCount|createdHeight
40
+ cairn watch # live auto-refreshing board
41
+ cairn recent # recent proposals and support
42
+ cairn show <id> # item detail and integrity check
43
+ cairn verify <id> # recompute the content hash and check it
44
+ cairn ls --json # machine-readable output
45
+ ```
46
+
47
+ Posting needs a token from the board operator:
48
+
49
+ ```bash
50
+ export CAIRN_TOKEN=… # the instance's write token
51
+ cairn propose --domain csd:features --title "Wallet GUI" --body "A graphical wallet…" --link https://…
52
+ cairn support <id> --fee 5000000 --score 90 --confidence 80
53
+ ```
54
+
55
+ ## Configuration (environment variables)
56
+
57
+ | Variable | Default | Purpose |
58
+ |---|---|---|
59
+ | `CAIRN_API` | `https://cairn-substrate.com` | the board to talk to (use your own, e.g. `http://127.0.0.1:7777`) |
60
+ | `CAIRN_TOKEN` | – | required only to post (propose or support) |
61
+ | `CAIRN_RPC` | – | optional csd node RPC; enables fully trustless `verify` by recomputing the hash and confirming the one on-chain |
62
+
63
+ ## How it works
64
+
65
+ - `browse`, `show`, `recent`, and `watch` read the board's public API.
66
+ - `verify` fetches an item, recomputes `sha256(canonical content)` locally, and if `CAIRN_RPC` is set,
67
+ confirms that hash is the one committed on-chain. You trust the math, not the server.
68
+ - `propose` and `support` post through the instance, which records the item and submits the on-chain
69
+ proposal or attestation. Fees go to miners. Support is a paid demand signal, not a payment to the
70
+ author.
71
+
72
+ Fees are in base units (1 CSD = 1e8). Minimums are 0.25 CSD to propose and 0.05 CSD to attest.
73
+
74
+ ## License
75
+
76
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ // cairn — command-line client for a Cairn signal board on Compute Substrate.
3
+ // Reads are public; posting needs CAIRN_TOKEN. Config via env: CAIRN_API, CAIRN_TOKEN, CAIRN_RPC.
4
+ import { CAIRN_API, MIN_FEE_PROPOSE, MIN_FEE_ATTEST, CSD_PER_COIN, csdToCoins } from "./lib/config.js";
5
+ import * as api from "./lib/api.js";
6
+ import { buildCommitment } from "./lib/item.js";
7
+ import { c, banner, bannerAnimated, rule, badge, bar, csd as csdFmt, ok, warn, err, key as kdim, pad, spinner, sleep, isTty, clearScreen } from "./lib/ui.js";
8
+ function parse(argv) {
9
+ const _ = [];
10
+ const flags = {};
11
+ const multi = {};
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const a = argv[i];
14
+ if (a.startsWith("--")) {
15
+ const k = a.slice(2);
16
+ const next = argv[i + 1];
17
+ if (next === undefined || next.startsWith("--"))
18
+ flags[k] = true;
19
+ else {
20
+ flags[k] = next;
21
+ (multi[k] ??= []).push(next);
22
+ i++;
23
+ }
24
+ }
25
+ else
26
+ _.push(a);
27
+ }
28
+ return { _, flags, multi };
29
+ }
30
+ const age = (sec) => {
31
+ if (!sec)
32
+ return "—";
33
+ const d = Math.max(0, Math.floor(Date.now() / 1000) - sec);
34
+ return d < 3600 ? `${Math.floor(d / 60)}m` : d < 86400 ? `${Math.floor(d / 3600)}h` : `${Math.floor(d / 86400)}d`;
35
+ };
36
+ // Lenses: each is one transparent reading of the same on-chain data (matches the web
37
+ // UI). The board returns all of these precomputed; we just sort client-side by one.
38
+ const LENS = {
39
+ totalWeight: "CSD support (raw)", quadratic: "quadratic", repWeight: "reputation-weighted",
40
+ conviction: "conviction", supporterCount: "# supporters", createdHeight: "newest",
41
+ };
42
+ function printRows(items, sort = "totalWeight") {
43
+ if (!items.length) {
44
+ console.log(c.gray(" (no items here)"));
45
+ return;
46
+ }
47
+ const max = items[0]?.[sort] || items[0]?.totalWeight || 1;
48
+ items.slice(0, 25).forEach((r, i) => {
49
+ // annotate only lenses whose value isn't already shown in the row (raw + supporters are)
50
+ const lens = !["totalWeight", "supporterCount"].includes(sort) && r[sort] != null
51
+ ? c.gray(" · " + (sort === "createdHeight" ? "h" + r[sort] : csdFmt(r[sort]) + " " + sort)) : "";
52
+ console.log("");
53
+ console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(r.title))} ${badge(r.source)}${r.sealed ? " " + c.gray(r.revealed ? "🔓 revealed" : "🔒 sealed") : ""}`);
54
+ console.log(` ${bar(r[sort] || r.totalWeight, max)} ${csdFmt(r.totalWeight)} ${c.gray("·")} ${c.green(String(r.supporterCount))} ${c.gray("supporters · score " + r.avgScore + " · " + age(r.createdTime) + " ago")}${lens}`);
55
+ console.log(c.gray(` ${r.domain} · id ${String(r.id).slice(0, 22)}…`));
56
+ });
57
+ }
58
+ async function cmdList(a) {
59
+ const domain = a._[1] ?? "all";
60
+ const window = String(a.flags.window ?? "all");
61
+ const sort = LENS[String(a.flags.sort ?? "")] ? String(a.flags.sort) : "totalWeight";
62
+ const r = await api.apiBoard(domain, window);
63
+ let items = r.items ?? [];
64
+ if (sort !== "totalWeight")
65
+ items = items.slice().sort((x, y) => (y[sort] || 0) - (x[sort] || 0));
66
+ if (a.flags.json) {
67
+ console.log(JSON.stringify(items, null, 2));
68
+ return;
69
+ }
70
+ banner();
71
+ rule(`${domain} · ${LENS[sort]} · ${window} · ${CAIRN_API.replace(/^https?:\/\//, "")}`);
72
+ printRows(items, sort);
73
+ }
74
+ async function cmdWatch(a) {
75
+ const domain = a._[1] ?? "all";
76
+ const window = String(a.flags.window ?? "trending");
77
+ if (!isTty) {
78
+ printRows((await api.apiBoard(domain, window)).items);
79
+ return;
80
+ }
81
+ process.stdout.write("\x1b[?25l");
82
+ process.on("SIGINT", () => { process.stdout.write("\x1b[?25h\n"); process.exit(0); });
83
+ for (;;) {
84
+ const r = await api.apiBoard(domain, window).catch(() => ({ items: [] }));
85
+ clearScreen();
86
+ banner();
87
+ rule(`watch · ${domain} · ${window} · ${new Date().toLocaleTimeString()}`);
88
+ printRows(r.items);
89
+ console.log(c.gray("\n ") + c.green("●") + c.gray(" live · refreshes every 5s · Ctrl+C to exit"));
90
+ await sleep(5000);
91
+ }
92
+ }
93
+ async function cmdRecent() {
94
+ const r = await api.apiActivity();
95
+ banner();
96
+ rule("recent activity");
97
+ for (const ev of r.activity ?? []) {
98
+ const verb = ev.type === "support" ? c.green("◈ supported") : c.cyan("✎ proposed ");
99
+ console.log(` ${verb} ${c.white(String(ev.item).slice(0, 42))} ${c.gray("· " + age(ev.time) + " ago · " + (ev.amount / 1e8) + " CSD")}`);
100
+ }
101
+ }
102
+ async function cmdShow(a) {
103
+ const id = a._[1];
104
+ if (!id) {
105
+ console.log(warn("usage: cairn show <id>"));
106
+ return;
107
+ }
108
+ const r = await api.apiItem(id).catch(() => null);
109
+ if (!r || !r.ok) {
110
+ console.log(err("not found"));
111
+ return;
112
+ }
113
+ const it = r.item;
114
+ rule(it.title);
115
+ console.log(` ${badge(it.source)} ${c.gray("·")} ${c.cyan(it.domain)}`);
116
+ console.log(`\n ${c.white(it.body)}\n`);
117
+ if (it.links?.length)
118
+ console.log(` ${kdim("links")} ${it.links.map((l) => c.cyan(l)).join(", ")}`);
119
+ const total = (r.supports ?? []).reduce((x, s) => x + s.weight, 0);
120
+ console.log(` ${kdim("support")} ${csdFmt(total)} ${c.gray("from")} ${c.green(String(new Set((r.supports ?? []).map((s) => s.attester)).size))} ${c.gray("supporters")}`);
121
+ console.log(` ${kdim("proposer")} ${c.gray(it.proposerHandle || it.proposer)}`);
122
+ console.log(` ${kdim("hash")} ${c.magenta(it.payloadHash)}`);
123
+ console.log(` ${kdim("integrity")} ${r.integrityOk ? ok("content matches commitment") : err("MISMATCH")}`);
124
+ }
125
+ async function cmdVerify(a) {
126
+ const id = a._[1];
127
+ if (!id) {
128
+ console.log(warn("usage: cairn verify <id>"));
129
+ return;
130
+ }
131
+ const sp = spinner("fetching + recomputing");
132
+ const r = await api.apiItem(id).catch(() => null);
133
+ if (!r || !r.ok) {
134
+ sp.stop();
135
+ console.log(err("not found"));
136
+ return;
137
+ }
138
+ const it = r.item;
139
+ const { payloadHash } = buildCommitment({ v: 1, domain: it.domain, title: it.title, body: it.body, links: it.links ?? [] });
140
+ const chain = await api.chainProposal(it.id);
141
+ sp.stop();
142
+ console.log(`${kdim("recomputed")} ${c.magenta(payloadHash)}`);
143
+ console.log(`${kdim("reported")} ${c.magenta(it.payloadHash)}`);
144
+ const contentOk = payloadHash.toLowerCase() === String(it.payloadHash).toLowerCase();
145
+ if (chain?.payload_hash) {
146
+ console.log(`${kdim("on-chain")} ${c.magenta(chain.payload_hash)}`);
147
+ console.log(contentOk && String(chain.payload_hash).toLowerCase() === payloadHash.toLowerCase()
148
+ ? ok("VERIFIED — content matches the on-chain commitment (trustless, via CAIRN_RPC)")
149
+ : err("MISMATCH"));
150
+ }
151
+ else {
152
+ console.log(contentOk ? ok("content matches the reported commitment") + c.gray(" (set CAIRN_RPC to also check the chain directly)") : err("content does NOT match the reported hash"));
153
+ }
154
+ }
155
+ async function cmdPropose(a) {
156
+ const domain = String(a.flags.domain ?? "");
157
+ const title = String(a.flags.title ?? "");
158
+ const body = String(a.flags.body ?? "");
159
+ const links = a.multi.link ?? [];
160
+ if (!domain || !title) {
161
+ console.log(warn("usage: ") + c.cyan("cairn propose --domain csd:features --title <t> --body <b> [--link <url>] [--fee <base>]"));
162
+ return;
163
+ }
164
+ const fee = Number(a.flags.fee ?? MIN_FEE_PROPOSE);
165
+ const sp = spinner("posting to Cairn");
166
+ try {
167
+ const r = await api.apiPropose({ domain, title, body, links, fee: Number.isFinite(fee) && fee >= MIN_FEE_PROPOSE ? Math.floor(fee) : MIN_FEE_PROPOSE });
168
+ sp.stop();
169
+ console.log(r.ok ? ok(`proposed ${c.cyan(r.id)}`) : err(r.error || "failed"));
170
+ }
171
+ catch (e) {
172
+ sp.stop();
173
+ console.log(err(e.message));
174
+ }
175
+ }
176
+ async function cmdSupport(a) {
177
+ const id = a._[1];
178
+ if (!id) {
179
+ console.log(warn("usage: ") + c.cyan("cairn support <id> --fee <base> [--score 0-100] [--confidence 0-100]"));
180
+ return;
181
+ }
182
+ const fee = Number(a.flags.fee ?? MIN_FEE_ATTEST);
183
+ const sp = spinner("posting support to Cairn");
184
+ try {
185
+ const r = await api.apiSupport({ id, fee: Number.isFinite(fee) && fee >= MIN_FEE_ATTEST ? Math.floor(fee) : MIN_FEE_ATTEST, score: Number(a.flags.score ?? 75), confidence: Number(a.flags.confidence ?? 60) });
186
+ sp.stop();
187
+ console.log(r.ok ? ok(`supported ${c.cyan(r.id)}`) : err(r.error || "failed"));
188
+ }
189
+ catch (e) {
190
+ sp.stop();
191
+ console.log(err(e.message));
192
+ }
193
+ }
194
+ async function cmdDomains() {
195
+ const r = await api.apiDomains();
196
+ banner();
197
+ rule("categories");
198
+ for (const dom of r.domains ?? [])
199
+ console.log(` ${c.cyan(pad(dom.key, 20))} ${c.white(dom.title)} ${c.gray(dom.count != null ? "(" + dom.count + ")" : "")}`);
200
+ // open domains: anyone can create one by proposing into it (cairn ls <domain> works for any).
201
+ const disc = r.discovered ?? [];
202
+ if (disc.length) {
203
+ console.log(c.gray("\n open domains (created by proposing into them):"));
204
+ for (const d of disc)
205
+ console.log(` ${c.cyan(pad(d.key, 20))} ${c.gray((d.count != null ? d.count + " items" : "") + (d.totalWeight ? " · " + csdToCoins(d.totalWeight) + " CSD" : ""))}`);
206
+ }
207
+ }
208
+ async function help() {
209
+ await bannerAnimated();
210
+ const cmd = (n, args, d) => console.log(` ${c.cyan(pad(n, 9))} ${c.gray(pad(args, 44))} ${c.dim(d)}`);
211
+ console.log(c.bold(" commands"));
212
+ cmd("domains", "", "list categories + open domains");
213
+ cmd("ls", "[domain] --window trending|7d|30d|all --sort <lens>", "browse the board (+ --json)");
214
+ cmd("top", "[domain]", "alias for ls");
215
+ cmd("watch", "[domain]", "live auto-refreshing board");
216
+ cmd("recent", "", "recent proposals + support");
217
+ cmd("show", "<id>", "item detail + integrity");
218
+ cmd("verify", "<id>", "recompute hash, check vs chain");
219
+ cmd("propose", "--domain <d> --title <t> --body <b>", "post an item (needs CAIRN_TOKEN)");
220
+ cmd("support", "<id> --fee <base>", "back an item (needs CAIRN_TOKEN)");
221
+ console.log(c.gray("\n lenses (--sort): " + Object.keys(LENS).join(" · ")));
222
+ console.log(c.gray(` api: ${CAIRN_API} · 1 CSD = ${CSD_PER_COIN} base · propose ≥ ${MIN_FEE_PROPOSE} · attest ≥ ${MIN_FEE_ATTEST}`));
223
+ console.log(c.gray(" config: CAIRN_API (board url) · CAIRN_TOKEN (instance write token, to post) · CAIRN_RPC (trustless verify)"));
224
+ console.log(c.gray(" display: honors NO_COLOR · --no-color · --no-anim · TERM=dumb (color/animation auto-off when piped)"));
225
+ console.log(c.gray(" keyless non-custodial posting + sealed claims: use the Cairn Wallet (browser extension)."));
226
+ }
227
+ async function main() {
228
+ const a = parse(process.argv.slice(2));
229
+ switch (a._[0]) {
230
+ case "domains": return cmdDomains();
231
+ case "ls":
232
+ case "list":
233
+ case "top": return cmdList(a);
234
+ case "watch": return cmdWatch(a);
235
+ case "recent": return cmdRecent();
236
+ case "show": return cmdShow(a);
237
+ case "verify": return cmdVerify(a);
238
+ case "propose": return cmdPropose(a);
239
+ case "support": return cmdSupport(a);
240
+ default: return help();
241
+ }
242
+ }
243
+ main().catch((e) => { console.error(err(String(e?.message ?? e))); process.exit(1); });
@@ -0,0 +1,48 @@
1
+ // Thin client for a Cairn instance's HTTP API. Reads are public; writes need a token.
2
+ import { CAIRN_API, CAIRN_TOKEN, CAIRN_RPC } from "./config.js";
3
+ async function req(path, init) {
4
+ let res;
5
+ try {
6
+ res = await fetch(`${CAIRN_API}${path}`, { signal: AbortSignal.timeout(8000), ...init });
7
+ }
8
+ catch (e) {
9
+ throw new Error(`cannot reach ${CAIRN_API} (${e?.message ?? e}) — set CAIRN_API to a running Cairn instance`);
10
+ }
11
+ if (res.status === 401)
12
+ throw new Error(`${CAIRN_API} is password-gated (401). Point CAIRN_API at a public instance or your own (e.g. http://127.0.0.1:7777)`);
13
+ if (!res.ok)
14
+ throw new Error(`API ${path} → HTTP ${res.status}`);
15
+ return res.json();
16
+ }
17
+ function writeReq(path, body) {
18
+ if (!CAIRN_TOKEN)
19
+ throw new Error("posting needs a token — set CAIRN_TOKEN (the operator's write token)");
20
+ return req(path, {
21
+ method: "POST",
22
+ headers: { "content-type": "application/json", "x-cairn-token": CAIRN_TOKEN },
23
+ body: JSON.stringify(body),
24
+ });
25
+ }
26
+ export const apiHealth = () => req("/api/health");
27
+ export const apiDomains = () => req("/api/domains");
28
+ export const apiStats = () => req("/api/stats");
29
+ export const apiBoard = (domain, window) => req(`/api/board?domain=${encodeURIComponent(domain)}&window=${encodeURIComponent(window)}`);
30
+ export const apiItem = (id) => req(`/api/item/${encodeURIComponent(id)}`);
31
+ export const apiActivity = () => req("/api/activity");
32
+ export const apiPropose = (body) => writeReq("/api/propose", body);
33
+ export const apiSupport = (body) => writeReq("/api/support", body);
34
+ // optional: query a raw csd node RPC (for trustless verify)
35
+ export async function chainProposal(id) {
36
+ if (!CAIRN_RPC)
37
+ return null;
38
+ try {
39
+ const r = await fetch(`${CAIRN_RPC}/proposal/${id}`, { signal: AbortSignal.timeout(6000) });
40
+ if (!r.ok)
41
+ return null;
42
+ const j = await r.json();
43
+ return j.proposal ?? j ?? null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
@@ -0,0 +1,10 @@
1
+ // cairn-cli configuration (env-overridable).
2
+ export const CAIRN_API = (process.env.CAIRN_API ?? "https://cairn-substrate.com").replace(/\/+$/, "");
3
+ export const CAIRN_TOKEN = process.env.CAIRN_TOKEN ?? ""; // required only for posting
4
+ export const CAIRN_RPC = process.env.CAIRN_RPC ?? ""; // optional: a csd node RPC, enables trustless verify
5
+ export const CSD_PER_COIN = 100_000_000;
6
+ export const MIN_FEE_PROPOSE = 25_000_000; // 0.25 CSD
7
+ export const MIN_FEE_ATTEST = 5_000_000; // 0.05 CSD
8
+ export function csdToCoins(base) {
9
+ return (base / CSD_PER_COIN).toLocaleString(undefined, { maximumFractionDigits: 4 });
10
+ }
@@ -0,0 +1,26 @@
1
+ // Canonical item record + integrity commitment.
2
+ // payload_hash = sha256(canonical JSON of the content record). No salt: the board
3
+ // is public, so the hash is for tamper-evidence, not secrecy.
4
+ import { createHash } from "node:crypto";
5
+ // Deterministic JSON: recursively sorted keys, no insignificant whitespace.
6
+ export function stableStringify(value) {
7
+ if (value === null || typeof value !== "object")
8
+ return JSON.stringify(value);
9
+ if (Array.isArray(value))
10
+ return `[${value.map(stableStringify).join(",")}]`;
11
+ const obj = value;
12
+ const keys = Object.keys(obj).sort();
13
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
14
+ }
15
+ export function sha256Hex(data) {
16
+ return "0x" + createHash("sha256").update(data).digest("hex");
17
+ }
18
+ export function buildCommitment(content) {
19
+ const canonical = stableStringify(content);
20
+ return { canonical, payloadHash: sha256Hex(canonical) };
21
+ }
22
+ // Verify that displayed content matches an on-chain payload_hash.
23
+ export function verifyContent(content, onchainHash) {
24
+ const { payloadHash } = buildCommitment(content);
25
+ return payloadHash.toLowerCase() === onchainHash.toLowerCase();
26
+ }
package/dist/lib/ui.js ADDED
@@ -0,0 +1,121 @@
1
+ // Cairn CLI theme — matches the website + wallet: black-and-white terminal with a
2
+ // single phosphor-green accent, sharp edges, a blinking cursor and a fast decode
3
+ // "boot" reveal. Zero deps.
4
+ //
5
+ // clig.dev compliance:
6
+ // • color off when stdout/stderr isn't a TTY, NO_COLOR is set, TERM=dumb, or
7
+ // --no-color is passed (CAIRN_COLOR=1 forces it on for demos)
8
+ // • no animation unless interactive + color (CAIRN_NO_ANIM / --no-anim opt out)
9
+ // • the spinner (transient progress) goes to STDERR so stdout stays pipeable
10
+ const argv = process.argv;
11
+ const TTY = !!process.stdout.isTTY || process.env.CAIRN_COLOR === "1";
12
+ const NO_COLOR = (!!process.env.NO_COLOR && process.env.NO_COLOR !== "") || process.env.TERM === "dumb"
13
+ || process.env.CAIRN_NO_COLOR === "1" || argv.includes("--no-color");
14
+ const COLOR = (TTY || process.env.CAIRN_COLOR === "1") && !NO_COLOR;
15
+ const TRUE = COLOR && /^(truecolor|24bit)$/.test(process.env.COLORTERM ?? "");
16
+ const ANIM = COLOR && !!process.stdout.isTTY && !process.env.CAIRN_NO_ANIM && !argv.includes("--no-anim");
17
+ // truecolor with a 256-color fallback, so the exact wallet/site palette renders when
18
+ // the terminal supports it and degrades gracefully when it doesn't.
19
+ const fg = (rgb, c256) => (s) => (COLOR ? `\x1b[${TRUE ? rgb : c256}m${s}\x1b[0m` : String(s));
20
+ const sgr = (code) => (s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : String(s));
21
+ // palette — phosphor green #6ee7a0 accent, off-white text, dim grays, soft red #ff6b6b
22
+ const GREEN = fg("38;2;110;231;160", "38;5;114");
23
+ const WHITE = fg("38;2;230;230;230", "97");
24
+ const GRAY = fg("38;2;125;125;125", "38;5;245");
25
+ const FAINT = fg("38;2;90;90;90", "38;5;240");
26
+ const RED = fg("38;2;255;107;107", "38;5;203");
27
+ export const c = {
28
+ green: GREEN, white: WHITE, gray: GRAY, faint: FAINT, red: RED,
29
+ dim: sgr("2"), bold: sgr("1"),
30
+ // legacy role names remapped to the monochrome+green theme so call sites stay put:
31
+ cyan: GREEN, // accents · ids · bars → phosphor green
32
+ magenta: WHITE, // emphasis · rank · wordmark → bright white
33
+ amber: GRAY, // demo / muted → gray
34
+ };
35
+ const W = 64;
36
+ const TAG = "paid-attention board · compute substrate";
37
+ export function banner() {
38
+ if (!TTY)
39
+ return; // chrome is for humans; piped output skips it
40
+ console.log(` ${c.white(c.bold("▓▒░ CAIRN"))} ${c.green("▮")} ${c.gray(TAG)}`);
41
+ rule();
42
+ }
43
+ export function rule(label = "") {
44
+ if (label) {
45
+ const tail = Math.max(0, W - label.length - 5);
46
+ console.log(`${c.green("──")} ${c.white(c.bold(label))} ${c.faint("─".repeat(tail))}`);
47
+ }
48
+ else
49
+ console.log(c.faint("─".repeat(W)));
50
+ }
51
+ export function badge(source) {
52
+ if (source === "chain")
53
+ return c.green("⛓ on-chain");
54
+ if (source === "demo")
55
+ return c.gray("◆ demo");
56
+ if (source === "draft")
57
+ return c.gray("✎ draft");
58
+ return c.gray(source);
59
+ }
60
+ // proportional bar — green fill, faint track (matches the site's support bars)
61
+ export function bar(value, max, width = 16) {
62
+ const frac = max > 0 ? Math.min(1, value / max) : 0;
63
+ const fill = Math.round(frac * width);
64
+ return c.green("█".repeat(fill)) + c.faint("░".repeat(Math.max(0, width - fill)));
65
+ }
66
+ export function csd(base) {
67
+ return c.green(`${(base / 1e8).toLocaleString(undefined, { maximumFractionDigits: 4 })}`) + c.gray(" CSD");
68
+ }
69
+ export function ok(s) { return c.green("✓ ") + s; }
70
+ export function warn(s) { return c.gray("⚠ ") + s; }
71
+ export function err(s) { return c.red("✗ ") + s; }
72
+ export function key(k) { return c.gray(k); }
73
+ export function pad(s, n) {
74
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, "").length;
75
+ return s + " ".repeat(Math.max(0, n - visible));
76
+ }
77
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
78
+ export const isTty = TTY;
79
+ // Phosphor braille spinner for network/async work. Writes to STDERR (clig.dev: keep
80
+ // stdout clean for piping) and is a no-op when output isn't an interactive terminal.
81
+ export function spinner(label) {
82
+ if (!ANIM)
83
+ return { stop: (final) => { if (final)
84
+ console.log(final); } };
85
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
86
+ let i = 0;
87
+ const t = setInterval(() => process.stderr.write(`\r${c.green(frames[i++ % frames.length])} ${c.gray(label)} `), 80);
88
+ return {
89
+ stop: (final) => {
90
+ clearInterval(t);
91
+ process.stderr.write("\r\x1b[K");
92
+ if (final)
93
+ console.log(final);
94
+ },
95
+ };
96
+ }
97
+ // Fast decode-reveal of the CAIRN wordmark: scramble glyphs resolve L→R into white
98
+ // letters with a green cursor — the site's "boot" feel. ~360ms, and only on an
99
+ // interactive TTY (static banner otherwise, so help piped to a file isn't garbage).
100
+ export async function bannerAnimated() {
101
+ if (!ANIM) {
102
+ banner();
103
+ return;
104
+ }
105
+ const word = "CAIRN";
106
+ const pool = "▖▗▘▙▚▛▜▝▞▟01#%/\\<>=".split("");
107
+ const rnd = () => pool[Math.floor(Math.random() * pool.length)];
108
+ for (let step = 0; step <= word.length; step++) {
109
+ let s = "";
110
+ for (let i = 0; i < word.length; i++)
111
+ s += i < step ? c.white(c.bold(word[i])) : c.green(rnd());
112
+ process.stdout.write(`\r ${c.gray("▓▒░")} ${s} ${c.green("▋")} ${c.gray("· " + TAG)} `);
113
+ await sleep(60);
114
+ }
115
+ process.stdout.write(`\r ${c.gray("▓▒░")} ${c.white(c.bold(word))} ${c.green("▮")} ${c.gray("· " + TAG)}\n`);
116
+ rule();
117
+ }
118
+ export function clearScreen() {
119
+ if (TTY)
120
+ process.stdout.write("\x1b[2J\x1b[H");
121
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@inversealtruism/cairn-cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line client for a Cairn signal board on Compute Substrate — browse, propose, and back what to build.",
5
+ "type": "module",
6
+ "bin": { "cairn": "dist/cli.js" },
7
+ "files": ["dist", "README.md"],
8
+ "engines": { "node": ">=20" },
9
+ "repository": { "type": "git", "url": "git+https://github.com/InverseAltruism/cairn-cli.git" },
10
+ "homepage": "https://cairn-substrate.com",
11
+ "publishConfig": { "access": "public" },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "tsx src/cli.ts",
15
+ "test": "tsc && node test/e2e.mjs",
16
+ "prepare": "tsc"
17
+ },
18
+ "keywords": ["cairn", "compute-substrate", "cli"],
19
+ "license": "MIT",
20
+ "devDependencies": {
21
+ "@types/node": "^22.10.0",
22
+ "tsx": "^4.19.2",
23
+ "typescript": "^5.7.2"
24
+ }
25
+ }