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 +3 -1
- package/dist/api.d.ts +3 -2
- package/dist/api.js +3 -2
- 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 +13 -2
- package/dist/plugins/markdown/read-space.js +2 -1
- 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
|
|
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 {
|
|
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 {
|
|
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';
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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.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.
|
|
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"
|