structured-context 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +5 -3
  2. package/dist/api.d.ts +3 -2
  3. package/dist/api.js +3 -2
  4. package/dist/commands/dump.js +1 -1
  5. package/dist/commands/spaces.d.ts +5 -0
  6. package/dist/commands/spaces.js +46 -10
  7. package/dist/commands/validate.js +8 -8
  8. package/dist/config/health.d.ts +31 -0
  9. package/dist/config/health.js +86 -0
  10. package/dist/{config.d.ts → config/index.d.ts} +18 -3
  11. package/dist/{config.js → config/index.js} +76 -37
  12. package/dist/config/write.d.ts +27 -0
  13. package/dist/config/write.js +91 -0
  14. package/dist/constants.d.ts +2 -0
  15. package/dist/constants.js +2 -0
  16. package/dist/errors.d.ts +9 -0
  17. package/dist/errors.js +8 -0
  18. package/dist/filter/augment-nodes.d.ts +5 -4
  19. package/dist/filter/augment-nodes.js +20 -19
  20. package/dist/filter/expand-include.d.ts +2 -2
  21. package/dist/filter/expand-include.js +15 -17
  22. package/dist/filter/filter-nodes.js +2 -2
  23. package/dist/index.js +13 -2
  24. package/dist/judge/context.js +12 -12
  25. package/dist/judge/judge-node.d.ts +3 -3
  26. package/dist/judge/judge-node.js +7 -7
  27. package/dist/judge/prompt.js +3 -3
  28. package/dist/judge/provider/fake.d.ts +1 -1
  29. package/dist/judge/provider/fake.js +3 -6
  30. package/dist/judge/provider/jev.js +1 -1
  31. package/dist/judge/provider/openai-decisions.js +1 -1
  32. package/dist/judge/types.d.ts +4 -1
  33. package/dist/plugins/markdown/index.d.ts +6 -0
  34. package/dist/plugins/markdown/index.js +1 -0
  35. package/dist/plugins/markdown/parse-embedded.d.ts +7 -2
  36. package/dist/plugins/markdown/parse-embedded.js +23 -21
  37. package/dist/plugins/markdown/read-space.js +10 -8
  38. package/dist/plugins/markdown/render-bullets.js +3 -3
  39. package/dist/plugins/markdown/util.d.ts +11 -0
  40. package/dist/plugins/markdown/util.js +21 -0
  41. package/dist/plugins/mermaid/render-mermaid.js +5 -6
  42. package/dist/plugins/miro/cache.js +1 -1
  43. package/dist/plugins/miro/sync.js +3 -2
  44. package/dist/read/resolve-graph-edges.js +12 -18
  45. package/dist/schema/evaluate-rule.d.ts +4 -4
  46. package/dist/schema/evaluate-rule.js +14 -10
  47. package/dist/schema/gating.d.ts +10 -10
  48. package/dist/schema/gating.js +45 -44
  49. package/dist/schema/validate-graph.js +8 -11
  50. package/dist/schema/validate-rules.d.ts +4 -4
  51. package/dist/schema/validate-rules.js +15 -17
  52. package/dist/schemas/_ost_strict.json +1 -1
  53. package/dist/space-graph.d.ts +9 -9
  54. package/dist/space-graph.js +16 -16
  55. package/dist/testing.d.ts +1 -1
  56. package/dist/testing.js +3 -3
  57. package/dist/types.d.ts +14 -10
  58. package/dist/validate.d.ts +2 -2
  59. package/dist/validate.js +18 -19
  60. package/docs/concepts.md +26 -8
  61. package/docs/config.md +31 -6
  62. package/docs/rules.md +17 -13
  63. package/package.json +4 -4
  64. package/schemas/_ost_strict.json +1 -1
package/README.md CHANGED
@@ -27,7 +27,7 @@ Content and structure
27
27
  Valid 16
28
28
  Schema validation errors 0
29
29
  Broken links 1
30
- Duplicate keys 0
30
+ Duplicate ids 0
31
31
  Rule violations (error) 0
32
32
  Rule violations (warning) 1
33
33
  Rule violations (info) 1
@@ -77,6 +77,8 @@ claude plugin marketplace add mindsocket/structured-context
77
77
  claude plugin install structured-context
78
78
  ```
79
79
 
80
+ Ask Claude to set up a space (or run `/setup-space`) in the folder that holds your notes. The plugin's hooks need Bun.
81
+
80
82
  Skills can also be installed standalone without the plugin:
81
83
 
82
84
  ```
@@ -89,7 +91,7 @@ See [docs/concepts.md](docs/concepts.md) (`sctx docs concepts`) for terminology:
89
91
 
90
92
  ## Configuration
91
93
 
92
- The config file maps space names to paths and schemas, with optional plugin settings. It is found from `--config`, then `$SCTX_CONFIG`, then `$XDG_CONFIG_HOME/structured-context/config.json` (default `~/.config/...`), then `./config.json`. Paths resolve relative to the config file, and `includeSpacesFrom` pulls in spaces from other config files.
94
+ The config file maps space names to paths and schemas, with optional plugin settings. It is found from `--config`, then `$SCTX_CONFIG`, then the nearest `.sctx.json` walking up from the working directory, then `$XDG_CONFIG_HOME/structured-context/config.json` (default `~/.config/...`). Paths resolve relative to the config file, and `includeSpacesFrom` pulls in spaces from other config files.
93
95
 
94
96
  See `config.example.json` and [docs/config.md](docs/config.md) (`sctx docs config`) for the full reference, including `fieldMap`, `typeInference`, templates, filter views and plugin loading.
95
97
 
@@ -138,7 +140,7 @@ Validates markdown files against the JSON schema:
138
140
  - Extracts YAML frontmatter from each `.md` file
139
141
  - Skips files without frontmatter or without a `type` field
140
142
  - Reports validation results with counts and per-file errors
141
- - Runs executable rules (`$metadata.rules`) with **cascade gating**: a schema, hierarchy, or blocking-rule failure on a node suppresses rule evaluation for that node's hierarchy subtree. Gated rules are reported in a Coverage section so you know what wasn't evaluated and why. See [docs/rules.md](docs/rules.md#cascade-gating) for details.
143
+ - Runs executable rules (`$metadata.rules`) with **cascade gating**: a schema, hierarchy or gating rule violation on a node suppresses rule evaluation for that node's hierarchy subtree. Gated rules are reported in a Coverage section so you know what wasn't evaluated and why. See [docs/rules.md](docs/rules.md#cascade-gating) for details.
142
144
  - `--depth <n>` limits evaluation to hierarchy nodes within `n` breadth-first levels from the top — useful for staged agentic fix loops (fix the top level, then request the next).
143
145
  - `--include-gated` evaluates rules even on gated nodes (still reported as gated) — for surfacing cheap bottom-up checks on request.
144
146
  - `--semantic` also evaluates the `semantic` part of rules with the configured model provider (Jev, Anthropic or OpenAI-compatible); `--semantic <provider>` uses that configured model provider instead; `--rule-filter` narrows which rules run. See [docs/semantic-rules.md](docs/semantic-rules.md) (`sctx docs semantic-rules`).
package/dist/api.d.ts CHANGED
@@ -19,8 +19,9 @@
19
19
  */
20
20
  export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
21
21
  export type { Config, SpaceConfig } from './config';
22
- export { loadConfig, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
23
- export { PLUGIN_PREFIX } from './constants';
22
+ export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
23
+ export { addSpace } from './config/write';
24
+ export { LOCAL_CONFIG_FILE, PLUGIN_PREFIX } from './constants';
24
25
  export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
25
26
  export type { ParseHook, ParseResult, PluginContext, RenderFileOutput, RenderFormat, RenderHook, RenderMultiFileOutput, RenderOptions, RenderResult, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
26
27
  export { isRenderMultiFileOutput, resolvePluginDir, resolvePluginSchemasDir, resolveSpacePluginSchemas, } from './plugins/util';
package/dist/api.js CHANGED
@@ -17,8 +17,9 @@
17
17
  * const graph = await assembleSpaceGraph(ctx, { readResult });
18
18
  * const validation = await validateSpace(ctx, { readResult });
19
19
  */
20
- export { loadConfig, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
21
- export { PLUGIN_PREFIX } from './constants';
20
+ export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
21
+ export { addSpace } from './config/write';
22
+ export { LOCAL_CONFIG_FILE, PLUGIN_PREFIX } from './constants';
22
23
  export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
23
24
  export { isRenderMultiFileOutput, resolvePluginDir, resolvePluginSchemasDir, resolveSpacePluginSchemas, } from './plugins/util';
24
25
  export { readSpace } from './read/read-space';
@@ -11,7 +11,7 @@ export async function buildDump(context, options = {}) {
11
11
  if (!options.filter)
12
12
  return readResult;
13
13
  const graph = await assembleSpaceGraph(context, { ...options, readResult });
14
- const nodes = readResult.nodes.filter((node) => graph.nodes.get(node.title) === node);
14
+ const nodes = readResult.nodes.filter((node) => graph.nodes.get(node.id) === node);
15
15
  return { ...readResult, nodes };
16
16
  }
17
17
  export async function dump(context, options = {}) {
@@ -1,3 +1,8 @@
1
1
  export declare function listSpaces(options?: {
2
2
  json?: boolean;
3
3
  }): void;
4
+ export declare function addSpaceCommand(name: string, path: string, options: {
5
+ schema?: string;
6
+ global?: boolean;
7
+ json?: boolean;
8
+ }): void;
@@ -1,23 +1,38 @@
1
1
  import { basename, resolve } from 'node:path';
2
- import { configPath, getConfigSourceFiles, loadConfig } from '../config';
2
+ import { loadConfig } from '../config';
3
+ import { checkConfig, hasBlockingProblem } from '../config/health';
4
+ import { addSpace } from '../config/write';
5
+ import { SctxError } from '../errors';
3
6
  function renderConfigValue(v) {
4
7
  if (typeof v === 'object' && v !== null)
5
8
  return JSON.stringify(v);
6
9
  return String(v);
7
10
  }
8
11
  export function listSpaces(options = {}) {
9
- const path = resolve(configPath());
10
- const config = loadConfig();
12
+ const health = checkConfig();
11
13
  if (options.json) {
12
- const spaces = config.spaces.map((space) => ({
13
- name: space.name,
14
- path: space.path,
15
- schema: space.schema ?? config.schema,
16
- }));
17
- console.log(JSON.stringify({ configFiles: [...getConfigSourceFiles()], spaces }));
14
+ console.log(JSON.stringify(health));
15
+ if (hasBlockingProblem(health))
16
+ process.exitCode = 1;
18
17
  return;
19
18
  }
20
- console.log(`Config: ${path}\n`);
19
+ const path = resolve(health.config.path);
20
+ if (health.problems.length > 0) {
21
+ console.log(`Config: ${path} (${health.config.source}${health.config.exists ? '' : ', not found'})\n`);
22
+ for (const p of health.problems) {
23
+ console.log(`${p.severity.toUpperCase()} ${p.code}: ${p.message}`);
24
+ if (p.fix)
25
+ console.log(` fix: ${p.fix}`);
26
+ }
27
+ console.log('');
28
+ if (hasBlockingProblem(health))
29
+ process.exitCode = 1;
30
+ if (!health.config.exists)
31
+ return;
32
+ }
33
+ const config = loadConfig();
34
+ if (health.problems.length === 0)
35
+ console.log(`Config: ${path} (${health.config.source})\n`);
21
36
  for (const space of config.spaces) {
22
37
  console.log(`${space.name}`);
23
38
  console.log(` path: ${space.path}`);
@@ -42,3 +57,24 @@ export function listSpaces(options = {}) {
42
57
  console.log('');
43
58
  }
44
59
  }
60
+ export function addSpaceCommand(name, path, options) {
61
+ try {
62
+ const result = addSpace({ name, path, schema: options.schema, global: options.global });
63
+ if (options.json) {
64
+ console.log(JSON.stringify(result));
65
+ return;
66
+ }
67
+ console.log(`${result.created ? 'Created' : 'Updated'} ${result.configPath}`);
68
+ console.log(`Added space "${result.space.name}" (path: ${result.space.path})`);
69
+ }
70
+ catch (err) {
71
+ if (!(err instanceof SctxError))
72
+ throw err;
73
+ const hint = err.code === 'E_TARGET_REQUIRED' ? ' Use --config <folder>/.sctx.json or --global.' : '';
74
+ if (options.json)
75
+ console.log(JSON.stringify({ error: { code: err.code, message: err.message + hint } }));
76
+ else
77
+ console.error(`${err.code}: ${err.message}${hint}`);
78
+ process.exitCode = 1;
79
+ }
80
+ }
@@ -34,10 +34,10 @@ export async function validate(context, options = {}) {
34
34
  for (const { file, parent, error } of result.refErrors) {
35
35
  addError(file, `broken-link:${parent}`, 'broken-link', `${parent} → ${error}`);
36
36
  }
37
- for (const { title, files } of result.duplicateErrors) {
37
+ for (const { id, files } of result.duplicateErrors) {
38
38
  for (const file of files) {
39
39
  const others = files.filter((f) => f !== file);
40
- addError(file, `duplicate:${title}`, 'duplicate', `Duplicate title "${title}" also exists in: ${others.join(', ')}`);
40
+ addError(file, `duplicate:${id}`, 'duplicate', `Duplicate id "${id}" also exists in: ${others.join(', ')}`);
41
41
  }
42
42
  }
43
43
  for (const v of result.ruleViolations) {
@@ -97,7 +97,7 @@ export async function validate(context, options = {}) {
97
97
  console.log(fmt(' Valid', result.validCount));
98
98
  console.log(fmt(' Schema validation errors', result.nodeErrorCount, true));
99
99
  console.log(fmt(' Broken links', result.refErrors.length, true));
100
- console.log(fmt(' Duplicate keys', result.duplicateErrors.length, true));
100
+ console.log(fmt(' Duplicate ids', result.duplicateErrors.length, true));
101
101
  console.log(fmt(' Rule violations (error)', ruleCounts.error, true));
102
102
  console.log(fmt(' Rule violations (warning)', ruleCounts.warning, true, true));
103
103
  console.log(fmt(' Rule violations (info)', ruleCounts.info, true, true));
@@ -114,7 +114,7 @@ export async function validate(context, options = {}) {
114
114
  if (result.orphans.length > 0) {
115
115
  console.log(`\nOrphans (hierarchy nodes - no parent):`);
116
116
  for (const node of result.orphans)
117
- console.log(` ${node.label}`);
117
+ console.log(` ${node.id}`);
118
118
  }
119
119
  if (result.unresolvedContentLinks.length > 0) {
120
120
  console.log(`\nUnresolved content links (may be outside-space or broken):`);
@@ -157,9 +157,9 @@ export async function validate(context, options = {}) {
157
157
  });
158
158
  }
159
159
  if (result.duplicateErrors.length > 0) {
160
- console.log(`\nDuplicate keys (same title in multiple files):`);
161
- result.duplicateErrors.forEach(({ title, files }) => {
162
- console.log(` "${title}":`);
160
+ console.log(`\nDuplicate ids (same id on multiple nodes):`);
161
+ result.duplicateErrors.forEach(({ id, files }) => {
162
+ console.log(` "${id}":`);
163
163
  for (const f of files) {
164
164
  console.log(` ${f}`);
165
165
  }
@@ -194,7 +194,7 @@ export async function validate(context, options = {}) {
194
194
  console.log(`\nCoverage — rule checks gated by cascade (use --include-gated to evaluate anyway):`);
195
195
  for (const gate of result.coverageGates) {
196
196
  const cause = gate.reason === 'rule' ? `blocking rule "${gate.ruleId}"` : `${gate.reason} failure`;
197
- console.log(` ${gate.file} (${gate.nodeTitle}) — ${cause} gated ${gate.gatedFiles.length} node(s):`);
197
+ console.log(` ${gate.file} (${gate.nodeId}) — ${cause} gated ${gate.gatedFiles.length} node(s):`);
198
198
  for (const file of gate.gatedFiles)
199
199
  console.log(` ${file}`);
200
200
  }
@@ -0,0 +1,31 @@
1
+ import { type SctxErrorCode } from '../errors';
2
+ import { type ResolvedConfigPath } from '.';
3
+ export type ConfigProblem = {
4
+ code: SctxErrorCode;
5
+ severity: 'error' | 'warning' | 'info';
6
+ message: string;
7
+ fix: string;
8
+ space?: string;
9
+ };
10
+ export type ConfigHealth = {
11
+ config: ResolvedConfigPath & {
12
+ exists: boolean;
13
+ };
14
+ configFiles: string[];
15
+ spaces: {
16
+ name: string;
17
+ path: string;
18
+ schema?: string;
19
+ }[];
20
+ problems: ConfigProblem[];
21
+ };
22
+ export declare function fixFor(code: SctxErrorCode): string;
23
+ /**
24
+ * Check that the config for the working directory can be found and loaded, and that each space's
25
+ * path and schema exist. Never throws: every failure becomes a problem with a stable code.
26
+ */
27
+ export declare function checkConfig(options?: {
28
+ startDir?: string;
29
+ }): ConfigHealth;
30
+ /** True when the health check found something that stops validation from running. */
31
+ export declare function hasBlockingProblem(health: ConfigHealth): boolean;
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import JSON5 from 'json5';
3
+ import { LOCAL_CONFIG_FILE } from '../constants';
4
+ import { SctxError } from '../errors';
5
+ import { getConfigSourceFiles, loadConfig, resolveConfigPath, resolveSchema } from '.';
6
+ const FIXES = {
7
+ E_CONFIG_NOT_FOUND: `Create a config with \`sctx spaces add <name> <path> --schema <schema> --config <folder>/${LOCAL_CONFIG_FILE}\`, or use the setup-space skill.`,
8
+ E_CONFIG_INVALID: 'Fix the JSON in the config file named in the message; `sctx docs config` describes the shape.',
9
+ E_INCLUDE_NOT_FOUND: 'Correct or remove the missing path in `includeSpacesFrom`.',
10
+ E_NO_SPACES: 'Add a space with `sctx spaces add`.',
11
+ E_SPACE_PATH_MISSING: 'The folder or file was moved or deleted. Correct `path` in the config, or remove the space.',
12
+ E_SCHEMA_MISSING: 'Set `schema` on the space or at the top level of the config file.',
13
+ E_SCHEMA_NOT_FOUND: 'Check the schema name or path. `sctx schemas list` shows the bundled schemas.',
14
+ E_SCHEMA_INVALID: 'The schema file is not valid JSON5. Open it and fix the syntax.',
15
+ };
16
+ export function fixFor(code) {
17
+ return FIXES[code] ?? '';
18
+ }
19
+ function problem(code, severity, message, space) {
20
+ return { code, severity, message, fix: fixFor(code), ...(space ? { space } : {}) };
21
+ }
22
+ /**
23
+ * Check that the config for the working directory can be found and loaded, and that each space's
24
+ * path and schema exist. Never throws: every failure becomes a problem with a stable code.
25
+ */
26
+ export function checkConfig(options = {}) {
27
+ const resolved = resolveConfigPath(options.startDir);
28
+ const exists = existsSync(resolved.path);
29
+ const health = { config: { ...resolved, exists }, configFiles: [], spaces: [], problems: [] };
30
+ if (!exists) {
31
+ const detail = resolved.source === 'none'
32
+ ? `No ${LOCAL_CONFIG_FILE} found from the working directory upward, and no global config at ${resolved.path}`
33
+ : `Config file not found: ${resolved.path}`;
34
+ health.problems.push(problem('E_CONFIG_NOT_FOUND', resolved.source === 'none' ? 'info' : 'error', detail));
35
+ return health;
36
+ }
37
+ let config;
38
+ try {
39
+ config = loadConfig({ startDir: options.startDir });
40
+ }
41
+ catch (err) {
42
+ const code = err instanceof SctxError ? err.code : 'E_CONFIG_INVALID';
43
+ health.problems.push(problem(code, 'error', err.message));
44
+ return health;
45
+ }
46
+ health.configFiles = [...getConfigSourceFiles()];
47
+ health.spaces = config.spaces.map((s) => ({ name: s.name, path: s.path, schema: s.schema ?? config.schema }));
48
+ if (config.spaces.length === 0) {
49
+ health.problems.push(problem('E_NO_SPACES', 'warning', `Config ${resolved.path} defines no spaces`));
50
+ }
51
+ for (const space of config.spaces) {
52
+ if (!existsSync(space.path)) {
53
+ health.problems.push(problem('E_SPACE_PATH_MISSING', 'error', `Space "${space.name}" path does not exist: ${space.path}`, space.name));
54
+ }
55
+ const schemaRef = space.schema ?? config.schema;
56
+ if (!schemaRef) {
57
+ health.problems.push(problem('E_SCHEMA_MISSING', 'error', `Space "${space.name}" has no schema`, space.name));
58
+ continue;
59
+ }
60
+ let schemaPath;
61
+ try {
62
+ schemaPath = resolveSchema(config, space);
63
+ }
64
+ catch (err) {
65
+ health.problems.push(problem('E_SCHEMA_NOT_FOUND', 'error', err.message, space.name));
66
+ continue;
67
+ }
68
+ if (/^https?:\/\//i.test(schemaPath))
69
+ continue;
70
+ if (!existsSync(schemaPath)) {
71
+ health.problems.push(problem('E_SCHEMA_NOT_FOUND', 'error', `Space "${space.name}" schema not found: ${schemaPath}`, space.name));
72
+ continue;
73
+ }
74
+ try {
75
+ JSON5.parse(readFileSync(schemaPath, 'utf-8'));
76
+ }
77
+ catch (err) {
78
+ health.problems.push(problem('E_SCHEMA_INVALID', 'error', `Space "${space.name}" schema ${schemaPath} is not valid: ${err.message}`, space.name));
79
+ }
80
+ }
81
+ return health;
82
+ }
83
+ /** True when the health check found something that stops validation from running. */
84
+ export function hasBlockingProblem(health) {
85
+ return health.problems.some((p) => p.severity === 'error');
86
+ }
@@ -1,4 +1,4 @@
1
- import { type JudgeConfig, type ModelProviderConfig } from './judge/types';
1
+ import { type JudgeConfig, type ModelProviderConfig } from '../judge/types';
2
2
  export type SpaceConfig = {
3
3
  name: string;
4
4
  path: string;
@@ -31,8 +31,23 @@ export type Config = {
31
31
  export declare function setConfigPath(path: string | undefined): void;
32
32
  /** Get all config file paths that were loaded (main config + included configs). */
33
33
  export declare function getConfigSourceFiles(): Set<string>;
34
- export declare function configPath(): string;
35
- export declare function loadConfig(): Config;
34
+ /** How the config file was found. `none` means nothing exists and `path` is where the global file would go. */
35
+ export type ConfigSource = 'override' | 'env' | 'local' | 'global' | 'none';
36
+ export type ResolvedConfigPath = {
37
+ path: string;
38
+ source: ConfigSource;
39
+ };
40
+ /** Path of the global config file, whether or not it exists. */
41
+ export declare function globalConfigPath(): string;
42
+ /**
43
+ * Find the config file. Precedence: explicit override (`--config`), `$SCTX_CONFIG`, the nearest
44
+ * `.sctx.json` walking up from `startDir` (default: the working directory), then the global file.
45
+ */
46
+ export declare function resolveConfigPath(startDir?: string): ResolvedConfigPath;
47
+ export declare function configPath(startDir?: string): string;
48
+ export declare function loadConfig(options?: {
49
+ startDir?: string;
50
+ }): Config;
36
51
  /** Get the full space config entry by name. Throws if not found. */
37
52
  export declare function getSpaceConfig(name: string, config: Config): SpaceConfig;
38
53
  /**
@@ -1,12 +1,13 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import Ajv2020 from 'ajv/dist/2020';
5
5
  import JSON5 from 'json5';
6
- import { ENV_CONFIG_VAR, XDG_CONFIG_DIR } from './constants';
7
- import { MODEL_PROVIDER_APIS } from './judge/types';
8
- import { normalizePluginName, resolvePluginSchemasDir, resolveSpacePluginSchemas, shortenPluginName, } from './plugins/util';
9
- import { bundledSchemasDir } from './schema/schema';
6
+ import { ENV_CONFIG_VAR, LOCAL_CONFIG_FILE, XDG_CONFIG_DIR } from '../constants';
7
+ import { SctxError } from '../errors';
8
+ import { MODEL_PROVIDER_APIS } from '../judge/types';
9
+ import { normalizePluginName, resolvePluginSchemasDir, resolveSpacePluginSchemas, shortenPluginName, } from '../plugins/util';
10
+ import { bundledSchemasDir } from '../schema/schema';
10
11
  const MODEL_PROVIDER_SCHEMA = {
11
12
  type: 'object',
12
13
  properties: {
@@ -75,23 +76,40 @@ export function getConfigSourceFiles() {
75
76
  }
76
77
  return new Set(_spaceSourceFiles.values());
77
78
  }
78
- export function configPath() {
79
- if (_configPathOverride) {
80
- return _configPathOverride;
81
- }
82
- if (process.env[ENV_CONFIG_VAR]) {
83
- return process.env[ENV_CONFIG_VAR];
84
- }
79
+ /** Path of the global config file, whether or not it exists. */
80
+ export function globalConfigPath() {
85
81
  const xdgBase = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
86
- const xdgPath = join(xdgBase, XDG_CONFIG_DIR, 'config.json');
87
- if (existsSync(xdgPath)) {
88
- return xdgPath;
89
- }
90
- const cwdPath = join(process.cwd(), 'config.json');
91
- if (existsSync(cwdPath)) {
92
- return cwdPath;
82
+ return join(xdgBase, XDG_CONFIG_DIR, 'config.json');
83
+ }
84
+ function findLocalConfig(startDir) {
85
+ let dir = resolve(startDir);
86
+ for (;;) {
87
+ const candidate = join(dir, LOCAL_CONFIG_FILE);
88
+ if (existsSync(candidate) && statSync(candidate).isFile())
89
+ return candidate;
90
+ const parent = dirname(dir);
91
+ if (parent === dir)
92
+ return undefined;
93
+ dir = parent;
93
94
  }
94
- return xdgPath;
95
+ }
96
+ /**
97
+ * Find the config file. Precedence: explicit override (`--config`), `$SCTX_CONFIG`, the nearest
98
+ * `.sctx.json` walking up from `startDir` (default: the working directory), then the global file.
99
+ */
100
+ export function resolveConfigPath(startDir = process.cwd()) {
101
+ if (_configPathOverride)
102
+ return { path: _configPathOverride, source: 'override' };
103
+ if (process.env[ENV_CONFIG_VAR])
104
+ return { path: process.env[ENV_CONFIG_VAR], source: 'env' };
105
+ const local = findLocalConfig(startDir);
106
+ if (local)
107
+ return { path: local, source: 'local' };
108
+ const global = globalConfigPath();
109
+ return { path: global, source: existsSync(global) ? 'global' : 'none' };
110
+ }
111
+ export function configPath(startDir) {
112
+ return resolveConfigPath(startDir).path;
95
113
  }
96
114
  function normalizePlugins(plugins) {
97
115
  if (!plugins)
@@ -128,48 +146,69 @@ function resolveRelativePaths(config, configDir) {
128
146
  }
129
147
  function _loadConfig(path) {
130
148
  if (!existsSync(path)) {
131
- throw new Error(`Config file not found: ${path}`);
149
+ throw new SctxError('E_CONFIG_NOT_FOUND', `Config file not found: ${path}`);
150
+ }
151
+ let config;
152
+ try {
153
+ config = JSON5.parse(readFileSync(path, 'utf-8'));
154
+ }
155
+ catch (err) {
156
+ throw new SctxError('E_CONFIG_INVALID', `Invalid config in ${path}: ${err.message}`);
132
157
  }
133
- const config = JSON5.parse(readFileSync(path, 'utf-8'));
134
158
  const ajv = new Ajv2020();
135
159
  const validate = ajv.compile(CONFIG_SCHEMA);
136
160
  if (!validate(config)) {
137
- throw new Error(`Invalid config in ${path}: ${JSON.stringify(validate.errors)}`);
161
+ throw new SctxError('E_CONFIG_INVALID', `Invalid config in ${path}: ${JSON.stringify(validate.errors)}`);
138
162
  }
139
- if (!config.spaces)
140
- config.spaces = [];
141
- return config;
163
+ const parsed = config;
164
+ if (!parsed.spaces)
165
+ parsed.spaces = [];
166
+ return parsed;
142
167
  }
143
- export function loadConfig() {
144
- const path = configPath();
168
+ function expandHome(p) {
169
+ return p === '~' || p.startsWith('~/') ? join(process.env.HOME ?? homedir(), p.slice(1)) : p;
170
+ }
171
+ export function loadConfig(options = {}) {
172
+ const { path, source } = resolveConfigPath(options.startDir);
145
173
  if (!existsSync(path)) {
146
- console.warn(`Config file not found: ${path}`);
174
+ const where = source === 'none'
175
+ ? `no ${LOCAL_CONFIG_FILE} found from ${resolve(options.startDir ?? process.cwd())} upward, and none at ${path}`
176
+ : path;
177
+ console.warn(`E_CONFIG_NOT_FOUND: Config file not found (${where})`);
147
178
  return { spaces: [] };
148
179
  }
149
180
  const config = _loadConfig(path);
150
181
  _spaceSourceFiles.clear();
151
182
  // Track which spaces come from the main config file
152
183
  const mainSourceDir = dirname(resolve(path));
184
+ const ownNames = new Set(config.spaces.map((s) => s.name));
153
185
  for (const space of config.spaces) {
154
186
  _spaceSourceFiles.set(space.name, path);
155
187
  space.sourceDir = mainSourceDir;
156
188
  }
157
- // Load includeSpacesFrom configs and merge their spaces in, with later entries taking precedence over earlier ones
189
+ // Merge includeSpacesFrom configs. A space defined in the including file wins over an included one
190
+ // of the same name; between includes, later entries win over earlier ones.
158
191
  if (config.includeSpacesFrom) {
159
192
  for (const includePath of config.includeSpacesFrom) {
160
193
  // Resolve relative to the main config file
161
- const resolvedIncludePath = isAbsolute(includePath) ? includePath : resolve(dirname(path), includePath);
162
- const includedConfig = resolveRelativePaths(_loadConfig(resolvedIncludePath), dirname(resolvedIncludePath));
163
- if (includedConfig.spaces.some((s) => config.spaces.some((existing) => existing.name === s.name))) {
164
- throw new Error(`Included config contains spaces with duplicate names: ${resolvedIncludePath}`);
194
+ const expanded = expandHome(includePath);
195
+ const resolvedIncludePath = isAbsolute(expanded) ? expanded : resolve(dirname(path), expanded);
196
+ if (!existsSync(resolvedIncludePath)) {
197
+ throw new SctxError('E_INCLUDE_NOT_FOUND', `Config ${path} includes ${resolvedIncludePath}, which does not exist`);
165
198
  }
166
- // Track which spaces come from this included config file
199
+ const includedConfig = resolveRelativePaths(_loadConfig(resolvedIncludePath), dirname(resolvedIncludePath));
167
200
  const includeSourceDir = dirname(resolvedIncludePath);
168
201
  for (const space of includedConfig.spaces) {
169
- _spaceSourceFiles.set(space.name, resolvedIncludePath);
202
+ if (ownNames.has(space.name))
203
+ continue;
170
204
  space.sourceDir = includeSourceDir;
205
+ _spaceSourceFiles.set(space.name, resolvedIncludePath);
206
+ const existing = config.spaces.findIndex((s) => s.name === space.name);
207
+ if (existing >= 0)
208
+ config.spaces[existing] = space;
209
+ else
210
+ config.spaces.push(space);
171
211
  }
172
- config.spaces.push(...includedConfig.spaces);
173
212
  }
174
213
  }
175
214
  return resolveRelativePaths(config, dirname(resolve(path)));
@@ -0,0 +1,27 @@
1
+ import { type SpaceConfig } from '.';
2
+ type AddSpaceOptions = Pick<SpaceConfig, 'name' | 'path' | 'schema'> & {
3
+ /** Write to the global config file. */
4
+ global?: boolean;
5
+ /** Write to this config file (created if missing) instead of the discovered one. */
6
+ configFile?: string;
7
+ /** Where discovery starts when no explicit target is given. Defaults to the working directory. */
8
+ startDir?: string;
9
+ };
10
+ type AddSpaceResult = {
11
+ configPath: string;
12
+ created: boolean;
13
+ space: Pick<SpaceConfig, 'name' | 'path' | 'schema'>;
14
+ };
15
+ /**
16
+ * Decide which file to write. A config named explicitly or found by walking up from the working directory
17
+ * is used as is. If discovery would land on the global file or on nothing, the caller must say where to
18
+ * write, so a vault's space never ends up in the global file by accident.
19
+ */
20
+ export declare function resolveWriteTarget(options: {
21
+ global?: boolean;
22
+ configFile?: string;
23
+ startDir?: string;
24
+ }): string;
25
+ /** Add a space to the chosen config file, creating the file when it does not exist. */
26
+ export declare function addSpace(options: AddSpaceOptions): AddSpaceResult;
27
+ export {};
@@ -0,0 +1,91 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ import { LOCAL_CONFIG_FILE } from '../constants';
4
+ import { SctxError } from '../errors';
5
+ import { globalConfigPath, resolveConfigPath, resolveSchemaPath } from '.';
6
+ /**
7
+ * Decide which file to write. A config named explicitly or found by walking up from the working directory
8
+ * is used as is. If discovery would land on the global file or on nothing, the caller must say where to
9
+ * write, so a vault's space never ends up in the global file by accident.
10
+ */
11
+ export function resolveWriteTarget(options) {
12
+ if (options.configFile)
13
+ return options.configFile;
14
+ const resolved = resolveConfigPath(options.startDir);
15
+ if (options.global) {
16
+ if (resolved.source === 'override' || resolved.source === 'env') {
17
+ throw new SctxError('E_TARGET_REQUIRED', 'The global config cannot be combined with an explicitly named config');
18
+ }
19
+ return globalConfigPath();
20
+ }
21
+ if (resolved.source === 'override' || resolved.source === 'env' || resolved.source === 'local') {
22
+ return resolved.path;
23
+ }
24
+ const found = resolved.source === 'global' ? `only the global config (${resolved.path}) exists` : 'no config exists';
25
+ throw new SctxError('E_TARGET_REQUIRED', `No ${LOCAL_CONFIG_FILE} found from ${resolve(options.startDir ?? process.cwd())} upward; ${found}. ` +
26
+ `Specify the config file location or global option.`);
27
+ }
28
+ function realOrSelf(p) {
29
+ try {
30
+ return realpathSync(p);
31
+ }
32
+ catch {
33
+ return p;
34
+ }
35
+ }
36
+ /** The path as written in the config: relative to the config's folder when inside it, else absolute. */
37
+ function storedPath(configFile, absolutePath) {
38
+ // Compare real paths so a symlinked folder (such as macOS's /var -> /private/var) does not look like it escapes
39
+ const rel = relative(realOrSelf(dirname(resolve(configFile))), realOrSelf(absolutePath));
40
+ if (rel === '')
41
+ return '.';
42
+ return rel.startsWith('..') || isAbsolute(rel) ? absolutePath : rel;
43
+ }
44
+ /** Add a space to the chosen config file, creating the file when it does not exist. */
45
+ export function addSpace(options) {
46
+ if (!/^[a-z0-9_-]+$/.test(options.name)) {
47
+ throw new SctxError('E_CONFIG_INVALID', `Space name "${options.name}" must use lowercase letters, digits, "-" and "_"`);
48
+ }
49
+ const target = resolve(resolveWriteTarget(options));
50
+ const spacePath = resolve(options.startDir ?? process.cwd(), options.path);
51
+ if (!existsSync(spacePath)) {
52
+ throw new SctxError('E_SPACE_PATH_MISSING', `Space path does not exist: ${spacePath}`);
53
+ }
54
+ const created = !existsSync(target);
55
+ let config = { spaces: [] };
56
+ if (!created) {
57
+ const raw = readFileSync(target, 'utf-8');
58
+ try {
59
+ config = JSON.parse(raw);
60
+ }
61
+ catch {
62
+ const entry = { name: options.name, path: storedPath(target, spacePath), schema: options.schema };
63
+ throw new SctxError('E_CONFIG_NEEDS_MANUAL_EDIT', `${target} is not strict JSON (comments, trailing commas or a syntax error), so it was not rewritten. ` +
64
+ `Add this entry to "spaces" by hand: ${JSON.stringify(entry)}`);
65
+ }
66
+ }
67
+ if (!config.schema && !options.schema) {
68
+ throw new SctxError('E_SCHEMA_MISSING', 'Give --schema <name or path>: neither the space nor the config file names a schema');
69
+ }
70
+ if (options.schema && !/^https?:\/\//i.test(options.schema)) {
71
+ if (!existsSync(resolveSchemaPath(options.schema, dirname(target)))) {
72
+ const hint = options.schema.startsWith('sctx://')
73
+ ? 'Name the schema by file, such as strategy_general.json, not by sctx:// URI.'
74
+ : '`sctx schemas list` shows the bundled schemas; a file name needs its extension.';
75
+ throw new SctxError('E_SCHEMA_NOT_FOUND', `Schema not found: ${options.schema}. ${hint}`);
76
+ }
77
+ }
78
+ config.spaces = config.spaces ?? [];
79
+ if (config.spaces.some((s) => s.name === options.name)) {
80
+ throw new SctxError('E_SPACE_EXISTS', `${target} already defines a space named "${options.name}"`);
81
+ }
82
+ const space = {
83
+ name: options.name,
84
+ path: storedPath(target, spacePath),
85
+ ...(options.schema ? { schema: options.schema } : {}),
86
+ };
87
+ config.spaces.push(space);
88
+ mkdirSync(dirname(target), { recursive: true });
89
+ writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`);
90
+ return { configPath: target, created, space };
91
+ }
@@ -4,3 +4,5 @@ export declare const CLI_NAME = "sctx";
4
4
  export declare const PLUGIN_PREFIX = "sctx-";
5
5
  export declare const ENV_CONFIG_VAR = "SCTX_CONFIG";
6
6
  export declare const SCHEMA_URI_SCHEME = "sctx";
7
+ /** Name of the config file discovered by walking up from the working directory. */
8
+ export declare const LOCAL_CONFIG_FILE = ".sctx.json";