geneseed 3.1.1 → 3.1.2
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/CHANGELOG.md +68 -1
- package/DESIGN.md +15 -15
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +111 -83
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +27 -28
- package/js/{render.mjs → build/render.mjs} +13 -9
- package/js/{checkout.mjs → build/source.mjs} +9 -9
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +18 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +63 -55
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +34 -32
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +84 -85
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +72 -50
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +21 -19
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
- package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +1 -1
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1750
- package/js/emit.mjs +0 -1411
- package/web/dist/assets/index-B-mXquBZ.css +0 -1
- package/web/dist/assets/index-CH_Z725C.js +0 -12
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every emit shares — the constants that name what this tool owns, the small readers it
|
|
3
|
+
* walks a tree with, and the three writers both GLOBAL emits call.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `emit.mjs` because each of `globalMemory`, `globalNotebook`, `shipLeanLaws`
|
|
6
|
+
* and `stripCapabilityLinks` has two callers in two different host emits. A helper with two
|
|
7
|
+
* hosts belongs to neither, and putting it in one of them is how a file grows to 1400 lines.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here decides WHICH host is being emitted. `emit-opencode.mjs` and
|
|
10
|
+
* `emit-claude.mjs` do that; `emit.mjs` renders the bundle they read from.
|
|
11
|
+
*/
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { copyFile, writeText } from '../lib/fs.mjs';
|
|
14
|
+
import { SRC_DIR_TOKENS, destRel } from './render.mjs';
|
|
15
|
+
import { mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
16
|
+
|
|
17
|
+
/** `_build_render.SRC_DIRS_MARKER`. */
|
|
18
|
+
export const SRC_DIRS_MARKER = '.geneseed-srcdirs.json';
|
|
19
|
+
/**
|
|
20
|
+
* `_build_render.OWNED_SRC_DIRS` — wiped and regenerated each run.
|
|
21
|
+
*
|
|
22
|
+
* `ontology` and `doctrines` are appended, not inserted: this array's iteration order is the
|
|
23
|
+
* key order of the `.geneseed-srcdirs.json` marker it fills, and appending keeps the three
|
|
24
|
+
* original keys where an existing install's marker already has them.
|
|
25
|
+
*
|
|
26
|
+
* They belong here for the same reason `laws` does — they are GENERATED, so an install must
|
|
27
|
+
* not keep a copy the source no longer produces. Without them a build writes the dirs on
|
|
28
|
+
* every run and never wipes them, which turns the doctrines toggle into a one-way switch:
|
|
29
|
+
* disable a pack, rebuild, and its rendered file sits in the install forever, contradicting
|
|
30
|
+
* the `Active packs:` line in the AGENT.md right beside it.
|
|
31
|
+
*/
|
|
32
|
+
export const OWNED_SRC_DIRS = ['laws', 'agents', 'skills', 'ontology', 'doctrines'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The constitution dirs a LEAN emit re-ships in full text — see `shipLeanLaws`.
|
|
36
|
+
*
|
|
37
|
+
* Not all of `OWNED_SRC_DIRS`: `agents` and `skills` are already written as whole files by
|
|
38
|
+
* the native layer, and only the three constitution dirs are inlined into AGENT.md in a
|
|
39
|
+
* form (terse, or pack-filtered) that loses text the reader may still need.
|
|
40
|
+
*/
|
|
41
|
+
const LEAN_FULL_TEXT_DIRS = ['laws', 'ontology', 'doctrines'];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `_build_core.CAPABILITY_LINK_RE` — the DEFAULT only.
|
|
45
|
+
*
|
|
46
|
+
* `cfg.capabilityLinkRe` overrides it and the driver always supplies one, for the same
|
|
47
|
+
* reason `cfg.structure` exists: the constant is in `_OWNED` because a doctor test
|
|
48
|
+
* redirects it to the pre-fix form and asserts the emit then renders dead links, and a
|
|
49
|
+
* redirect that stops at the process boundary half-works silently. The literal stays as
|
|
50
|
+
* the fallback for the parity harnesses, which drive this module in-process.
|
|
51
|
+
*/
|
|
52
|
+
const CAPABILITY_LINK_RE =
|
|
53
|
+
/\[([^\]]+)\]\((?:(?!https?:\/\/|\/)[A-Za-z0-9_.-]+\/)*(?:agents|skills)\/[A-Za-z0-9_-]+\.md\)/g;
|
|
54
|
+
|
|
55
|
+
/** `_build_render._TMPL_SPEC_RE`. */
|
|
56
|
+
export const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Small filesystem predicates. Python spells these `Path.is_file()` / `is_dir()`, both
|
|
60
|
+
// of which answer False for a missing path rather than raising; `statSync` throws.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export function isFile(p) {
|
|
64
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isDir(p) {
|
|
68
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `dict.get(key)` with Python's semantics — OWN properties only.
|
|
72
|
+
*
|
|
73
|
+
* The same hazard `js/hosts/settings.mjs` and `js/hosts/native.mjs` both spell out: the keys here come
|
|
74
|
+
* out of a manifest the user can edit, and `old['constructor']` hands back
|
|
75
|
+
* `Object.prototype`'s member where Python's `.get` returns None. */
|
|
76
|
+
export function get(obj, key) {
|
|
77
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isDict(v) {
|
|
81
|
+
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `Path.rglob("*")` restricted to files, skipping `__pycache__` as Python's callers do. */
|
|
85
|
+
export function rglobFiles(root, out = []) {
|
|
86
|
+
for (const name of readdirSync(root)) {
|
|
87
|
+
if (name === '__pycache__') continue;
|
|
88
|
+
const full = path.join(root, name);
|
|
89
|
+
if (isDir(full)) rglobFiles(full, out);
|
|
90
|
+
else out.push(full);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `Path.rglob("*")` restricted to files, keeping EVERYTHING — including `__pycache__`.
|
|
97
|
+
*
|
|
98
|
+
* Not a duplicate of `rglobFiles` by accident: `source_fingerprint`'s Python filters
|
|
99
|
+
* `__pycache__` and the legacy-store migration in `_global_memory` does not, so a single
|
|
100
|
+
* walk would be wrong for one caller either way. A user's legacy bundle really can carry
|
|
101
|
+
* one (the harness ships `.py` skill scripts), and copying it or not is a byte difference
|
|
102
|
+
* the two runtimes would disagree on silently.
|
|
103
|
+
*/
|
|
104
|
+
function rglobAllFiles(root, out = []) {
|
|
105
|
+
for (const name of readdirSync(root)) {
|
|
106
|
+
const full = path.join(root, name);
|
|
107
|
+
if (isDir(full)) rglobAllFiles(full, out);
|
|
108
|
+
else out.push(full);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `p.relative_to(base).as_posix()`. */
|
|
114
|
+
export function relPosix(base, p) {
|
|
115
|
+
return path.relative(base, p).split(path.sep).join('/');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `_build_emit._strip_capability_links` — `re.sub(r"\1")`, so the pattern needs `g`.
|
|
120
|
+
*
|
|
121
|
+
* Exported for `js/inspect/checks-build.mjs`: `_rendered_problems` applies the same de-linking before
|
|
122
|
+
* comparing an OpenCode bundle's AGENT.md against a fresh render, because the emit puts
|
|
123
|
+
* that difference back by design and a check that did not would report the file stale on
|
|
124
|
+
* every run forever.
|
|
125
|
+
*/
|
|
126
|
+
export function stripCapabilityLinks(cfg, text) {
|
|
127
|
+
const re = cfg.capabilityLinkRe
|
|
128
|
+
? new RegExp(cfg.capabilityLinkRe, 'g')
|
|
129
|
+
: CAPABILITY_LINK_RE;
|
|
130
|
+
return text.replace(re, '$1');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// The global-store helpers, shared by the `opencode-global` and `claude` jobs
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `_build_global._global_memory` — ensure `<cfg>/memory` exists, without ever touching a
|
|
139
|
+
* store that already holds something.
|
|
140
|
+
*
|
|
141
|
+
* The three outcomes are the returned status string, which the emit prints, so they are
|
|
142
|
+
* compared through stdout as well as through the tree. Python's `theme` parameter is
|
|
143
|
+
* dropped: the store dir is ALWAYS the classic English `memory/`, never themed (the
|
|
144
|
+
* OpenCode config dir uses fixed names), so the argument was never read.
|
|
145
|
+
*
|
|
146
|
+
* The migration branch copies arbitrary USER files out of a legacy bundle, which is the
|
|
147
|
+
* reason this is not a plain translation: everything it touches is the user's, and the
|
|
148
|
+
* only thing keeping it safe is that it runs at all only when the destination is empty.
|
|
149
|
+
*/
|
|
150
|
+
export function globalMemory(cfgDir, items, legacy, srcRoot) {
|
|
151
|
+
const memName = 'memory';
|
|
152
|
+
const memDir = path.join(cfgDir, memName);
|
|
153
|
+
if (isDir(memDir) && readdirSync(memDir).length) return `kept ${memName}/`;
|
|
154
|
+
mkdirSync(memDir, { recursive: true });
|
|
155
|
+
if (legacy) {
|
|
156
|
+
// `dict.fromkeys([mem_name, "memory", "anamnesis"])` — de-duplicated, order kept.
|
|
157
|
+
// `mem_name` is the literal 'memory' and the Python docstring beside it says the
|
|
158
|
+
// store name is NEVER themed, so the first two entries always collapse and
|
|
159
|
+
// `anamnesis` — an older themed install's name — is the only live alias. The dedupe
|
|
160
|
+
// is what keeps the duplicate harmless rather than copying the same dir twice.
|
|
161
|
+
for (const nm of [...new Set([memName, 'memory', 'anamnesis'])]) {
|
|
162
|
+
const src = path.join(legacy, nm);
|
|
163
|
+
if (isDir(src) && readdirSync(src).length) {
|
|
164
|
+
for (const f of rglobAllFiles(src)) {
|
|
165
|
+
const dest = path.join(memDir, path.relative(src, f));
|
|
166
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
167
|
+
copyFile(f, dest);
|
|
168
|
+
}
|
|
169
|
+
return `migrated ${nm}/ -> ${memName}/`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const { text, src } of items) {
|
|
174
|
+
const sp = relPosix(srcRoot, src).split('/');
|
|
175
|
+
if (sp[0] === 'memory' && sp.length > 1) {
|
|
176
|
+
// `destRel`, because this seeds from the SOURCE path and not from the item's `rel`
|
|
177
|
+
// (which is themed, and `memory/` never is). Without it the store would be seeded
|
|
178
|
+
// with the on-disk name `gitignore` — see `js/build/render.mjs`'s `destRel`.
|
|
179
|
+
const dest = path.join(memDir, destRel(path.join(...sp.slice(1))));
|
|
180
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
181
|
+
if (text !== null) writeText(dest, text);
|
|
182
|
+
else copyFile(src, dest);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return `seeded ${memName}/`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `_build_global._global_notebook` — the same shape, with no `anamnesis` alias. */
|
|
189
|
+
export function globalNotebook(cfgDir, items, legacy, srcRoot) {
|
|
190
|
+
const nbName = 'notebook';
|
|
191
|
+
const nbDir = path.join(cfgDir, nbName);
|
|
192
|
+
if (isDir(nbDir) && readdirSync(nbDir).length) return `kept ${nbName}/`;
|
|
193
|
+
mkdirSync(nbDir, { recursive: true });
|
|
194
|
+
if (legacy) {
|
|
195
|
+
const src = path.join(legacy, nbName);
|
|
196
|
+
if (isDir(src) && readdirSync(src).length) {
|
|
197
|
+
for (const f of rglobAllFiles(src)) {
|
|
198
|
+
const dest = path.join(nbDir, path.relative(src, f));
|
|
199
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
200
|
+
copyFile(f, dest);
|
|
201
|
+
}
|
|
202
|
+
return `migrated ${nbName}/`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const { text, src } of items) {
|
|
206
|
+
const sp = relPosix(srcRoot, src).split('/');
|
|
207
|
+
if (sp[0] === nbName && sp.length > 1) {
|
|
208
|
+
// `destRel` — same reason as `globalMemory` above.
|
|
209
|
+
const dest = path.join(nbDir, destRel(path.join(...sp.slice(1))));
|
|
210
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
211
|
+
if (text !== null) writeText(dest, text);
|
|
212
|
+
else copyFile(src, dest);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return `seeded ${nbName}/`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `_build_global._ship_lean_laws`.
|
|
220
|
+
*
|
|
221
|
+
* Historically the one RENDER that ran AFTER a WIRE, which is why the phase-order
|
|
222
|
+
* normalisation had to move it before the seam could be cut. Re-derived rather than
|
|
223
|
+
* trusted: its inputs are `items` and `theme` (both from `render_all`) and its only
|
|
224
|
+
* output besides the files is what it APPENDS to `owned` — which travels back in the
|
|
225
|
+
* payload for the manifest and the prune. It reads nothing any wiring stage writes, so
|
|
226
|
+
* its new position is not merely legal, it has no dependency on the old one.
|
|
227
|
+
*
|
|
228
|
+
* NO LONGER LEAN-ONLY, and the name is kept because the lean case is still the whole reason
|
|
229
|
+
* it exists. `doctrines` ships at BOTH footprints, because the two footprints lose different
|
|
230
|
+
* things: lean loses text (every rule is truncated to its first sentence, so the full text has
|
|
231
|
+
* to be on disk), while FULL loses whole packs (only the active ones are inlined, so the
|
|
232
|
+
* inactive ones have to be on disk). AGENT.md tells the agent it may read a rule in a pack
|
|
233
|
+
* that is not active, and `src/doctrines/README.md` rests cross-pack citations on exactly
|
|
234
|
+
* that — a claim the emit has to make true, not merely the `files` bundle.
|
|
235
|
+
*/
|
|
236
|
+
export function shipLeanLaws(items, theme, cfgDir, owned, footprint) {
|
|
237
|
+
// Themed names, resolved through the same token table `build`'s wipe resolves, so the dir
|
|
238
|
+
// a lean emit re-ships and the dir it prunes can never be two different spellings.
|
|
239
|
+
const wanted = footprint === 'lean' ? LEAN_FULL_TEXT_DIRS : ['doctrines'];
|
|
240
|
+
const dirs = new Set(wanted.map((d) => theme[SRC_DIR_TOKENS[d]] ?? d));
|
|
241
|
+
for (const { rel, text } of items) {
|
|
242
|
+
const parts = rel.split('/');
|
|
243
|
+
// `parts.length` mirrors Python's `parts and parts[0]`, where `Path("").parts` really
|
|
244
|
+
// is empty. `String.split` never returns an empty array, so this half can never be
|
|
245
|
+
// false here — kept for the mirror, and its mutation stays green because there is
|
|
246
|
+
// nothing to detect, not because the gate cannot see it.
|
|
247
|
+
if (text !== null && parts.length && dirs.has(parts[0])) {
|
|
248
|
+
const dest = path.join(cfgDir, ...parts);
|
|
249
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
250
|
+
writeText(dest, text);
|
|
251
|
+
owned.push(rel);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenCode emits — a project's `.opencode/` and the global config directory.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `emit.mjs` beside `emit-claude.mjs`, over the shared writers in
|
|
5
|
+
* `emit-common.mjs`. The two are siblings on purpose: an emit that is right for one host and
|
|
6
|
+
* wrong for the other is the defect these have historically carried, and it reads far more
|
|
7
|
+
* plainly as two files with the same shape than as two sections of one.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { VERSION_MARKER } from '../hosts/hosts.mjs';
|
|
11
|
+
import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
|
|
12
|
+
import {
|
|
13
|
+
copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
|
|
14
|
+
writePonytailCommand, writePrimaryAgent, writeTheme,
|
|
15
|
+
} from '../hosts/opencode.mjs';
|
|
16
|
+
import { mergeOpencodeJson } from '../hosts/settings.mjs';
|
|
17
|
+
import { readText, writeText } from '../lib/fs.mjs';
|
|
18
|
+
import { assertSourceComplete, build, phaseLog } from './bundle.mjs';
|
|
19
|
+
import {
|
|
20
|
+
globalMemory, globalNotebook, isFile, relPosix, shipLeanLaws, stripCapabilityLinks,
|
|
21
|
+
} from './emit-common.mjs';
|
|
22
|
+
import { renderAll } from './render.mjs';
|
|
23
|
+
import {
|
|
24
|
+
ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex, ensureProfileStub, ensureRulesStub,
|
|
25
|
+
ensureWikiStub,
|
|
26
|
+
} from './stubs.mjs';
|
|
27
|
+
import { writeVersion } from './version.mjs';
|
|
28
|
+
import { mkdirSync } from 'node:fs';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// emit_opencode — the RENDER stage only
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* RENDER and WIRE for `_build_emit.emit_opencode` — everything through the
|
|
36
|
+
* `mergeOpencodeJson` call at the bottom. PRUNE and MANIFEST are the driver's, in
|
|
37
|
+
* `bin/build-driver.mjs`.
|
|
38
|
+
*
|
|
39
|
+
* `oldOwned` and `manifestExisted` arrive in the job rather than being read here. The
|
|
40
|
+
* manifest is the DRIVER's stage: it reads the file, hands the prior claim in, and prunes
|
|
41
|
+
* and rewrites it after this returns. One owner of the file, and this function is not it.
|
|
42
|
+
*
|
|
43
|
+
* `nativeCatalog` likewise arrives decided. It is `HOSTS['opencode']['native_catalog']` from
|
|
44
|
+
* `js/hosts/hosts.mjs`, resolved by the driver before the call; passing the decision rather
|
|
45
|
+
* than re-reading the registry keeps one owner of it.
|
|
46
|
+
*/
|
|
47
|
+
export function emitOpencodeRender(cfg, job) {
|
|
48
|
+
phaseLog('RENDER');
|
|
49
|
+
const {
|
|
50
|
+
theme: _theme, out, root, footprint, nativeCatalog, oldOwned, manifestExisted, agentPath,
|
|
51
|
+
} = job;
|
|
52
|
+
const oc = path.join(root, '.opencode');
|
|
53
|
+
|
|
54
|
+
build(cfg, _theme, out, { footprint, nativeCatalog });
|
|
55
|
+
|
|
56
|
+
// OpenCode loads agents/skills natively, so strip AGENT.md's per-row spec links to
|
|
57
|
+
// plain names (the portable build keeps them). A deliberate de-link, not a fix.
|
|
58
|
+
const agentMd = path.join(out, 'AGENT.md');
|
|
59
|
+
if (isFile(agentMd)) writeText(agentMd, stripCapabilityLinks(cfg, readText(agentMd)));
|
|
60
|
+
|
|
61
|
+
const owned = [];
|
|
62
|
+
const { theme, items } = renderAll(cfg, _theme);
|
|
63
|
+
|
|
64
|
+
ensureAgentOverridesStub(cfg, out);
|
|
65
|
+
const overrides = loadAgentOverrides(out);
|
|
66
|
+
|
|
67
|
+
const { nAgents, nSkills, written } = writeNativeLayer(
|
|
68
|
+
items, path.join(oc, 'agents'), path.join(oc, 'skills'), overrides,
|
|
69
|
+
{ host: 'opencode', oldOwned, cfg: oc, manifestExisted, theme, src: cfg.src });
|
|
70
|
+
for (const p of written) owned.push(relPosix(oc, p));
|
|
71
|
+
|
|
72
|
+
const primary = writePrimaryAgent(cfg, path.join(oc, 'agents'), overrides);
|
|
73
|
+
if (primary) owned.push(relPosix(oc, primary));
|
|
74
|
+
|
|
75
|
+
const commands = writeCommandLayer(cfg, items, path.join(oc, 'command'));
|
|
76
|
+
commands.push(writePonytailCommand(path.join(oc, 'command'))); // always-on /ponytail
|
|
77
|
+
for (const p of commands) owned.push(relPosix(oc, p));
|
|
78
|
+
|
|
79
|
+
owned.push(relPosix(oc, writeTheme(path.join(oc, 'themes'), _theme, theme)));
|
|
80
|
+
for (const p of writeColorThemes(cfg, path.join(oc, 'themes'))) owned.push(relPosix(oc, p));
|
|
81
|
+
|
|
82
|
+
const nPlugins = copyPlugins(cfg, path.join(oc, 'plugins'), owned);
|
|
83
|
+
const nWorkflows = copyWorkflows(cfg, path.join(oc, 'workflows'), owned);
|
|
84
|
+
|
|
85
|
+
// WIRE — the one file of this emit the user co-owns. `agentPath` arrives decided
|
|
86
|
+
// (`_rel_under` is the Python side by design), and only the BASENAME is consumed
|
|
87
|
+
// downstream: `opencode.json`, or the `.jsonc` sibling when that is what is on disk.
|
|
88
|
+
phaseLog('WIRE');
|
|
89
|
+
const cfgName = path.basename(mergeOpencodeJson(path.join(root, 'opencode.json'), agentPath,
|
|
90
|
+
cfg.doctrines, cfg.excludeRules));
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
owned,
|
|
94
|
+
stats: {
|
|
95
|
+
nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
|
|
96
|
+
},
|
|
97
|
+
cfgName,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// emit_opencode_global — the ninth, and the last emit to cross
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* BOTH halves of `_build_global.emit_opencode_global`, the "everything global, zero
|
|
107
|
+
* per-repo" OpenCode deployment. PRUNE and MANIFEST are the driver's; there is no VERIFY,
|
|
108
|
+
* because this emit writes no settings file.
|
|
109
|
+
*
|
|
110
|
+
* ASSEMBLY, NOT TRANSLATION. Every unit it needs was already here — 58 of the 65 functions
|
|
111
|
+
* in its Python closure, counted with `ast` rather than by name-mapping, which is how the
|
|
112
|
+
* scout that produced this list first reported `_posture_body` and `_mode_body` as missing
|
|
113
|
+
* when both are `registerBody` in `js/build/render.mjs`. What is new is the assembly and the
|
|
114
|
+
* three values that had to be decided on the Python side.
|
|
115
|
+
*
|
|
116
|
+
* `cfgDir` IS THE FIRST OF THEM AND THE REASON THE OTHERS WERE ASKED ABOUT.
|
|
117
|
+
* `_build_core._opencode_config_dir` is an `_OWNED` name — the suite and `harness diff`
|
|
118
|
+
* both redirect it at a sandbox — so resolving it here would render 135 files into the
|
|
119
|
+
* developer's real `~/.config/opencode`. Python's entry point resolves it once, before
|
|
120
|
+
* anything else, and sends the answer. Fourth phase running (`STRUCTURE` at P2d,
|
|
121
|
+
* `capabilityLinkRe` at P2e, `_PREAMBLE_CONFIG_DIR` at P3b): send the decision, never the
|
|
122
|
+
* resolver.
|
|
123
|
+
*
|
|
124
|
+
* `out` IS NOT THE TARGET, and with the default nobody can tell. `emit_opencode_global`
|
|
125
|
+
* writes into `<cfg>` and takes `out` ONLY as the legacy bundle to migrate a memory or
|
|
126
|
+
* notebook store from; the CLI's `--out` defaults to a directory this emit never writes a
|
|
127
|
+
* byte into. A port that derived the legacy source from `<cfg>` would pass every cell that
|
|
128
|
+
* leaves them coincident — the `claude/bundle-in-subfolder` finding at a third call site.
|
|
129
|
+
*
|
|
130
|
+
* `agentPath` travels for the same reason `emitOpencodeRender`'s does: it is WIRE's one
|
|
131
|
+
* input, and `Path.as_posix()` is the Python side's spelling of it.
|
|
132
|
+
*/
|
|
133
|
+
export function emitOpencodeGlobalRender(cfg, job) {
|
|
134
|
+
phaseLog('RENDER');
|
|
135
|
+
const {
|
|
136
|
+
theme: themeName, cfgDir, out, footprint, nativeCatalog, oldOwned, agentPath,
|
|
137
|
+
} = job;
|
|
138
|
+
|
|
139
|
+
// No `lawsPrefix`: the standalone laws dir sits beside AGENT.md in <cfg>, so the lean
|
|
140
|
+
// pointer's relative `laws/universal.md` resolves with no prefix.
|
|
141
|
+
const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
|
|
142
|
+
assertSourceComplete(cfg, 'opencode-global');
|
|
143
|
+
mkdirSync(cfgDir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
const owned = [];
|
|
146
|
+
const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
|
|
147
|
+
if (agentText !== null) {
|
|
148
|
+
// OpenCode loads agents/skills natively, so drop AGENT.md's per-row spec links to
|
|
149
|
+
// plain names. Memory links stay RELATIVE: in the global layout AGENT.md and the
|
|
150
|
+
// store are siblings, so `memory/` resolves from AGENT.md's own location and stays
|
|
151
|
+
// hermetic — no absolute path a doctor check would flag as an escape.
|
|
152
|
+
writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(cfg, agentText));
|
|
153
|
+
owned.push('AGENT.md');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
ensureAgentOverridesStub(cfg, cfgDir);
|
|
157
|
+
const overrides = loadAgentOverrides(cfgDir);
|
|
158
|
+
|
|
159
|
+
// `manifestExisted` is deliberately not passed — the Python does not pass it either, so
|
|
160
|
+
// the pre-manifest header line is unreachable from this emit on both sides.
|
|
161
|
+
const { nAgents, nSkills, written } = writeNativeLayer(
|
|
162
|
+
items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'), overrides,
|
|
163
|
+
{ host: 'opencode', oldOwned, cfg: cfgDir, theme, src: cfg.src });
|
|
164
|
+
for (const p of written) owned.push(relPosix(cfgDir, p));
|
|
165
|
+
|
|
166
|
+
const primary = writePrimaryAgent(cfg, path.join(cfgDir, 'agents'), overrides);
|
|
167
|
+
if (primary) owned.push(relPosix(cfgDir, primary));
|
|
168
|
+
|
|
169
|
+
const commands = writeCommandLayer(cfg, items, path.join(cfgDir, 'command'));
|
|
170
|
+
commands.push(writePonytailCommand(path.join(cfgDir, 'command'))); // always-on /ponytail
|
|
171
|
+
for (const p of commands) owned.push(relPosix(cfgDir, p));
|
|
172
|
+
|
|
173
|
+
owned.push(relPosix(cfgDir, writeTheme(path.join(cfgDir, 'themes'), themeName, theme)));
|
|
174
|
+
for (const p of writeColorThemes(cfg, path.join(cfgDir, 'themes'))) {
|
|
175
|
+
owned.push(relPosix(cfgDir, p));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const nPlugins = copyPlugins(cfg, path.join(cfgDir, 'plugins'), owned);
|
|
179
|
+
const nWorkflows = copyWorkflows(cfg, path.join(cfgDir, 'workflows'), owned);
|
|
180
|
+
|
|
181
|
+
const memStatus = globalMemory(cfgDir, items, out, cfg.src);
|
|
182
|
+
ensureMemoryIndex(path.join(cfgDir, 'memory'));
|
|
183
|
+
const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
|
|
184
|
+
ensureNotebookIndex(path.join(cfgDir, 'notebook'));
|
|
185
|
+
ensureWikiStub(cfgDir);
|
|
186
|
+
ensureRulesStub(cfgDir);
|
|
187
|
+
ensureProfileStub(cfgDir);
|
|
188
|
+
ensureExcludesStub(cfgDir);
|
|
189
|
+
|
|
190
|
+
writeVersion(cfg, cfgDir);
|
|
191
|
+
owned.push(VERSION_MARKER);
|
|
192
|
+
|
|
193
|
+
shipLeanLaws(items, theme, cfgDir, owned, footprint);
|
|
194
|
+
|
|
195
|
+
// WIRE — the one file of this emit the user co-owns, and the last render is now behind
|
|
196
|
+
// us. Inlined the way `emitOpencodeRender`'s is (one call, the same merge, the same file
|
|
197
|
+
// name). Nothing walks the SOURCE for that split now: `tests/unit/emit_phase_order.test.mjs`
|
|
198
|
+
// reads the runtime `GENESEED_PHASE_LOG` trace instead, so the `phaseLog('WIRE')` below is
|
|
199
|
+
// what the gate sees — and deleting it is mutation M30.
|
|
200
|
+
phaseLog('WIRE');
|
|
201
|
+
const cfgName = path.basename(mergeOpencodeJson(path.join(cfgDir, 'opencode.json'),
|
|
202
|
+
agentPath, cfg.doctrines, cfg.excludeRules));
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
owned,
|
|
206
|
+
stats: {
|
|
207
|
+
nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
|
|
208
|
+
},
|
|
209
|
+
memStatus,
|
|
210
|
+
nbStatus,
|
|
211
|
+
cfgName,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The three cheap generator verbs — `build`, `prompt` and `theme`.
|
|
3
3
|
*
|
|
4
4
|
* These are the harness's GENERATOR FACE: the subcommands whose whole job is to drive the
|
|
5
|
-
* thing `bin/
|
|
5
|
+
* thing `bin/build-driver.mjs` already is. That is why they are the cheapest slice left and why
|
|
6
6
|
* they are the last one that can be measured by a closure walk alone — `cmd_build`'s body is
|
|
7
7
|
* three lines because the work is a `subprocess`, and a name walk cannot see through one.
|
|
8
8
|
*
|
|
9
9
|
* WHY `build` IMPORTS THE DRIVER RATHER THAN SPAWNING IT. The Python is
|
|
10
10
|
* `run([sys.executable, BUILD, *extra]).returncode` — a passthrough to a second process.
|
|
11
|
-
* Reproducing that shape here would mean spawning `node bin/
|
|
11
|
+
* Reproducing that shape here would mean spawning `node bin/build-driver.mjs`, and
|
|
12
12
|
* `test_the_cli_reaches_no_child_process_module` forbids it: the ban is transitive over this
|
|
13
13
|
* entry's imports, and P5c's argument for keeping it is that `web`, `upgrade` and `setup`
|
|
14
14
|
* will genuinely need to spawn, so the ban should outlive them rather than be dismantled by
|
|
15
15
|
* a verb that does not. It is also the wrong shape on its own terms. Python needs a second
|
|
16
|
-
* process because `build.py` is a different PROGRAM; `bin/
|
|
16
|
+
* process because `build.py` is a different PROGRAM; `bin/build-driver.mjs` is a module in this
|
|
17
17
|
* one, and a Node CLI spawning a Node driver to do work it could do in-process is exactly
|
|
18
18
|
* the passthrough the whole port has been removing. So the driver's `main` is exported and
|
|
19
19
|
* called directly, and the observable contract — the tree, the streams, the exit code — is
|
|
@@ -32,19 +32,18 @@
|
|
|
32
32
|
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
|
|
35
|
-
import { main as driverMain } from '
|
|
36
|
-
import { makeCfg, PACK_ORDER } from './
|
|
37
|
-
import { opencodeConfigDir,
|
|
35
|
+
import { main as driverMain } from '../../bin/build-driver.mjs';
|
|
36
|
+
import { makeCfg, PACK_ORDER } from './source.mjs';
|
|
37
|
+
import { opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
|
|
38
38
|
import {
|
|
39
39
|
EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
|
|
40
40
|
excludedRulesOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
|
|
41
41
|
readMaybe, themeOfDir,
|
|
42
|
-
} from '
|
|
43
|
-
import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '
|
|
42
|
+
} from '../hosts/installs.mjs';
|
|
43
|
+
import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
|
|
44
44
|
import { renderAll } from './render.mjs';
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
} from './lib/fs.mjs';
|
|
45
|
+
import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
|
|
46
|
+
import { jsonDumpsIndent, parseJson, formatRepr } from '../lib/json.mjs';
|
|
48
47
|
|
|
49
48
|
/** `_harness_build._HEX_RE`. Anchored at BOTH ends — `#123` and `#1122334` both fail. */
|
|
50
49
|
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
@@ -52,18 +51,18 @@ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
|
52
51
|
/**
|
|
53
52
|
* `raise SystemExit(msg)` — the message on stderr, exit 1.
|
|
54
53
|
*
|
|
55
|
-
* The convention `js/
|
|
54
|
+
* The convention `js/build/bundle.mjs`'s `assertSourceComplete` set and `bin/geneseed-cli.mjs`
|
|
56
55
|
* honours: write at the raise site, mark the throw as a DELIBERATE refusal with `exitCode`,
|
|
57
56
|
* and let anything unmarked keep its stack. Python prints a traceback for an unhandled
|
|
58
57
|
* exception, so dressing a crash as a one-liner would hide a bug.
|
|
59
58
|
*/
|
|
60
59
|
function sysExit(msg) {
|
|
61
|
-
// `
|
|
60
|
+
// `printErr`, NOT a local newline translation. The first draft inlined the `\n` ->
|
|
62
61
|
// `\r\n` rule here and every cell passed, because `harness_golden` reads stderr through
|
|
63
62
|
// `subprocess`'s universal-newline decode and cannot see the difference — the same
|
|
64
63
|
// transport hole P5b measured. A second copy of a translation is exactly what P5d's
|
|
65
|
-
// `pyStrPath`/`
|
|
66
|
-
|
|
64
|
+
// `pyStrPath`/`toPlatformPath` split was, and it is ungated in precisely the same way.
|
|
65
|
+
printErr(`${msg}\n`);
|
|
67
66
|
const e = new Error(msg);
|
|
68
67
|
e.exitCode = 1;
|
|
69
68
|
throw e;
|
|
@@ -188,7 +187,7 @@ export const DEFAULT_EMIT = new Map([
|
|
|
188
187
|
export function cmdRebuildAll() {
|
|
189
188
|
const targets = installTargets().filter(([h, s, r]) => installState(r, h, s) === 'active');
|
|
190
189
|
if (!targets.length) {
|
|
191
|
-
|
|
190
|
+
printOut('[rebuild-all] no active installs detected.\n');
|
|
192
191
|
return 0;
|
|
193
192
|
}
|
|
194
193
|
const failures = [];
|
|
@@ -218,20 +217,20 @@ export function cmdRebuildAll() {
|
|
|
218
217
|
const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
|
|
219
218
|
PACK_ORDER, excludeRules);
|
|
220
219
|
const label = `${host}:${scope} (${root})`;
|
|
221
|
-
|
|
220
|
+
printOut(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
|
|
222
221
|
+ `posture=${posture} mode=${mode}\n`);
|
|
223
222
|
const rc = driverMain(argv);
|
|
224
223
|
if (rc !== 0) {
|
|
225
224
|
failures.push(label);
|
|
226
|
-
|
|
225
|
+
printErr(`[rebuild-all] FAILED ${label} (exit ${rc})\n`);
|
|
227
226
|
}
|
|
228
227
|
}
|
|
229
228
|
if (failures.length) {
|
|
230
|
-
|
|
229
|
+
printErr(`[rebuild-all] ${failures.length}/${targets.length} install(s) failed: `
|
|
231
230
|
+ `${failures.join(', ')}\n`);
|
|
232
231
|
return 1;
|
|
233
232
|
}
|
|
234
|
-
|
|
233
|
+
printOut(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
|
|
235
234
|
return 0;
|
|
236
235
|
}
|
|
237
236
|
|
|
@@ -311,9 +310,9 @@ export function cmdPrompt(args) {
|
|
|
311
310
|
if (dest) mkdirSync(dest, { recursive: true });
|
|
312
311
|
writeText(args.out, text);
|
|
313
312
|
// The RAW argument, not the resolved path — `f"[prompt] wrote {args.out}"`.
|
|
314
|
-
|
|
313
|
+
printOut(`[prompt] wrote ${args.out} (${themeName})\n`);
|
|
315
314
|
} else {
|
|
316
|
-
|
|
315
|
+
printOut(text);
|
|
317
316
|
}
|
|
318
317
|
return 0;
|
|
319
318
|
}
|
|
@@ -324,8 +323,8 @@ export function cmdPrompt(args) {
|
|
|
324
323
|
|
|
325
324
|
/** `_harness_build._resolve_themes_dir` — explicit `--dir`, else a repo `.opencode/`, else global. */
|
|
326
325
|
export function resolveThemesDir(args) {
|
|
327
|
-
// `Path(args.dir).expanduser().resolve()` — `
|
|
328
|
-
if (args.dir) return
|
|
326
|
+
// `Path(args.dir).expanduser().resolve()` — `resolvePath` already expands a leading `~`.
|
|
327
|
+
if (args.dir) return resolvePath(args.dir);
|
|
329
328
|
const repo = path.join(process.cwd(), '.opencode');
|
|
330
329
|
if (!args.globalDir && isDir(repo)) return path.join(repo, 'themes');
|
|
331
330
|
return path.join(opencodeConfigDir(), 'themes');
|
|
@@ -383,7 +382,7 @@ export function loadUserPalette(args, cfg) {
|
|
|
383
382
|
}
|
|
384
383
|
// `f"{r}={pal[r]!r}"` — Python's repr, so a string is single-quoted and an int is bare.
|
|
385
384
|
const bad = [...pal].filter(([, v]) => !(typeof v === 'string' && HEX_RE.test(v)))
|
|
386
|
-
.map(([r, v]) => `${r}=${
|
|
385
|
+
.map(([r, v]) => `${r}=${formatRepr(v)}`);
|
|
387
386
|
if (bad.length) sysExit(`[theme] non-#rrggbb value(s): ${bad.join(', ')}`);
|
|
388
387
|
return pal;
|
|
389
388
|
}
|
|
@@ -421,8 +420,8 @@ export function cmdTheme(args) {
|
|
|
421
420
|
writeText(dest, `${jsonDumpsIndent(colorThemeJson(Object.fromEntries(pal), transparent))}\n`);
|
|
422
421
|
written.push(dest);
|
|
423
422
|
}
|
|
424
|
-
|
|
425
|
-
|
|
423
|
+
printOut(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
|
|
424
|
+
printOut(`[theme] select in OpenCode with: /theme ${full}`
|
|
426
425
|
+ (args.solidOnly ? '' : ` (or /theme ${full}-transparent)`) + '\n');
|
|
427
426
|
return 0;
|
|
428
427
|
}
|