structured-context 0.14.0 → 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.
package/README.md CHANGED
@@ -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
 
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';
@@ -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
+ }
@@ -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";
package/dist/constants.js CHANGED
@@ -4,3 +4,5 @@ export const CLI_NAME = 'sctx';
4
4
  export const PLUGIN_PREFIX = `${CLI_NAME}-`;
5
5
  export const ENV_CONFIG_VAR = 'SCTX_CONFIG';
6
6
  export const SCHEMA_URI_SCHEME = CLI_NAME;
7
+ /** Name of the config file discovered by walking up from the working directory. */
8
+ export const LOCAL_CONFIG_FILE = '.sctx.json';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Stable codes for configuration and environment problems. Hooks and agent skills map a code to a
3
+ * fix, so the message wording can change without breaking them.
4
+ */
5
+ export type SctxErrorCode = 'E_CONFIG_NOT_FOUND' | 'E_CONFIG_INVALID' | 'E_INCLUDE_NOT_FOUND' | 'E_NO_SPACES' | 'E_SPACE_PATH_MISSING' | 'E_SCHEMA_MISSING' | 'E_SCHEMA_NOT_FOUND' | 'E_SCHEMA_INVALID' | 'E_SPACE_EXISTS' | 'E_TARGET_REQUIRED' | 'E_CONFIG_NEEDS_MANUAL_EDIT';
6
+ export declare class SctxError extends Error {
7
+ readonly code: SctxErrorCode;
8
+ constructor(code: SctxErrorCode, message: string);
9
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,8 @@
1
+ export class SctxError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = 'SctxError';
7
+ }
8
+ }
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { listPlugins } from './commands/plugins';
10
10
  import { render, renderList } from './commands/render';
11
11
  import { listSchemas, showSchema } from './commands/schemas';
12
12
  import { show } from './commands/show';
13
- import { listSpaces } from './commands/spaces';
13
+ import { addSpaceCommand, listSpaces } from './commands/spaces';
14
14
  import { templateSync } from './commands/template-sync';
15
15
  import { validate, watchValidate } from './commands/validate';
16
16
  import { validateFileCommand } from './commands/validate-file';
@@ -54,7 +54,7 @@ program
54
54
  .name(CLI_NAME)
55
55
  .description('Structured context validation and diagram generation tool')
56
56
  .version(packageJson.version)
57
- .option('--config <path>', 'Path to config file (overrides default config.json locations)');
57
+ .option('--config <path>', 'Path to config file (overrides config discovery)');
58
58
  program.hook('preAction', () => {
59
59
  setConfigPath(program.opts().config);
60
60
  });
@@ -150,6 +150,17 @@ spacesCmd
150
150
  .description('List all configured spaces and their paths')
151
151
  .option('--json', 'Output as JSON: config files, and each space with its path and schema')
152
152
  .action((options) => listSpaces(options));
153
+ spacesCmd
154
+ .command('add')
155
+ .description('Add a space to a config file, creating the file if needed')
156
+ .argument('<name>', 'Space name (lowercase letters, digits, - and _)')
157
+ .argument('<path>', 'Path to the space (a folder or a file); must exist')
158
+ .option('--schema <schema>', 'Schema name or path (required unless the config names a top-level schema)')
159
+ .option('--global', 'Write to the global config file')
160
+ .option('--json', 'Output as JSON')
161
+ .addHelpText('after', '\nWrites to the config found by walking up from the working directory, or the one named with --config.\n' +
162
+ 'If only the global config (or none) exists, name the target: --config <folder>/.sctx.json or --global.')
163
+ .action((name, path, options) => addSpaceCommand(name, path, options));
153
164
  program.addCommand(spacesCmd);
154
165
  const providersCmd = new Command('providers').description('List model providers (bundled and configured) with their effective config');
155
166
  providersCmd
@@ -97,7 +97,8 @@ export async function readSpaceDirectory(context, options) {
97
97
  continue;
98
98
  }
99
99
  const pageType = data.type;
100
- const explicitTitle = data.title;
100
+ // A non-string title (e.g. `title: 42`) isn't a usable name or link target; schema validation reports it.
101
+ const explicitTitle = typeof data.title === 'string' ? data.title : undefined;
101
102
  const title = explicitTitle ?? defaultPageTitle(file, mdCfg.folderIndex);
102
103
  const pathTargets = pathLinkTargets(file);
103
104
  const fmMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
package/docs/concepts.md CHANGED
@@ -10,7 +10,7 @@ This document is the canonical reference for concepts and terminology used in th
10
10
 
11
11
  ## Space
12
12
 
13
- A **space** is a named collection of nodes organised according to a schema. Spaces are the primary unit of organisation — a space has a backing format (a `space directory` or a `space on a page` file) and may be registered in `config.json` with a name for convenient access.
13
+ A **space** is a named collection of nodes organised according to a schema. Spaces are the primary unit of organisation — a space has a backing format (a `space directory` or a `space on a page` file) and may be registered in a config file (`.sctx.json` or the global `config.json`) with a name for convenient access.
14
14
 
15
15
  ```mermaid
16
16
  flowchart TD
package/docs/config.md CHANGED
@@ -6,13 +6,30 @@ title: "Configuration Reference"
6
6
 
7
7
  ## Config file location
8
8
 
9
- structured-context looks for its config file in this order:
9
+ structured-context uses the first config file it finds, in this order:
10
10
 
11
- 1. `$SCTX_CONFIG` — explicit path override
12
- 2. `~/.config/structured-context/config.json` (or `$XDG_CONFIG_HOME/structured-context/config.json`)
13
- 3. `./config.json` in the current working directory
11
+ 1. `--config <path>`
12
+ 2. `$SCTX_CONFIG`
13
+ 3. The nearest `.sctx.json`, looking in the working directory and then each parent folder
14
+ 4. `~/.config/structured-context/config.json` (or `$XDG_CONFIG_HOME/structured-context/config.json`)
14
15
 
15
- See `config.example.json` for the full structure. Paths in config files are resolved relative to the config file.
16
+ It is recommended to keep a `.sctx.json` at the base of the notes it describes (for example the root of an Obsidian vault), or to use the global config. The CLI and agent sessions look in parent folders only, so run commands from inside the folder that holds the config. To use the spaces from another config file too, list it in `includeSpacesFrom`.
17
+
18
+ Paths in a config file are relative to the folder that holds that file. In a `.sctx.json` at a vault root, `"path": "."` is the whole vault and `"path": "notes"` is one folder.
19
+
20
+ `sctx spaces` shows which config file is used and reports problems with it. `sctx spaces --json` returns the same information for tools and hooks.
21
+
22
+ ### Adding a space
23
+
24
+ ```bash
25
+ sctx spaces add wiki notes --schema knowledge_wiki.json
26
+ ```
27
+
28
+ This adds the space to the config file found from the working directory, or to the one named with `--config`. When no `.sctx.json` is found, or only the global config exists, pass `--config <folder>/.sctx.json` (creates the file) or `--global`. If the config file has comments or trailing commas, the command prints the entry to add by hand.
29
+
30
+ Custom schemas can sit anywhere. Reference them by relative path, for example `./.sctx/schemas/my_wiki.json`; `.sctx/schemas/` is the suggested folder.
31
+
32
+ See `config.example.json` for the full structure.
16
33
 
17
34
  ## Spaces
18
35
 
@@ -36,7 +53,7 @@ A space is a named directory or single file registered in the config. Example:
36
53
  - A plugin schema prefixed with the plugin name (e.g. `sctx-wardley-mapping/wardley_map.json` or `wardley-mapping/wardley_map.json`). Specifying a plugin-prefixed schema automatically loads that plugin for the space.
37
54
  - A simple filename (e.g. `wardley_map.json`) provided by a plugin explicitly declared under `plugins`.
38
55
 
39
- **`includeSpacesFrom`** — import space definitions from other config files. Useful for aggregating spaces from multiple projects into a central config. Duplicate space names are not allowed.
56
+ **`includeSpacesFrom`** — import space definitions from other config files, for example a shared team config or your global config (`"~/.config/structured-context/config.json"`; a leading `~` is expanded). A space defined in the including file overrides an included space of the same name; between includes, later entries win. Only `spaces` are taken from an included file.
40
57
 
41
58
  A config must define at least one of `spaces` or `includeSpacesFrom`. When using `includeSpacesFrom` alone, `spaces` can be omitted entirely.
42
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "structured-context",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Keep your content clean and your thinking clear with assistive tools for structured knowledge",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -54,11 +54,11 @@
54
54
  "postversion": "git push --follow-tags"
55
55
  },
56
56
  "devDependencies": {
57
- "@anthropic-ai/claude-agent-sdk": "^0.3.191",
57
+ "@anthropic-ai/claude-agent-sdk": "^0.3.310",
58
58
  "@biomejs/biome": "^2.5.15",
59
- "@types/bun": "latest",
59
+ "@types/bun": "^1.4.4",
60
60
  "json-schema-to-ts": "^3.1.1",
61
- "lefthook": "^2.1.9"
61
+ "lefthook": "^2.2.2"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "typescript": "^5"