@heytherevibin/skillforge 0.10.1 → 0.11.7

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 (58) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CONTRIBUTING.md +5 -3
  3. package/README.md +37 -345
  4. package/RELEASING.md +7 -6
  5. package/STRATEGY.md +2 -2
  6. package/bin/cli.js +297 -52
  7. package/ci/test-user-env-profile.cjs +65 -0
  8. package/docs/README.md +14 -0
  9. package/docs/architecture-and-data.md +90 -0
  10. package/docs/cli-reference.md +57 -0
  11. package/docs/environment-and-configuration.md +76 -0
  12. package/docs/getting-started.md +88 -0
  13. package/docs/mcp-integration.md +75 -0
  14. package/docs/troubleshooting.md +50 -0
  15. package/lib/templates/claude-code-skillforge-global.md +3 -3
  16. package/lib/templates/cursor-skillforge-global.md +6 -2
  17. package/lib/user-env-profile.js +141 -0
  18. package/package.json +3 -2
  19. package/python/app/agent_cli.py +334 -0
  20. package/python/app/explain_route.py +170 -0
  21. package/python/app/health_cli.py +13 -0
  22. package/python/app/main.py +131 -48
  23. package/python/app/materialize.py +150 -68
  24. package/python/app/mcp_contract.py +2 -1
  25. package/python/app/mcp_operator.py +252 -0
  26. package/python/app/mcp_server.py +290 -118
  27. package/python/app/npm_pkg_version.py +38 -0
  28. package/python/app/pick_diversify.py +51 -0
  29. package/python/app/replay_cli.py +145 -0
  30. package/python/app/route_cli.py +251 -87
  31. package/python/app/route_cli_pick.py +35 -0
  32. package/python/app/route_policies.py +18 -3
  33. package/python/app/route_quality.py +70 -1
  34. package/python/app/router_llm.py +85 -0
  35. package/python/app/router_mode.py +21 -0
  36. package/python/app/routing_signals.py +7 -1
  37. package/python/app/skill_manifest.py +67 -0
  38. package/python/app/skills_author_cli.py +117 -0
  39. package/python/app/tips_cli.py +37 -0
  40. package/python/app/tools_cli.py +276 -0
  41. package/python/fixtures/route_eval/smoke.json +5 -0
  42. package/python/requirements.txt +1 -0
  43. package/python/tests/test_capabilities_bundle.py +33 -0
  44. package/python/tests/test_materialize_hosts.py +108 -0
  45. package/python/tests/test_mcp_contract.py +1 -1
  46. package/python/tests/test_mcp_initialize_clientinfo.py +26 -0
  47. package/python/tests/test_mcp_operator.py +84 -0
  48. package/python/tests/test_npm_pkg_version.py +21 -0
  49. package/python/tests/test_pick_diversify.py +47 -0
  50. package/python/tests/test_replay_cli.py +31 -0
  51. package/python/tests/test_route_cli_pick.py +25 -0
  52. package/python/tests/test_route_policies.py +29 -0
  53. package/python/tests/test_route_quality.py +72 -0
  54. package/python/tests/test_router_llm.py +63 -0
  55. package/python/tests/test_router_mode_env.py +21 -0
  56. package/python/tests/test_routing_signals.py +20 -0
  57. package/python/tests/test_skill_manifest.py +48 -0
  58. package/python/tests/test_tools_cli.py +69 -0
package/bin/cli.js CHANGED
@@ -6,15 +6,20 @@
6
6
  * skillforge, skillforge --help Show help (primary path: MCP, not a web app)
7
7
  * skillforge mcp MCP stdio server (Claude / Cursor / …)
8
8
  * skillforge events [--watch] [--limit=N] Print SQLite routing events
9
+ * skillforge replay [--session-id] Chronological SQLite event replay
10
+ * skillforge tools <cmd> [--json] MCP tool parity (see skillforge tools -h)
11
+ * skillforge tips Short MCP + terminal cheatsheet
12
+ * skillforge agent [--prompt=…] Terminal chat agent (OpenAI-compatible API tools)
9
13
  * skillforge route [words…] [--prompt=…] Same routing as MCP route_skills (terminal)
10
14
  * skillforge index --project-root=… Chunk/embed repo files for project RAG
11
15
  * skillforge health [--quick] [--json] Preflight: paths, catalog, optional router load
12
16
  * skillforge route-eval --fixture=… Embedding-only regression cases (CI-friendly)
13
17
  * skillforge weights export|import Snapshot learned weights (JSON)
14
18
  * skillforge install One-time Python venv + deps (+ Cursor /skillforge when detected)
19
+ * skillforge config path|init|validate … ~/.skillforge/env (dotenv-style profile + linter)
15
20
  * skillforge hosts init [--force] Install global /skillforge for Cursor + Claude Code (no Python setup)
16
21
  * skillforge cursor init [--force] Same as hosts init (alias)
17
- * skillforge skills … / pack … / reset
22
+ * skillforge skills list|add|remove|init|lint ; pack … ; reset
18
23
  */
19
24
 
20
25
  const path = require('path');
@@ -22,6 +27,7 @@ const fs = require('fs');
22
27
  const { spawn, spawnSync } = require('child_process');
23
28
  const os = require('os');
24
29
  const packs = require('../lib/packs');
30
+ const userEnvProfile = require('../lib/user-env-profile');
25
31
 
26
32
  const PKG_ROOT = path.resolve(__dirname, '..');
27
33
  const PKG = require(path.join(PKG_ROOT, 'package.json'));
@@ -34,6 +40,8 @@ const USER_SKILLS_DIR = path.join(CONFIG_DIR, 'skills');
34
40
  /** Bearer-token file for the removed HTTP API (<=0.6.x); deleted on first CLI use. */
35
41
  const LEGACY_AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
36
42
  const SETUP_MARKER = path.join(CONFIG_DIR, '.setup-complete');
43
+ /** User-owned KEY=VALUE profile (merged before process.env — shell / MCP host overrides). */
44
+ const USER_ENV_PATH = path.join(CONFIG_DIR, 'env');
37
45
 
38
46
  const args = process.argv.slice(2);
39
47
  const cmd = args[0];
@@ -179,7 +187,9 @@ function setupIfNeeded() {
179
187
  }
180
188
 
181
189
  function buildEnv(extra = {}) {
190
+ const { vars: profile } = userEnvProfile.readUserEnvProfileFromFile(USER_ENV_PATH);
182
191
  return {
192
+ ...profile,
183
193
  ...process.env,
184
194
  SKILLFORGE_BUNDLED_SKILLS: path.join(PKG_ROOT, 'skills'),
185
195
  SKILLFORGE_USER_SKILLS: USER_SKILLS_DIR,
@@ -190,10 +200,121 @@ function buildEnv(extra = {}) {
190
200
  };
191
201
  }
192
202
 
203
+ function printUserEnvProfilePathLine() {
204
+ process.stdout.write(USER_ENV_PATH + '\n');
205
+ }
206
+
207
+ function runInitUserEnv(templateForce) {
208
+ ensureDirs();
209
+ if (fs.existsSync(USER_ENV_PATH) && !templateForce) {
210
+ err(`${USER_ENV_PATH} already exists.`);
211
+ log(c.dim(' Use --force to replace it with the template (you will lose current contents).'));
212
+ process.exit(1);
213
+ }
214
+ const template =
215
+ '# Skillforge user environment profile (~/.skillforge/env).\n' +
216
+ '# Loaded for every Skillforge subprocess (CLI and `skillforge mcp`).\n' +
217
+ '# Merge order: entries here first, then process/MCP host env overwrites duplicates; SKILLFORGE_BUNDLED_SKILLS,\n' +
218
+ '# SKILLFORGE_USER_SKILLS, SKILLFORGE_DB_PATH, and PYTHONPATH are always finalized by Skillforge.\n' +
219
+ '# Omit secrets from VCS; chmod 600 is recommended.\n#\n' +
220
+ '# Examples (uncomment and set):\n' +
221
+ '# ANTHROPIC_API_KEY=\n' +
222
+ '# SKILLFORGE_ROUTER_MODE=host\n' +
223
+ '# OPENAI_API_BASE=http://127.0.0.1:11434/v1\n' +
224
+ '# SKILLFORGE_ROUTER_LLM_BACKEND=openai_compatible\n' +
225
+ '# SKILLFORGE_AGENT_MODEL=llama3.2\n' +
226
+ '\n';
227
+ fs.writeFileSync(USER_ENV_PATH, template, 'utf8');
228
+ if (!isWindows()) {
229
+ try {
230
+ fs.chmodSync(USER_ENV_PATH, 0o600);
231
+ } catch (_) {
232
+ /* chmod optional */
233
+ }
234
+ }
235
+ ok(`Wrote commented template:\n${c.dim(` ${USER_ENV_PATH}`)}`);
236
+ }
237
+
238
+ function runValidateEnvProfileCmd() {
239
+ const r = userEnvProfile.readUserEnvProfileFromFile(USER_ENV_PATH);
240
+ if (r.missingFile) {
241
+ info('Optional profile file not created yet.');
242
+ log(c.dim(` Path: ${USER_ENV_PATH}`));
243
+ log(c.dim(' scaffold: skillforge config init'));
244
+ process.exit(0);
245
+ }
246
+
247
+ let nErr = 0;
248
+ let nWarn = 0;
249
+ for (const issue of r.issues) {
250
+ const loc = issue.line ? ` ${c.dim(`(line ${issue.line})`)}` : '';
251
+ if (issue.level === 'error') {
252
+ nErr += 1;
253
+ err(`${issue.message}${loc}`);
254
+ } else {
255
+ nWarn += 1;
256
+ log(c.yellow('⚠'), `${issue.message}${loc}`);
257
+ }
258
+ }
259
+
260
+ if (nErr === 0 && nWarn === 0) {
261
+ ok(`Profile syntax OK — ${USER_ENV_PATH}`);
262
+ process.exit(0);
263
+ }
264
+ if (nErr === 0) {
265
+ ok(`Validated — ${nWarn} warning(s) only.`);
266
+ process.exit(0);
267
+ }
268
+ log(c.dim(` Correct ${USER_ENV_PATH}, then run skillforge config validate again.`));
269
+ process.exit(1);
270
+ }
271
+
272
+ function runConfigCmd() {
273
+ const sub = args[1];
274
+ if (
275
+ sub === undefined ||
276
+ sub === '--help' ||
277
+ sub === '-h'
278
+ ) {
279
+ log(c.bold('Usage'));
280
+ log(c.dim(' skillforge config path'));
281
+ log(c.dim(' skillforge config init [--force]'));
282
+ log(c.dim(' skillforge config validate\n'));
283
+ log(c.bold('Description'));
284
+ log(
285
+ c.dim(
286
+ ' path Print ~/.skillforge/env (dotenv KEY=value; optional export; # comments).\n' +
287
+ ' init Create a commented template (--force replaces an existing profile).\n' +
288
+ ' validate Lint the profile — errors exit 1; missing file exits 0 (optional).',
289
+ ),
290
+ );
291
+ process.exit(sub === undefined ? 1 : 0);
292
+ }
293
+ if (sub === 'path') {
294
+ printUserEnvProfilePathLine();
295
+ return;
296
+ }
297
+ if (sub === 'init') {
298
+ const templateForce =
299
+ args.includes('--force');
300
+ runInitUserEnv(templateForce);
301
+ log(c.dim(' Run skillforge config validate after editing.'));
302
+ return;
303
+ }
304
+ if (sub === 'validate') {
305
+ runValidateEnvProfileCmd();
306
+ return;
307
+ }
308
+ err(`Unknown config subcommand: ${sub}`);
309
+ log(c.dim(' Try: path, init, validate'));
310
+ process.exit(1);
311
+ }
312
+
193
313
  function printMcpConfig() {
194
314
  setupIfNeeded();
195
315
  const useLocal = args.includes('--local');
196
316
  const withKey = args.includes('--with-anthropic');
317
+ const withEnv = args.includes('--with-env');
197
318
  const cliJs = path.join(PKG_ROOT, 'bin', 'cli.js');
198
319
  /** @type {Record<string, unknown>} */
199
320
  const entry = useLocal
@@ -206,20 +327,32 @@ function printMcpConfig() {
206
327
  args: ['-y', NPM_PKG_NAME, 'mcp'],
207
328
  };
208
329
  if (withKey) {
209
- entry.env = { ANTHROPIC_API_KEY: 'sk-ant-…' };
330
+ entry.env = {
331
+ SKILLFORGE_ROUTER_MODE: 'auto',
332
+ ANTHROPIC_API_KEY: 'sk-ant-…',
333
+ };
334
+ } else if (withEnv) {
335
+ entry.env = {
336
+ SKILLFORGE_ROUTER_MODE: 'host',
337
+ };
210
338
  }
211
339
  const out = { mcpServers: { skillforge: entry } };
212
340
  process.stdout.write(JSON.stringify(out, null, 2) + '\n');
213
- process.stderr.write(
214
- c.dim(
215
- 'Merge into ~/.cursor/mcp.json, Claude Desktop config, etc. --local uses this package checkout; --with-anthropic adds env placeholder for Haiku routing.\n'
216
- )
217
- );
341
+ let note =
342
+ 'Merge into ~/.cursor/mcp.json, Claude Desktop config, etc. --local uses this package checkout. ' +
343
+ 'Routing: default host (two-step picked_names); --with-anthropic SKILLFORGE_ROUTER_MODE=auto plus ANTHROPIC_API_KEY placeholder. ' +
344
+ '--with-env adds server.env SKILLFORGE_ROUTER_MODE=host explicitly (combine with ~/.skillforge/env for secrets).';
345
+ if (withKey && withEnv) {
346
+ note += ' When both --with-env and --with-anthropic are passed, the emitted env matches --with-anthropic only.';
347
+ }
348
+ process.stderr.write(c.dim(`${note}\n`));
218
349
  }
219
350
 
220
351
  function runMcpServer() {
221
352
  setupIfNeeded();
222
- const env = buildEnv();
353
+ const env = buildEnv({
354
+ SKILLFORGE_TRANSPORT: 'mcp',
355
+ });
223
356
  // MCP JSON-RPC must own stdout. This process must not log to stdout after this point.
224
357
  const proc = spawn(venvPython(), ['-m', 'app.mcp_server'], {
225
358
  stdio: ['inherit', 'inherit', 'inherit'],
@@ -240,6 +373,55 @@ function runEventsCmd() {
240
373
  proc.on('exit', (code) => process.exit(code ?? 0));
241
374
  }
242
375
 
376
+ function runReplayCmd() {
377
+ setupIfNeeded();
378
+ const sub = args.slice(1);
379
+ const proc = spawn(venvPython(), ['-m', 'app.replay_cli', ...sub], {
380
+ stdio: 'inherit',
381
+ env: buildEnv(),
382
+ });
383
+ proc.on('exit', (code) => process.exit(code ?? 0));
384
+ }
385
+
386
+ function runTipsCmd() {
387
+ setupIfNeeded();
388
+ const proc = spawn(venvPython(), ['-m', 'app.tips_cli'], {
389
+ stdio: 'inherit',
390
+ env: buildEnv(),
391
+ });
392
+ proc.on('exit', (code) => process.exit(code ?? 0));
393
+ }
394
+
395
+ function runToolsCmd() {
396
+ setupIfNeeded();
397
+ const sub = args.slice(2);
398
+ const proc = spawn(venvPython(), ['-m', 'app.tools_cli', ...sub], {
399
+ stdio: 'inherit',
400
+ env: buildEnv(),
401
+ });
402
+ proc.on('exit', (code) => process.exit(code ?? 0));
403
+ }
404
+
405
+ function runAgentCmd() {
406
+ setupIfNeeded();
407
+ // args = argv after 'skillforge' — ["agent", ...], same pattern as route (slice after subcommand).
408
+ const sub = args.slice(1);
409
+ const proc = spawn(venvPython(), ['-m', 'app.agent_cli', ...sub], {
410
+ stdio: 'inherit',
411
+ env: buildEnv(),
412
+ });
413
+ proc.on('exit', (code) => process.exit(code ?? 0));
414
+ }
415
+
416
+ function runSkillsAuthorCmd(mode, argv) {
417
+ setupIfNeeded();
418
+ const proc = spawn(venvPython(), ['-m', 'app.skills_author_cli', mode, ...argv], {
419
+ stdio: 'inherit',
420
+ env: buildEnv(),
421
+ });
422
+ proc.on('exit', (code) => process.exit(code ?? 0));
423
+ }
424
+
243
425
  function runRouteCmd() {
244
426
  setupIfNeeded();
245
427
  const sub = args.slice(1);
@@ -378,55 +560,101 @@ function runHostsInit() {
378
560
  }
379
561
 
380
562
  function showHelp() {
381
- log(`
382
- ${c.bold('skillforge')} skill orchestrator co-tool for Claude (MCP-first)
383
-
384
- ${c.bold('Run modes:')}
385
- skillforge --help This message (recommended first step)
386
- skillforge mcp MCP stdio — primary integration for Claude / Cursor
387
- skillforge mcp config [--local] [--with-anthropic] Print JSON for MCP host (merge into mcp.json)
388
- skillforge events [--watch] [--limit=N] [--verbose] [--user=…] Live routing log + usage (see --help)
389
- skillforge route [words…] [--project-root=…] [--include-project-rag] Route a prompt (see skillforge route --help)
390
- skillforge index --project-root=… [--reset] [--stats-only] Index repo text for include_project_rag
391
- skillforge health [--quick] [--json] [--project-root=…] Paths + SKILL.md counts; omit --quick to load the embedder
392
- skillforge route-eval --fixture=path/to/cases.json [--router-mode=embedding] Run routing eval cases
393
-
394
- ${c.bold('Skills:')}
395
- skillforge skills list List bundled and user skills
396
- skillforge skills add <path> Add a local skill folder
397
- skillforge skills remove <name> Remove a user-added skill
398
-
399
- ${c.bold('Skill packs (install from git):')}
400
- skillforge pack install <repo> Install pack (e.g. "user/repo" or git URL)
401
- skillforge pack list List installed packs
402
- skillforge pack update <name> Update a pack
403
- skillforge pack remove <name> Uninstall a pack
404
-
405
- ${c.bold('Maintenance:')}
406
- skillforge reset Wipe learned state and event log
407
- skillforge weights export Dump learned weights JSON (see skillforge weights export --help)
408
- skillforge weights import Restore weights snapshot (see skillforge weights import --help)
409
- skillforge install Re-run setup (auto-runs on first launch; installs editor /skillforge when detected)
410
- skillforge install --force-cursor Replace managed host command files even if present
411
- skillforge hosts init [--force] Write ~/.cursor/commands + ~/.claude/commands /skillforge (no Python)
412
- skillforge cursor init [--force] Alias for hosts init
413
- skillforge --help This message
414
-
415
- ${c.bold('First run:')} ${c.cyan('skillforge install')} (auto on first command) or ${c.cyan('npx -y')} ${NPM_PKG_NAME} ${c.cyan('install')}. Detects ${c.cyan('Cursor')} and ${c.cyan('Claude Code')} and installs managed **/skillforge** under ${c.cyan('~/.cursor/commands')} and ${c.cyan('~/.claude/commands')} (skip: ${c.dim('SKILLFORGE_SKIP_CURSOR_SETUP')}, ${c.dim('SKILLFORGE_SKIP_CLAUDE_CODE_SETUP')}; force paths: ${c.dim('SKILLFORGE_CURSOR_GLOBAL_COMMAND')} / ${c.dim('SKILLFORGE_CLAUDE_CODE_GLOBAL_COMMAND')}). ${c.cyan('skillforge mcp')} needs no API key for embedding-only routing.
416
- ${c.bold('Config dir:')} ${CONFIG_DIR}
417
-
418
- ${c.bold('MCP integration:')}
419
- Generate a config snippet: ${c.cyan('skillforge mcp config')} (add ${c.cyan('--local')} for this checkout, ${c.cyan('--with-anthropic')} for a key placeholder)
420
- Minimal npx example:
421
- ${JSON.stringify({ mcpServers: { skillforge: { command: 'npx', args: ['-y', NPM_PKG_NAME, 'mcp'] } } })}
422
- `);
563
+ const colW = 48;
564
+ const row = (cmd, desc) => {
565
+ const s = `${cmd}`;
566
+ const padLen = Math.max(2, colW - s.length);
567
+ const pad = ' '.repeat(padLen);
568
+ return ` ${c.cyan(s)}${pad}${desc}`;
569
+ };
570
+ const sec = title => `\n${c.bold(title)}\n${c.dim(' ' + '─'.repeat(72))}`;
571
+ log('');
572
+ log(c.bold(`Skillforge`) + ` ${c.dim('local SKILL.md orchestration')}`);
573
+ log(
574
+ `${c.dim('Version')} ${PKG_VERSION}${c.dim(' · ')}Enterprise automation and MCP hosts share the same Python engine.`,
575
+ );
576
+ log(`${c.dim('State directory')} ${CONFIG_DIR}`);
577
+ log(
578
+ `\n${c.dim('PRIMARY INTEGRATION (production workloads):')} ${c.bold('stdio MCP')} — ${c.dim('configure')} ${c.cyan('skillforge mcp config')} ${c.dim(', then restart the IDE / agent.')}`,
579
+ );
580
+ log(
581
+ `${c.dim('TERMINAL (operators, scripting, CI):')} ${c.dim('routing, observability, and')} ${c.cyan('skillforge tools')} ${c.dim('(MCP tool parity).')}`,
582
+ );
583
+
584
+ log(sec('Model Context Protocol'));
585
+ log(row('skillforge mcp', 'Start JSON-RPC MCP server over stdio (Cursor, Claude Desktop, compatible hosts)'));
586
+ log(
587
+ row(
588
+ 'skillforge mcp config [--local] [--with-anthropic] [--with-env]',
589
+ 'Emit MCP host JSON (--with-env adds SKILLFORGE_ROUTER_MODE=host)',
590
+ ),
591
+ );
592
+ log(` ${c.dim('Default routing')} SKILLFORGE_ROUTER_MODE=host · two-step shortlist then picked_names`);
593
+ log(
594
+ ` ${c.dim('Minimal npx host entry')} ${JSON.stringify({
595
+ mcpServers: {
596
+ skillforge: { command: 'npx', args: ['-y', NPM_PKG_NAME, 'mcp'] },
597
+ },
598
+ })}`,
599
+ );
600
+
601
+ log(sec('Routing & context'));
602
+ log(row('skillforge agent [--prompt TEXT]', 'Standalone chat agent · OpenAI-compatible API + MCP tool handlers'));
603
+ log(row('skillforge route [TEXT…]', 'Interactive / scripted routing · --json · -i · --explain (see route --help)'));
604
+ log(row('skillforge index --project-root=…', 'Project text index RAG chunks (SQLite project_chunks)'));
605
+ log(row('skillforge tips', 'Short operator reference (routing, env, MCP host mode)'));
606
+
607
+ log(sec('MCP tool parity (CLI)'));
608
+ log(row('skillforge tools …', 'Subcommands mirror MCP tools (same handlers)'));
609
+ log(
610
+ row(
611
+ 'skillforge tools --help',
612
+ 'search · explain · get · catalog · feedback · disable · referenced',
613
+ ),
614
+ );
615
+ log(
616
+ ` ${c.dim('└')} ${c.dim('materialize · bootstrap · capabilities · router-status · index-status · weights-snapshot · events-recent')}`,
617
+ );
618
+ log(row('skillforge tools … --json', 'Raw tool envelope (content + _meta) for automation'));
619
+
620
+ log(sec('Observability & diagnostics'));
621
+ log(row('skillforge events …', '--watch routing / usage SQLite tail'));
622
+ log(row('skillforge replay …', 'Chronological event timeline (--session-id, --json)'));
623
+ log(row('skillforge health …', 'Preflight: paths · catalog (--quick skips embed load)'));
624
+ log(row('skillforge route-eval …', 'Fixture embedding regression harness (CI)'));
625
+ log(row('skillforge weights export|import …', 'Portable learned weights snapshot'));
626
+
627
+ log(sec('Catalog & authoring'));
628
+ log(row('skillforge skills list|add|remove|init|lint …', 'Filesystem skill trees + scaffold + manifest lint'));
629
+ log(row('skillforge pack install|list|update|remove …', 'Git-hosted skill bundles'));
630
+
631
+ log(sec('Setup & lifecycle'));
632
+ log(row('skillforge install [--force-cursor]', 'Python venv + deps + optional Cursor / Claude Code slash templates'));
633
+ log(row('skillforge config path|init|validate …', 'Stable ~/.skillforge/env (dotenv linter: config validate · README)'));
634
+ log(row('skillforge hosts init · skillforge cursor init', 'Rewrite managed /skillforge host commands (--force)'));
635
+ log(row('skillforge reset', 'Drop SQLite learning + event history'));
636
+
637
+ log(`\n${c.bold('First run')} ${c.cyan('skillforge install')} ${c.dim('or')} ${c.cyan(`npx -y ${NPM_PKG_NAME} install`)}`);
638
+ log(
639
+ c.dim(
640
+ ' Auto-provisions ~/.skillforge/venv · optional Cursor + Claude Code /skillforge commands ' +
641
+ '(SKILLFORGE_SKIP_CURSOR_SETUP, SKILLFORGE_SKIP_CLAUDE_CODE_SETUP).',
642
+ ),
643
+ );
644
+ log(`\n${c.bold('Documentation')} README.md + docs/ · npm/GitHub (${NPM_PKG_NAME})`);
423
645
  }
424
646
 
425
647
  // ---- main ----
426
648
  async function main() {
427
649
  dropLegacyAuthJsonIfPresent();
428
650
 
429
- if (args.includes('--help') || args.includes('-h') || cmd === 'help') {
651
+ if (cmd === 'help') {
652
+ showHelp();
653
+ return;
654
+ }
655
+
656
+ const wantsCliHelp = args.includes('--help') || args.includes('-h');
657
+ if (wantsCliHelp && (cmd === undefined || cmd === '--help' || cmd === '-h')) {
430
658
  showHelp();
431
659
  return;
432
660
  }
@@ -438,6 +666,18 @@ async function main() {
438
666
  case 'events':
439
667
  runEventsCmd();
440
668
  break;
669
+ case 'replay':
670
+ runReplayCmd();
671
+ break;
672
+ case 'tips':
673
+ runTipsCmd();
674
+ break;
675
+ case 'tools':
676
+ runToolsCmd();
677
+ break;
678
+ case 'agent':
679
+ runAgentCmd();
680
+ break;
441
681
  case 'route':
442
682
  runRouteCmd();
443
683
  break;
@@ -486,14 +726,19 @@ async function main() {
486
726
  case 'reset':
487
727
  reset();
488
728
  break;
729
+ case 'config':
730
+ runConfigCmd();
731
+ break;
489
732
  case 'skills': {
490
733
  const sub = args[1];
491
734
  if (sub === 'list') skillsList();
492
735
  else if (sub === 'add') skillsAdd(args[2]);
493
736
  else if (sub === 'remove' || sub === 'rm') skillsRemove(args[2]);
737
+ else if (sub === 'init') runSkillsAuthorCmd('init', args.slice(2));
738
+ else if (sub === 'lint') runSkillsAuthorCmd('lint', args.slice(2));
494
739
  else {
495
740
  err(`Unknown skills subcommand: ${sub}`);
496
- log(c.dim(' Try: list, add, remove'));
741
+ log(c.dim(' Try: list, add, remove, init, lint'));
497
742
  process.exit(1);
498
743
  }
499
744
  break;
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const test = require('node:test');
7
+ const assert = require('node:assert/strict');
8
+
9
+ const {
10
+ parseUserEnvProfileText,
11
+ readUserEnvProfileFromFile,
12
+ } = require('../lib/user-env-profile');
13
+
14
+ test('parse basic KEY=value pairs', () => {
15
+ const { vars, issues } = parseUserEnvProfileText('A=1\nB=two');
16
+ assert.equal(vars.A, '1');
17
+ assert.equal(vars.B, 'two');
18
+ assert.equal(issues.length, 0);
19
+ });
20
+
21
+ test('export prefix and stripping quotes', () => {
22
+ const { vars, issues } = parseUserEnvProfileText(`export Z="quoted"\nQ='single'`);
23
+ assert.equal(vars.Z, 'quoted');
24
+ assert.equal(vars.Q, 'single');
25
+ assert.equal(issues.length, 0);
26
+ });
27
+
28
+ test('duplicate KEY warns last wins', () => {
29
+ const { vars, issues } = parseUserEnvProfileText('A=1\nA=2\n');
30
+ assert.equal(vars.A, '2');
31
+ assert.ok(issues.some(i => i.message.includes('duplicate')));
32
+ });
33
+
34
+ test('invalid KEY name error does not bind', () => {
35
+ const { vars, issues } = parseUserEnvProfileText('1BAD=x');
36
+ assert.equal(vars['1BAD'], undefined);
37
+ assert.ok(issues.some(i => i.level === 'error'));
38
+ });
39
+
40
+ test('bare line warns', () => {
41
+ const { issues } = parseUserEnvProfileText('not-a-pair');
42
+ assert.ok(issues.some(i => i.level === 'warning' && i.message.includes('dotenv')));
43
+ });
44
+
45
+ test('readUserEnvProfileFromFile missing yields missingFile', () => {
46
+ const p = path.join(os.tmpdir(), `sf-env-missing-${Math.random().toString(36).slice(2)}`);
47
+ fs.rmSync(p, { force: true });
48
+ const r = readUserEnvProfileFromFile(p);
49
+ assert.equal(r.missingFile, true);
50
+ assert.deepEqual(r.vars, {});
51
+ assert.deepEqual(r.issues, []);
52
+ });
53
+
54
+ test('readUserEnvProfileFromFile parses file', () => {
55
+ const p = path.join(os.tmpdir(), `sf-env-ok-${Math.random().toString(36).slice(2)}`);
56
+ fs.writeFileSync(p, 'HELLO_CI=world\n', 'utf8');
57
+ try {
58
+ const r = readUserEnvProfileFromFile(p);
59
+ assert.ok(!r.missingFile);
60
+ assert.equal(r.vars.HELLO_CI, 'world');
61
+ assert.equal(r.issues.length, 0);
62
+ } finally {
63
+ fs.unlinkSync(p);
64
+ }
65
+ });
package/docs/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # Skillforge documentation
2
+
3
+ These guides supplement the repo root **[README](../README.md)** (overview and badges). Follow them **in order** the first time you install Skillforge.
4
+
5
+ | Order | Guide | What you learn |
6
+ |:-----:|-------|----------------|
7
+ | 1 | [Getting started](getting-started.md) | Install, MCP wiring preflight, first commands |
8
+ | 2 | [Environment & configuration](environment-and-configuration.md) | Layers: `~/.skillforge/env`, MCP `env`, env variable reference |
9
+ | 3 | [MCP integration](mcp-integration.md) | Router modes (`host`, `auto`, …), JSON snippet, MCP tools summary |
10
+ | 4 | [CLI reference](cli-reference.md) | Every `skillforge` subcommand cluster and parity with MCP |
11
+ | 5 | [Architecture & data](architecture-and-data.md) | Routing pipeline, SQLite layout, policies, project RAG, skills/packs |
12
+ | 6 | [Troubleshooting](troubleshooting.md) | Common failures (tools missing, npm CI, policies JSON) |
13
+
14
+ **Elsewhere:** **Current release (`main`):** **`0.11.7`** — [CHANGELOG](../CHANGELOG.md) · [SECURITY](../SECURITY.md) · [RELEASING](../RELEASING.md) · [CONTRIBUTING](../CONTRIBUTING.md) · [STRATEGY](../STRATEGY.md).
@@ -0,0 +1,90 @@
1
+ # Architecture & data
2
+
3
+ Skillforge stitches three layers:
4
+
5
+ 1. **Node bootstrap** (**`bin/cli.js`**) — ensures **`~/.skillforge`**, wires env, forks Python.
6
+ 2. **Python orchestration** (**`python/app/main.py`** + satellite modules) — embeddings, **`Router`**, policies, MCP surfaces.
7
+ 3. **Filesystem catalog** (**`skills/`**) — bundled SKILL.md corpus + **`~/.skillforge/skills/`** overlays + optional **`pack`** trees.
8
+
9
+ ASCII overview:
10
+
11
+ ```
12
+ MCP IDE host ──stdin/stdout JSON-RPC──► skillforge mcp ──► app.mcp_server
13
+
14
+ CLI / systemd / CI ──► bin/cli.js ───────────────────────► app.*_cli (+ shared Router)
15
+
16
+ SQLite stores per global (~/.skillforge/data) vs project (.skillforge) roots
17
+ sentence-transformers (default MiniLM family) ─ skill card embeddings ─► shortlists
18
+ Optional Anthropic Async client when router modes demand Haiku rerank/final picks
19
+ Standalone OpenAIRouterLLM when transport ≠ MCP AND SKILLFORGE_ROUTER_LLM_BACKEND=openai_compatible
20
+ ```
21
+
22
+ ### Routing stages (mental model)
23
+
24
+ ```
25
+ prompt (+ optional fused conversation overlays)
26
+ → encode embedding query (skills + hybrid sparse boosts)
27
+ → merge learned weights / overlay boosts / exclusions (project policies)
28
+ → top-K candidates
29
+ → optional LLM rerank + final picks (embedding-only / Haiku / host pick / openai_compatible)
30
+ → chunk context + optional fusion w/ project_chunks
31
+ → markdown payload + MCP _meta auditing
32
+ → SQLite telemetry (sessions, events, weights, optional project_chunks)
33
+ ```
34
+
35
+ **Re-route guard:** **`SKILLFORGE_REROUTE_THRESHOLD`** hysteresis when picks swing hard.
36
+
37
+ ### Policy + overlay ingestion
38
+
39
+ **`load_route_policies_config`** merges:
40
+
41
+ 1. **`SKILLFORGE_ROUTE_POLICIES`** (**inline JSON**) — malformed JSON ⇒ stderr warning · empty rules fallback.
42
+ 2. **`SKILLFORGE_ROUTE_POLICIES_FILE`** path.
43
+ 3. **`<project>/.skillforge/policies.json`**
44
+ 4. **`<project>/skillforge-policies.json`**
45
+
46
+ Policy JSON optionally embeds **`rules`**, **`exclude_skills`** / boosts / **`project_notes`**. Overlay notes deliberately **never** activate without **`project_root`**.
47
+
48
+ ### Data directories
49
+
50
+ #### Global (**`~/.skillforge`**)
51
+
52
+ ```
53
+ ~/.skillforge/
54
+ ├── env # Optional dotenv-style operator profile (config commands)
55
+ ├── venv/ # Managed Python toolchain
56
+ ├── data/orchestrator.db
57
+ ├── skills/ # Operator-authored skills
58
+ ├── packs/ # Expanded pack artefacts
59
+ ├── .setup-complete # Marker after install completes
60
+ └── ...
61
+ ```
62
+
63
+ #### Per project (**`<repo>/.skillforge`** once routing/indexing attaches **`project_root`**)
64
+
65
+ ```
66
+ <repo>/.skillforge/
67
+ ├── orchestrator.db # SQLite (sessions/events/weights/chunks overlay)
68
+ ├── policies.json # Optional overlays (alternate: repo-root manifest)
69
+ ├── last_route.json # Debugging snapshot from CLI routing
70
+ └── ...
71
+ ```
72
+
73
+ ### Learning + portability
74
+
75
+ - **`skill_weights`** table mutated by MCP **`route_skills`**, **`skill_feedback`**, etc.
76
+ - Export/import symmetry via **`skillforge weights export|import`** (JSON payloads align with MCP **`weights_snapshot`**).
77
+
78
+ ### Project RAG ingestion
79
+
80
+ Steps:
81
+
82
+ ```bash
83
+ skillforge index --project-root=/absolute/path/to/repo
84
+ ```
85
+
86
+ Then MCP / CLI **`include_project_rag`** toggles fused retrieval guarded by **`project_index.py`** (embedding dimension mismatches skipped defensively).
87
+
88
+ ### Bundled skills gate
89
+
90
+ **`ci/bundle-gate.json`** field **`minSkillMdFiles`** is the CI-enforced minimum **`skills/**/**/SKILL.md`** count (see `.github/workflows/ci.yml` → **Verify skills bundle**).