argsbarg 3.6.4 → 4.0.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.
- package/CHANGELOG.md +32 -2
- package/README.md +21 -9
- package/docs/README.md +12 -8
- package/docs/bundled-docs.md +1 -1
- package/docs/cli-program.md +62 -2
- package/docs/config-schema.md +192 -0
- package/docs/developing.md +13 -0
- package/docs/install.md +38 -1
- package/docs/mcp.md +43 -19
- package/docs/output-schema.md +74 -52
- package/docs/templates/cursor/rules/cli-program.mdc +10 -5
- package/examples/config-app/main.ts +20 -0
- package/examples/config-app/program.ts +81 -0
- package/examples/config-app/schema.ts +37 -0
- package/examples/config-app/types.ts +19 -0
- package/examples/consumer-app/README.md +56 -0
- package/examples/consumer-app/bun.lock +75 -0
- package/examples/consumer-app/capabilities.test.ts +69 -0
- package/examples/consumer-app/package.json +17 -0
- package/examples/consumer-app/schemas/configSchemas.ts +6 -0
- package/examples/consumer-app/schemas/generated/app-config.json +40 -0
- package/examples/consumer-app/schemas/generated/status.json +28 -0
- package/examples/consumer-app/schemas/outputSchemas.ts +6 -0
- package/examples/consumer-app/scripts/schemagen/discover-schema-roots.test.ts +25 -0
- package/examples/consumer-app/scripts/schemagen/discover-schema-roots.ts +93 -0
- package/examples/consumer-app/scripts/schemagen/naming.ts +82 -0
- package/examples/consumer-app/scripts/schemagen.ts +76 -0
- package/examples/consumer-app/src/commands/status/types.ts +11 -0
- package/examples/consumer-app/src/main.ts +15 -0
- package/examples/consumer-app/src/program.ts +116 -0
- package/examples/consumer-app/src/types.ts +23 -0
- package/examples/consumer-app/tsconfig.json +14 -0
- package/examples/formats.ts +10 -3
- package/examples/mcp-test.ts +27 -8
- package/examples/minimal.ts +4 -3
- package/examples/nested.ts +5 -4
- package/examples/option-required.ts +8 -4
- package/index.d.ts +158 -75
- package/package.json +1 -1
- package/src/builtins/builtins.test.ts +13 -0
- package/src/builtins/config.test.ts +82 -0
- package/src/builtins/config.ts +220 -0
- package/src/builtins/dispatch.ts +18 -9
- package/src/builtins/export.ts +8 -33
- package/src/builtins/index.ts +1 -0
- package/src/builtins/install.ts +13 -0
- package/src/builtins/mcp.ts +1 -1
- package/src/builtins/presentation.ts +2 -17
- package/src/builtins/registry.ts +40 -0
- package/src/capabilities.ts +46 -0
- package/src/cli-errors.ts +15 -0
- package/src/cli.ts +389 -0
- package/src/config/bootstrap.ts +265 -0
- package/src/config/context.test.ts +79 -0
- package/src/config/context.ts +110 -0
- package/src/config/entry.ts +81 -0
- package/src/config/file.test.ts +112 -0
- package/src/config/file.ts +120 -0
- package/src/config/manifest.ts +62 -0
- package/src/config/resolve.test.ts +88 -0
- package/src/config/resolve.ts +167 -0
- package/src/config/schema.ts +101 -0
- package/src/config/validate.test.ts +63 -0
- package/src/config/validate.ts +292 -0
- package/src/config.integration.test.ts +100 -0
- package/src/context.ts +5 -0
- package/src/docs/docs.test.ts +16 -16
- package/src/docs/mcp-guide.ts +35 -11
- package/src/hidden-mcpb.test.ts +40 -2
- package/src/index.ts +4 -3
- package/src/install/index.ts +46 -3
- package/src/install/paths.ts +5 -20
- package/src/install/plan.ts +6 -0
- package/src/install/status.ts +12 -0
- package/src/install/uninstall.ts +11 -0
- package/src/install/update.test.ts +5 -5
- package/src/invoke.test.ts +207 -0
- package/src/mcp/bundle.ts +9 -116
- package/src/mcp/claude.test.ts +73 -0
- package/src/mcp/claude.ts +168 -0
- package/src/mcp/env.ts +3 -37
- package/src/mcp/server.ts +18 -10
- package/src/mcp/tools.ts +3 -7
- package/src/mcp/zip.ts +82 -0
- package/src/mcp.integration.test.ts +502 -0
- package/src/{index.test.ts → parse.test.ts} +24 -935
- package/src/paths/host.ts +40 -0
- package/src/schema.ts +1 -1
- package/src/skill/generate.ts +18 -5
- package/src/skill/hint.ts +18 -0
- package/src/skill/install.ts +1 -5
- package/src/test-fixtures.ts +192 -0
- package/src/types.ts +39 -13
- package/src/validate.ts +70 -0
- package/src/completion.ts +0 -13
- package/src/invoke.ts +0 -217
- package/src/mcp.ts +0 -28
- package/src/runtime.ts +0 -134
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Resolve program.appConfig values: defaults, file, env override, export to process.env.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CliAppConfigEntry, CliProgram } from "../types.ts";
|
|
6
|
+
import { configEntryRequired, jsonSchemaRequiredKeys } from "./entry.ts";
|
|
7
|
+
import type { AppConfigFileData } from "./file.ts";
|
|
8
|
+
import { displayAppConfigPath } from "./file.ts";
|
|
9
|
+
import { effectiveJsonSchema, schemaDefaultForKey } from "./schema.ts";
|
|
10
|
+
|
|
11
|
+
export type ResolvedConfig = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
function isPresent(value: unknown): boolean {
|
|
14
|
+
if (value === undefined || value === null) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === "string" && value.length === 0) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function envOverrideValue(envName: string): string | undefined {
|
|
24
|
+
const val = process.env[envName];
|
|
25
|
+
if (val === undefined || val.length === 0) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return val;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Coerce env string to typed value using property schema when possible. */
|
|
32
|
+
function coerceEnvValue(program: CliProgram, key: string, raw: string): unknown {
|
|
33
|
+
const jsonSchema = effectiveJsonSchema(program);
|
|
34
|
+
if (!jsonSchema) {
|
|
35
|
+
return raw;
|
|
36
|
+
}
|
|
37
|
+
const properties = jsonSchema.properties;
|
|
38
|
+
if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
const prop = (properties as Record<string, unknown>)[key];
|
|
42
|
+
if (typeof prop !== "object" || prop === null || Array.isArray(prop)) {
|
|
43
|
+
return raw;
|
|
44
|
+
}
|
|
45
|
+
const type = (prop as Record<string, unknown>).type;
|
|
46
|
+
if (type === "number" || type === "integer") {
|
|
47
|
+
const n = Number(raw);
|
|
48
|
+
return Number.isNaN(n) ? raw : n;
|
|
49
|
+
}
|
|
50
|
+
if (type === "boolean") {
|
|
51
|
+
const lower = raw.toLowerCase();
|
|
52
|
+
if (lower === "true" || lower === "1") return true;
|
|
53
|
+
if (lower === "false" || lower === "0") return false;
|
|
54
|
+
}
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolve all schema keys from file data and process.env. */
|
|
59
|
+
export function resolveAppConfig(program: CliProgram, fileData: AppConfigFileData): ResolvedConfig {
|
|
60
|
+
const appConfig = program.appConfig;
|
|
61
|
+
if (!appConfig) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
const out: ResolvedConfig = {};
|
|
65
|
+
for (const [key, entry] of Object.entries(appConfig.entries)) {
|
|
66
|
+
const value = resolveConfigKey(program, key, entry, fileData);
|
|
67
|
+
if (value !== undefined) {
|
|
68
|
+
out[key] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveConfigKey(
|
|
75
|
+
program: CliProgram,
|
|
76
|
+
key: string,
|
|
77
|
+
entry: CliAppConfigEntry,
|
|
78
|
+
fileData: AppConfigFileData,
|
|
79
|
+
): unknown {
|
|
80
|
+
if (entry.env) {
|
|
81
|
+
const fromEnv = envOverrideValue(entry.env);
|
|
82
|
+
if (fromEnv !== undefined) {
|
|
83
|
+
return coerceEnvValue(program, key, fromEnv);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (key in fileData && isPresent(fileData[key])) {
|
|
87
|
+
return fileData[key];
|
|
88
|
+
}
|
|
89
|
+
const def = schemaDefaultForKey(program, key);
|
|
90
|
+
if (def !== undefined) {
|
|
91
|
+
return def;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Write mapped config values to process.env for subprocess inheritance. */
|
|
97
|
+
export function exportConfigToEnv(program: CliProgram, resolved: ResolvedConfig): void {
|
|
98
|
+
const appConfig = program.appConfig;
|
|
99
|
+
if (!appConfig) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const [key, entry] of Object.entries(appConfig.entries)) {
|
|
103
|
+
if (!entry.env) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const value = resolved[key];
|
|
107
|
+
if (!isPresent(value)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
process.env[entry.env] = stringifyConfigValue(value);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function stringifyConfigValue(value: unknown): string {
|
|
115
|
+
if (typeof value === "string") {
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
119
|
+
return String(value);
|
|
120
|
+
}
|
|
121
|
+
return JSON.stringify(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Required schema keys that are still missing after resolution. */
|
|
125
|
+
export function missingRequiredConfig(program: CliProgram, resolved: ResolvedConfig): string[] {
|
|
126
|
+
const appConfig = program.appConfig;
|
|
127
|
+
if (!appConfig) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const jsonSchema = effectiveJsonSchema(program);
|
|
131
|
+
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
132
|
+
const missing: string[] = [];
|
|
133
|
+
for (const [key, entry] of Object.entries(appConfig.entries)) {
|
|
134
|
+
if (!configEntryRequired(key, entry, fromSchema)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!isPresent(resolved[key])) {
|
|
138
|
+
missing.push(key);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return missing;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Stderr message when required config keys are missing. */
|
|
145
|
+
export function formatMissingConfigMessage(program: CliProgram, keys: string[]): string {
|
|
146
|
+
const list = keys.join(", ");
|
|
147
|
+
const path = displayAppConfigPath(program);
|
|
148
|
+
return [
|
|
149
|
+
`Missing required configuration: ${list}`,
|
|
150
|
+
`Configure interactively: ${program.key} install --configure`,
|
|
151
|
+
`Or set via: ${program.key} config set <key> <value>`,
|
|
152
|
+
`Config file: ${path}`,
|
|
153
|
+
`See: ${program.key} docs mcp`,
|
|
154
|
+
].join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** MCP tools/call error when required config is missing. */
|
|
158
|
+
export function formatMcpMissingConfigMessage(program: CliProgram, keys: string[]): string {
|
|
159
|
+
const list = keys.join(", ");
|
|
160
|
+
const path = displayAppConfigPath(program);
|
|
161
|
+
return [
|
|
162
|
+
`Missing required configuration: ${list}`,
|
|
163
|
+
`Configure: ${program.key} install --configure`,
|
|
164
|
+
`Or set via: ${program.key} config set`,
|
|
165
|
+
`Config file: ${path}`,
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Effective JSON Schema for program.appConfig (block schema or all-string synthesis).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CliAppConfigEntry, CliProgram } from "../types.ts";
|
|
6
|
+
import { configEntryRequired, jsonSchemaRequiredKeys } from "./entry.ts";
|
|
7
|
+
|
|
8
|
+
/** Synthesize a draft-07 object schema with string properties from metadata entries. */
|
|
9
|
+
export function synthesizeAllStringSchema(
|
|
10
|
+
schema: Record<string, CliAppConfigEntry>,
|
|
11
|
+
): Record<string, unknown> {
|
|
12
|
+
const properties: Record<string, unknown> = {};
|
|
13
|
+
const required: string[] = [];
|
|
14
|
+
for (const [key, entry] of Object.entries(schema)) {
|
|
15
|
+
const prop: Record<string, unknown> = {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: entry.description,
|
|
18
|
+
};
|
|
19
|
+
if (entry.default !== undefined) {
|
|
20
|
+
prop.default = entry.default;
|
|
21
|
+
}
|
|
22
|
+
properties[key] = prop;
|
|
23
|
+
if (entry.required !== false) {
|
|
24
|
+
required.push(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const out: Record<string, unknown> = {
|
|
28
|
+
type: "object",
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
properties,
|
|
31
|
+
};
|
|
32
|
+
if (required.length > 0) {
|
|
33
|
+
out.required = required;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Block JSON Schema used for validation, or synthesized all-string schema. */
|
|
39
|
+
export function effectiveJsonSchema(program: CliProgram): Record<string, unknown> | undefined {
|
|
40
|
+
const appConfig = program.appConfig;
|
|
41
|
+
if (!appConfig) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
if (appConfig.jsonSchema !== undefined) {
|
|
45
|
+
return appConfig.jsonSchema;
|
|
46
|
+
}
|
|
47
|
+
return synthesizeAllStringSchema(appConfig.entries);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Property subschema for one config key from the effective root schema. */
|
|
51
|
+
export function configPropertySchema(
|
|
52
|
+
jsonSchema: Record<string, unknown>,
|
|
53
|
+
key: string,
|
|
54
|
+
): Record<string, unknown> | undefined {
|
|
55
|
+
const properties = jsonSchema.properties;
|
|
56
|
+
if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const prop = (properties as Record<string, unknown>)[key];
|
|
60
|
+
if (typeof prop !== "object" || prop === null || Array.isArray(prop)) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
return prop as Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Default value for a key from JSON Schema property or entry metadata. */
|
|
67
|
+
export function schemaDefaultForKey(program: CliProgram, key: string): unknown | undefined {
|
|
68
|
+
const appConfig = program.appConfig;
|
|
69
|
+
if (!appConfig) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const entry = appConfig.entries[key];
|
|
73
|
+
if (!entry) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const jsonSchema = effectiveJsonSchema(program);
|
|
77
|
+
if (jsonSchema) {
|
|
78
|
+
const prop = configPropertySchema(jsonSchema, key);
|
|
79
|
+
if (prop && "default" in prop) {
|
|
80
|
+
return prop.default;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return entry.default;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Required key set for the program config schema. */
|
|
87
|
+
export function programConfigRequiredKeys(program: CliProgram): Set<string> {
|
|
88
|
+
const appConfig = program.appConfig;
|
|
89
|
+
if (!appConfig) {
|
|
90
|
+
return new Set();
|
|
91
|
+
}
|
|
92
|
+
const jsonSchema = effectiveJsonSchema(program);
|
|
93
|
+
const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
|
|
94
|
+
const required = new Set<string>();
|
|
95
|
+
for (const [key, entry] of Object.entries(appConfig.entries)) {
|
|
96
|
+
if (configEntryRequired(key, entry, fromSchema)) {
|
|
97
|
+
required.add(key);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return required;
|
|
101
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseConfigSetValue, validateConfigDocument } from "./validate.ts";
|
|
3
|
+
|
|
4
|
+
const rootSchema = {
|
|
5
|
+
type: "object",
|
|
6
|
+
additionalProperties: false,
|
|
7
|
+
required: ["apiToken", "maxRetries"],
|
|
8
|
+
properties: {
|
|
9
|
+
apiToken: { type: "string", minLength: 1 },
|
|
10
|
+
maxRetries: { type: "integer", minimum: 0, maximum: 10 },
|
|
11
|
+
enabled: { type: "boolean", default: true },
|
|
12
|
+
prefs: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: { ttl: { type: "number" } },
|
|
15
|
+
required: ["ttl"],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("config/validate", () => {
|
|
21
|
+
test("accepts valid document", () => {
|
|
22
|
+
const result = validateConfigDocument(
|
|
23
|
+
{ apiToken: "x", maxRetries: 3, prefs: { ttl: 3600 } },
|
|
24
|
+
rootSchema,
|
|
25
|
+
);
|
|
26
|
+
expect(result.valid).toBe(true);
|
|
27
|
+
expect(result.errors).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("rejects missing required property", () => {
|
|
31
|
+
const result = validateConfigDocument({ apiToken: "x" }, rootSchema);
|
|
32
|
+
expect(result.valid).toBe(false);
|
|
33
|
+
expect(result.errors.some((e) => e.includes("maxRetries"))).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("rejects unknown property when additionalProperties is false", () => {
|
|
37
|
+
const result = validateConfigDocument(
|
|
38
|
+
{ apiToken: "x", maxRetries: 1, extra: true },
|
|
39
|
+
rootSchema,
|
|
40
|
+
);
|
|
41
|
+
expect(result.valid).toBe(false);
|
|
42
|
+
expect(result.errors.some((e) => e.includes("extra"))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rejects type mismatch", () => {
|
|
46
|
+
const result = validateConfigDocument({ apiToken: "x", maxRetries: "nope" }, rootSchema);
|
|
47
|
+
expect(result.valid).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("parseConfigSetValue coerces number and boolean", () => {
|
|
51
|
+
expect(parseConfigSetValue("5", { type: "integer" }, rootSchema, false)).toBe(5);
|
|
52
|
+
expect(parseConfigSetValue("true", { type: "boolean" }, rootSchema, false)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("parseConfigSetValue requires --json for objects", () => {
|
|
56
|
+
expect(() => parseConfigSetValue('{"ttl":1}', { type: "object" }, rootSchema, false)).toThrow(
|
|
57
|
+
/--json/,
|
|
58
|
+
);
|
|
59
|
+
expect(parseConfigSetValue('{"ttl":1}', { type: "object" }, rootSchema, true)).toEqual({
|
|
60
|
+
ttl: 1,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Draft-07 JSON Schema subset validator for program.appConfig files.
|
|
3
|
+
Aligned with common ts-json-schema-generator output (local $ref, objects, scalars).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
type JsonSchema = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export interface ValidateResult {
|
|
9
|
+
valid: boolean;
|
|
10
|
+
errors: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Validate `data` against a JSON Schema root. Returns human-readable error messages. */
|
|
14
|
+
export function validateConfigDocument(data: unknown, rootSchema: JsonSchema): ValidateResult {
|
|
15
|
+
const errors: string[] = [];
|
|
16
|
+
validateValue(data, rootSchema, rootSchema, "$", errors);
|
|
17
|
+
return { valid: errors.length === 0, errors };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validateValue(
|
|
21
|
+
value: unknown,
|
|
22
|
+
schema: JsonSchema,
|
|
23
|
+
root: JsonSchema,
|
|
24
|
+
path: string,
|
|
25
|
+
errors: string[],
|
|
26
|
+
): void {
|
|
27
|
+
const resolved = resolveSchema(schema, root);
|
|
28
|
+
if (!resolved) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if ("const" in resolved) {
|
|
33
|
+
if (value !== resolved.const) {
|
|
34
|
+
errors.push(`${path}: must be ${JSON.stringify(resolved.const)}`);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (Array.isArray(resolved.enum)) {
|
|
40
|
+
if (!resolved.enum.some((item) => item === value)) {
|
|
41
|
+
errors.push(`${path}: must be one of ${JSON.stringify(resolved.enum)}`);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const anyOf = resolved.anyOf ?? resolved.oneOf;
|
|
47
|
+
if (Array.isArray(anyOf)) {
|
|
48
|
+
const branchErrors: string[][] = [];
|
|
49
|
+
for (const branch of anyOf) {
|
|
50
|
+
if (typeof branch !== "object" || branch === null || Array.isArray(branch)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const branchErrs: string[] = [];
|
|
54
|
+
validateValue(value, branch as JsonSchema, root, path, branchErrs);
|
|
55
|
+
if (branchErrs.length === 0) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
branchErrors.push(branchErrs);
|
|
59
|
+
}
|
|
60
|
+
errors.push(`${path}: must match one of the allowed types`);
|
|
61
|
+
if (branchErrors.length === 1 && branchErrors[0]?.[0]) {
|
|
62
|
+
errors.push(branchErrors[0][0]);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const types = normalizeTypes(resolved.type);
|
|
68
|
+
if (types.length > 0 && !matchesAnyType(value, types)) {
|
|
69
|
+
errors.push(`${path}: must be ${types.join(" or ")}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (types.includes("string") || (types.length === 0 && typeof value === "string")) {
|
|
74
|
+
validateString(value, resolved, path, errors);
|
|
75
|
+
}
|
|
76
|
+
if (types.includes("number") || types.includes("integer")) {
|
|
77
|
+
validateNumber(value, resolved, path, errors, types.includes("integer"));
|
|
78
|
+
}
|
|
79
|
+
if (types.includes("boolean")) {
|
|
80
|
+
if (typeof value !== "boolean") {
|
|
81
|
+
errors.push(`${path}: must be boolean`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (types.includes("null") && value !== null) {
|
|
85
|
+
errors.push(`${path}: must be null`);
|
|
86
|
+
}
|
|
87
|
+
if (types.includes("object") || (types.length === 0 && isPlainObject(value))) {
|
|
88
|
+
validateObject(value, resolved, root, path, errors);
|
|
89
|
+
}
|
|
90
|
+
if (types.includes("array") || (types.length === 0 && Array.isArray(value))) {
|
|
91
|
+
validateArray(value, resolved, root, path, errors);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveSchema(schema: JsonSchema, root: JsonSchema): JsonSchema | undefined {
|
|
96
|
+
const ref = schema.$ref;
|
|
97
|
+
if (typeof ref === "string" && ref.startsWith("#/definitions/")) {
|
|
98
|
+
const name = decodeURIComponent(ref.slice("#/definitions/".length));
|
|
99
|
+
const definitions = root.definitions;
|
|
100
|
+
if (typeof definitions !== "object" || definitions === null || Array.isArray(definitions)) {
|
|
101
|
+
return schema;
|
|
102
|
+
}
|
|
103
|
+
const target = (definitions as Record<string, unknown>)[name];
|
|
104
|
+
if (typeof target === "object" && target !== null && !Array.isArray(target)) {
|
|
105
|
+
return target as JsonSchema;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return schema;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeTypes(type: unknown): string[] {
|
|
112
|
+
if (typeof type === "string") {
|
|
113
|
+
return [type];
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(type)) {
|
|
116
|
+
return type.filter((t): t is string => typeof t === "string");
|
|
117
|
+
}
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function matchesAnyType(value: unknown, types: string[]): boolean {
|
|
122
|
+
for (const t of types) {
|
|
123
|
+
if (t === "null" && value === null) return true;
|
|
124
|
+
if (t === "array" && Array.isArray(value)) return true;
|
|
125
|
+
if (t === "object" && isPlainObject(value)) return true;
|
|
126
|
+
if (t === "integer" && typeof value === "number" && Number.isInteger(value)) return true;
|
|
127
|
+
if (t === "number" && typeof value === "number") return true;
|
|
128
|
+
if (t === "boolean" && typeof value === "boolean") return true;
|
|
129
|
+
if (t === "string" && typeof value === "string") return true;
|
|
130
|
+
}
|
|
131
|
+
return types.length === 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
135
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validateString(value: unknown, schema: JsonSchema, path: string, errors: string[]): void {
|
|
139
|
+
if (typeof value !== "string") {
|
|
140
|
+
errors.push(`${path}: must be string`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
|
144
|
+
errors.push(`${path}: string shorter than minLength ${schema.minLength}`);
|
|
145
|
+
}
|
|
146
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
|
|
147
|
+
errors.push(`${path}: string longer than maxLength ${schema.maxLength}`);
|
|
148
|
+
}
|
|
149
|
+
if (typeof schema.pattern === "string") {
|
|
150
|
+
try {
|
|
151
|
+
if (!new RegExp(schema.pattern).test(value)) {
|
|
152
|
+
errors.push(`${path}: does not match pattern`);
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
/* invalid pattern in schema — skip */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validateNumber(
|
|
161
|
+
value: unknown,
|
|
162
|
+
schema: JsonSchema,
|
|
163
|
+
path: string,
|
|
164
|
+
errors: string[],
|
|
165
|
+
integer: boolean,
|
|
166
|
+
): void {
|
|
167
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
168
|
+
errors.push(`${path}: must be number`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (integer && !Number.isInteger(value)) {
|
|
172
|
+
errors.push(`${path}: must be integer`);
|
|
173
|
+
}
|
|
174
|
+
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
|
175
|
+
errors.push(`${path}: below minimum ${schema.minimum}`);
|
|
176
|
+
}
|
|
177
|
+
if (typeof schema.maximum === "number" && value > schema.maximum) {
|
|
178
|
+
errors.push(`${path}: above maximum ${schema.maximum}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validateObject(
|
|
183
|
+
value: unknown,
|
|
184
|
+
schema: JsonSchema,
|
|
185
|
+
root: JsonSchema,
|
|
186
|
+
path: string,
|
|
187
|
+
errors: string[],
|
|
188
|
+
): void {
|
|
189
|
+
if (!isPlainObject(value)) {
|
|
190
|
+
errors.push(`${path}: must be object`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const properties = schema.properties;
|
|
194
|
+
const propMap =
|
|
195
|
+
typeof properties === "object" && properties !== null && !Array.isArray(properties)
|
|
196
|
+
? (properties as Record<string, JsonSchema>)
|
|
197
|
+
: undefined;
|
|
198
|
+
|
|
199
|
+
if (propMap) {
|
|
200
|
+
for (const [key, propSchema] of Object.entries(propMap)) {
|
|
201
|
+
if (key in value) {
|
|
202
|
+
validateValue(value[key], propSchema, root, `${path}.${key}`, errors);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const required = schema.required;
|
|
208
|
+
if (Array.isArray(required)) {
|
|
209
|
+
for (const key of required) {
|
|
210
|
+
if (typeof key === "string" && !(key in value)) {
|
|
211
|
+
errors.push(`${path}: missing required property '${key}'`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (schema.additionalProperties === false && propMap) {
|
|
217
|
+
for (const key of Object.keys(value)) {
|
|
218
|
+
if (!(key in propMap)) {
|
|
219
|
+
errors.push(`${path}: unknown property '${key}'`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function validateArray(
|
|
226
|
+
value: unknown,
|
|
227
|
+
schema: JsonSchema,
|
|
228
|
+
root: JsonSchema,
|
|
229
|
+
path: string,
|
|
230
|
+
errors: string[],
|
|
231
|
+
): void {
|
|
232
|
+
if (!Array.isArray(value)) {
|
|
233
|
+
errors.push(`${path}: must be array`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
|
|
237
|
+
errors.push(`${path}: fewer than minItems ${schema.minItems}`);
|
|
238
|
+
}
|
|
239
|
+
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
|
|
240
|
+
errors.push(`${path}: more than maxItems ${schema.maxItems}`);
|
|
241
|
+
}
|
|
242
|
+
const items = schema.items;
|
|
243
|
+
if (typeof items === "object" && items !== null && !Array.isArray(items)) {
|
|
244
|
+
for (let i = 0; i < value.length; i++) {
|
|
245
|
+
validateValue(value[i], items as JsonSchema, root, `${path}[${i}]`, errors);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Parse a CLI/MCP set value against a property schema. */
|
|
251
|
+
export function parseConfigSetValue(
|
|
252
|
+
raw: string,
|
|
253
|
+
propertySchema: JsonSchema | undefined,
|
|
254
|
+
rootSchema: JsonSchema,
|
|
255
|
+
useJson: boolean,
|
|
256
|
+
): unknown {
|
|
257
|
+
if (useJson) {
|
|
258
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
259
|
+
const errors: string[] = [];
|
|
260
|
+
if (propertySchema) {
|
|
261
|
+
validateValue(parsed, propertySchema, rootSchema, "$", errors);
|
|
262
|
+
if (errors.length > 0) {
|
|
263
|
+
throw new Error(errors[0] ?? "Invalid config value");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return parsed;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const resolved = propertySchema ? resolveSchema(propertySchema, rootSchema) : undefined;
|
|
270
|
+
const types = resolved ? normalizeTypes(resolved.type) : ["string"];
|
|
271
|
+
|
|
272
|
+
if (types.includes("boolean")) {
|
|
273
|
+
const lower = raw.trim().toLowerCase();
|
|
274
|
+
if (lower === "true" || lower === "1") return true;
|
|
275
|
+
if (lower === "false" || lower === "0") return false;
|
|
276
|
+
throw new Error("Expected boolean: true, false, 1, or 0");
|
|
277
|
+
}
|
|
278
|
+
if (types.includes("number") || types.includes("integer")) {
|
|
279
|
+
const n = Number(raw);
|
|
280
|
+
if (Number.isNaN(n)) {
|
|
281
|
+
throw new Error("Expected number");
|
|
282
|
+
}
|
|
283
|
+
if (types.includes("integer") && !Number.isInteger(n)) {
|
|
284
|
+
throw new Error("Expected integer");
|
|
285
|
+
}
|
|
286
|
+
return n;
|
|
287
|
+
}
|
|
288
|
+
if (types.includes("object") || types.includes("array")) {
|
|
289
|
+
throw new Error("Use --json for object or array config values");
|
|
290
|
+
}
|
|
291
|
+
return raw;
|
|
292
|
+
}
|