structured-context 0.14.0 → 0.16.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 +11 -1
- package/dist/api.d.ts +4 -2
- package/dist/api.js +5 -2
- package/dist/commands/schemas.d.ts +3 -1
- package/dist/commands/schemas.js +22 -51
- package/dist/commands/spaces.d.ts +5 -0
- package/dist/commands/spaces.js +46 -10
- package/dist/config/health.d.ts +31 -0
- package/dist/config/health.js +86 -0
- package/dist/{config.d.ts → config/index.d.ts} +18 -3
- package/dist/{config.js → config/index.js} +76 -37
- package/dist/config/write.d.ts +27 -0
- package/dist/config/write.js +91 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +8 -0
- package/dist/index.js +16 -4
- package/dist/plugins/markdown/read-space.js +2 -1
- package/dist/schema/list-schemas.d.ts +27 -0
- package/dist/schema/list-schemas.js +108 -0
- package/docs/concepts.md +1 -1
- package/docs/config.md +23 -6
- package/package.json +4 -4
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/...`)
|
|
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
|
|
|
@@ -186,6 +188,14 @@ Generates a Mermaid `graph TD` diagram from validated space nodes:
|
|
|
186
188
|
- Handles orphan nodes (no parent) as a separate cluster
|
|
187
189
|
- Outputs to file or stdout
|
|
188
190
|
|
|
191
|
+
### List schemas
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
sctx schemas list [--json]
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Lists the bundled schemas, schemas contributed by plugins, and any other schema file your config uses, with the spaces that use each. `--json` gives the data that `listSchemas` returns; see [docs/api.md](docs/api.md).
|
|
198
|
+
|
|
189
199
|
### Show schema ERD
|
|
190
200
|
|
|
191
201
|
```bash
|
package/dist/api.d.ts
CHANGED
|
@@ -19,13 +19,15 @@
|
|
|
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 {
|
|
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';
|
|
27
28
|
export { readSpace } from './read/read-space';
|
|
28
29
|
export { buildTargetIndex, formatWikilink, type ParsedWikilink, parseWikilink, wikilinkToTarget, } from './read/wikilink-utils';
|
|
30
|
+
export { listSchemas } from './schema/list-schemas';
|
|
29
31
|
export type { SharedEmbeddingFields } from './schema/metadata-contract';
|
|
30
32
|
export { bundledSchemasDir, loadSchema, type PluginSchemaSource, setBundledSchemasDir } from './schema/schema';
|
|
31
33
|
export { createSpaceContext, SpaceNotFoundError } from './space-context';
|
package/dist/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Public entry point: do not add exports without the maintainer's agreement (AGENTS.md, "Public API surface").
|
|
1
2
|
/**
|
|
2
3
|
* Public API for structured-context.
|
|
3
4
|
*
|
|
@@ -17,12 +18,14 @@
|
|
|
17
18
|
* const graph = await assembleSpaceGraph(ctx, { readResult });
|
|
18
19
|
* const validation = await validateSpace(ctx, { readResult });
|
|
19
20
|
*/
|
|
20
|
-
export { loadConfig, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
|
|
21
|
-
export {
|
|
21
|
+
export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
|
|
22
|
+
export { addSpace } from './config/write';
|
|
23
|
+
export { LOCAL_CONFIG_FILE, PLUGIN_PREFIX } from './constants';
|
|
22
24
|
export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
|
|
23
25
|
export { isRenderMultiFileOutput, resolvePluginDir, resolvePluginSchemasDir, resolveSpacePluginSchemas, } from './plugins/util';
|
|
24
26
|
export { readSpace } from './read/read-space';
|
|
25
27
|
export { buildTargetIndex, formatWikilink, parseWikilink, wikilinkToTarget, } from './read/wikilink-utils';
|
|
28
|
+
export { listSchemas } from './schema/list-schemas';
|
|
26
29
|
export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
|
|
27
30
|
export { createSpaceContext, SpaceNotFoundError } from './space-context';
|
|
28
31
|
export { buildSpaceGraph } from './space-graph';
|
package/dist/commands/schemas.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
3
|
import { loadConfig } from '../config';
|
|
4
4
|
import { discoverPlugins } from '../plugins/loader';
|
|
5
5
|
import { normalizePluginName, resolvePluginSchemasDir, resolveSpacePluginSchemas, shortenPluginName, } from '../plugins/util';
|
|
6
|
+
import { listSchemas } from '../schema/list-schemas';
|
|
6
7
|
import { ruleSemantic } from '../schema/rule-severity';
|
|
7
8
|
import { bundledSchemasDir, extractEntityInfo, loadSchema, readRawSchema, } from '../schema/schema';
|
|
8
9
|
import { mergeVariantProperties, resolveRef } from '../schema/schema-refs';
|
|
9
10
|
import { DEFAULT_SEVERITY_BY_CATEGORY } from '../schema/validate-rules';
|
|
10
11
|
import { createSpaceContext } from '../space-context';
|
|
11
|
-
function isBundledPath(schemaPath) {
|
|
12
|
-
return dirname(schemaPath) === bundledSchemasDir;
|
|
13
|
-
}
|
|
14
12
|
function extractRefs(obj, refs) {
|
|
15
13
|
if (typeof obj !== 'object' || obj === null)
|
|
16
14
|
return;
|
|
@@ -207,57 +205,30 @@ function ruleAnnotations(rule) {
|
|
|
207
205
|
notes.push(`when: ${rule.when}`);
|
|
208
206
|
return notes.length > 0 ? ` [${notes.join('] [')}]` : '';
|
|
209
207
|
}
|
|
210
|
-
export async function
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
.filter((f) => f.endsWith('.json'))
|
|
216
|
-
.sort();
|
|
217
|
-
if (files.length > 0) {
|
|
218
|
-
console.log('Bundled schemas:');
|
|
219
|
-
for (const file of files) {
|
|
220
|
-
const schema = readRawSchema(join(bundledSchemasDir, file));
|
|
221
|
-
const id = typeof schema.$id === 'string' ? schema.$id : '(no $id)';
|
|
222
|
-
console.log(` ${file}${file.startsWith('_') ? ' [partial]' : ''} (${id})`);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
208
|
+
export async function printSchemas(options = {}) {
|
|
209
|
+
const listings = await listSchemas();
|
|
210
|
+
if (options.json) {
|
|
211
|
+
console.log(JSON.stringify(listings, null, 2));
|
|
212
|
+
return;
|
|
225
213
|
}
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
214
|
+
const sources = [...new Set(listings.map((l) => l.source))].sort((a, b) => Number(b === 'core') - Number(a === 'core') || Number(a === 'configured') - Number(b === 'configured'));
|
|
215
|
+
for (const source of sources) {
|
|
216
|
+
const group = listings.filter((l) => l.source === source);
|
|
217
|
+
if (source === 'core')
|
|
218
|
+
console.log('Bundled schemas:');
|
|
219
|
+
else if (source === 'configured')
|
|
220
|
+
console.log('\nConfigured schemas:');
|
|
221
|
+
else
|
|
222
|
+
console.log(`\nPlugin schemas (${source}):`);
|
|
223
|
+
for (const l of group) {
|
|
224
|
+
if (source === 'configured')
|
|
225
|
+
console.log(` ${l.usedBy.join(', ')}: ${l.path ?? l.schema}${l.error ? ` [${l.error}]` : ''}`);
|
|
226
|
+
else {
|
|
227
|
+
const notes = [l.partial && '[partial]', l.id && `(${l.id})`, l.error && `[${l.error}]`].filter(Boolean);
|
|
228
|
+
console.log(` ${l.schema}${notes.length > 0 ? ` ${notes.join(' ')}` : ''}`);
|
|
240
229
|
}
|
|
241
230
|
}
|
|
242
231
|
}
|
|
243
|
-
const configured = [];
|
|
244
|
-
const seen = new Set();
|
|
245
|
-
if (config.schema && !isBundledPath(config.schema)) {
|
|
246
|
-
configured.push({ source: 'global', path: config.schema });
|
|
247
|
-
seen.add(config.schema);
|
|
248
|
-
}
|
|
249
|
-
for (const space of config.spaces) {
|
|
250
|
-
if (space.schema && !seen.has(space.schema) && !isBundledPath(space.schema)) {
|
|
251
|
-
configured.push({ source: space.name, path: space.schema });
|
|
252
|
-
seen.add(space.schema);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
if (configured.length > 0) {
|
|
256
|
-
console.log('\nConfigured schemas:');
|
|
257
|
-
for (const { source, path } of configured) {
|
|
258
|
-
console.log(` ${source}: ${path}`);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
232
|
}
|
|
262
233
|
export async function showSchema(file, options) {
|
|
263
234
|
const config = loadConfig();
|
package/dist/commands/spaces.js
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
import { basename, resolve } from 'node:path';
|
|
2
|
-
import {
|
|
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
|
|
10
|
-
const config = loadConfig();
|
|
12
|
+
const health = checkConfig();
|
|
11
13
|
if (options.json) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
35
|
-
export
|
|
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 '
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
161
|
+
throw new SctxError('E_CONFIG_INVALID', `Invalid config in ${path}: ${JSON.stringify(validate.errors)}`);
|
|
138
162
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
163
|
+
const parsed = config;
|
|
164
|
+
if (!parsed.spaces)
|
|
165
|
+
parsed.spaces = [];
|
|
166
|
+
return parsed;
|
|
142
167
|
}
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
throw new
|
|
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
|
-
|
|
199
|
+
const includedConfig = resolveRelativePaths(_loadConfig(resolvedIncludePath), dirname(resolvedIncludePath));
|
|
167
200
|
const includeSourceDir = dirname(resolvedIncludePath);
|
|
168
201
|
for (const space of includedConfig.spaces) {
|
|
169
|
-
|
|
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
|
+
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -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';
|
package/dist/errors.d.ts
ADDED
|
@@ -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
package/dist/index.js
CHANGED
|
@@ -8,9 +8,9 @@ import { dump } from './commands/dump';
|
|
|
8
8
|
import { miroSyncCommand } from './commands/miro-sync';
|
|
9
9
|
import { listPlugins } from './commands/plugins';
|
|
10
10
|
import { render, renderList } from './commands/render';
|
|
11
|
-
import {
|
|
11
|
+
import { printSchemas, 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
|
|
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
|
|
@@ -165,7 +176,8 @@ const schemasCmd = new Command('schemas').alias('schema').description('List and
|
|
|
165
176
|
schemasCmd
|
|
166
177
|
.command('list', { isDefault: true })
|
|
167
178
|
.description('List available schemas')
|
|
168
|
-
.
|
|
179
|
+
.option('--json', 'Output as JSON')
|
|
180
|
+
.action(async (options) => await printSchemas({ json: options.json ?? false }));
|
|
169
181
|
schemasCmd
|
|
170
182
|
.command('show')
|
|
171
183
|
.description('Show schema structure (or raw JSON with --raw, or Mermaid ERD with --mermaid-erd)')
|
|
@@ -97,7 +97,8 @@ export async function readSpaceDirectory(context, options) {
|
|
|
97
97
|
continue;
|
|
98
98
|
}
|
|
99
99
|
const pageType = data.type;
|
|
100
|
-
|
|
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?/);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Config } from '../config';
|
|
2
|
+
type SchemaListing = {
|
|
3
|
+
/** The value for a config's `schema` field. */
|
|
4
|
+
schema: string;
|
|
5
|
+
/** Absolute location or URL. Omitted for bundled schemas. */
|
|
6
|
+
path?: string;
|
|
7
|
+
/** `core`, a plugin's short name, or `configured`. */
|
|
8
|
+
source: string;
|
|
9
|
+
/** Names of the spaces that use it, and `global` when the top-level `schema` does. */
|
|
10
|
+
usedBy: string[];
|
|
11
|
+
partial: boolean;
|
|
12
|
+
id?: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
/** Why the schema could not be read. */
|
|
16
|
+
error?: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* List the schemas available to the config: bundled schemas, schemas contributed by plugins, and any other
|
|
20
|
+
* schema file the config points at. Each schema appears once, under the place its file lives.
|
|
21
|
+
*/
|
|
22
|
+
export declare function listSchemas(options?: {
|
|
23
|
+
/** Loaded by discovery from `startDir` when omitted. */
|
|
24
|
+
config?: Config;
|
|
25
|
+
startDir?: string;
|
|
26
|
+
}): Promise<SchemaListing[]>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { loadConfig, resolveConfigPath, resolveSchemaPath } from '../config';
|
|
4
|
+
import { PLUGIN_PREFIX } from '../constants';
|
|
5
|
+
import { CONFIG_PLUGINS_DIR, resolvePluginSchemasDir, resolveSpacePluginSchemas, shortenPluginName, } from '../plugins/util';
|
|
6
|
+
import { bundledSchemasDir, readRawSchema } from './schema';
|
|
7
|
+
function isRemote(ref) {
|
|
8
|
+
return /^https?:\/\//.test(ref);
|
|
9
|
+
}
|
|
10
|
+
function describeSchema(path) {
|
|
11
|
+
try {
|
|
12
|
+
const schema = readRawSchema(path);
|
|
13
|
+
return {
|
|
14
|
+
...(typeof schema.$id === 'string' && { id: schema.$id }),
|
|
15
|
+
...(typeof schema.title === 'string' && { title: schema.title }),
|
|
16
|
+
...(typeof schema.description === 'string' && { description: schema.description }),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function jsonFiles(dir) {
|
|
24
|
+
if (!dir || !existsSync(dir))
|
|
25
|
+
return [];
|
|
26
|
+
return readdirSync(dir)
|
|
27
|
+
.filter((f) => f.endsWith('.json'))
|
|
28
|
+
.sort();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* List the schemas available to the config: bundled schemas, schemas contributed by plugins, and any other
|
|
32
|
+
* schema file the config points at. Each schema appears once, under the place its file lives.
|
|
33
|
+
*/
|
|
34
|
+
export async function listSchemas(options = {}) {
|
|
35
|
+
const config = options.config ?? loadConfig({ startDir: options.startDir });
|
|
36
|
+
const listings = [];
|
|
37
|
+
const byPath = new Map();
|
|
38
|
+
const add = (listing, location) => {
|
|
39
|
+
listings.push(listing);
|
|
40
|
+
byPath.set(resolve(location), listing);
|
|
41
|
+
};
|
|
42
|
+
for (const file of jsonFiles(bundledSchemasDir)) {
|
|
43
|
+
const location = join(bundledSchemasDir, file);
|
|
44
|
+
add({ schema: file, source: 'core', usedBy: [], partial: file.startsWith('_'), ...describeSchema(location) }, location);
|
|
45
|
+
}
|
|
46
|
+
// Relative schema references are anchored at the folder of the config that holds them
|
|
47
|
+
const fallbackDir = dirname(resolveConfigPath(options.startDir).path);
|
|
48
|
+
const configDirs = [...new Set([fallbackDir, ...config.spaces.map((s) => s.sourceDir ?? fallbackDir)])];
|
|
49
|
+
// Plugins are found the way schema references resolve them, without loading their code: those declared by a
|
|
50
|
+
// space or the config, and any installed beside a config file
|
|
51
|
+
const pluginSchemaDirs = new Map();
|
|
52
|
+
for (const space of [undefined, ...config.spaces]) {
|
|
53
|
+
for (const { pluginName, schemasDir } of resolveSpacePluginSchemas(space, config, space?.sourceDir ?? fallbackDir)) {
|
|
54
|
+
pluginSchemaDirs.set(pluginName, schemasDir);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const dir of configDirs) {
|
|
58
|
+
const pluginsDir = join(dir, CONFIG_PLUGINS_DIR);
|
|
59
|
+
if (!existsSync(pluginsDir))
|
|
60
|
+
continue;
|
|
61
|
+
for (const entry of readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX))) {
|
|
62
|
+
const schemasDir = resolvePluginSchemasDir(entry, dir);
|
|
63
|
+
if (schemasDir && !pluginSchemaDirs.has(shortenPluginName(entry))) {
|
|
64
|
+
pluginSchemaDirs.set(shortenPluginName(entry), schemasDir);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const [source, schemasDir] of pluginSchemaDirs) {
|
|
69
|
+
for (const file of jsonFiles(schemasDir)) {
|
|
70
|
+
const location = join(schemasDir, file);
|
|
71
|
+
add({
|
|
72
|
+
schema: `${source}/${file}`,
|
|
73
|
+
path: location,
|
|
74
|
+
source,
|
|
75
|
+
usedBy: [],
|
|
76
|
+
partial: file.startsWith('_'),
|
|
77
|
+
...describeSchema(location),
|
|
78
|
+
}, location);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const references = [
|
|
82
|
+
...(config.schema ? [{ user: 'global', raw: config.schema, dir: fallbackDir }] : []),
|
|
83
|
+
...config.spaces.flatMap((s) => s.schema ? [{ user: s.name, raw: s.schema, dir: s.sourceDir ?? fallbackDir }] : []),
|
|
84
|
+
];
|
|
85
|
+
for (const { user, raw, dir } of references) {
|
|
86
|
+
const location = isRemote(raw) ? raw : resolveSchemaPath(raw, dir);
|
|
87
|
+
const key = isRemote(raw) ? raw : resolve(location);
|
|
88
|
+
let listing = byPath.get(key);
|
|
89
|
+
if (!listing) {
|
|
90
|
+
listing = {
|
|
91
|
+
schema: raw,
|
|
92
|
+
path: isRemote(raw) || isAbsolute(location) ? location : resolve(location),
|
|
93
|
+
source: 'configured',
|
|
94
|
+
usedBy: [],
|
|
95
|
+
partial: basename(raw).startsWith('_'),
|
|
96
|
+
...(isRemote(raw)
|
|
97
|
+
? { error: 'Remote schemas are not read' }
|
|
98
|
+
: existsSync(location)
|
|
99
|
+
? describeSchema(location)
|
|
100
|
+
: { error: `Schema file not found: ${location}` }),
|
|
101
|
+
};
|
|
102
|
+
add(listing, key);
|
|
103
|
+
}
|
|
104
|
+
if (!listing.usedBy.includes(user))
|
|
105
|
+
listing.usedBy.push(user);
|
|
106
|
+
}
|
|
107
|
+
return listings;
|
|
108
|
+
}
|
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
|
|
9
|
+
structured-context uses the first config file it finds, in this order:
|
|
10
10
|
|
|
11
|
-
1.
|
|
12
|
-
2.
|
|
13
|
-
3.
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.16.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.
|
|
57
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.310",
|
|
58
58
|
"@biomejs/biome": "^2.5.15",
|
|
59
|
-
"@types/bun": "
|
|
59
|
+
"@types/bun": "^1.4.4",
|
|
60
60
|
"json-schema-to-ts": "^3.1.1",
|
|
61
|
-
"lefthook": "^2.
|
|
61
|
+
"lefthook": "^2.2.2"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"typescript": "^5"
|