@gregiteen/ssss-cli 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/LICENSE +15 -0
  3. package/README.md +162 -0
  4. package/VERSION +1 -0
  5. package/conformance/README.md +72 -0
  6. package/conformance/fixtures.json +763 -0
  7. package/conformance/reference-bundle.ucw.json +135 -0
  8. package/docs/help/autolink.md +15 -0
  9. package/docs/help/bundle.md +47 -0
  10. package/docs/help/conformance.md +34 -0
  11. package/docs/help/export.md +20 -0
  12. package/docs/help/import.md +19 -0
  13. package/docs/help/overview.md +33 -0
  14. package/docs/help/portability.md +27 -0
  15. package/docs/help/provision.md +19 -0
  16. package/docs/help/provisioning.md +43 -0
  17. package/docs/help/roles.md +61 -0
  18. package/docs/help/runtime.md +78 -0
  19. package/docs/help/scaffold.md +47 -0
  20. package/docs/ssss-spec.md +1325 -0
  21. package/package.json +62 -0
  22. package/registry/core.json +225 -0
  23. package/registry/extensions/festech.json +134 -0
  24. package/scripts/audit-registry-field-usage.mjs +136 -0
  25. package/scripts/autolink.mjs +341 -0
  26. package/scripts/build-reference-bundle.mjs +169 -0
  27. package/scripts/cmd-export.mjs +72 -0
  28. package/scripts/cmd-help.mjs +60 -0
  29. package/scripts/cmd-import.mjs +71 -0
  30. package/scripts/cmd-inspect.mjs +78 -0
  31. package/scripts/cmd-new.mjs +262 -0
  32. package/scripts/cmd-provision.mjs +68 -0
  33. package/scripts/cmd-validate.mjs +43 -0
  34. package/scripts/conformance.mjs +408 -0
  35. package/scripts/lib/cli.mjs +92 -0
  36. package/scripts/release.sh +35 -0
  37. package/scripts/ssss.mjs +93 -0
  38. package/scripts/validate-skills.mjs +115 -0
  39. package/src/bundle.mjs +261 -0
  40. package/src/engine.mjs +452 -0
  41. package/src/frontmatter.mjs +109 -0
  42. package/src/registry.mjs +67 -0
  43. package/src/runtime.mjs +212 -0
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Skill-package conformance: every skill shipped under skills/ (tracked,
4
+ * published — as opposed to an agent tool's local .agent/skills/ vendor
5
+ * copy) must (a) validate as an SSSS `skill` primitive per
6
+ * registry/core.json, and (b) satisfy the skill package's own required
7
+ * directory structure (scripts/, references/, evals/, subagents/, each
8
+ * non-empty, evals/evals.json with 3+ assertions).
9
+ *
10
+ * This is the automatic backstop for a bug this repo actually shipped: the
11
+ * skill-format guide told agents `type: skill` was a frontmatter
12
+ * anti-pattern, when registry/core.json and docs/ssss-spec.md §5.4 both
13
+ * REQUIRE it. Every skill in this repo silently violated its own spec.
14
+ * Wired into `ssss conformance` (scripts/conformance.mjs) so that class of
15
+ * drift can't recur unnoticed.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { loadRegistries } from '../src/registry.mjs';
21
+ import { parseDocument } from '../src/frontmatter.mjs';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const SKILLS_DIR = path.resolve(__dirname, '..', 'skills');
25
+ const REQUIRED_DIRS = ['scripts', 'references', 'evals', 'subagents'];
26
+ const MIN_EVALS = 3;
27
+ const NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
28
+
29
+ function nonEmptyRealFiles(dir) {
30
+ if (!fs.existsSync(dir)) return [];
31
+ return fs.readdirSync(dir).filter((f) => f !== '.gitkeep' && !f.startsWith('.'));
32
+ }
33
+
34
+ /**
35
+ * @returns {string[]} human-readable problem descriptions; empty = fully conformant.
36
+ */
37
+ export function validateSkills(skillsDir = SKILLS_DIR) {
38
+ const problems = [];
39
+ if (!fs.existsSync(skillsDir)) return problems; // nothing shipped yet is not an error
40
+
41
+ const { types, core } = loadRegistries();
42
+ const skillDef = types.get('skill');
43
+ if (!skillDef) {
44
+ problems.push("registry/core.json no longer defines a 'skill' primitive — validate-skills.mjs is stale.");
45
+ return problems;
46
+ }
47
+ const requiredFields = [
48
+ ...new Set([
49
+ ...(core.universal_frontmatter?.required || ['type']),
50
+ ...(skillDef.required_fields || ['type', 'name', 'description']),
51
+ ]),
52
+ ];
53
+
54
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
55
+ for (const entry of entries) {
56
+ const name = entry.name;
57
+ const dir = path.join(skillsDir, name);
58
+ const skillMd = path.join(dir, 'SKILL.md');
59
+
60
+ if (!fs.existsSync(skillMd)) {
61
+ problems.push(`skills/${name}/: missing SKILL.md (canonical filename, spec §4.4).`);
62
+ continue;
63
+ }
64
+
65
+ // ── Frontmatter: must validate against the registry's skill primitive ──
66
+ const { data } = parseDocument(fs.readFileSync(skillMd, 'utf8'));
67
+ for (const field of requiredFields) {
68
+ if (data[field] === undefined || data[field] === null || data[field] === '') {
69
+ problems.push(`skills/${name}/SKILL.md: missing required field '${field}' (registry/core.json skill primitive).`);
70
+ }
71
+ }
72
+ if (data.type !== undefined && data.type !== 'skill') {
73
+ problems.push(`skills/${name}/SKILL.md: type is '${data.type}', expected 'skill'.`);
74
+ }
75
+ if (data.name !== undefined && data.name !== name) {
76
+ problems.push(`skills/${name}/SKILL.md: frontmatter name '${data.name}' does not match its directory name '${name}' (spec §13 naming convention).`);
77
+ }
78
+ if (!NAME_RE.test(name)) {
79
+ problems.push(`skills/${name}/: directory name is not kebab-case (spec §13 naming convention).`);
80
+ }
81
+
82
+ // ── Required directory structure ──
83
+ for (const sub of REQUIRED_DIRS) {
84
+ const files = nonEmptyRealFiles(path.join(dir, sub));
85
+ if (!files.length) {
86
+ problems.push(`skills/${name}/${sub}/: required and must contain at least one real file (a bare .gitkeep does not count).`);
87
+ }
88
+ }
89
+
90
+ // ── evals/evals.json shape ──
91
+ const evalsPath = path.join(dir, 'evals', 'evals.json');
92
+ if (fs.existsSync(evalsPath)) {
93
+ try {
94
+ const evals = JSON.parse(fs.readFileSync(evalsPath, 'utf8'));
95
+ if (!Array.isArray(evals) || evals.length < MIN_EVALS) {
96
+ problems.push(`skills/${name}/evals/evals.json: must be an array of at least ${MIN_EVALS} assertions (found ${Array.isArray(evals) ? evals.length : 'non-array'}).`);
97
+ }
98
+ } catch (err) {
99
+ problems.push(`skills/${name}/evals/evals.json: invalid JSON (${err.message}).`);
100
+ }
101
+ }
102
+ }
103
+
104
+ return problems;
105
+ }
106
+
107
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
108
+ const problems = validateSkills();
109
+ if (problems.length) {
110
+ console.error('❌ Skill conformance failed:');
111
+ for (const p of problems) console.error(` • ${p}`);
112
+ process.exit(1);
113
+ }
114
+ console.log('✅ Skill conformance: every shipped skill validates against the skill primitive and required directory structure.');
115
+ }
package/src/bundle.mjs ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The `.ucw` bundle format (§16) and the provisioning contract (§17).
3
+ *
4
+ * Harvested and canonicalized from ultrachat's `WorkspaceVfsPackageService`
5
+ * (the export/package shape) and `server/types/workspace.ts` (the template
6
+ * variable, provisioning-step, and edge-relation vocabularies). Generalized to
7
+ * be registry-driven: portability filtering reads `registry/core.json` §5.5
8
+ * rather than ultrachat's hard-coded capability booleans, and the primitive
9
+ * inventory counts by registry `type` rather than a closed category enum.
10
+ *
11
+ * Three pure-ish verbs (§17.1):
12
+ * exportBundle(vault, opts) vault + profile → bundle (pure)
13
+ * provisionBundle(bundle, opts) bundle + params → replayable plan
14
+ * importBundle(plan, vault, eng) plan + engine → committed state (idempotent)
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { loadRegistries, resolvePortability } from './registry.mjs';
20
+ import { parseDocument } from './frontmatter.mjs';
21
+
22
+ const PROFILE_INCLUDES = {
23
+ backup: new Set(['structural', 'tenant_private', 'resource_bound']),
24
+ template: new Set(['structural', 'resource_bound']),
25
+ sale: new Set(['structural', 'resource_bound']),
26
+ };
27
+ const STEP_SYSTEMS = new Set(['workspace', 'branding', 'email', 'phone', 'domains', 'accounts', 'deployments', 'marketplace', 'tabs', 'automation']);
28
+ const STEP_MODES = new Set(['existing', 'provision', 'install', 'generate', 'configure']);
29
+ const PARAM_SOURCES = new Set(['user', 'llm', 'graph', 'system']);
30
+
31
+ function isSafeBundlePath(p) {
32
+ if (typeof p !== 'string' || !p || p.includes('\0')) return false;
33
+ if (p.startsWith('/') || p.startsWith('\\') || p.includes('\\')) return false;
34
+ const parts = p.split('/');
35
+ return !parts.some((part) => part === '' || part === '.' || part === '..');
36
+ }
37
+
38
+ /** Canonical, path-sorted serialization of files — the hash + transport pre-image (§16.1). */
39
+ function canonicalFiles(files) {
40
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
41
+ return sorted.map((f) => ({ path: f.path, content: f.content }));
42
+ }
43
+
44
+ /** sha256 over the canonical file serialization (§16.3). */
45
+ export function contentHash(files) {
46
+ const pre = JSON.stringify(canonicalFiles(files));
47
+ return 'sha256:' + crypto.createHash('sha256').update(pre).digest('hex');
48
+ }
49
+
50
+ /** Recursively list *.md files under a vault, excluding the event log. */
51
+ function walkVault(vaultRoot) {
52
+ const out = [];
53
+ (function rec(dir, rel) {
54
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
55
+ if (entry.name.startsWith('.')) continue; // skip .events, dotfiles
56
+ if (entry.isSymbolicLink()) throw new Error(`Refusing to export symlinked vault entry: ${rel ? `${rel}/` : ''}${entry.name}`);
57
+ const abs = path.join(dir, entry.name);
58
+ const r = rel ? `${rel}/${entry.name}` : entry.name;
59
+ if (entry.isDirectory()) rec(abs, r);
60
+ else if (entry.name.endsWith('.md')) out.push({ path: r, content: fs.readFileSync(abs, 'utf8') });
61
+ }
62
+ })(vaultRoot, '');
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * export(vault, profile) → bundle (§17.1). Walks the vault, filters by the §5.5
68
+ * portability rules for the profile, sorts by path, builds the inventory, and
69
+ * computes provenance. Pure: equal vault state + profile → byte-identical bundle.
70
+ */
71
+ export function exportBundle(vaultRoot, opts = {}) {
72
+ const { profile = 'backup', registryDir, name = 'untitled', description = '', version = '0.1.0',
73
+ exporter = '@ssss/cli', requiredExtensions = [], parameters = [], provisioning = [],
74
+ sourceWorkspaceId, exportedAt = '1970-01-01T00:00:00.000Z' } = opts;
75
+ const includes = PROFILE_INCLUDES[profile];
76
+ if (!includes) throw new Error(`Unknown export profile '${profile}'. Use backup | template | sale.`);
77
+
78
+ const { types } = loadRegistries(registryDir);
79
+ const dropped = [];
80
+ const kept = [];
81
+ const inventory = {};
82
+
83
+ for (const file of walkVault(vaultRoot)) {
84
+ const { data } = parseDocument(file.content);
85
+ const type = data.type;
86
+ const typeDef = types.get(type);
87
+ const klass = resolvePortability(typeDef, data);
88
+ if (!includes.has(klass)) { dropped.push({ path: file.path, type, portability: klass }); continue; }
89
+ kept.push(file);
90
+ if (type) inventory[type] = (inventory[type] || 0) + 1;
91
+ }
92
+
93
+ const files = canonicalFiles(kept);
94
+ const manifest = {
95
+ name, description, version,
96
+ exported_at: exportedAt,
97
+ ssss_core_version: loadRegistries(registryDir).core.spec_version,
98
+ required_extensions: requiredExtensions,
99
+ export_profile: profile,
100
+ primitive_inventory: inventory,
101
+ provisioning,
102
+ parameters,
103
+ file_count: files.length,
104
+ provenance: { content_hash: contentHash(files), exporter },
105
+ };
106
+ // source_workspace_id is operator-identifying — only carried in a backup (§16.2).
107
+ if (profile === 'backup' && sourceWorkspaceId) manifest.source_workspace_id = sourceWorkspaceId;
108
+ return { manifest, files, _dropped: dropped };
109
+ }
110
+
111
+ /**
112
+ * Validate a bundle against the registry bundle schema (§16) and the profile/
113
+ * portability conformance rules (§5.5). Returns { valid, errors }.
114
+ */
115
+ export function validateBundle(bundle, opts = {}) {
116
+ const errors = [];
117
+ const { types, core } = loadRegistries(opts.registryDir);
118
+ const m = bundle && bundle.manifest;
119
+ if (!m) return { valid: false, errors: ['bundle has no manifest'] };
120
+
121
+ for (const f of core.bundle.manifest_required_fields) {
122
+ if (m[f] === undefined || m[f] === null) errors.push(`manifest missing required field '${f}'`);
123
+ }
124
+ if (!PROFILE_INCLUDES[m.export_profile]) errors.push(`invalid export_profile '${m.export_profile}'`);
125
+ if (m.ssss_core_version && m.ssss_core_version !== core.spec_version) {
126
+ errors.push(`bundle targets ssss_core_version ${m.ssss_core_version}; host is ${core.spec_version}`);
127
+ }
128
+ if (Array.isArray(bundle.files) && m.file_count !== bundle.files.length) {
129
+ errors.push(`file_count ${m.file_count} ≠ actual ${bundle.files.length}`);
130
+ }
131
+ const seenPaths = new Set();
132
+ for (const file of bundle.files || []) {
133
+ if (!isSafeBundlePath(file.path)) {
134
+ errors.push(`file '${file.path || '(missing)'}' has an unsafe bundle path`);
135
+ continue;
136
+ }
137
+ if (seenPaths.has(file.path)) errors.push(`duplicate file path '${file.path}'`);
138
+ seenPaths.add(file.path);
139
+ }
140
+ // Provenance: recompute the content hash and reject on mismatch (§16.3).
141
+ if (m.provenance) {
142
+ for (const f of core.bundle.provenance_required_fields) {
143
+ if (m.provenance[f] === undefined) errors.push(`provenance missing '${f}'`);
144
+ }
145
+ const recomputed = contentHash(bundle.files || []);
146
+ if (m.provenance.content_hash && m.provenance.content_hash !== recomputed) {
147
+ errors.push(`content_hash mismatch: manifest ${m.provenance.content_hash} ≠ recomputed ${recomputed}`);
148
+ }
149
+ }
150
+ // Portability conformance: no file may exceed what the profile permits (§5.5).
151
+ const includes = PROFILE_INCLUDES[m.export_profile];
152
+ if (includes) {
153
+ for (const file of bundle.files || []) {
154
+ if (!isSafeBundlePath(file.path)) continue;
155
+ const base = path.basename(file.path || '');
156
+ if (base === 'index.md' || base === 'log.md') continue;
157
+ const { data } = parseDocument(file.content);
158
+ const def = types.get(data.type);
159
+ if (!def) {
160
+ errors.push(`file '${file.path}' declares unknown type '${data.type || '(missing)'}'`);
161
+ continue;
162
+ }
163
+ const requiredFields = [
164
+ ...new Set([
165
+ ...(core.universal_frontmatter?.required || ['type']),
166
+ ...(def.required_fields || []),
167
+ ]),
168
+ ];
169
+ for (const f of requiredFields) {
170
+ if (data[f] === undefined || data[f] === null || data[f] === '' || (Array.isArray(data[f]) && data[f].length === 0)) {
171
+ errors.push(`file '${file.path}' missing required frontmatter field '${f}'`);
172
+ }
173
+ }
174
+ const klass = resolvePortability(types.get(data.type), data);
175
+ if (!includes.has(klass)) {
176
+ errors.push(`file '${file.path}' is ${klass}, excluded by profile '${m.export_profile}'`);
177
+ }
178
+ }
179
+ }
180
+ // Provisioning step + parameter vocabularies (§17.2 / §16.5).
181
+ for (const s of m.provisioning || []) {
182
+ if (!STEP_SYSTEMS.has(s.system)) errors.push(`provisioning step '${s.id}' has invalid system '${s.system}'`);
183
+ if (!STEP_MODES.has(s.mode)) errors.push(`provisioning step '${s.id}' has invalid mode '${s.mode}'`);
184
+ }
185
+ for (const p of m.parameters || []) {
186
+ for (const f of core.bundle.parameter_fields.required) {
187
+ if (p[f] === undefined) errors.push(`parameter '${p.key || '?'}' missing '${f}'`);
188
+ }
189
+ if (p.source && !PARAM_SOURCES.has(p.source)) errors.push(`parameter '${p.key}' has invalid source '${p.source}'`);
190
+ }
191
+ return { valid: errors.length === 0, errors };
192
+ }
193
+
194
+ /** Extract internal references a file points at: `[[slug]]` links and relative `*.md` paths. */
195
+ function internalRefs(content) {
196
+ const refs = new Set();
197
+ for (const m of content.matchAll(/\[\[([^\]]+)\]\]/g)) refs.add(m[1].trim());
198
+ return [...refs];
199
+ }
200
+
201
+ /**
202
+ * provision(bundle, params, target) → replayable plan (§17.1). Resolves
203
+ * parameters, checks link integrity under id-remap (§17.3), and emits an ordered
204
+ * list of Operation Contract envelopes (§6). Does not touch the filesystem.
205
+ */
206
+ export function provisionBundle(bundle, opts = {}) {
207
+ const { parameters = {}, workspaceId = 'ws-provision', pathPrefix = '', dryRun = false } = opts;
208
+ const validation = validateBundle(bundle, { registryDir: opts.registryDir });
209
+ if (!validation.valid) {
210
+ return { ok: false, plan: [], unresolved: [], danglingLinks: [], errors: validation.errors, steps: [] };
211
+ }
212
+ const m = bundle.manifest;
213
+
214
+ // Resolve required parameters (§16.5).
215
+ const unresolved = [];
216
+ for (const p of m.parameters || []) {
217
+ if (p.required && parameters[p.key] === undefined && p.defaultValue === undefined) unresolved.push(p.key);
218
+ }
219
+
220
+ // Id-remap: every file is placed under the target prefix; build the slug set so
221
+ // we can verify link integrity (§17.3) against the remapped target.
222
+ const normalizedPrefix = pathPrefix ? pathPrefix.replace(/\/$/, '') : '';
223
+ if (normalizedPrefix && !isSafeBundlePath(normalizedPrefix)) {
224
+ return { ok: false, plan: [], unresolved, danglingLinks: [], errors: [`unsafe path prefix '${pathPrefix}'`], steps: m.provisioning || [] };
225
+ }
226
+ const remap = (rel) => (normalizedPrefix ? `${normalizedPrefix}/${rel}` : rel);
227
+ const presentSlugs = new Set();
228
+ for (const f of bundle.files) {
229
+ const { data } = parseDocument(f.content);
230
+ if (data.slug) presentSlugs.add(data.slug);
231
+ presentSlugs.add(f.path.replace(/\.md$/, ''));
232
+ presentSlugs.add(f.path.replace(/.*\//, '').replace(/\.md$/, ''));
233
+ }
234
+ const danglingLinks = [];
235
+ for (const f of bundle.files) {
236
+ for (const ref of internalRefs(f.content)) {
237
+ if (!presentSlugs.has(ref)) danglingLinks.push({ file: f.path, ref });
238
+ }
239
+ }
240
+
241
+ // Build the envelope plan. Deterministic idempotency keys make import a no-op on re-run.
242
+ const plan = bundle.files.map((f) => {
243
+ const targetPath = remap(f.path);
244
+ const key = 'prov-' + crypto.createHash('sha256').update(workspaceId + ':' + targetPath).digest('hex').slice(0, 24);
245
+ return { type: 'operation', idempotency_key: key, path: targetPath, workspace_id: workspaceId, content: f.content, intent: 'provision', dry_run: dryRun, actor: { role: 'system' } };
246
+ });
247
+
248
+ const ok = unresolved.length === 0 && danglingLinks.length === 0;
249
+ return { ok, plan, unresolved, danglingLinks, steps: m.provisioning || [] };
250
+ }
251
+
252
+ /**
253
+ * import(plan, target) → replay (§17.1). Runs each envelope through the engine
254
+ * (§6.3). Idempotent: each envelope's idempotency_key makes a re-run a no-op.
255
+ */
256
+ export function importBundle(plan, vaultRoot, engine) {
257
+ const results = [];
258
+ for (const env of plan) results.push(engine.processOperation(env, vaultRoot));
259
+ const committed = results.filter((r) => r.success && !r.replay).length;
260
+ return { ok: results.every((r) => r.success), committed, results };
261
+ }