argsbarg 5.1.7 → 5.1.9

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 (92) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/docs/cli-program.md +3 -1
  3. package/docs/config-schema.md +5 -3
  4. package/docs/configure.md +13 -7
  5. package/docs/distribution-homebrew.md +3 -2
  6. package/examples/full-example/justfile +2 -5
  7. package/examples/full-example/scripts/with-dev-formula.ts +6 -1
  8. package/index.d.ts +10 -1
  9. package/package.json +1 -1
  10. package/src/builtins/builtins.test.ts +1 -3
  11. package/src/builtins/completion-bash.ts +6 -27
  12. package/src/builtins/completion-group.ts +1 -2
  13. package/src/builtins/completion-simulate-shared.ts +2 -10
  14. package/src/builtins/completion-zsh.ts +7 -33
  15. package/src/builtins/config.test.ts +28 -0
  16. package/src/builtins/config.ts +40 -10
  17. package/src/builtins/configure-copy.ts +3 -6
  18. package/src/builtins/dispatch.ts +2 -10
  19. package/src/builtins/export.ts +1 -3
  20. package/src/builtins/presentation.ts +2 -7
  21. package/src/builtins/registry.ts +1 -5
  22. package/src/cli-tool/create.test.ts +3 -6
  23. package/src/cli-tool/create.ts +5 -22
  24. package/src/cli-tool/post-create.ts +1 -3
  25. package/src/cli-tool/program.ts +1 -2
  26. package/src/cli-tool/run-create.ts +2 -8
  27. package/src/cli.ts +6 -27
  28. package/src/config/bindings.test.ts +72 -0
  29. package/src/config/bindings.ts +115 -0
  30. package/src/config/bootstrap.ts +80 -65
  31. package/src/config/context.test.ts +45 -0
  32. package/src/config/context.ts +74 -14
  33. package/src/config/entry.ts +1 -3
  34. package/src/config/file.test.ts +46 -3
  35. package/src/config/file.ts +59 -19
  36. package/src/config/resolve.ts +1 -4
  37. package/src/config/schema.ts +1 -3
  38. package/src/config/validate.test.ts +3 -11
  39. package/src/config/validate.ts +30 -30
  40. package/src/config.integration.test.ts +1 -5
  41. package/src/configure/configure.test.ts +19 -5
  42. package/src/configure/index.ts +17 -15
  43. package/src/docs/api-guide.ts +1 -6
  44. package/src/docs/builtin.ts +1 -5
  45. package/src/docs/docs.test.ts +2 -6
  46. package/src/docs/mcp-guide.ts +3 -13
  47. package/src/docs/mcp-resources.test.ts +1 -3
  48. package/src/docs/mcp-resources.ts +1 -6
  49. package/src/docs/save.ts +2 -10
  50. package/src/formats.test.ts +1 -7
  51. package/src/formats.ts +1 -5
  52. package/src/headless.test.ts +5 -17
  53. package/src/help.ts +9 -51
  54. package/src/hidden-mcpb.test.ts +3 -12
  55. package/src/hidden.ts +1 -3
  56. package/src/install/binary-placement.test.ts +2 -6
  57. package/src/install/binary-placement.ts +1 -4
  58. package/src/install/gh-release-update.ts +5 -22
  59. package/src/install/mcp-codex.ts +1 -2
  60. package/src/install/mcp-config.ts +2 -12
  61. package/src/install/mcp-opencode.test.ts +1 -3
  62. package/src/install/mcp-opencode.ts +3 -15
  63. package/src/install/plan.ts +2 -10
  64. package/src/install/status.ts +3 -5
  65. package/src/install/target-base.ts +2 -11
  66. package/src/install/target-detect.ts +1 -5
  67. package/src/install/target-effective.ts +3 -13
  68. package/src/install/target-mcp-cli.ts +4 -26
  69. package/src/install/target-mcp-json.ts +3 -14
  70. package/src/install/target-plan-build.ts +1 -5
  71. package/src/install/target-registry.ts +13 -21
  72. package/src/install/target-scope.ts +8 -25
  73. package/src/install/target-types.ts +1 -6
  74. package/src/install/targets/app.ts +2 -2
  75. package/src/install/targets/opencode-mcp.ts +1 -6
  76. package/src/install/targets.test.ts +3 -10
  77. package/src/install/uninstall.ts +2 -9
  78. package/src/invoke.test.ts +1 -3
  79. package/src/mcp/bundle.ts +4 -19
  80. package/src/mcp/claude.test.ts +3 -3
  81. package/src/mcp/claude.ts +4 -17
  82. package/src/mcp/env.ts +1 -3
  83. package/src/mcp/server.ts +3 -12
  84. package/src/mcp/tools.ts +2 -11
  85. package/src/mcp.integration.test.ts +4 -10
  86. package/src/parse.test.ts +9 -34
  87. package/src/parse.ts +10 -44
  88. package/src/schema.ts +2 -10
  89. package/src/skill/hint.ts +1 -5
  90. package/src/skill/install.ts +1 -5
  91. package/src/test-fixtures.ts +1 -3
  92. package/src/validate.ts +29 -90
@@ -79,4 +79,49 @@ describe("config/context", () => {
79
79
  expect(ctx.dir).toBe(resolveAppConfigDir(program));
80
80
  expect(ctx.dir).toBe(dirname(ctx.path));
81
81
  });
82
+
83
+ test("AppConfigSnapshot unsafe read/write", () => {
84
+ const dir = mkdtempSync(join(tmpdir(), "ctx-unsafe-"));
85
+ const prevHome = process.env.HOME;
86
+ process.env.HOME = dir;
87
+ const prevToken = process.env.API_TOKEN;
88
+ delete process.env.API_TOKEN;
89
+ try {
90
+ const fileData = { apiToken: "tok" };
91
+ const resolved = resolveAppConfig(program, fileData);
92
+ const ctx = createAppConfigSnapshot(program, fileData, resolved);
93
+ expect(ctx.getUnsafe("apiToken")).toBe("tok");
94
+ expect(ctx.readUnsafe().apiToken).toBe("tok");
95
+ ctx.setUnsafe("note", "raw");
96
+ expect(ctx.getUnsafe("note")).toBe("raw");
97
+ } finally {
98
+ if (prevHome === undefined) delete process.env.HOME;
99
+ else process.env.HOME = prevHome;
100
+ if (prevToken === undefined) delete process.env.API_TOKEN;
101
+ else process.env.API_TOKEN = prevToken;
102
+ rmSync(dir, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ test("EmptyAppConfigSnapshot unsafe read/write", () => {
107
+ const dir = mkdtempSync(join(tmpdir(), "ctx-empty-unsafe-"));
108
+ const prevHome = process.env.HOME;
109
+ process.env.HOME = dir;
110
+ try {
111
+ const programWithoutConfig: CliProgram = {
112
+ key: "rawapp",
113
+ version: "1.0.0",
114
+ description: "No config.",
115
+ handler: () => {},
116
+ };
117
+ const empty = createAppConfigSnapshot(programWithoutConfig, {}, {});
118
+ empty.setUnsafe("custom", 42);
119
+ expect(empty.getUnsafe("custom")).toBe(42);
120
+ expect(empty.readUnsafe().custom).toBe(42);
121
+ } finally {
122
+ if (prevHome === undefined) delete process.env.HOME;
123
+ else process.env.HOME = prevHome;
124
+ rmSync(dir, { recursive: true, force: true });
125
+ }
126
+ });
82
127
  });
@@ -3,13 +3,36 @@ Handler-facing resolved app config snapshot (ctx.appConfig).
3
3
  */
4
4
 
5
5
  import type { CliProgram } from "../types.ts";
6
- import { resolveAppConfigDir, resolveAppConfigPath, writeAppConfigFile } from "./file.ts";
6
+ import { isFrameworkConfigKey, setBinding } from "./bindings.ts";
7
+ import {
8
+ readAppConfigFileRaw,
9
+ resolveAppConfigDir,
10
+ resolveAppConfigPath,
11
+ writeAppConfigFile,
12
+ writeAppConfigFileRaw,
13
+ } from "./file.ts";
7
14
  import type { ResolvedConfig } from "./resolve.ts";
8
15
  import { captureMappedHostEnv, exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
16
+ import { configPropertySchema, effectiveJsonSchema } from "./schema.ts";
17
+ import { validateParsedConfigValue } from "./validate.ts";
18
+
19
+ function rebuildResolved(program: CliProgram, fileData: Record<string, unknown>): ResolvedConfig {
20
+ const hostEnv = captureMappedHostEnv(program);
21
+ const resolved = resolveAppConfig(program, fileData, hostEnv);
22
+ exportConfigToEnv(program, resolved, hostEnv);
23
+ return resolved;
24
+ }
9
25
 
10
26
  /** Empty snapshot when program.appConfig is not set. */
11
27
  export class EmptyAppConfigSnapshot {
12
- constructor(private readonly program: CliProgram) {}
28
+ private fileData: Record<string, unknown>;
29
+
30
+ constructor(
31
+ private readonly program: CliProgram,
32
+ fileData?: Record<string, unknown>,
33
+ ) {
34
+ this.fileData = fileData ?? readAppConfigFileRaw(resolveAppConfigPath(program));
35
+ }
13
36
 
14
37
  get(_key: string): undefined {
15
38
  return undefined;
@@ -27,6 +50,20 @@ export class EmptyAppConfigSnapshot {
27
50
  return {};
28
51
  }
29
52
 
53
+ readUnsafe(): Record<string, unknown> {
54
+ return { ...this.fileData };
55
+ }
56
+
57
+ getUnsafe(key: string): unknown {
58
+ return this.fileData[key];
59
+ }
60
+
61
+ setUnsafe(key: string, value: unknown): void {
62
+ const next = { ...this.fileData, [key]: value };
63
+ writeAppConfigFileRaw(this.program, next);
64
+ this.fileData = next;
65
+ }
66
+
30
67
  /** Resolved absolute path to the app JSON config file (OS default from `program.key`). */
31
68
  get path(): string {
32
69
  return resolveAppConfigPath(this.program);
@@ -59,11 +96,7 @@ export class AppConfigSnapshot {
59
96
 
60
97
  require(key: string): unknown {
61
98
  const value = this.get(key);
62
- if (
63
- value === undefined ||
64
- value === null ||
65
- (typeof value === "string" && value.length === 0)
66
- ) {
99
+ if (value === undefined || value === null || (typeof value === "string" && value.length === 0)) {
67
100
  throw new Error(`Missing required configuration: ${key}`);
68
101
  }
69
102
  return value;
@@ -71,18 +104,34 @@ export class AppConfigSnapshot {
71
104
 
72
105
  set(key: string, value: unknown): void {
73
106
  this.assertEntryKey(key);
74
- const hostEnv = captureMappedHostEnv(this.program);
75
- const next = { ...this.fileData, [key]: value };
76
- writeAppConfigFile(this.program, next);
77
- this.fileData = next;
78
- this.snapshot = resolveAppConfig(this.program, next, hostEnv);
79
- exportConfigToEnv(this.program, this.snapshot, hostEnv);
107
+ const jsonSchema = effectiveJsonSchema(this.program);
108
+ if (!jsonSchema) {
109
+ throw new Error("Internal error: missing effective jsonSchema.");
110
+ }
111
+ const propSchema = configPropertySchema(jsonSchema, key);
112
+ validateParsedConfigValue(value, propSchema, jsonSchema);
113
+ const next = setBinding({ ...this.fileData, [key]: value }, key, "file");
114
+ this.persistFileData(next);
80
115
  }
81
116
 
82
117
  read(): ResolvedConfig {
83
118
  return { ...this.snapshot };
84
119
  }
85
120
 
121
+ readUnsafe(): Record<string, unknown> {
122
+ return { ...this.fileData };
123
+ }
124
+
125
+ getUnsafe(key: string): unknown {
126
+ return this.fileData[key];
127
+ }
128
+
129
+ setUnsafe(key: string, value: unknown): void {
130
+ this.assertUnsafeKey(key);
131
+ const next = { ...this.fileData, [key]: value };
132
+ this.persistFileData(next);
133
+ }
134
+
86
135
  /** Resolved absolute path to the app JSON config file (`~/.local/lib/<key>/config.json`). */
87
136
  get path(): string {
88
137
  return resolveAppConfigPath(this.program);
@@ -99,12 +148,23 @@ export class AppConfigSnapshot {
99
148
  this.snapshot = { ...resolved };
100
149
  }
101
150
 
151
+ private persistFileData(next: Record<string, unknown>): void {
152
+ writeAppConfigFile(this.program, next, { partial: true });
153
+ this.fileData = next;
154
+ this.snapshot = rebuildResolved(this.program, next);
155
+ }
156
+
102
157
  private assertEntryKey(key: string): void {
103
158
  const entries = this.program.appConfig?.entries;
104
159
  if (!entries || !(key in entries)) {
105
160
  throw new Error(`Unknown configuration key: ${key}`);
106
161
  }
107
162
  }
163
+
164
+ private assertUnsafeKey(key: string): void {
165
+ if (isFrameworkConfigKey(key)) return;
166
+ this.assertEntryKey(key);
167
+ }
108
168
  }
109
169
 
110
170
  export type AnyAppConfigSnapshot = AppConfigSnapshot | EmptyAppConfigSnapshot;
@@ -115,7 +175,7 @@ export function createAppConfigSnapshot(
115
175
  resolved: ResolvedConfig,
116
176
  ): AnyAppConfigSnapshot {
117
177
  if (!program.appConfig) {
118
- return new EmptyAppConfigSnapshot(program);
178
+ return new EmptyAppConfigSnapshot(program, fileData);
119
179
  }
120
180
  return new AppConfigSnapshot(program, fileData, resolved);
121
181
  }
@@ -46,9 +46,7 @@ export function configUserConfigKey(key: string): string {
46
46
  }
47
47
 
48
48
  /** Required keys from a JSON Schema object root. */
49
- export function jsonSchemaRequiredKeys(
50
- jsonSchema: Record<string, unknown>,
51
- ): Set<string> | undefined {
49
+ export function jsonSchemaRequiredKeys(jsonSchema: Record<string, unknown>): Set<string> | undefined {
52
50
  const required = jsonSchema.required;
53
51
  if (!Array.isArray(required)) {
54
52
  return undefined;
@@ -7,13 +7,17 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
7
7
  import { tmpdir } from "node:os";
8
8
  import { dirname, join } from "node:path";
9
9
  import type { CliProgram } from "../types.ts";
10
+ import { CONFIG_BINDINGS_KEY } from "./bindings.ts";
10
11
  import { bootstrapAppConfig } from "./bootstrap.ts";
11
12
  import {
13
+ appConfigFileExists,
14
+ ensureAppConfigFile,
12
15
  readAppConfigFile,
13
16
  resolveAppConfigDir,
14
17
  resolveAppConfigPath,
15
18
  uninstallAppConfig,
16
19
  writeAppConfigFile,
20
+ writeAppConfigFileRaw,
17
21
  } from "./file.ts";
18
22
  import { buildProgramUserConfig } from "./manifest.ts";
19
23
  import { formatMissingConfigMessage, missingRequiredConfig, resolveAppConfig } from "./resolve.ts";
@@ -58,9 +62,7 @@ describe("config/file", () => {
58
62
 
59
63
  test("resolveAppConfigPath uses config.json", () => {
60
64
  withHome((home) => {
61
- expect(resolveAppConfigPath(program)).toBe(
62
- join(home, ".local", "lib", "myapp", "config.json"),
63
- );
65
+ expect(resolveAppConfigPath(program)).toBe(join(home, ".local", "lib", "myapp", "config.json"));
64
66
  });
65
67
  });
66
68
 
@@ -126,4 +128,45 @@ describe("config/file", () => {
126
128
  expect(existsSync(configDir)).toBe(false);
127
129
  });
128
130
  });
131
+
132
+ test("ensureAppConfigFile creates empty config", () => {
133
+ withHome(() => {
134
+ const created = ensureAppConfigFile(program);
135
+ expect(created).toBe(resolveAppConfigPath(program));
136
+ expect(appConfigFileExists(program)).toBe(true);
137
+ expect(readAppConfigFile(program)).toEqual({});
138
+ });
139
+ });
140
+
141
+ test("ensureAppConfigFile no-op when file exists", () => {
142
+ withHome(() => {
143
+ writeAppConfigFileRaw(program, { apiToken: "x" });
144
+ expect(ensureAppConfigFile(program)).toBeNull();
145
+ });
146
+ });
147
+
148
+ test("read allows _bindings framework key", () => {
149
+ withHome(() => {
150
+ writeAppConfigFileRaw(program, {
151
+ [CONFIG_BINDINGS_KEY]: { apiToken: "env" },
152
+ });
153
+ expect(readAppConfigFile(program)).toEqual({
154
+ [CONFIG_BINDINGS_KEY]: { apiToken: "env" },
155
+ });
156
+ });
157
+ });
158
+
159
+ test("partial write allows bindings without required keys", () => {
160
+ withHome(() => {
161
+ writeAppConfigFile(program, { [CONFIG_BINDINGS_KEY]: { apiToken: "env" } }, { partial: true });
162
+ expect(readAppConfigFile(program)[CONFIG_BINDINGS_KEY]).toEqual({ apiToken: "env" });
163
+ });
164
+ });
165
+
166
+ test("appConfigFileExists false when only directory exists", () => {
167
+ withHome(() => {
168
+ mkdirSync(resolveAppConfigDir(program), { recursive: true });
169
+ expect(appConfigFileExists(program)).toBe(false);
170
+ });
171
+ });
129
172
  });
@@ -7,8 +7,9 @@ import { dirname, join } from "node:path";
7
7
  import { sanitizeToolSegment } from "../mcp/tools.ts";
8
8
  import { appConfigLibHome, displayHomePath } from "../paths/host.ts";
9
9
  import type { CliProgram } from "../types.ts";
10
+ import { isFrameworkConfigKey, validateBindingsShape } from "./bindings.ts";
10
11
  import { effectiveJsonSchema } from "./schema.ts";
11
- import { validateConfigDocument } from "./validate.ts";
12
+ import { validateConfigDocument, validateConfigDocumentPartial } from "./validate.ts";
12
13
 
13
14
  export type AppConfigFileData = Record<string, unknown>;
14
15
 
@@ -56,15 +57,13 @@ export function readAppConfigFileRaw(path: string): AppConfigFileData {
56
57
  return parseConfigJson(text, path);
57
58
  }
58
59
 
59
- /** Read and validate config file against program schema. */
60
- export function readAppConfigFile(program: CliProgram): AppConfigFileData {
61
- const path = resolveAppConfigPath(program);
62
- const data = readAppConfigFileRaw(path);
63
- if (Object.keys(data).length === 0) {
64
- return data;
65
- }
66
- validateAppConfigData(program, data, path);
67
- return data;
60
+ function isEmptyConfigDocument(data: AppConfigFileData): boolean {
61
+ return Object.keys(data).every((k) => isFrameworkConfigKey(k));
62
+ }
63
+
64
+ /** True when `config.json` exists on disk. */
65
+ export function appConfigFileExists(program: CliProgram): boolean {
66
+ return existsSync(resolveAppConfigPath(program));
68
67
  }
69
68
 
70
69
  /** Validate in-memory config data; throws on failure. */
@@ -72,15 +71,21 @@ export function validateAppConfigData(
72
71
  program: CliProgram,
73
72
  data: AppConfigFileData,
74
73
  pathLabel?: string,
74
+ opts: { partial?: boolean } = {},
75
75
  ): void {
76
76
  const appConfig = program.appConfig;
77
77
  if (!appConfig) {
78
78
  throw new Error("program.appConfig is not set");
79
79
  }
80
+ const where = pathLabel ? displayHomePath(pathLabel) : "config";
81
+ const bindingErrors = validateBindingsShape(data);
82
+ if (bindingErrors.length > 0) {
83
+ throw new Error(`Invalid config in ${where}: ${bindingErrors.join("; ")}`);
84
+ }
80
85
  const allowed = new Set(Object.keys(appConfig.entries));
81
86
  for (const key of Object.keys(data)) {
87
+ if (isFrameworkConfigKey(key)) continue;
82
88
  if (!allowed.has(key)) {
83
- const where = pathLabel ? displayHomePath(pathLabel) : "config";
84
89
  throw new Error(`Unknown config key '${key}' in ${where}`);
85
90
  }
86
91
  }
@@ -88,26 +93,61 @@ export function validateAppConfigData(
88
93
  if (!jsonSchema) {
89
94
  return;
90
95
  }
96
+ if (opts.partial || isEmptyConfigDocument(data)) {
97
+ const result = validateConfigDocumentPartial(data, jsonSchema);
98
+ if (!result.valid) {
99
+ throw new Error(`Invalid config in ${where}: ${result.errors.join("; ")}`);
100
+ }
101
+ return;
102
+ }
91
103
  const result = validateConfigDocument(data, jsonSchema);
92
104
  if (!result.valid) {
93
- const where = pathLabel ? displayHomePath(pathLabel) : "config";
94
105
  throw new Error(`Invalid config in ${where}: ${result.errors.join("; ")}`);
95
106
  }
96
107
  }
97
108
 
98
- /** Merge-write schema keys to the config file (`0o600`). */
99
- export function writeAppConfigFile(program: CliProgram, data: AppConfigFileData): void {
100
- validateAppConfigData(program, data, displayAppConfigPath(program));
109
+ /** Write JSON to the config path without schema validation (`0o600`). */
110
+ export function writeAppConfigFileRaw(program: CliProgram, data: AppConfigFileData, dry = false): void {
101
111
  const path = resolveAppConfigPath(program);
112
+ if (dry) return;
102
113
  mkdirSync(dirname(path), { recursive: true });
103
114
  writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
104
115
  }
105
116
 
106
- /** True when the app config file or config directory is present. */
107
- export function appConfigInstalled(program: CliProgram): boolean {
117
+ /** Create `config.json` as `{}` when missing; returns absolute path if created. */
118
+ export function ensureAppConfigFile(program: CliProgram, dry = false): string | null {
119
+ const path = resolveAppConfigPath(program);
120
+ if (existsSync(path)) {
121
+ return null;
122
+ }
123
+ writeAppConfigFileRaw(program, {}, dry);
124
+ return path;
125
+ }
126
+
127
+ /** Read and validate config file against program schema. */
128
+ export function readAppConfigFile(program: CliProgram): AppConfigFileData {
108
129
  const path = resolveAppConfigPath(program);
109
- if (existsSync(path)) return true;
110
- return existsSync(resolveAppConfigDir(program));
130
+ const data = readAppConfigFileRaw(path);
131
+ if (isEmptyConfigDocument(data)) {
132
+ return data;
133
+ }
134
+ validateAppConfigData(program, data, path);
135
+ return data;
136
+ }
137
+
138
+ /** Merge-write schema keys to the config file (`0o600`). */
139
+ export function writeAppConfigFile(
140
+ program: CliProgram,
141
+ data: AppConfigFileData,
142
+ opts: { partial?: boolean } = {},
143
+ ): void {
144
+ validateAppConfigData(program, data, displayAppConfigPath(program), opts);
145
+ writeAppConfigFileRaw(program, data);
146
+ }
147
+
148
+ /** True when the app config file is present. */
149
+ export function appConfigInstalled(program: CliProgram): boolean {
150
+ return appConfigFileExists(program);
111
151
  }
112
152
 
113
153
  /** Removes the app config file and config directory when present. */
@@ -35,10 +35,7 @@ export function captureMappedHostEnv(program: CliProgram): Record<string, string
35
35
  return out;
36
36
  }
37
37
 
38
- function envOverrideValue(
39
- envName: string,
40
- hostEnv?: Record<string, string | undefined>,
41
- ): string | undefined {
38
+ function envOverrideValue(envName: string, hostEnv?: Record<string, string | undefined>): string | undefined {
42
39
  const val = hostEnv && envName in hostEnv ? hostEnv[envName] : process.env[envName];
43
40
  if (val === undefined || val.length === 0) {
44
41
  return undefined;
@@ -6,9 +6,7 @@ import type { CliAppConfigEntry, CliProgram } from "../types.ts";
6
6
  import { configEntryRequired, jsonSchemaRequiredKeys } from "./entry.ts";
7
7
 
8
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> {
9
+ export function synthesizeAllStringSchema(schema: Record<string, CliAppConfigEntry>): Record<string, unknown> {
12
10
  const properties: Record<string, unknown> = {};
13
11
  const required: string[] = [];
14
12
  for (const [key, entry] of Object.entries(schema)) {
@@ -24,10 +24,7 @@ const rootSchema = {
24
24
  /** Tests for config/validate. */
25
25
  describe("config/validate", () => {
26
26
  test("accepts valid document", () => {
27
- const result = validateConfigDocument(
28
- { apiToken: "x", maxRetries: 3, prefs: { ttl: 3600 } },
29
- rootSchema,
30
- );
27
+ const result = validateConfigDocument({ apiToken: "x", maxRetries: 3, prefs: { ttl: 3600 } }, rootSchema);
31
28
  expect(result.valid).toBe(true);
32
29
  expect(result.errors).toEqual([]);
33
30
  });
@@ -39,10 +36,7 @@ describe("config/validate", () => {
39
36
  });
40
37
 
41
38
  test("rejects unknown property when additionalProperties is false", () => {
42
- const result = validateConfigDocument(
43
- { apiToken: "x", maxRetries: 1, extra: true },
44
- rootSchema,
45
- );
39
+ const result = validateConfigDocument({ apiToken: "x", maxRetries: 1, extra: true }, rootSchema);
46
40
  expect(result.valid).toBe(false);
47
41
  expect(result.errors.some((e) => e.includes("extra"))).toBe(true);
48
42
  });
@@ -58,9 +52,7 @@ describe("config/validate", () => {
58
52
  });
59
53
 
60
54
  test("parseConfigSetValue requires --json for objects", () => {
61
- expect(() => parseConfigSetValue("ttl:1", { type: "object" }, rootSchema, false)).toThrow(
62
- /--json/,
63
- );
55
+ expect(() => parseConfigSetValue("ttl:1", { type: "object" }, rootSchema, false)).toThrow(/--json/);
64
56
  expect(parseConfigSetValue('{"ttl":1}', { type: "object" }, rootSchema, true)).toEqual({
65
57
  ttl: 1,
66
58
  });
@@ -4,6 +4,7 @@ Aligned with common ts-json-schema-generator output (local $ref, objects, scalar
4
4
  */
5
5
 
6
6
  import { parseCommaList, parseDate, parseDateTime } from "../formats.ts";
7
+ import { isFrameworkConfigKey } from "./bindings.ts";
7
8
 
8
9
  type JsonSchema = Record<string, unknown>;
9
10
 
@@ -21,7 +22,14 @@ export interface ValidateResult {
21
22
  /** Validate `data` against a JSON Schema root. Returns human-readable error messages. */
22
23
  export function validateConfigDocument(data: unknown, rootSchema: JsonSchema): ValidateResult {
23
24
  const errors: string[] = [];
24
- validateValue(data, rootSchema, rootSchema, "$", errors);
25
+ validateValue(data, rootSchema, rootSchema, "$", errors, false);
26
+ return { valid: errors.length === 0, errors };
27
+ }
28
+
29
+ /** Validate present keys only — skips root `required` checks (partial writes / bootstrap). */
30
+ export function validateConfigDocumentPartial(data: unknown, rootSchema: JsonSchema): ValidateResult {
31
+ const errors: string[] = [];
32
+ validateValue(data, rootSchema, rootSchema, "$", errors, true);
25
33
  return { valid: errors.length === 0, errors };
26
34
  }
27
35
 
@@ -31,6 +39,7 @@ function validateValue(
31
39
  root: JsonSchema,
32
40
  path: string,
33
41
  errors: string[],
42
+ partial: boolean,
34
43
  ): void {
35
44
  const resolved = resolveSchema(schema, root);
36
45
  if (!resolved) {
@@ -59,7 +68,7 @@ function validateValue(
59
68
  continue;
60
69
  }
61
70
  const branchErrs: string[] = [];
62
- validateValue(value, branch as JsonSchema, root, path, branchErrs);
71
+ validateValue(value, branch as JsonSchema, root, path, branchErrs, partial);
63
72
  if (branchErrs.length === 0) {
64
73
  return;
65
74
  }
@@ -93,10 +102,10 @@ function validateValue(
93
102
  errors.push(`${path}: must be null`);
94
103
  }
95
104
  if (types.includes("object") || (types.length === 0 && isPlainObject(value))) {
96
- validateObject(value, resolved, root, path, errors);
105
+ validateObject(value, resolved, root, path, errors, partial);
97
106
  }
98
107
  if (types.includes("array") || (types.length === 0 && Array.isArray(value))) {
99
- validateArray(value, resolved, root, path, errors);
108
+ validateArray(value, resolved, root, path, errors, partial);
100
109
  }
101
110
  }
102
111
 
@@ -165,13 +174,7 @@ function validateString(value: unknown, schema: JsonSchema, path: string, errors
165
174
  }
166
175
  }
167
176
 
168
- function validateNumber(
169
- value: unknown,
170
- schema: JsonSchema,
171
- path: string,
172
- errors: string[],
173
- integer: boolean,
174
- ): void {
177
+ function validateNumber(value: unknown, schema: JsonSchema, path: string, errors: string[], integer: boolean): void {
175
178
  if (typeof value !== "number" || Number.isNaN(value)) {
176
179
  errors.push(`${path}: must be number`);
177
180
  return;
@@ -193,6 +196,7 @@ function validateObject(
193
196
  root: JsonSchema,
194
197
  path: string,
195
198
  errors: string[],
199
+ partial: boolean,
196
200
  ): void {
197
201
  if (!isPlainObject(value)) {
198
202
  errors.push(`${path}: must be object`);
@@ -207,22 +211,25 @@ function validateObject(
207
211
  if (propMap) {
208
212
  for (const [key, propSchema] of Object.entries(propMap)) {
209
213
  if (key in value) {
210
- validateValue(value[key], propSchema, root, `${path}.${key}`, errors);
214
+ validateValue(value[key], propSchema, root, `${path}.${key}`, errors, partial);
211
215
  }
212
216
  }
213
217
  }
214
218
 
215
- const required = schema.required;
216
- if (Array.isArray(required)) {
217
- for (const key of required) {
218
- if (typeof key === "string" && !(key in value)) {
219
- errors.push(`${path}: missing required property '${key}'`);
219
+ if (!partial) {
220
+ const required = schema.required;
221
+ if (Array.isArray(required)) {
222
+ for (const key of required) {
223
+ if (typeof key === "string" && !(key in value)) {
224
+ errors.push(`${path}: missing required property '${key}'`);
225
+ }
220
226
  }
221
227
  }
222
228
  }
223
229
 
224
230
  if (schema.additionalProperties === false && propMap) {
225
231
  for (const key of Object.keys(value)) {
232
+ if (isFrameworkConfigKey(key)) continue;
226
233
  if (!(key in propMap)) {
227
234
  errors.push(`${path}: unknown property '${key}'`);
228
235
  }
@@ -236,6 +243,7 @@ function validateArray(
236
243
  root: JsonSchema,
237
244
  path: string,
238
245
  errors: string[],
246
+ partial: boolean,
239
247
  ): void {
240
248
  if (!Array.isArray(value)) {
241
249
  errors.push(`${path}: must be array`);
@@ -250,12 +258,12 @@ function validateArray(
250
258
  const items = schema.items;
251
259
  if (typeof items === "object" && items !== null && !Array.isArray(items)) {
252
260
  for (let i = 0; i < value.length; i++) {
253
- validateValue(value[i], items as JsonSchema, root, `${path}[${i}]`, errors);
261
+ validateValue(value[i], items as JsonSchema, root, `${path}[${i}]`, errors, partial);
254
262
  }
255
263
  }
256
264
  }
257
265
 
258
- function validateParsedConfigValue(
266
+ export function validateParsedConfigValue(
259
267
  parsed: unknown,
260
268
  propertySchema: JsonSchema | undefined,
261
269
  rootSchema: JsonSchema,
@@ -264,18 +272,14 @@ function validateParsedConfigValue(
264
272
  return parsed;
265
273
  }
266
274
  const errors: string[] = [];
267
- validateValue(parsed, propertySchema, rootSchema, "$", errors);
275
+ validateValue(parsed, propertySchema, rootSchema, "$", errors, false);
268
276
  if (errors.length > 0) {
269
277
  throw new Error(errors[0] ?? "Invalid config value");
270
278
  }
271
279
  return parsed;
272
280
  }
273
281
 
274
- function parseJsonLiteral(
275
- raw: string,
276
- propertySchema: JsonSchema | undefined,
277
- rootSchema: JsonSchema,
278
- ): unknown {
282
+ function parseJsonLiteral(raw: string, propertySchema: JsonSchema | undefined, rootSchema: JsonSchema): unknown {
279
283
  let parsed: unknown;
280
284
  try {
281
285
  parsed = JSON.parse(raw) as unknown;
@@ -346,11 +350,7 @@ function parsePrimitiveArraySegment(segment: string, items: PrimitiveArrayItems)
346
350
  }
347
351
  }
348
352
 
349
- function parseHomogeneousPrimitiveArray(
350
- raw: string,
351
- arraySchema: JsonSchema,
352
- rootSchema: JsonSchema,
353
- ): unknown[] {
353
+ function parseHomogeneousPrimitiveArray(raw: string, arraySchema: JsonSchema, rootSchema: JsonSchema): unknown[] {
354
354
  const items = homogeneousPrimitiveArrayItems(arraySchema, rootSchema);
355
355
  if (!items) {
356
356
  throw new Error("Use --json for object or array config values");
@@ -85,11 +85,7 @@ test("MCP config file loads and exports vars for tool handlers", async () => {
85
85
  const dir = mkdtempSync(join(tmpdir(), "argsbarg-mcp-"));
86
86
  const configFile = join(dir, ".local", "lib", "mcp_test", "config");
87
87
  mkdirSync(dirname(configFile), { recursive: true });
88
- writeFileSync(
89
- configFile,
90
- `${JSON.stringify({ argsTestSecret: "file-value" }, null, 2)}\n`,
91
- "utf8",
92
- );
88
+ writeFileSync(configFile, `${JSON.stringify({ argsTestSecret: "file-value" }, null, 2)}\n`, "utf8");
93
89
  const responses = await mcpRequest(
94
90
  [
95
91
  {
@@ -6,11 +6,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
6
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
- import {
10
- appConfigHasEntries,
11
- parseConfigureOpts,
12
- validateConfigureOpts,
13
- } from "../configure/index.ts";
9
+ import { appConfigFileExists } from "../config/file.ts";
10
+ import { appConfigHasEntries, parseConfigureOpts, validateConfigureOpts } from "../configure/index.ts";
11
+ import { Cli } from "../index.ts";
14
12
  import { detectInstalledArtifacts } from "../install/detect-installed.ts";
15
13
  import { parseInstallOpts } from "../install/opts.ts";
16
14
  import { resolveClaudeDesktopMcpPath, resolveInstallPaths } from "../install/paths.ts";
@@ -157,3 +155,19 @@ describe("app config wizard", () => {
157
155
  expect(appConfigHasEntries(fixture)).toBe(false);
158
156
  });
159
157
  });
158
+
159
+ describe("configure --sync bootstrap", () => {
160
+ test("creates config.json for apps without appConfig", async () => {
161
+ const program: CliProgram = {
162
+ key: "syncboot",
163
+ version: "0.0.0",
164
+ description: "Sync bootstrap test.",
165
+ handler: () => {},
166
+ configure: { enabled: true },
167
+ };
168
+ expect(appConfigFileExists(program)).toBe(false);
169
+ const result = await new Cli(program).invoke(["configure", "--sync", "--yes"]);
170
+ expect(result.exitCode).toBe(0);
171
+ expect(appConfigFileExists(program)).toBe(true);
172
+ });
173
+ });