@ucsandman/legcli 0.8.0 → 0.9.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/CHANGELOG.md +45 -0
- package/NOTICE +8 -0
- package/README.md +601 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +21 -12
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +71 -0
- package/docs/README.md +2 -0
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +21 -0
- package/docs/board-guide.md +13 -0
- package/docs/cli-contracts.md +22 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +22 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +1 -2
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +75 -19
- package/src/auth.mjs +2 -2
- package/src/board/board.js +3 -3
- package/src/board/sessions.js +77 -3
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- package/src/hook.mjs +49 -49
- package/src/land.mjs +7 -35
- package/src/launcher.mjs +38 -26
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +5 -5
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +37 -3
- package/src/redact.mjs +1 -1
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +38 -10
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +6 -3
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +170 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +2 -2
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +5 -4
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- package/fixtures/live/grok/supervisor.log +0 -2
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/harness/common.cjs — helpers shared by every source and target adapter.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here knows a client. Paths, guarded writes, directory links, managed
|
|
5
|
+
* regions inside user-owned files, frontmatter, TOML string escaping, and the
|
|
6
|
+
* secret detector that keeps token values out of generated config.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const { spawnSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Embedding configuration
|
|
19
|
+
//
|
|
20
|
+
// The engine runs as this repo's own CLI and as a library inside another
|
|
21
|
+
// product. Everything a host may need to own is here, and the defaults are
|
|
22
|
+
// exactly what the repo did before this object existed:
|
|
23
|
+
// brand.mark the ownership claim written into every generated file (and
|
|
24
|
+
// the string makeWriter looks for to recognise a file as ours)
|
|
25
|
+
// brand.region the name inside managed-region markers
|
|
26
|
+
// brand.id a safe identifier for a key a host owns inside a user file
|
|
27
|
+
// secretPatterns regexes replacing core/safety/guards.json (null = read it)
|
|
28
|
+
// shimPath the hook shim targets point at (null = engine/hooks/shim.cjs)
|
|
29
|
+
// `configure()` merges; `GENERATED_MARK` below is a live getter over it.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
const config = {
|
|
32
|
+
brand: { id: 'agnostic-ai', mark: 'GENERATED by agnostic-ai', region: 'agnostic-ai' },
|
|
33
|
+
secretPatterns: null,
|
|
34
|
+
shimPath: null,
|
|
35
|
+
// Directories besides the user's home that a rules `@import` may resolve into
|
|
36
|
+
// (null = this repo). A host that ships no importable rules passes [].
|
|
37
|
+
importRoots: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let patternCache = null;
|
|
41
|
+
|
|
42
|
+
function configure(opts = {}) {
|
|
43
|
+
if (opts.brand) config.brand = Object.assign({}, config.brand, opts.brand);
|
|
44
|
+
if (opts.secretPatterns !== undefined) { config.secretPatterns = opts.secretPatterns; patternCache = null; }
|
|
45
|
+
if (opts.shimPath !== undefined) config.shimPath = opts.shimPath;
|
|
46
|
+
if (opts.importRoots !== undefined) config.importRoots = opts.importRoots;
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function importRoots() {
|
|
51
|
+
return Array.isArray(config.importRoots) ? config.importRoots : [ROOT];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function expandPath(p, home = os.homedir()) {
|
|
55
|
+
if (!p) return '';
|
|
56
|
+
let resolved = String(p).replace(/%([^%]+)%/g, (_, n) => process.env[n] || '');
|
|
57
|
+
if (resolved === '~') return home;
|
|
58
|
+
if (resolved.startsWith('~/') || resolved.startsWith('~\\')) return path.join(home, resolved.slice(2));
|
|
59
|
+
if (!path.isAbsolute(resolved)) return path.join(ROOT, resolved);
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Path comparison folds case where the filesystem does (Windows, macOS) and
|
|
64
|
+
// keeps it where two spellings are two files (Linux).
|
|
65
|
+
const foldCase = (s) => (process.platform === 'linux' ? s : s.toLowerCase());
|
|
66
|
+
|
|
67
|
+
/** Render an absolute path back to `~/...` for display and for generated headers. */
|
|
68
|
+
function tildePath(p, home = os.homedir()) {
|
|
69
|
+
const norm = (s) => foldCase(path.resolve(s).replace(/\\/g, '/'));
|
|
70
|
+
const abs = path.resolve(p).replace(/\\/g, '/');
|
|
71
|
+
return norm(p).startsWith(norm(home) + '/') ? '~' + abs.slice(path.resolve(home).length) : abs;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const readText = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; } };
|
|
75
|
+
const readJSON = (p) => { const raw = readText(p); if (raw == null) return null; try { return JSON.parse(raw); } catch (_) { return undefined; } };
|
|
76
|
+
const sha = (text) => crypto.createHash('sha256').update(String(text), 'utf8').digest('hex');
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Secret detection (patterns from core/safety/guards.json, fail closed)
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
const FALLBACK_SENSITIVE = [
|
|
82
|
+
'(?i)sk-[0-9a-zA-Z]{20,}', '(?i)ghp_[0-9a-zA-Z]{36}', '(?i)xox[baprs]-[0-9a-zA-Z-]{10,}',
|
|
83
|
+
'(?i)AIza[0-9A-Za-z-_]{35}', '(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----', '(?i)bearer\\s+[a-zA-Z0-9_\\-\\.]{20,}',
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const toRegExp = (p) => (p instanceof RegExp ? p : new RegExp(String(p).replace(/^\(\?i\)/, ''), String(p).startsWith('(?i)') ? 'i' : ''));
|
|
87
|
+
|
|
88
|
+
// Compiled once per configuration: looksSecret() runs on every config value a
|
|
89
|
+
// capture sees, and re-reading guards.json for each one showed up in a host's
|
|
90
|
+
// hand-off timing.
|
|
91
|
+
function sensitivePatterns() {
|
|
92
|
+
if (patternCache) return patternCache;
|
|
93
|
+
if (Array.isArray(config.secretPatterns) && config.secretPatterns.length) {
|
|
94
|
+
patternCache = config.secretPatterns.map(toRegExp);
|
|
95
|
+
return patternCache;
|
|
96
|
+
}
|
|
97
|
+
const guards = readJSON(path.join(ROOT, 'core', 'safety', 'guards.json'));
|
|
98
|
+
const list = guards && guards.guards && guards.guards.secretScan && guards.guards.secretScan.sensitivePatterns;
|
|
99
|
+
patternCache = (list && list.length ? list : FALLBACK_SENSITIVE).map(toRegExp);
|
|
100
|
+
return patternCache;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* True when a config VALUE looks like a credential: matches a known token
|
|
105
|
+
* shape, or its key names a secret and the value is long and high-entropy.
|
|
106
|
+
*/
|
|
107
|
+
const SECRET_KEY = /(key|token|secret|password|passwd|pwd|credential|auth|dsn)/i;
|
|
108
|
+
const charClasses = (v) => [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(v)).length;
|
|
109
|
+
// A long, space-free, non-path value that is all hex or mixes three classes
|
|
110
|
+
// is a credential shape whatever its key is called.
|
|
111
|
+
function highEntropy(v) {
|
|
112
|
+
if (v.length < 32 || /\s/.test(v) || /[\\/]/.test(v)) return false;
|
|
113
|
+
if (/^[0-9a-f]+$/i.test(v)) return true;
|
|
114
|
+
return charClasses(v) >= 3;
|
|
115
|
+
}
|
|
116
|
+
// A URL carries a credential in its userinfo password or in a query value
|
|
117
|
+
// that is itself secret-shaped.
|
|
118
|
+
function urlSecret(v, keyed) {
|
|
119
|
+
let u;
|
|
120
|
+
try { u = new URL(v); } catch (_) { return false; }
|
|
121
|
+
if (u.password) return true;
|
|
122
|
+
for (const [k, val] of u.searchParams) if (SECRET_KEY.test(k) && val.length >= 16) return true;
|
|
123
|
+
// under a secret-named key, a long mixed path segment is the credential (a
|
|
124
|
+
// webhook URL, a signed link); under any other key a URL is an address
|
|
125
|
+
if (keyed) for (const seg of u.pathname.split('/')) if (seg.length >= 16 && /\d/.test(seg) && /[a-z]/i.test(seg)) return true;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function looksSecret(key, value) {
|
|
130
|
+
if (typeof value !== 'string' || !value) return false;
|
|
131
|
+
if (/^\$\{[A-Za-z0-9_]+\}$/.test(value) || /^\$[A-Za-z0-9_]+$/.test(value)) return false; // already a reference
|
|
132
|
+
if (sensitivePatterns().some((re) => re.test(value))) return true;
|
|
133
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return urlSecret(value, SECRET_KEY.test(String(key || '')));
|
|
134
|
+
if (highEntropy(value)) return true;
|
|
135
|
+
if (!SECRET_KEY.test(String(key || ''))) return false;
|
|
136
|
+
if (value.length < 16) return false;
|
|
137
|
+
if (/^(true|false|\d+)$/i.test(value)) return false;
|
|
138
|
+
if (/\s/.test(value)) return false;
|
|
139
|
+
// under a secret-named key, one class is enough once the value is long
|
|
140
|
+
return charClasses(value) >= 2 || value.length >= 32;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** An MCP server URL that carries a credential in its userinfo, its query or a long token-like path segment. */
|
|
144
|
+
const urlCarriesCredential = (v) => typeof v === 'string' && urlSecret(v, true);
|
|
145
|
+
|
|
146
|
+
/** Every span of `text` that matches a known token shape, for scanning free text. */
|
|
147
|
+
function findSecrets(text) {
|
|
148
|
+
const out = [];
|
|
149
|
+
const s = String(text == null ? '' : text);
|
|
150
|
+
for (const re of sensitivePatterns()) {
|
|
151
|
+
const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
|
|
152
|
+
let m;
|
|
153
|
+
while ((m = g.exec(s)) !== null) { out.push(m[0]); if (m[0].length === 0) g.lastIndex++; }
|
|
154
|
+
}
|
|
155
|
+
// two patterns matching one span (a host's list beside this one) is one secret
|
|
156
|
+
return [...new Set(out)];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The same text with every token shape replaced; used before a capture is saved. */
|
|
160
|
+
function redactSecrets(text) {
|
|
161
|
+
let s = String(text == null ? '' : text);
|
|
162
|
+
for (const re of sensitivePatterns()) s = s.replace(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'), '[REDACTED]');
|
|
163
|
+
return s;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Guarded writes (same semantics as engine/sync/sync.cjs::writeGuarded)
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
function backupFile(backupsDir, tag, targetPath) {
|
|
170
|
+
fs.mkdirSync(backupsDir, { recursive: true });
|
|
171
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
172
|
+
const dest = path.join(backupsDir, `${tag}-${path.basename(targetPath)}-${stamp}.bak`);
|
|
173
|
+
fs.copyFileSync(targetPath, dest);
|
|
174
|
+
return dest;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build the `write(path, content, opts)` function handed to adapters.
|
|
179
|
+
* state.files[path] = sha of what we last wrote
|
|
180
|
+
* state.drift[path] = sha of a hand edit we already backed up (backup once)
|
|
181
|
+
* A file whose first line still carries GENERATED_MARK (or the given `header`)
|
|
182
|
+
* is ours to overwrite whatever touched it: the header IS the ownership claim.
|
|
183
|
+
*/
|
|
184
|
+
function makeWriter({ state, backupsDir, tag, check, force, log }) {
|
|
185
|
+
state.files = state.files || {};
|
|
186
|
+
state.drift = state.drift || {};
|
|
187
|
+
return function write(targetPath, content, opts = {}) {
|
|
188
|
+
const exists = fs.existsSync(targetPath);
|
|
189
|
+
const existing = exists ? fs.readFileSync(targetPath, 'utf8') : null;
|
|
190
|
+
if (existing === content) {
|
|
191
|
+
state.files[targetPath] = sha(content);
|
|
192
|
+
return { action: 'unchanged', backup: null };
|
|
193
|
+
}
|
|
194
|
+
const recorded = Object.prototype.hasOwnProperty.call(state.files, targetPath);
|
|
195
|
+
// The ownership claim is the GENERATED mark inside the preamble (a title
|
|
196
|
+
// line usually comes first). A title alone is not a claim: a user who keeps
|
|
197
|
+
// the title and deletes the mark has taken the file over.
|
|
198
|
+
const headLines = existing === null ? [] : existing.split(/\r?\n/, 6).slice(0, 5);
|
|
199
|
+
const stillOurs = Boolean(existing !== null && headLines.some((l) => l.includes(config.brand.mark)));
|
|
200
|
+
const managedRegion = Boolean(opts.region); // caller only replaces a marked region; the rest is the user's
|
|
201
|
+
const drifted = Boolean(exists && recorded && state.files[targetPath] !== sha(existing) && !stillOurs && !managedRegion);
|
|
202
|
+
if (check) return { action: drifted ? 'skipped-hand-edited' : 'would-write', backup: null };
|
|
203
|
+
let backup = null;
|
|
204
|
+
if (drifted) {
|
|
205
|
+
if (state.drift[targetPath] !== sha(existing)) {
|
|
206
|
+
backup = backupFile(backupsDir, tag, targetPath);
|
|
207
|
+
state.drift[targetPath] = sha(existing);
|
|
208
|
+
}
|
|
209
|
+
if (!force) {
|
|
210
|
+
if (log) log(` ! skipped (hand-edited): ${targetPath}${backup ? ` (backup ${backup})` : ''}`);
|
|
211
|
+
return { action: 'skipped-hand-edited', backup };
|
|
212
|
+
}
|
|
213
|
+
} else if (exists) {
|
|
214
|
+
backup = backupFile(backupsDir, tag, targetPath);
|
|
215
|
+
}
|
|
216
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
217
|
+
fs.writeFileSync(targetPath, content, 'utf8');
|
|
218
|
+
state.files[targetPath] = sha(content);
|
|
219
|
+
delete state.drift[targetPath];
|
|
220
|
+
return { action: 'written', backup };
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Managed regions inside files the user also owns
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
const regionMarkers = (component, comment = '#') => ({
|
|
228
|
+
start: `${comment} >>> ${config.brand.region} ${component} start (generated, do not edit)`,
|
|
229
|
+
end: `${comment} <<< ${config.brand.region} ${component} end`,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
233
|
+
|
|
234
|
+
/** Replace (or append) the marked region; everything outside is preserved byte for byte. */
|
|
235
|
+
function replaceRegion(text, markers, body) {
|
|
236
|
+
const re = new RegExp(`\\n*${escapeRe(markers.start)}[\\s\\S]*?${escapeRe(markers.end)}\\n?`, 'g');
|
|
237
|
+
const region = body.trim() ? `${markers.start}\n${body.trimEnd()}\n${markers.end}\n` : '';
|
|
238
|
+
const base = (text || '').replace(re, '\n');
|
|
239
|
+
if (!region) return base.replace(/\n{3,}/g, '\n\n');
|
|
240
|
+
return base.replace(/\n{3,}/g, '\n\n').replace(/\s*$/, base.trim() ? '\n\n' : '') + region;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readRegion(text, markers) {
|
|
244
|
+
const re = new RegExp(`${escapeRe(markers.start)}\\n([\\s\\S]*?)${escapeRe(markers.end)}`);
|
|
245
|
+
const m = (text || '').match(re);
|
|
246
|
+
return m ? m[1] : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Directory links (junction on Windows, symlink elsewhere). Never replaces a real dir.
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
const plainPath = (p) => foldCase(path.resolve(String(p).replace(/^\\\\\?\\/, '')).replace(/\\/g, '/'));
|
|
253
|
+
|
|
254
|
+
function readLinkTarget(p) {
|
|
255
|
+
try { return fs.readlinkSync(p); } catch (_) { return null; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function link(src, dest, { check } = {}) {
|
|
259
|
+
const current = readLinkTarget(dest);
|
|
260
|
+
if (current && plainPath(current) === plainPath(src)) return { action: 'unchanged' };
|
|
261
|
+
// A real directory is never replaced, in check mode or otherwise; report the
|
|
262
|
+
// same verdict both ways so --check after a clean apply stays clean.
|
|
263
|
+
if (!current && fs.existsSync(dest)) return { action: 'skipped-real-directory' };
|
|
264
|
+
if (check) return { action: 'would-link' };
|
|
265
|
+
if (current) fs.rmSync(dest, { recursive: true, force: true });
|
|
266
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
267
|
+
try {
|
|
268
|
+
fs.symlinkSync(src, dest, 'junction');
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (process.platform !== 'win32') throw err;
|
|
271
|
+
// cmd.exe re-parses its command line whatever spawnSync was given, so a
|
|
272
|
+
// path carrying one of its metacharacters is refused rather than quoted
|
|
273
|
+
// and hoped for; the adapter reports the skill as not linked.
|
|
274
|
+
if (/[&|^<>()%!"]/.test(dest) || /[&|^<>()%!"]/.test(src)) throw new Error(`refusing mklink fallback: a path carries a cmd metacharacter (${err.message})`);
|
|
275
|
+
const r = spawnSync('cmd', ['/c', 'mklink', '/J', dest, src], { stdio: 'pipe', windowsHide: true, encoding: 'utf8' });
|
|
276
|
+
if (r.status !== 0) throw new Error(`mklink /J failed: ${(r.stderr || r.stdout || '').trim() || err.message}`);
|
|
277
|
+
}
|
|
278
|
+
return { action: 'linked' };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function unlinkIfLink(dest) {
|
|
282
|
+
if (readLinkTarget(dest) === null) return false;
|
|
283
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Markdown frontmatter + TOML strings
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
function parseFrontmatter(md) {
|
|
291
|
+
const m = String(md).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
292
|
+
if (!m) return { meta: {}, body: String(md) };
|
|
293
|
+
const meta = {};
|
|
294
|
+
let key = null;
|
|
295
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
296
|
+
const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
297
|
+
if (kv) { key = kv[1]; meta[key] = kv[2].trim().replace(/^["'](.*)["']$/, '$1'); }
|
|
298
|
+
else if (key && /^\s+\S/.test(line)) meta[key] = `${meta[key]} ${line.trim()}`.trim(); // folded continuation
|
|
299
|
+
}
|
|
300
|
+
return { meta, body: m[2] };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Quote a frontmatter value when YAML would otherwise read it as something
|
|
305
|
+
* else: `[a b]` is a flow sequence, `{}` a map, `yes`/`no`/`true`/`1.5` typed
|
|
306
|
+
* scalars, a leading `*`, `&`, `!`, `%`, `@`, `` ` ``, `|`, `>`, `'`, `"`, `#`
|
|
307
|
+
* or a `: ` / ` #` inside changes the parse. Bit 2026-09-06: an argument-hint
|
|
308
|
+
* of `[text to translate]` was emitted bare and became a one-item list.
|
|
309
|
+
*/
|
|
310
|
+
function yamlScalar(v) {
|
|
311
|
+
const s = String(v);
|
|
312
|
+
// Typed scalars (true, 12, null) are left bare on purpose: `readonly: true`
|
|
313
|
+
// and `alwaysApply: true` must stay booleans for the client that reads them.
|
|
314
|
+
const risky = /^[[\]{}*&!%@`|>'"#,?-]|: | #|^\s|\s$/;
|
|
315
|
+
return risky.test(s) ? JSON.stringify(s) : s;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderFrontmatter(meta, body) {
|
|
319
|
+
const lines = ['---'];
|
|
320
|
+
for (const [k, v] of Object.entries(meta)) if (v !== undefined && v !== null && v !== '') lines.push(`${k}: ${yamlScalar(v)}`);
|
|
321
|
+
lines.push('---');
|
|
322
|
+
return `${lines.join('\n')}\n\n${String(body).trim()}\n`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const tomlStr = (s) => JSON.stringify(String(s)); // TOML basic strings share JSON escaping
|
|
326
|
+
function tomlMultiline(s) {
|
|
327
|
+
return '"""\n' + String(s).replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"') + '\n"""';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Remove whole `## <title>` sections from a markdown document. Used by every
|
|
332
|
+
* target that drops source-client-only sections (core/port.json
|
|
333
|
+
* rules.dropSectionsForTargets); engine/sync/sync.cjs shares it.
|
|
334
|
+
*/
|
|
335
|
+
function stripSections(markdown, sectionTitles) {
|
|
336
|
+
let result = String(markdown);
|
|
337
|
+
for (const title of sectionTitles || []) {
|
|
338
|
+
// `$(?![\s\S])` is end-of-input; a bare `$` under /m would stop at the first newline.
|
|
339
|
+
const regex = new RegExp(`(^##\\s+${escapeRe(String(title))}\\b[\\s\\S]*?)(?=^##\\s|$(?![\\s\\S]))`, 'm');
|
|
340
|
+
result = result.replace(regex, '');
|
|
341
|
+
}
|
|
342
|
+
return result.replace(/\n{3,}/g, '\n\n').trim();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The hook shim every non-Codex target points its ported hooks at. */
|
|
346
|
+
function shimPath() {
|
|
347
|
+
return config.shimPath || path.join(ROOT, 'engine', 'hooks', 'shim.cjs');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** List the skill directories (entries holding a SKILL.md) under a dir. Follows links. */
|
|
351
|
+
function listSkills(dir) {
|
|
352
|
+
const out = [];
|
|
353
|
+
let entries;
|
|
354
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return out; }
|
|
355
|
+
for (const e of entries) {
|
|
356
|
+
if (e.name.startsWith('.')) continue;
|
|
357
|
+
const full = path.join(dir, e.name);
|
|
358
|
+
let isDir = e.isDirectory();
|
|
359
|
+
if (e.isSymbolicLink()) { try { isDir = fs.statSync(full).isDirectory(); } catch (_) { isDir = false; } }
|
|
360
|
+
if (isDir && fs.existsSync(path.join(full, 'SKILL.md'))) out.push({ name: e.name, path: full, link: readLinkTarget(full) });
|
|
361
|
+
}
|
|
362
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = {
|
|
366
|
+
ROOT, config, configure, shimPath, importRoots, expandPath, tildePath, readText, readJSON, sha,
|
|
367
|
+
looksSecret, sensitivePatterns, findSecrets, redactSecrets, urlCarriesCredential,
|
|
368
|
+
makeWriter, backupFile,
|
|
369
|
+
regionMarkers, replaceRegion, readRegion, escapeRe, stripSections,
|
|
370
|
+
link, unlinkIfLink, readLinkTarget, plainPath, listSkills,
|
|
371
|
+
parseFrontmatter, renderFrontmatter, yamlScalar, tomlStr, tomlMultiline,
|
|
372
|
+
};
|
|
373
|
+
// Live: every adapter reads `common.GENERATED_MARK` at call time, so a host that
|
|
374
|
+
// calls configure() before its first capture/apply brands every generated file.
|
|
375
|
+
Object.defineProperty(module.exports, 'GENERATED_MARK', { enumerable: true, get: () => config.brand.mark });
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/harness/index.cjs — the port engine as a library.
|
|
3
|
+
*
|
|
4
|
+
* This is the one file a host product requires. Everything a host may need to
|
|
5
|
+
* own is an option, never a repo path:
|
|
6
|
+
*
|
|
7
|
+
* const engine = require('agnostic-ai/engine/harness/index.cjs');
|
|
8
|
+
* engine.configure({ brand: { id, mark, region }, secretPatterns, shimPath });
|
|
9
|
+
* const registry = engine.loadRegistry(home, { targets: myTargets });
|
|
10
|
+
* const { bundle, warnings } = engine.capture({ from: 'claude', home, outDir, registry, port });
|
|
11
|
+
* const report = engine.apply({ bundle, home, registry, port, storageDir, to: ['codex'] });
|
|
12
|
+
* engine.status({ bundle, home, registry, port, storageDir });
|
|
13
|
+
*
|
|
14
|
+
* Nothing here reads `core/port.json`, `core/templates/targets.json` or
|
|
15
|
+
* `core/safety/guards.json` when the host passes `port`, `registry` and
|
|
16
|
+
* `secretPatterns`. The engine's own CLI (cli.cjs) is a thin caller of the
|
|
17
|
+
* same functions with the repo's files as defaults.
|
|
18
|
+
*
|
|
19
|
+
* Node's ESM loader imports this file directly (`import engine from
|
|
20
|
+
* '.../index.cjs'`), which is how a zero-dependency ESM host embeds it
|
|
21
|
+
* without a build step.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const common = require('./common.cjs');
|
|
25
|
+
const bundle = require('./bundle.cjs');
|
|
26
|
+
const capture = require('./capture.cjs');
|
|
27
|
+
const apply = require('./apply.cjs');
|
|
28
|
+
const status = require('./status.cjs');
|
|
29
|
+
const toml = require('./toml.cjs');
|
|
30
|
+
|
|
31
|
+
module.exports = {
|
|
32
|
+
// configuration
|
|
33
|
+
configure: common.configure,
|
|
34
|
+
config: common.config,
|
|
35
|
+
// the pipeline
|
|
36
|
+
capture: capture.capture,
|
|
37
|
+
apply: apply.apply,
|
|
38
|
+
status: status.status,
|
|
39
|
+
// registry and policy
|
|
40
|
+
loadRegistry: capture.loadRegistry,
|
|
41
|
+
loadPort: capture.loadPort,
|
|
42
|
+
detectSource: capture.detectSource,
|
|
43
|
+
selectTargets: apply.selectTargets,
|
|
44
|
+
// reporting helpers
|
|
45
|
+
formatTable: apply.formatTable,
|
|
46
|
+
formatDropped: status.formatDropped,
|
|
47
|
+
renderHtml: status.renderHtml,
|
|
48
|
+
readState: apply.readState,
|
|
49
|
+
// the modules themselves, for a host that needs the details
|
|
50
|
+
common,
|
|
51
|
+
bundle,
|
|
52
|
+
toml,
|
|
53
|
+
COMPONENTS: bundle.COMPONENTS,
|
|
54
|
+
GLYPH: apply.GLYPH,
|
|
55
|
+
};
|