@superdoc-dev/sdk 1.0.0-alpha.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.
@@ -0,0 +1,190 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { CONTRACT } from '../generated/contract';
3
+ import { SuperDocCliError } from './errors';
4
+ import { buildOperationArgv, resolveInvocation, type InvokeOptions, type OperationSpec } from './transport-common';
5
+
6
+ type CliEnvelopeSuccess = {
7
+ ok: true;
8
+ command: string;
9
+ data: Record<string, unknown>;
10
+ meta?: {
11
+ version?: string;
12
+ };
13
+ };
14
+
15
+ type CliEnvelopeError = {
16
+ ok: false;
17
+ error: {
18
+ code: string;
19
+ message: string;
20
+ details?: unknown;
21
+ };
22
+ meta?: {
23
+ version?: string;
24
+ };
25
+ };
26
+
27
+ type CliEnvelope = CliEnvelopeSuccess | CliEnvelopeError;
28
+
29
+ function parseEnvelope(stdout: string, stderr: string): CliEnvelope {
30
+ const output = stdout || stderr;
31
+ if (!output.trim()) {
32
+ throw new SuperDocCliError('CLI returned no JSON envelope.', {
33
+ code: 'COMMAND_FAILED',
34
+ details: { stdout, stderr },
35
+ });
36
+ }
37
+
38
+ const attempts: string[] = [output.trim()];
39
+ const lines = output.split(/\r?\n/);
40
+ for (let index = 0; index < lines.length; index += 1) {
41
+ if (!lines[index]?.trim().startsWith('{')) continue;
42
+ attempts.push(lines.slice(index).join('\n').trim());
43
+ }
44
+
45
+ for (const candidate of attempts) {
46
+ if (!candidate) continue;
47
+ try {
48
+ const parsed = JSON.parse(candidate) as CliEnvelope;
49
+ if (typeof parsed === 'object' && parsed != null && 'ok' in parsed) {
50
+ return parsed;
51
+ }
52
+ } catch {
53
+ // try next candidate
54
+ }
55
+ }
56
+
57
+ try {
58
+ return JSON.parse(output.trim()) as CliEnvelope;
59
+ } catch (error) {
60
+ throw new SuperDocCliError('CLI returned invalid JSON envelope.', {
61
+ code: 'JSON_PARSE_ERROR',
62
+ details: {
63
+ stdout,
64
+ stderr,
65
+ message: error instanceof Error ? error.message : String(error),
66
+ },
67
+ });
68
+ }
69
+ }
70
+
71
+ function parseSemver(version: string): [number, number, number] | null {
72
+ const core = version.split('-', 1)[0];
73
+ const [majorText, minorText, patchText] = core.split('.');
74
+ if (!majorText || !minorText || !patchText) return null;
75
+
76
+ const major = Number(majorText);
77
+ const minor = Number(minorText);
78
+ const patch = Number(patchText);
79
+ if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch)) {
80
+ return null;
81
+ }
82
+ return [major, minor, patch];
83
+ }
84
+
85
+ function isVersionLessThan(actual: string, minimum: string): boolean {
86
+ const actualParsed = parseSemver(actual);
87
+ const minimumParsed = parseSemver(minimum);
88
+ if (!actualParsed || !minimumParsed) return false;
89
+
90
+ if (actualParsed[0] !== minimumParsed[0]) return actualParsed[0] < minimumParsed[0];
91
+ if (actualParsed[1] !== minimumParsed[1]) return actualParsed[1] < minimumParsed[1];
92
+ return actualParsed[2] < minimumParsed[2];
93
+ }
94
+
95
+ function assertCompatibleCliVersion(envelope: CliEnvelope): void {
96
+ const actualVersion = envelope.meta?.version;
97
+ const minimumVersion = CONTRACT.cli.minVersion;
98
+ if (!actualVersion || !minimumVersion) return;
99
+ if (actualVersion === '0.0.0') return;
100
+
101
+ if (isVersionLessThan(actualVersion, minimumVersion)) {
102
+ throw new SuperDocCliError(
103
+ `CLI version ${actualVersion} is older than minimum required ${minimumVersion}.`,
104
+ {
105
+ code: 'CLI_VERSION_UNSUPPORTED',
106
+ details: {
107
+ cliVersion: actualVersion,
108
+ minVersion: minimumVersion,
109
+ },
110
+ },
111
+ );
112
+ }
113
+ }
114
+
115
+ export class SpawnTransport {
116
+ private readonly cliBin: string;
117
+ private readonly env?: Record<string, string | undefined>;
118
+
119
+ constructor(options: { cliBin: string; env?: Record<string, string | undefined> }) {
120
+ this.cliBin = options.cliBin;
121
+ this.env = options.env;
122
+ }
123
+
124
+ async invoke<TData extends Record<string, unknown>>(
125
+ operation: OperationSpec,
126
+ params: Record<string, unknown> = {},
127
+ options: InvokeOptions = {},
128
+ ): Promise<TData> {
129
+ const { command, prefixArgs } = resolveInvocation(this.cliBin);
130
+ const commandArgs = buildOperationArgv(operation, params, options, undefined, true);
131
+ const args: string[] = [...prefixArgs, ...commandArgs];
132
+
133
+ const spawned = spawn(command, args, {
134
+ env: {
135
+ ...process.env,
136
+ ...(this.env ?? {}),
137
+ },
138
+ stdio: ['pipe', 'pipe', 'pipe'],
139
+ });
140
+
141
+ let stdout = '';
142
+ let stderr = '';
143
+
144
+ const stdoutPromise = new Promise<void>((resolve) => {
145
+ spawned.stdout.on('data', (chunk) => {
146
+ stdout += String(chunk);
147
+ });
148
+ spawned.stdout.on('end', () => resolve());
149
+ });
150
+
151
+ const stderrPromise = new Promise<void>((resolve) => {
152
+ spawned.stderr.on('data', (chunk) => {
153
+ stderr += String(chunk);
154
+ });
155
+ spawned.stderr.on('end', () => resolve());
156
+ });
157
+
158
+ if (options.stdinBytes) {
159
+ spawned.stdin.write(options.stdinBytes);
160
+ }
161
+ spawned.stdin.end();
162
+
163
+ const exitCode = await new Promise<number>((resolve, reject) => {
164
+ spawned.on('error', reject);
165
+ spawned.on('close', (code) => resolve(code ?? 1));
166
+ });
167
+
168
+ await Promise.all([stdoutPromise, stderrPromise]);
169
+
170
+ const envelope = parseEnvelope(stdout, stderr);
171
+ assertCompatibleCliVersion(envelope);
172
+ if (envelope.ok) {
173
+ return envelope.data as TData;
174
+ }
175
+
176
+ throw new SuperDocCliError(envelope.error.message, {
177
+ code: envelope.error.code,
178
+ details: envelope.error.details,
179
+ exitCode,
180
+ });
181
+ }
182
+
183
+ async connect(): Promise<void> {
184
+ // no-op in one-shot spawn mode
185
+ }
186
+
187
+ async dispose(): Promise<void> {
188
+ // no-op in one-shot spawn mode
189
+ }
190
+ }
@@ -0,0 +1,134 @@
1
+ import { SuperDocCliError } from './errors';
2
+
3
+ export type ParamType = 'string' | 'number' | 'boolean' | 'json' | 'string[]';
4
+ export type ParamKind = 'doc' | 'flag' | 'jsonFlag';
5
+
6
+ export interface OperationParamSpec {
7
+ readonly name: string;
8
+ readonly kind: ParamKind;
9
+ flag?: string;
10
+ readonly type: ParamType;
11
+ required?: boolean;
12
+ }
13
+
14
+ export interface OperationSpec {
15
+ readonly id: string;
16
+ readonly command: readonly string[];
17
+ readonly params: readonly OperationParamSpec[];
18
+ }
19
+
20
+ export interface InvokeOptions {
21
+ timeoutMs?: number;
22
+ stdinBytes?: Uint8Array;
23
+ }
24
+
25
+ export type SuperDocTransport = 'spawn' | 'host';
26
+
27
+ export interface SuperDocHostOptions {
28
+ startupTimeoutMs?: number;
29
+ shutdownTimeoutMs?: number;
30
+ requestTimeoutMs?: number;
31
+ watchdogTimeoutMs?: number;
32
+ maxQueueDepth?: number;
33
+ }
34
+
35
+ export interface SuperDocClientOptions {
36
+ cliBin?: string;
37
+ env?: Record<string, string | undefined>;
38
+ transport?: SuperDocTransport;
39
+ host?: SuperDocHostOptions;
40
+ }
41
+
42
+ export interface CliInvocation {
43
+ command: string;
44
+ prefixArgs: string[];
45
+ }
46
+
47
+ function hasExtension(filePath: string, extension: string): boolean {
48
+ return filePath.toLowerCase().endsWith(extension);
49
+ }
50
+
51
+ export function resolveInvocation(cliBin: string): CliInvocation {
52
+ if (hasExtension(cliBin, '.js')) {
53
+ return { command: 'node', prefixArgs: [cliBin] };
54
+ }
55
+
56
+ if (hasExtension(cliBin, '.ts')) {
57
+ return { command: 'bun', prefixArgs: [cliBin] };
58
+ }
59
+
60
+ return { command: cliBin, prefixArgs: [] };
61
+ }
62
+
63
+ function toCliFlag(flag: string): string {
64
+ return `--${flag}`;
65
+ }
66
+
67
+ function encodeParam(args: string[], spec: OperationParamSpec, value: unknown): void {
68
+ if (value == null) {
69
+ if (spec.required) {
70
+ throw new SuperDocCliError(`Missing required parameter: ${spec.name}`, {
71
+ code: 'INVALID_ARGUMENT',
72
+ });
73
+ }
74
+ return;
75
+ }
76
+
77
+ if (spec.kind === 'doc') {
78
+ args.push(String(value));
79
+ return;
80
+ }
81
+
82
+ const flag = toCliFlag(spec.flag ?? spec.name);
83
+
84
+ if (spec.type === 'boolean') {
85
+ if (value === true) {
86
+ args.push(flag);
87
+ }
88
+ return;
89
+ }
90
+
91
+ if (spec.type === 'string[]') {
92
+ if (!Array.isArray(value)) {
93
+ throw new SuperDocCliError(`Parameter ${spec.name} must be an array of strings.`, {
94
+ code: 'INVALID_ARGUMENT',
95
+ });
96
+ }
97
+
98
+ for (const item of value) {
99
+ args.push(flag, String(item));
100
+ }
101
+ return;
102
+ }
103
+
104
+ if (spec.type === 'json') {
105
+ args.push(flag, JSON.stringify(value));
106
+ return;
107
+ }
108
+
109
+ args.push(flag, String(value));
110
+ }
111
+
112
+ export function buildOperationArgv(
113
+ operation: OperationSpec,
114
+ params: Record<string, unknown>,
115
+ options: InvokeOptions,
116
+ runtimeTimeoutMs: number | undefined,
117
+ includeOutputFlag: boolean,
118
+ ): string[] {
119
+ const args: string[] = [...operation.command];
120
+ for (const param of operation.params) {
121
+ encodeParam(args, param, params[param.name]);
122
+ }
123
+
124
+ const timeoutMs = options.timeoutMs ?? runtimeTimeoutMs;
125
+ if (timeoutMs != null) {
126
+ args.push('--timeout-ms', String(timeoutMs));
127
+ }
128
+
129
+ if (includeOutputFlag) {
130
+ args.push('--output', 'json');
131
+ }
132
+
133
+ return args;
134
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { SuperDocCliError } from './runtime/errors';
5
+
6
+ const skillsDir = path.resolve(fileURLToPath(new URL('../skills', import.meta.url)));
7
+ const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
8
+
9
+ function resolveSkillFilePath(skillName: string): string {
10
+ const filePath = path.resolve(skillsDir, `${skillName}.md`);
11
+ const root = `${skillsDir}${path.sep}`;
12
+ if (!filePath.startsWith(root)) {
13
+ throw new SuperDocCliError('Skill name resolved outside SDK skill directory.', {
14
+ code: 'INVALID_ARGUMENT',
15
+ details: { skillName },
16
+ });
17
+ }
18
+ return filePath;
19
+ }
20
+
21
+ export function listSkills(): string[] {
22
+ try {
23
+ return readdirSync(skillsDir)
24
+ .filter((entry) => path.extname(entry) === '.md')
25
+ .map((entry) => path.basename(entry, '.md'))
26
+ .sort();
27
+ } catch (error) {
28
+ throw new SuperDocCliError('Unable to enumerate SDK skills.', {
29
+ code: 'SKILL_IO_ERROR',
30
+ details: {
31
+ skillsDir,
32
+ message: error instanceof Error ? error.message : String(error),
33
+ },
34
+ });
35
+ }
36
+ }
37
+
38
+ export function getSkill(name: string): string {
39
+ const normalized = name.trim();
40
+ if (!normalized || !SKILL_NAME_RE.test(normalized)) {
41
+ throw new SuperDocCliError('Skill name is required.', {
42
+ code: 'INVALID_ARGUMENT',
43
+ details: { name },
44
+ });
45
+ }
46
+
47
+ const filePath = resolveSkillFilePath(normalized);
48
+ try {
49
+ return readFileSync(filePath, 'utf8');
50
+ } catch (error) {
51
+ const nodeError = error as NodeJS.ErrnoException;
52
+ if (nodeError?.code === 'ENOENT') {
53
+ let available: string[] = [];
54
+ try {
55
+ available = listSkills();
56
+ } catch {
57
+ // Keep available empty when directory enumeration itself fails.
58
+ }
59
+ throw new SuperDocCliError('Requested SDK skill was not found.', {
60
+ code: 'SKILL_NOT_FOUND',
61
+ details: {
62
+ name: normalized,
63
+ available,
64
+ },
65
+ });
66
+ }
67
+
68
+ throw new SuperDocCliError('Requested SDK skill was not found.', {
69
+ code: 'SKILL_IO_ERROR',
70
+ details: {
71
+ name: normalized,
72
+ message: error instanceof Error ? error.message : String(error),
73
+ },
74
+ });
75
+ }
76
+ }