@rover-studio/answer-me 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/answerme-toolkit.mjs +6 -0
  2. package/distribution/npm/migrations.json +605 -0
  3. package/distribution/npm/package-manifest.json +192 -0
  4. package/distribution/npm/skills/answerme/SKILL.md +94 -0
  5. package/distribution/npm/skills/answerme/agents/openai.yaml +4 -0
  6. package/distribution/npm/skills/answerme/references/api.md +135 -0
  7. package/distribution/npm/skills/answerme/references/creator-credential-deployment.md +19 -0
  8. package/distribution/npm/skills/answerme/references/creator-credential-recovery.md +24 -0
  9. package/distribution/npm/skills/answerme/references/errors.md +44 -0
  10. package/distribution/npm/skills/answerme/references/handoff.md +46 -0
  11. package/distribution/npm/skills/answerme/references/install-self-test.md +28 -0
  12. package/distribution/npm/skills/answerme/references/result-token-store.md +50 -0
  13. package/distribution/npm/skills/answerme/references/templates.md +139 -0
  14. package/distribution/npm/skills/answerme/scripts/answerme-api-base-url.ps1 +40 -0
  15. package/distribution/npm/skills/answerme/scripts/create-answerme.ps1 +1892 -0
  16. package/distribution/npm/skills/answerme/scripts/creator-credential-store.windows.ps1 +503 -0
  17. package/distribution/npm/skills/answerme/scripts/deploy-answerme-creator-credential.ps1 +447 -0
  18. package/distribution/npm/skills/answerme/scripts/enroll-answerme-creator.ps1 +764 -0
  19. package/distribution/npm/skills/answerme/scripts/open-answerme-page.windows.ps1 +272 -0
  20. package/distribution/npm/skills/answerme/scripts/remove-answerme-result-token.ps1 +63 -0
  21. package/distribution/npm/skills/answerme/scripts/result-token-store.windows.ps1 +261 -0
  22. package/distribution/npm/skills/answerme/scripts/test-answerme-installation.ps1 +498 -0
  23. package/distribution/npm/skills/answerme/scripts/wait-answerme-result.ps1 +908 -0
  24. package/distribution/npm/skills/answerme/scripts/windows-crypto.ps1 +57 -0
  25. package/distribution/npm/skills/answerme/scripts/windows-http.ps1 +45 -0
  26. package/distribution/npm/skills/answerme/scripts/windows-process-start-info.ps1 +76 -0
  27. package/distribution/npm/skills/ask-when-needed/SKILL.md +164 -0
  28. package/distribution/npm/skills/ask-when-needed/agents/openai.yaml +4 -0
  29. package/distribution/npm/skills/ask-when-needed/references/interview-strategies.md +43 -0
  30. package/lib/npm-cli/commands.mjs +247 -0
  31. package/lib/npm-cli/constants.mjs +51 -0
  32. package/lib/npm-cli/errors.mjs +15 -0
  33. package/lib/npm-cli/filesystem.mjs +193 -0
  34. package/lib/npm-cli/host-discovery.mjs +404 -0
  35. package/lib/npm-cli/main.mjs +42 -0
  36. package/lib/npm-cli/package-integrity.mjs +212 -0
  37. package/lib/npm-cli/transaction.mjs +375 -0
  38. package/lib/npm-cli/usage-validation.mjs +349 -0
  39. package/package.json +17 -0
@@ -0,0 +1,51 @@
1
+ export const PACKAGE_NAME = '@rover-studio/answer-me';
2
+ export const PACKAGE_VERSION = '0.1.0-rc.1';
3
+ export const MANAGED_SKILLS = Object.freeze(['ask-when-needed', 'answerme']);
4
+ export const INSTALL_LEDGER = '.answerme-toolkit-install.json';
5
+ export const TRANSACTION_DIRECTORY = '.answerme-toolkit-transaction';
6
+ export const JOURNAL_FILE = 'journal.json';
7
+ export const MACHINE_SCHEMA_VERSION = 1;
8
+ export const JOURNAL_SCHEMA_VERSION = 1;
9
+ export const DEFAULT_TIMEOUT_MS = 8_000;
10
+ export const MAX_BUFFER = 1024 * 1024;
11
+ export const USAGE_VALIDATION_TIMEOUT_SECONDS = 300;
12
+ export const OFFICIAL_API_ROOT = 'https://answerme.rover.studio';
13
+
14
+ export const PUBLIC_CODES = Object.freeze(new Set([
15
+ 'ok',
16
+ 'no-change',
17
+ 'removed',
18
+ 'invalid-command',
19
+ 'unsupported-platform',
20
+ 'node-version-unsupported',
21
+ 'codex-executable-not-found',
22
+ 'codex-executable-untrusted',
23
+ 'codex-version-unverified',
24
+ 'codex-protocol-timeout',
25
+ 'codex-protocol-invalid',
26
+ 'codex-root-untrusted',
27
+ 'codex-discovery-invalid',
28
+ 'codex-discovery-conflict',
29
+ 'package-integrity-invalid',
30
+ 'unsafe-filesystem-entry',
31
+ 'unknown-existing-files',
32
+ 'untrusted-legacy-installation',
33
+ 'not-installed',
34
+ 'target-drift',
35
+ 'transaction-pending',
36
+ 'transaction-failed',
37
+ 'rollback-completed',
38
+ 'recovery-required',
39
+ 'usage-validation-passed',
40
+ 'usage-validation-issue',
41
+ 'usage-validation-timeout',
42
+ 'usage-validation-output-invalid',
43
+ 'usage-validation-runner-unavailable',
44
+ 'cleanup-warning',
45
+ 'doctor-healthy',
46
+ 'doctor-issue',
47
+ ]));
48
+
49
+ export function publicCode(value, fallback = 'transaction-failed') {
50
+ return typeof value === 'string' && PUBLIC_CODES.has(value) ? value : fallback;
51
+ }
@@ -0,0 +1,15 @@
1
+ import { publicCode } from './constants.mjs';
2
+
3
+ export class InstallerError extends Error {
4
+ constructor(code, options = {}) {
5
+ super(publicCode(code));
6
+ this.name = 'InstallerError';
7
+ this.code = publicCode(code);
8
+ this.mutated = options.mutated === true;
9
+ this.recoveryRequired = options.recoveryRequired === true;
10
+ }
11
+ }
12
+
13
+ export function errorCode(error, fallback = 'transaction-failed') {
14
+ return publicCode(error?.code ?? error?.message, fallback);
15
+ }
@@ -0,0 +1,193 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ copyFile,
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ readdir,
9
+ realpath,
10
+ rename,
11
+ rm,
12
+ } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import { InstallerError } from './errors.mjs';
15
+
16
+ export function normalizePath(value) {
17
+ let comparable = value;
18
+ if (process.platform === 'win32') {
19
+ comparable = comparable.replaceAll('/', '\\');
20
+ if (comparable.toLowerCase().startsWith('\\\\?\\unc\\')) comparable = `\\\\${comparable.slice(8)}`;
21
+ else if (/^\\\\\?\\[A-Za-z]:\\/.test(comparable)) comparable = comparable.slice(4);
22
+ }
23
+ const resolved = path.resolve(comparable).replaceAll('\\', '/').replace(/\/$/, '');
24
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
25
+ }
26
+
27
+ export function isAbsolutePath(value) {
28
+ return typeof value === 'string'
29
+ && value.length > 0
30
+ && value.length <= 32_768
31
+ && !value.includes('\0')
32
+ && path.isAbsolute(value);
33
+ }
34
+
35
+ export async function pathExists(value) {
36
+ try {
37
+ await lstat(value);
38
+ return true;
39
+ } catch (error) {
40
+ if (error?.code === 'ENOENT') return false;
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ async function samePlainFilesystemObject(candidate, canonical, inspect = lstat) {
46
+ if (!isAbsolutePath(canonical)) return false;
47
+ try {
48
+ // BigInt avoids rounding distinct Windows file IDs to the same Number.
49
+ const [original, resolved] = await Promise.all([
50
+ inspect(candidate, { bigint: true }),
51
+ inspect(canonical, { bigint: true }),
52
+ ]);
53
+ if (original.ino === 0n || resolved.ino === 0n
54
+ || original.dev !== resolved.dev || original.ino !== resolved.ino
55
+ || original.isSymbolicLink() || resolved.isSymbolicLink()
56
+ || original.isDirectory() !== resolved.isDirectory()
57
+ || original.isFile() !== resolved.isFile()) return false;
58
+ const inspected = new Set();
59
+ for (const spelling of [candidate, canonical]) {
60
+ for (let ancestor = path.dirname(spelling); ; ancestor = path.dirname(ancestor)) {
61
+ if (!inspected.has(ancestor)) {
62
+ const info = await inspect(ancestor);
63
+ if (!info.isDirectory() || info.isSymbolicLink()) return false;
64
+ inspected.add(ancestor);
65
+ }
66
+ if (path.dirname(ancestor) === ancestor) break;
67
+ }
68
+ }
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ export async function assertPlainExisting(candidate, expected = null, dependencies = {}) {
76
+ const resolveCanonical = dependencies.realpath ?? realpath;
77
+ if (!isAbsolutePath(candidate)) throw new InstallerError('unsafe-filesystem-entry');
78
+ const info = await lstat(candidate).catch(() => null);
79
+ if (!info || info.isSymbolicLink()) throw new InstallerError('unsafe-filesystem-entry');
80
+ if (expected === 'file' && !info.isFile()) throw new InstallerError('unsafe-filesystem-entry');
81
+ if (expected === 'directory' && !info.isDirectory()) throw new InstallerError('unsafe-filesystem-entry');
82
+ const canonical = await resolveCanonical(candidate);
83
+ if (normalizePath(canonical) !== normalizePath(candidate)
84
+ && !(await samePlainFilesystemObject(candidate, canonical, dependencies.lstat ?? lstat))) {
85
+ throw new InstallerError('unsafe-filesystem-entry');
86
+ }
87
+ return info;
88
+ }
89
+
90
+ export async function assertPlainAncestor(candidate) {
91
+ if (!isAbsolutePath(candidate)) throw new InstallerError('unsafe-filesystem-entry');
92
+ let current = path.resolve(candidate);
93
+ while (!(await pathExists(current))) {
94
+ const parent = path.dirname(current);
95
+ if (parent === current) throw new InstallerError('unsafe-filesystem-entry');
96
+ current = parent;
97
+ }
98
+ await assertPlainExisting(current, 'directory');
99
+ return current;
100
+ }
101
+
102
+ export function sha256(bytes) {
103
+ return createHash('sha256').update(bytes).digest('hex');
104
+ }
105
+
106
+ async function walkPlain(root, current, entries) {
107
+ const children = await readdir(current, { withFileTypes: true });
108
+ children.sort((a, b) => a.name.localeCompare(b.name, 'en'));
109
+ for (const child of children) {
110
+ const absolute = path.join(current, child.name);
111
+ const info = await lstat(absolute);
112
+ if (info.isSymbolicLink()) throw new InstallerError('unsafe-filesystem-entry');
113
+ const relative = path.relative(root, absolute).replaceAll('\\', '/');
114
+ if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) {
115
+ throw new InstallerError('unsafe-filesystem-entry');
116
+ }
117
+ if (info.isDirectory()) {
118
+ entries.push({ path: relative, type: 'directory' });
119
+ await walkPlain(root, absolute, entries);
120
+ } else if (info.isFile()) {
121
+ const bytes = await readFile(absolute);
122
+ entries.push({ path: relative, type: 'file', size: bytes.length, sha256: sha256(bytes) });
123
+ } else {
124
+ throw new InstallerError('unsafe-filesystem-entry');
125
+ }
126
+ }
127
+ }
128
+
129
+ export async function snapshotPath(candidate, kind = 'directory') {
130
+ if (!(await pathExists(candidate))) return { exists: false, kind, entries: [] };
131
+ await assertPlainExisting(candidate, kind);
132
+ if (kind === 'file') {
133
+ const bytes = await readFile(candidate);
134
+ return { exists: true, kind, entries: [{ path: '.', type: 'file', size: bytes.length, sha256: sha256(bytes) }] };
135
+ }
136
+ const entries = [];
137
+ await walkPlain(candidate, candidate, entries);
138
+ entries.sort((a, b) => a.path.localeCompare(b.path, 'en'));
139
+ return { exists: true, kind, entries };
140
+ }
141
+
142
+ export function snapshotsEqual(left, right) {
143
+ return JSON.stringify(left) === JSON.stringify(right);
144
+ }
145
+
146
+ export async function copyPlainTree(source, destination) {
147
+ await assertPlainExisting(source, 'directory');
148
+ if (await pathExists(destination)) throw new InstallerError('unsafe-filesystem-entry');
149
+ await mkdir(destination, { recursive: false });
150
+ const entries = await readdir(source, { withFileTypes: true });
151
+ entries.sort((a, b) => a.name.localeCompare(b.name, 'en'));
152
+ for (const entry of entries) {
153
+ const from = path.join(source, entry.name);
154
+ const to = path.join(destination, entry.name);
155
+ const info = await lstat(from);
156
+ if (info.isSymbolicLink()) throw new InstallerError('unsafe-filesystem-entry');
157
+ if (info.isDirectory()) await copyPlainTree(from, to);
158
+ else if (info.isFile()) await copyFile(from, to);
159
+ else throw new InstallerError('unsafe-filesystem-entry');
160
+ }
161
+ }
162
+
163
+ export async function atomicWriteJson(destination, value) {
164
+ const parent = path.dirname(destination);
165
+ await assertPlainExisting(parent, 'directory');
166
+ const temporary = path.join(parent, `.${path.basename(destination)}.${process.pid}.tmp`);
167
+ if (await pathExists(temporary)) throw new InstallerError('unsafe-filesystem-entry');
168
+ const handle = await open(temporary, 'wx');
169
+ try {
170
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
171
+ await handle.sync();
172
+ } finally {
173
+ await handle.close();
174
+ }
175
+ await rename(temporary, destination);
176
+ }
177
+
178
+ export async function removeVerified(candidate, expectedSnapshot) {
179
+ const observed = await snapshotPath(candidate, expectedSnapshot.kind);
180
+ if (!snapshotsEqual(observed, expectedSnapshot)) throw new InstallerError('target-drift');
181
+ if (observed.exists) await rm(candidate, { recursive: expectedSnapshot.kind === 'directory', force: false });
182
+ }
183
+
184
+ export async function readJsonFile(candidate, maxBytes = 1024 * 1024) {
185
+ await assertPlainExisting(candidate, 'file');
186
+ const bytes = await readFile(candidate);
187
+ if (bytes.length === 0 || bytes.length > maxBytes) throw new InstallerError('unsafe-filesystem-entry');
188
+ try {
189
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
190
+ } catch {
191
+ throw new InstallerError('unsafe-filesystem-entry');
192
+ }
193
+ }
@@ -0,0 +1,404 @@
1
+ import { execFile as nodeExecFile, spawn as nodeSpawn } from 'node:child_process';
2
+ import { lstat, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import {
6
+ DEFAULT_TIMEOUT_MS,
7
+ MANAGED_SKILLS,
8
+ MAX_BUFFER,
9
+ PACKAGE_VERSION,
10
+ } from './constants.mjs';
11
+ import { InstallerError } from './errors.mjs';
12
+ import {
13
+ assertPlainExisting,
14
+ isAbsolutePath,
15
+ normalizePath,
16
+ pathExists,
17
+ } from './filesystem.mjs';
18
+
19
+ const execFile = promisify(nodeExecFile);
20
+ const INITIALIZE_ID = 'answerme-initialize-1';
21
+ const SKILLS_LIST_ID = 'answerme-skills-list-2';
22
+ const APP_SERVER_ARGS = Object.freeze(['app-server', '--listen', 'stdio://']);
23
+ const VERSION_PATTERN = /^codex-cli (\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)[\r\n]*$/;
24
+ const INITIALIZE_FIELDS = new Set(['codexHome', 'platformFamily', 'platformOs', 'userAgent']);
25
+ const ENTRY_FIELDS = new Set(['cwd', 'errors', 'skills']);
26
+ const SKILL_FIELDS = new Set([
27
+ 'description', 'enabled', 'interface', 'name', 'path', 'pluginId', 'scope', 'shortDescription', 'dependencies',
28
+ ]);
29
+ const SECURITY_FIELD = /(home|root|path|director|permission|sandbox|approval|credential|secret|token|skill)/i;
30
+
31
+ function isRecord(value) {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+
35
+ function executionContext(value = {}) {
36
+ const timeoutMs = value.timeoutMs ?? DEFAULT_TIMEOUT_MS;
37
+ const maxBuffer = value.maxBuffer ?? MAX_BUFFER;
38
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 30_000) return null;
39
+ if (!Number.isSafeInteger(maxBuffer) || maxBuffer < 4096 || maxBuffer > MAX_BUFFER) return null;
40
+ return { timeoutMs, maxBuffer };
41
+ }
42
+
43
+ function unknownSecurityField(value, known) {
44
+ return Object.keys(value).some((key) => !known.has(key) && SECURITY_FIELD.test(key));
45
+ }
46
+
47
+ async function invokeExecFile(executor, executable, args, options) {
48
+ if (typeof executor !== 'function') return execFile(executable, args, options);
49
+ return executor(executable, args, options);
50
+ }
51
+
52
+ function stdoutOf(result) {
53
+ if (typeof result === 'string') return result;
54
+ if (Buffer.isBuffer(result)) return result.toString('utf8');
55
+ if (isRecord(result)) {
56
+ return Buffer.isBuffer(result.stdout) ? result.stdout.toString('utf8') : result.stdout;
57
+ }
58
+ return '';
59
+ }
60
+
61
+ export async function validateCodexExecutable(candidate, dependencies = {}) {
62
+ if (!isAbsolutePath(candidate) || path.basename(candidate).toLowerCase() !== 'codex.exe') {
63
+ throw new InstallerError('codex-executable-untrusted');
64
+ }
65
+ if (typeof dependencies.inspectExecutable === 'function') {
66
+ if (await dependencies.inspectExecutable(candidate) !== true) throw new InstallerError('codex-executable-untrusted');
67
+ return path.resolve(candidate);
68
+ }
69
+ const info = await lstat(candidate).catch(() => null);
70
+ if (!info?.isFile() || info.isSymbolicLink()) throw new InstallerError('codex-executable-untrusted');
71
+ const canonical = await realpath(candidate);
72
+ if (normalizePath(canonical) !== normalizePath(candidate)) throw new InstallerError('codex-executable-untrusted');
73
+ return canonical;
74
+ }
75
+
76
+ export async function findCodexExecutable({ env = process.env } = {}, dependencies = {}) {
77
+ const injected = dependencies.codexExecutable ?? env.ANSWERME_CODEX_EXECUTABLE;
78
+ if (injected) return validateCodexExecutable(injected, dependencies);
79
+ const pathEntries = [];
80
+ for (const rawEntry of String(env.PATH ?? '').split(path.delimiter)) {
81
+ const entry = rawEntry.trim().replace(/^"|"$/g, '');
82
+ if (entry && isAbsolutePath(entry)) pathEntries.push(entry);
83
+ }
84
+ async function validatedCandidates(candidates) {
85
+ const valid = [];
86
+ for (const candidate of [...new Set(candidates.map((value) => path.resolve(value)))]) {
87
+ if (!(await pathExists(candidate))) continue;
88
+ try {
89
+ valid.push(await validateCodexExecutable(candidate, dependencies));
90
+ } catch {
91
+ throw new InstallerError('codex-executable-untrusted');
92
+ }
93
+ }
94
+ return [...new Map(valid.map((value) => [normalizePath(value), value])).values()];
95
+ }
96
+
97
+ const direct = await validatedCandidates(pathEntries.map((entry) => path.join(entry, 'codex.exe')));
98
+ if (direct.length === 1) return direct[0];
99
+ if (direct.length > 1) throw new InstallerError('codex-executable-untrusted');
100
+
101
+ const target = path.join('vendor', 'x86_64-pc-windows-msvc', 'codex', 'codex.exe');
102
+ const npmCandidates = [];
103
+ for (const entry of pathEntries) {
104
+ const hasShim = await pathExists(path.join(entry, 'codex.cmd')) || await pathExists(path.join(entry, 'codex'));
105
+ if (!hasShim) continue;
106
+ npmCandidates.push(
107
+ path.join(entry, 'node_modules', '@openai', 'codex', 'node_modules', '@openai', 'codex-win32-x64', target),
108
+ path.join(entry, 'node_modules', '@openai', 'codex-win32-x64', target),
109
+ path.join(entry, 'node_modules', '@openai', 'codex', target),
110
+ );
111
+ }
112
+ const npmNative = await validatedCandidates(npmCandidates);
113
+ if (npmNative.length === 1) return npmNative[0];
114
+ if (npmNative.length > 1) {
115
+ const versions = new Map();
116
+ for (const candidate of npmNative) {
117
+ try {
118
+ const version = await probeCodexVersion(candidate, {}, dependencies);
119
+ if (version === 'unknown') throw new InstallerError('codex-executable-untrusted');
120
+ if (!versions.has(version)) versions.set(version, candidate);
121
+ } catch {
122
+ throw new InstallerError('codex-executable-untrusted');
123
+ }
124
+ }
125
+ if (versions.size === 1) return versions.values().next().value;
126
+ throw new InstallerError('codex-executable-untrusted');
127
+ }
128
+ throw new InstallerError('codex-executable-not-found');
129
+ }
130
+
131
+ export async function probeCodexVersion(executable, context = {}, dependencies = {}) {
132
+ const bounded = executionContext(context);
133
+ if (!bounded) return 'unknown';
134
+ try {
135
+ const result = await invokeExecFile(dependencies.execFile, executable, ['--version'], {
136
+ shell: false,
137
+ windowsHide: true,
138
+ encoding: 'utf8',
139
+ timeout: bounded.timeoutMs,
140
+ maxBuffer: bounded.maxBuffer,
141
+ });
142
+ const match = stdoutOf(result)?.match(VERSION_PATTERN);
143
+ return match?.[1] ?? 'unknown';
144
+ } catch {
145
+ return 'unknown';
146
+ }
147
+ }
148
+
149
+ function createLineReader(stream, child, context, stderrState) {
150
+ let pending = Buffer.alloc(0);
151
+ let total = 0;
152
+ let terminalError = null;
153
+ const queue = [];
154
+ const waiters = [];
155
+
156
+ function fail(code) {
157
+ if (terminalError) return;
158
+ terminalError = new InstallerError(code);
159
+ while (waiters.length > 0) waiters.shift().reject(terminalError);
160
+ try { child.kill(); } catch { /* best effort */ }
161
+ }
162
+
163
+ function deliver() {
164
+ while (queue.length > 0 && waiters.length > 0) waiters.shift().resolve(queue.shift());
165
+ }
166
+
167
+ stream.on('data', (chunk) => {
168
+ const bytes = Buffer.from(chunk);
169
+ total += bytes.length;
170
+ if (total > context.maxBuffer) return fail('codex-protocol-invalid');
171
+ pending = Buffer.concat([pending, bytes]);
172
+ let newline;
173
+ while ((newline = pending.indexOf(0x0a)) >= 0) {
174
+ let line = pending.subarray(0, newline);
175
+ pending = pending.subarray(newline + 1);
176
+ if (line.at(-1) === 0x0d) line = line.subarray(0, -1);
177
+ if (line.length === 0 || line.length > context.maxBuffer) return fail('codex-protocol-invalid');
178
+ try {
179
+ queue.push(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(line)));
180
+ } catch {
181
+ return fail('codex-protocol-invalid');
182
+ }
183
+ }
184
+ deliver();
185
+ });
186
+ stream.on('error', () => fail('codex-protocol-invalid'));
187
+ child.once('error', () => fail('codex-protocol-invalid'));
188
+ child.once('exit', () => fail('codex-protocol-invalid'));
189
+
190
+ return async function readLine(timeoutMs = context.timeoutMs) {
191
+ if (stderrState.overflow) throw new InstallerError('codex-protocol-invalid');
192
+ if (queue.length > 0) return queue.shift();
193
+ if (terminalError) throw terminalError;
194
+ return new Promise((resolve, reject) => {
195
+ const waiter = { resolve, reject };
196
+ waiters.push(waiter);
197
+ const timer = setTimeout(() => {
198
+ const index = waiters.indexOf(waiter);
199
+ if (index >= 0) waiters.splice(index, 1);
200
+ reject(new InstallerError('codex-protocol-timeout'));
201
+ try { child.kill(); } catch { /* best effort */ }
202
+ }, timeoutMs);
203
+ timer.unref?.();
204
+ waiter.resolve = (value) => { clearTimeout(timer); resolve(value); };
205
+ waiter.reject = (error) => { clearTimeout(timer); reject(error); };
206
+ });
207
+ };
208
+ }
209
+
210
+ function allowedNotification(message) {
211
+ if (!isRecord(message) || typeof message.method !== 'string' || !isRecord(message.params)) return false;
212
+ if (message.method === 'remoteControl/status/changed') {
213
+ return typeof message.params.status === 'string';
214
+ }
215
+ if (message.method === 'configWarning') return typeof message.params.summary === 'string';
216
+ return false;
217
+ }
218
+
219
+ async function readResponse(readLine, expectedId, timeoutMs, seen) {
220
+ const deadline = Date.now() + timeoutMs;
221
+ for (let index = 0; index < 8; index += 1) {
222
+ const remaining = deadline - Date.now();
223
+ if (remaining <= 0) throw new InstallerError('codex-protocol-timeout');
224
+ const message = await readLine(remaining);
225
+ if (Object.hasOwn(isRecord(message) ? message : {}, 'id')) {
226
+ if (message.id !== expectedId || seen.has(message.id) || Object.hasOwn(message, 'error') || !isRecord(message.result)) {
227
+ throw new InstallerError('codex-protocol-invalid');
228
+ }
229
+ seen.add(message.id);
230
+ return message.result;
231
+ }
232
+ if (!allowedNotification(message)) throw new InstallerError('codex-protocol-invalid');
233
+ }
234
+ throw new InstallerError('codex-protocol-invalid');
235
+ }
236
+
237
+ async function writeMessage(child, message) {
238
+ const line = `${JSON.stringify(message)}\n`;
239
+ if (!child.stdin.write(line, 'utf8')) await new Promise((resolve) => child.stdin.once('drain', resolve));
240
+ }
241
+
242
+ function validateInitialize(result) {
243
+ if (!isRecord(result)
244
+ || unknownSecurityField(result, INITIALIZE_FIELDS)
245
+ || !isAbsolutePath(result.codexHome)
246
+ || typeof result.platformFamily !== 'string'
247
+ || typeof result.platformOs !== 'string'
248
+ || typeof result.userAgent !== 'string'
249
+ || result.userAgent.length === 0
250
+ || result.userAgent.length > 2048) {
251
+ throw new InstallerError('codex-protocol-invalid');
252
+ }
253
+ return result.codexHome;
254
+ }
255
+
256
+ function validateError(value) {
257
+ return isRecord(value) && typeof value.message === 'string' && typeof value.path === 'string';
258
+ }
259
+
260
+ function validateSkill(value) {
261
+ if (!isRecord(value)
262
+ || unknownSecurityField(value, SKILL_FIELDS)
263
+ || typeof value.description !== 'string'
264
+ || typeof value.enabled !== 'boolean'
265
+ || typeof value.name !== 'string'
266
+ || value.name.length === 0
267
+ || !isAbsolutePath(value.path)
268
+ || path.basename(value.path).toLowerCase() !== 'skill.md'
269
+ || typeof value.scope !== 'string'
270
+ || value.scope.length === 0) {
271
+ throw new InstallerError('codex-discovery-invalid');
272
+ }
273
+ return {
274
+ name: value.name,
275
+ path: path.resolve(value.path),
276
+ enabled: value.enabled,
277
+ scope: value.scope,
278
+ };
279
+ }
280
+
281
+ function validateSkillsList(result, cwd) {
282
+ if (!isRecord(result) || !Array.isArray(result.data) || result.data.length !== 1) {
283
+ throw new InstallerError('codex-discovery-invalid');
284
+ }
285
+ const entry = result.data[0];
286
+ if (!isRecord(entry)
287
+ || unknownSecurityField(entry, ENTRY_FIELDS)
288
+ || typeof entry.cwd !== 'string'
289
+ || normalizePath(entry.cwd) !== normalizePath(cwd)
290
+ || !Array.isArray(entry.errors)
291
+ || !entry.errors.every(validateError)
292
+ || entry.errors.length > 0
293
+ || !Array.isArray(entry.skills)) {
294
+ throw new InstallerError('codex-discovery-invalid');
295
+ }
296
+ return entry.skills.map(validateSkill);
297
+ }
298
+
299
+ export async function invokeCodexAppServer({ executable, hostVersion, cwd, context = {} }, dependencies = {}) {
300
+ const bounded = executionContext(context);
301
+ if (!bounded || !isAbsolutePath(cwd)) throw new InstallerError('codex-protocol-invalid');
302
+ const spawn = dependencies.spawn ?? nodeSpawn;
303
+ let child;
304
+ try {
305
+ child = spawn(executable, [...APP_SERVER_ARGS], {
306
+ cwd,
307
+ shell: false,
308
+ windowsHide: true,
309
+ stdio: ['pipe', 'pipe', 'pipe'],
310
+ });
311
+ if (!child?.stdin || !child?.stdout || !child?.stderr) throw new InstallerError('codex-protocol-invalid');
312
+ const stderrState = { bytes: 0, overflow: false };
313
+ child.stderr.on('data', (chunk) => {
314
+ stderrState.bytes += Buffer.byteLength(chunk);
315
+ if (stderrState.bytes > bounded.maxBuffer) {
316
+ stderrState.overflow = true;
317
+ try { child.kill(); } catch { /* best effort */ }
318
+ }
319
+ });
320
+ const readLine = createLineReader(child.stdout, child, bounded, stderrState);
321
+ const seen = new Set();
322
+ await writeMessage(child, {
323
+ id: INITIALIZE_ID,
324
+ method: 'initialize',
325
+ params: {
326
+ clientInfo: { name: 'answerme-toolkit', version: PACKAGE_VERSION },
327
+ capabilities: {
328
+ experimentalApi: true,
329
+ optOutNotificationMethods: ['configWarning', 'remoteControl/status/changed'],
330
+ },
331
+ },
332
+ });
333
+ const codexHome = validateInitialize(
334
+ await readResponse(readLine, INITIALIZE_ID, bounded.timeoutMs, seen),
335
+ hostVersion,
336
+ );
337
+ await writeMessage(child, { method: 'initialized' });
338
+ await writeMessage(child, {
339
+ id: SKILLS_LIST_ID,
340
+ method: 'skills/list',
341
+ params: { cwds: [cwd], forceReload: true },
342
+ });
343
+ const skills = validateSkillsList(
344
+ await readResponse(readLine, SKILLS_LIST_ID, bounded.timeoutMs, seen),
345
+ cwd,
346
+ );
347
+ if (stderrState.overflow) throw new InstallerError('codex-protocol-invalid');
348
+ return { codexHome: path.resolve(codexHome), skills };
349
+ } catch (error) {
350
+ if (error instanceof InstallerError) throw error;
351
+ throw new InstallerError('codex-protocol-invalid');
352
+ } finally {
353
+ try { child?.stdin?.end(); } catch { /* best effort */ }
354
+ try { if (child && child.exitCode === null) child.kill(); } catch { /* best effort */ }
355
+ }
356
+ }
357
+
358
+ export async function discoverCodexHost({ cwd, env = process.env, context = {} } = {}, dependencies = {}) {
359
+ if (!isAbsolutePath(cwd)) throw new InstallerError('codex-protocol-invalid');
360
+ const executable = await findCodexExecutable({ env }, dependencies);
361
+ const hostVersion = await probeCodexVersion(executable, context, dependencies);
362
+ const observed = await invokeCodexAppServer({ executable, hostVersion, cwd, context }, dependencies);
363
+ await assertPlainExisting(observed.codexHome, 'directory');
364
+ const skillsRoot = path.join(observed.codexHome, 'skills');
365
+ if (await pathExists(skillsRoot)) await assertPlainExisting(skillsRoot, 'directory');
366
+ return {
367
+ executable,
368
+ hostVersion,
369
+ codexHome: observed.codexHome,
370
+ skillsRoot,
371
+ skills: observed.skills,
372
+ };
373
+ }
374
+
375
+ export function evaluateManagedSkillDiscovery(observation, { requireInstalled = false, requireAbsent = false }) {
376
+ const grouped = new Map(MANAGED_SKILLS.map((name) => [name, []]));
377
+ for (const skill of observation.skills) {
378
+ if (grouped.has(skill.name)) grouped.get(skill.name).push(skill);
379
+ }
380
+ if ([...grouped.values()].some((items) => items.length > 1)) {
381
+ throw new InstallerError('codex-discovery-conflict');
382
+ }
383
+ for (const [name, items] of grouped) {
384
+ if (items.length === 0) {
385
+ if (requireInstalled) throw new InstallerError('codex-discovery-invalid');
386
+ continue;
387
+ }
388
+ if (requireAbsent) throw new InstallerError('codex-discovery-invalid');
389
+ const expected = path.join(observation.skillsRoot, name, 'SKILL.md');
390
+ if (normalizePath(items[0].path) !== normalizePath(expected)) {
391
+ throw new InstallerError('codex-discovery-conflict');
392
+ }
393
+ if (requireInstalled && items[0].enabled !== true) throw new InstallerError('codex-discovery-invalid');
394
+ }
395
+ return true;
396
+ }
397
+
398
+ export const __test = Object.freeze({
399
+ executionContext,
400
+ unknownSecurityField,
401
+ validateInitialize,
402
+ validateSkillsList,
403
+ readResponse,
404
+ });