@minhpnq1807/contextos 0.3.1 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+
5
+ - Fixes `ctx sync --skills` first-run ordering by running `skillshare init` before `skillshare backup`, matching skillshare's config requirement.
6
+
7
+ ## 0.5.0
8
+
9
+ - Adds `ctx sync --skills` for skillshare-backed skill sync across Codex, Claude Code, and Antigravity.
10
+ - Detects existing global/project skill directories, backs them up, optionally collects them into skillshare, runs sync, and rebuilds skill embeddings.
11
+ - Adds `~/.config/skillshare/skills` to skill discovery roots so ContextOS ranks the shared source of truth after sync.
12
+
13
+ ## 0.4.1
14
+
15
+ - Adds Antigravity skill discovery roots for `.gemini/skills`, `.gemini/antigravity/skills`, and `.gemini/antigravity-cli/skills`.
16
+ - Raises the skill catalog scan cap to cover large Antigravity skill catalogs before ranking.
17
+
18
+ ## 0.4.0
19
+
20
+ - Adds prompt-aware skill discovery to `ctx_score_context`, returning `suggestedSkills` alongside rules and files.
21
+ - Scans project/global `.codex/skills`, `.claude/skills`, and Antigravity `.gemini/**/skills` catalogs, ranks skill `name` + `description`, and injects top skill hints into prompt context.
22
+ - Warms skill embeddings during `ctx install` and `ctx embeddings warm`.
23
+
3
24
  ## 0.3.0
4
25
 
5
26
  - Adds `ctx sync --rules` for Ruler-backed project rule/MCP sync across Codex, Claude Code, and Antigravity.
package/README.md CHANGED
@@ -77,6 +77,7 @@ With ContextOS, each prompt gets a compact block:
77
77
  - Registers a `ctx-mcp` MCP server that owns model loading and semantic scoring.
78
78
  - Reads the active `AGENTS.md` chain for the current workspace.
79
79
  - Scores rules by relevance to the user prompt.
80
+ - Scans project/global `.codex/skills`, `.claude/skills`, and Antigravity `.gemini/**/skills`, ranks skill descriptions by task relevance, and injects top skill hints.
80
81
  - Filters host/session setup rules such as "run commands as user X" or `sudo -u user` because they are environment instructions, not project guidance.
81
82
  - Finds likely relevant files with a hybrid retriever:
82
83
  - first, local prompt/file heuristics create seed candidates;
@@ -168,6 +169,32 @@ npm exec --yes --package=@minhpnq1807/contextos@latest -- ctx --version
168
169
  npm exec --yes --package=@minhpnq1807/contextos@latest -- ctx debug -- "Recheck authen flow"
169
170
  ```
170
171
 
172
+ ## Skill Sync
173
+
174
+ Use skillshare when you want Codex, Claude Code, and Antigravity to share one skills catalog:
175
+
176
+ ```bash
177
+ ctx sync --skills
178
+ ```
179
+
180
+ ContextOS checks for `skillshare`, initializes it when needed, backs up existing skills before collection, runs `skillshare collect --all` unless `--no-collect` is provided, then runs `skillshare sync`. After sync, ContextOS rebuilds skill embeddings so prompt-time skill discovery can rank the shared source immediately.
181
+
182
+ The shared source is:
183
+
184
+ ```text
185
+ ~/.config/skillshare/skills/
186
+ ```
187
+
188
+ Useful variants:
189
+
190
+ ```bash
191
+ ctx sync --skills --dry-run
192
+ ctx sync --skills --no-collect
193
+ ctx sync --skills --agents codex,claude
194
+ ```
195
+
196
+ After this, `ctx debug -- "task"` and prompt hooks can suggest skills from `~/.config/skillshare/skills/` plus agent-specific skill folders.
197
+
171
198
  ## Modes
172
199
 
173
200
  Injection mode is the default:
@@ -287,7 +314,11 @@ This warning comes from a transitive dependency in the local embedding/WASM stac
287
314
  | `ctx sync --rules --dry-run` | Previews Ruler sync without writing files or running apply. | You want to inspect behavior before changing project config. | Prints the same flow with dry-run status. |
288
315
  | `ctx sync --rules --force` | Rewrites ContextOS-owned Ruler sections. | You changed the ContextOS install path or need to refresh `ctx-mcp`. | Removes and re-adds ContextOS-owned `mcp`, `mcp_servers.ctx-mcp`, and selected agent sections. |
289
316
  | `ctx sync --rules --no-import-codex-mcp` | Skips Codex MCP import. | You only want ContextOS' own `ctx-mcp` in Ruler. | Does not read `~/.codex/config.toml`. |
290
- | `ctx embeddings warm -- "task"` | Prepares local semantic embedding caches. | First install, CI smoke checks, or after changing AGENTS.md/project files. | Loads/downloads `Xenova/all-MiniLM-L6-v2` and writes vectors to `~/.ctx/contextos/embeddings.db`. |
317
+ | `ctx sync --skills` | Syncs agent skills through skillshare. | You want Codex, Claude Code, and Antigravity to share one skill source. | Installs or verifies `skillshare`, initializes it if needed, backs up and collects existing skills unless skipped, runs `skillshare sync`, and rebuilds ContextOS skill embeddings. |
318
+ | `ctx sync --skills --agents <list>` | Syncs skills only for selected agents. | You want to target a subset such as `codex,claude`. | Runs `skillshare sync --agents <list>` and refreshes skill embeddings. |
319
+ | `ctx sync --skills --dry-run` | Previews skillshare sync. | You want to inspect behavior before changing skill directories. | Runs `skillshare sync --dry-run` and skips embedding rebuild. |
320
+ | `ctx sync --skills --no-collect` | Skips collecting existing agent skills into skillshare. | You already manage `~/.config/skillshare/skills` and only want to push it out. | Initializes/syncs skillshare without running `skillshare backup` or `skillshare collect --all`. |
321
+ | `ctx embeddings warm -- "task"` | Prepares local semantic embedding caches. | First install, CI smoke checks, or after changing AGENTS.md/project files/skills. | Loads/downloads `Xenova/all-MiniLM-L6-v2` and writes rule, file-path, and skill vectors to `~/.ctx/contextos/embeddings.db`. |
291
322
  | `ctx --version` | Prints the installed ContextOS CLI version. | You want to confirm which npm version is being executed. | Prints the version from package metadata. |
292
323
 
293
324
  ## Runtime Files
package/bin/ctx.js CHANGED
@@ -22,6 +22,8 @@ import { installClaudeMcp } from "../plugins/ctx/lib/claude-mcp.js";
22
22
  import { installAntigravityHooks } from "../plugins/ctx/lib/antigravity-hooks.js";
23
23
  import { installAntigravityMcp } from "../plugins/ctx/lib/antigravity-mcp.js";
24
24
  import { syncRules } from "../plugins/ctx/lib/ruler-sync.js";
25
+ import { syncSkills } from "../plugins/ctx/lib/skillshare-sync.js";
26
+ import { scanSkills, warmSkillEmbeddings } from "../plugins/ctx/lib/skill-discoverer.js";
25
27
 
26
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
29
  const rootDir = path.resolve(__dirname, "..");
@@ -48,6 +50,10 @@ Usage:
48
50
  ctx sync --rules --agents codex,claude,antigravity
49
51
  ctx sync --rules --dry-run
50
52
  ctx sync --rules --no-import-codex-mcp
53
+ ctx sync --skills
54
+ ctx sync --skills --dry-run
55
+ ctx sync --skills --no-collect
56
+ ctx sync --skills --agents codex,claude,antigravity
51
57
  ctx embeddings warm -- "task"
52
58
  ctx --version
53
59
  `;
@@ -96,6 +102,7 @@ async function install({ copy = false, inject = true, agent = "codex" } = {}) {
96
102
  console.log(`Embedding model cache: ${modelCacheDir(contextOSDataDir())}`);
97
103
  console.log(`Embedding vectors cache: ${warmResult.cachePath}`);
98
104
  console.log(`File path embeddings warmed: ${warmResult.fileCount || 0}`);
105
+ console.log(`Skill embeddings warmed: ${warmResult.skillCount || 0}`);
99
106
  console.log(`Prompt context injection: ${inject ? "enabled" : "quiet logging only"}`);
100
107
  console.log("Restart Claude Code if it was already running, then submit a task to trigger ContextOS.");
101
108
  return;
@@ -113,6 +120,7 @@ async function install({ copy = false, inject = true, agent = "codex" } = {}) {
113
120
  console.log(`Embedding model cache: ${modelCacheDir(contextOSDataDir())}`);
114
121
  console.log(`Embedding vectors cache: ${warmResult.cachePath}`);
115
122
  console.log(`File path embeddings warmed: ${warmResult.fileCount || 0}`);
123
+ console.log(`Skill embeddings warmed: ${warmResult.skillCount || 0}`);
116
124
  console.log(`Prompt context injection: ${inject ? "enabled" : "quiet logging only"}`);
117
125
  console.log("Restart Antigravity or agy if it was already running, then submit a task to trigger ContextOS.");
118
126
  return;
@@ -143,6 +151,7 @@ async function install({ copy = false, inject = true, agent = "codex" } = {}) {
143
151
  console.log(`Embedding model cache: ${modelCacheDir(contextOSDataDir())}`);
144
152
  console.log(`Embedding vectors cache: ${warmResult.cachePath}`);
145
153
  console.log(`File path embeddings warmed: ${warmResult.fileCount || 0}`);
154
+ console.log(`Skill embeddings warmed: ${warmResult.skillCount || 0}`);
146
155
  console.log(`Prompt context injection: ${inject ? "enabled" : "quiet logging only"}`);
147
156
  console.log("Restart Codex if it was already running, then submit a task to trigger ContextOS.");
148
157
  }
@@ -169,7 +178,12 @@ async function warmInstallEmbeddings() {
169
178
  dataDir,
170
179
  allowRemote: !modelReady
171
180
  });
172
- return { ...result, modelAlreadyCached: modelReady, fileCount: fileResult.count };
181
+ const skillResult = await warmSkillEmbeddings({
182
+ cwd: process.cwd(),
183
+ dataDir,
184
+ allowRemote: !modelReady
185
+ });
186
+ return { ...result, modelAlreadyCached: modelReady, fileCount: fileResult.count, skillCount: skillResult.count };
173
187
  }
174
188
 
175
189
  function tryRunCodex(args) {
@@ -226,7 +240,8 @@ async function debug(task) {
226
240
  });
227
241
  const rules = scored.scoredRules;
228
242
  const relevantFiles = scored.suggestedFiles.slice(0, 3);
229
- const scheduled = scheduleContext({ rules, relevantFiles });
243
+ const suggestedSkills = (scored.suggestedSkills || []).slice(0, 3);
244
+ const scheduled = scheduleContext({ rules, relevantFiles, suggestedSkills });
230
245
 
231
246
  console.log("ContextOS debug");
232
247
  console.log(`cwd: ${cwd}`);
@@ -250,6 +265,14 @@ async function debug(task) {
250
265
  }
251
266
  if (!relevantFiles.length) console.log("(none)");
252
267
  console.log("");
268
+ console.log("Suggested skills:");
269
+ for (const skill of suggestedSkills) {
270
+ const score = Number(skill.score || 0).toFixed(2);
271
+ const location = skill.path ? ` path:${skill.path}` : "";
272
+ console.log(`${score} ${skill.name}${location}`);
273
+ }
274
+ if (!suggestedSkills.length) console.log("(none)");
275
+ console.log("");
253
276
  console.log("Final additionalContext:");
254
277
  console.log(scheduled.additionalContext || "(empty)");
255
278
  }
@@ -270,8 +293,14 @@ async function warmEmbeddings(task) {
270
293
  dataDir: contextOSDataDir(),
271
294
  allowRemote: true
272
295
  });
296
+ const skillResult = await warmSkillEmbeddings({
297
+ cwd,
298
+ dataDir: contextOSDataDir(),
299
+ allowRemote: true
300
+ });
273
301
  console.log(`Warmed ${result.count} embeddings`);
274
302
  console.log(`Warmed ${fileResult.count} file path embeddings`);
303
+ console.log(`Warmed ${skillResult.count} skill embeddings`);
275
304
  console.log(`Cache: ${result.cachePath}`);
276
305
  }
277
306
 
@@ -321,7 +350,20 @@ try {
321
350
  if (!task.trim()) throw new Error('Usage: ctx benchmark -- "task"');
322
351
  console.log(formatBenchmark(benchmarkWorkspace({ cwd: process.cwd(), task })));
323
352
  } else if (command === "sync") {
324
- await syncRules({ cwd: process.cwd(), rootDir, args: args.slice(1) });
353
+ if (args.includes("--skills")) {
354
+ await syncSkills({
355
+ cwd: process.cwd(),
356
+ args: args.slice(1),
357
+ rebuildSkillEmbeddings: async ({ cwd, sourceDir }) => warmSkillEmbeddings({
358
+ cwd,
359
+ dataDir: contextOSDataDir(),
360
+ allowRemote: !isModelCacheReady(contextOSDataDir()),
361
+ skills: scanSkills({ cwd, roots: [sourceDir] })
362
+ })
363
+ });
364
+ } else {
365
+ await syncRules({ cwd: process.cwd(), rootDir, args: args.slice(1) });
366
+ }
325
367
  } else {
326
368
  throw new Error(`Unknown command: ${command}\n\n${usage()}`);
327
369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.3.1",
3
+ "version": "0.5.1",
4
4
  "description": "Task-aware AGENTS.md context injection and compliance reporting for Codex, Claude Code, and Antigravity.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "CHANGELOG.md"
17
17
  ],
18
18
  "scripts": {
19
- "test": "vitest run",
19
+ "test": "vitest run test",
20
20
  "build": "node bin/ctx.js --version",
21
21
  "validate:plugin": "node test/validate-plugin.js",
22
22
  "test:mcp": "node test/mcp-protocol-smoke.js"
@@ -34,7 +34,8 @@ export async function handlePromptPayload(
34
34
  if (scored.error) throw new Error(scored.error);
35
35
  const scoredRules = scored.scoredRules || [];
36
36
  const relevantFiles = (scored.suggestedFiles || []).slice(0, 3);
37
- const scheduled = scheduleContext({ rules: scoredRules, relevantFiles });
37
+ const suggestedSkills = (scored.suggestedSkills || []).slice(0, 3);
38
+ const scheduled = scheduleContext({ rules: scoredRules, relevantFiles, suggestedSkills });
38
39
 
39
40
  const runtime = {
40
41
  at: now.toISOString(),
@@ -46,10 +47,12 @@ export async function handlePromptPayload(
46
47
  mcp: scored.telemetry || {}
47
48
  },
48
49
  relevantFiles,
50
+ suggestedSkills,
49
51
  telemetry: {
50
52
  ...(scored.telemetry || {}),
51
53
  rulesInjected: (scheduled.highRules?.length || 0) + (scheduled.midRules?.length || 0),
52
- filesSuggested: relevantFiles.length
54
+ filesSuggested: relevantFiles.length,
55
+ skillsSuggested: suggestedSkills.length
53
56
  },
54
57
  scheduled,
55
58
  injected: injectContext,
@@ -1,6 +1,6 @@
1
1
  const MAX_CONTEXT_CHARS = 4000;
2
2
 
3
- export function scheduleContext({ rules = [], relevantFiles = [], maxChars = MAX_CONTEXT_CHARS } = {}) {
3
+ export function scheduleContext({ rules = [], relevantFiles = [], suggestedSkills = [], maxChars = MAX_CONTEXT_CHARS } = {}) {
4
4
  const high = rules.filter((rule) => rule.score >= 0.5);
5
5
  const mid = rules.filter((rule) => rule.score >= 0.1 && rule.score < 0.5);
6
6
  const dropped = rules.filter((rule) => rule.score < 0.1);
@@ -12,6 +12,9 @@ export function scheduleContext({ rules = [], relevantFiles = [], maxChars = MAX
12
12
  if (relevantFiles.length) {
13
13
  sections.push(section("Suggested files to check", relevantFiles.map((file) => `- ${file.path}`)));
14
14
  }
15
+ if (suggestedSkills.length) {
16
+ sections.push(section("Skills to activate for this task", suggestedSkills.map(formatSkill)));
17
+ }
15
18
  if (mid.length) {
16
19
  sections.push(section("Additional relevant rules", mid.slice(0, 8).map(formatRule)));
17
20
  }
@@ -25,6 +28,7 @@ export function scheduleContext({ rules = [], relevantFiles = [], maxChars = MAX
25
28
  midRules: mid,
26
29
  droppedRules: dropped,
27
30
  relevantFiles,
31
+ suggestedSkills,
28
32
  additionalContext
29
33
  };
30
34
  }
@@ -39,6 +43,12 @@ function formatRule(rule) {
39
43
  return `- ${rule.content}${source}`;
40
44
  }
41
45
 
46
+ function formatSkill(skill) {
47
+ const description = skill.description ? `: ${skill.description}` : "";
48
+ const location = skill.path ? ` (${skill.path})` : "";
49
+ return `- ${skill.name}${description}${location}`;
50
+ }
51
+
42
52
  function trimToLimit(value, maxChars) {
43
53
  if (value.length <= maxChars) return value;
44
54
  return `${value.slice(0, Math.max(0, maxChars - 80)).trimEnd()}\n\n[ContextOS truncated context to ${maxChars} chars]`;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { readAgentsChain } from "./reader.js";
4
4
  import { filterActionableRules, parseRules, scoreRules, findRelevantFiles } from "./analyzer.js";
5
5
  import { enhanceRuleScoresWithEmbeddings } from "./embedding-scorer.js";
6
+ import { scanSkills, suggestSkills } from "./skill-discoverer.js";
6
7
 
7
8
  export async function scoreContext({
8
9
  cwd = process.cwd(),
@@ -10,6 +11,8 @@ export async function scoreContext({
10
11
  openFiles = [],
11
12
  dataDir,
12
13
  maxFiles = 5,
14
+ maxSkills = 3,
15
+ skills = null,
13
16
  embeddingTimeoutMs = 5000,
14
17
  fileEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS || 80)
15
18
  } = {}) {
@@ -36,12 +39,20 @@ export async function scoreContext({
36
39
  allowRemote: false
37
40
  }
38
41
  });
42
+ const skillCatalog = Array.isArray(skills) ? skills : scanSkills({ cwd });
43
+ const suggestedSkills = await suggestSkills({
44
+ prompt,
45
+ skills: skillCatalog,
46
+ dataDir,
47
+ limit: maxSkills
48
+ });
39
49
 
40
50
  return {
41
51
  cwd,
42
52
  prompt,
43
53
  scoredRules,
44
54
  suggestedFiles,
55
+ suggestedSkills,
45
56
  telemetry: {
46
57
  elapsedMs: Date.now() - started,
47
58
  modelStatus: embedding.status,
@@ -51,6 +62,8 @@ export async function scoreContext({
51
62
  rulesFiltered: rawRules.length - parsedRules.length,
52
63
  rulesInjected: scoredRules.filter((rule) => Number(rule.score || 0) >= 0.1).length,
53
64
  filesSuggested: suggestedFiles.length,
65
+ skillsScanned: skillCatalog.length,
66
+ skillsSuggested: suggestedSkills.length,
54
67
  sources: merged.sources.map((source) => path.relative(cwd, source))
55
68
  }
56
69
  };
@@ -0,0 +1,232 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { enhanceRuleScoresWithEmbeddings, warmRuleEmbeddings } from "./embedding-scorer.js";
6
+
7
+ const DEFAULT_LIMIT = 3;
8
+ const DEFAULT_MAX_SKILLS = 2000;
9
+ const DEFAULT_EMBEDDING_CANDIDATES = 120;
10
+ const DEFAULT_SEMANTIC_CATALOG_LIMIT = 300;
11
+
12
+ export function skillSearchRoots({ cwd = process.cwd(), home = os.homedir() } = {}) {
13
+ return [
14
+ path.join(cwd, ".codex", "skills"),
15
+ path.join(cwd, ".claude", "skills"),
16
+ path.join(cwd, ".gemini", "skills"),
17
+ path.join(cwd, ".gemini", "antigravity", "skills"),
18
+ path.join(cwd, ".gemini", "antigravity-cli", "skills"),
19
+ path.join(home, ".config", "skillshare", "skills"),
20
+ path.join(home, ".codex", "skills"),
21
+ path.join(home, ".claude", "skills"),
22
+ path.join(home, ".gemini", "skills"),
23
+ path.join(home, ".gemini", "antigravity", "skills"),
24
+ path.join(home, ".gemini", "antigravity-cli", "skills")
25
+ ];
26
+ }
27
+
28
+ export function parseSkillFrontmatter(content = "", { fallbackName = "", skillPath = "" } = {}) {
29
+ const text = String(content || "");
30
+ const frontmatter = text.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/);
31
+ const fields = frontmatter ? parseYamlishFields(frontmatter[1]) : {};
32
+ const body = frontmatter ? text.slice(frontmatter[0].length) : text;
33
+ const fallbackDescription = firstParagraph(body);
34
+ return {
35
+ name: fields.name || fallbackName || path.basename(path.dirname(skillPath)),
36
+ description: fields.description || fallbackDescription,
37
+ path: skillPath
38
+ };
39
+ }
40
+
41
+ function parseYamlishFields(frontmatter) {
42
+ const fields = {};
43
+ const lines = String(frontmatter || "").split(/\r?\n/);
44
+ for (const line of lines) {
45
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
46
+ if (!match) continue;
47
+ const key = match[1];
48
+ let value = match[2].trim();
49
+ value = value.replace(/^["']|["']$/g, "");
50
+ fields[key] = value;
51
+ }
52
+ return fields;
53
+ }
54
+
55
+ function firstParagraph(body) {
56
+ return String(body || "")
57
+ .split(/\n\s*\n/)
58
+ .map((part) => part.replace(/^#+\s*/gm, "").replace(/\s+/g, " ").trim())
59
+ .find(Boolean) || "";
60
+ }
61
+
62
+ export function scanSkills({ cwd = process.cwd(), roots = skillSearchRoots({ cwd }), maxSkills = DEFAULT_MAX_SKILLS } = {}) {
63
+ const skills = [];
64
+ const seen = new Set();
65
+ for (const root of roots) {
66
+ for (const skillPath of findSkillFiles(root)) {
67
+ if (skills.length >= maxSkills) return skills;
68
+ const realPath = safeRealpath(skillPath) || skillPath;
69
+ if (seen.has(realPath)) continue;
70
+ seen.add(realPath);
71
+ let content = "";
72
+ try {
73
+ content = fs.readFileSync(skillPath, "utf8");
74
+ } catch {
75
+ continue;
76
+ }
77
+ const skill = parseSkillFrontmatter(content, {
78
+ fallbackName: path.basename(path.dirname(skillPath)),
79
+ skillPath
80
+ });
81
+ if (!skill.name || !skill.description) continue;
82
+ skills.push({
83
+ ...skill,
84
+ root,
85
+ scope: isInsidePath(skillPath, cwd) ? "project" : "global",
86
+ relativePath: path.relative(cwd, skillPath)
87
+ });
88
+ }
89
+ }
90
+ return skills;
91
+ }
92
+
93
+ function findSkillFiles(root) {
94
+ const files = [];
95
+ walk(root, 0, files);
96
+ return files;
97
+ }
98
+
99
+ function walk(directory, depth, files) {
100
+ if (depth > 4) return;
101
+ let entries = [];
102
+ try {
103
+ entries = fs.readdirSync(directory, { withFileTypes: true });
104
+ } catch {
105
+ return;
106
+ }
107
+ for (const entry of entries) {
108
+ const fullPath = path.join(directory, entry.name);
109
+ if (entry.isDirectory()) {
110
+ walk(fullPath, depth + 1, files);
111
+ } else if (entry.isFile() && entry.name === "SKILL.md") {
112
+ files.push(fullPath);
113
+ }
114
+ }
115
+ }
116
+
117
+ function safeRealpath(filePath) {
118
+ try {
119
+ return fs.realpathSync(filePath);
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function isInsidePath(filePath, parentPath) {
126
+ const relative = path.relative(path.resolve(parentPath), path.resolve(filePath));
127
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative);
128
+ }
129
+
130
+ export async function suggestSkills({
131
+ prompt = "",
132
+ skills = [],
133
+ dataDir,
134
+ limit = DEFAULT_LIMIT,
135
+ timeoutMs = Number(process.env.CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS || process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || 800)
136
+ } = {}) {
137
+ if (!String(prompt || "").trim() || !skills.length) return [];
138
+ const base = scoreSkillsByKeyword({ prompt, skills });
139
+ if (skills.length > DEFAULT_SEMANTIC_CATALOG_LIMIT) {
140
+ return finalizeSkillScores(base, limit);
141
+ }
142
+
143
+ const embeddingCandidates = selectEmbeddingCandidates(base);
144
+ if (!embeddingCandidates.length) return [];
145
+
146
+ const embedding = await enhanceRuleScoresWithEmbeddings(embeddingCandidates, prompt, {
147
+ dataDir,
148
+ sources: embeddingCandidates.map((skill) => skill.path).filter(Boolean),
149
+ timeoutMs,
150
+ allowRemote: false
151
+ });
152
+
153
+ return finalizeSkillScores(embedding.rules, limit);
154
+ }
155
+
156
+ function finalizeSkillScores(skills, limit) {
157
+ return skills
158
+ .map((rule) => ({
159
+ name: rule.name,
160
+ description: rule.description,
161
+ path: rule.path,
162
+ scope: rule.scope,
163
+ keywordScore: rule.keywordScore,
164
+ score: Math.min(1, Number(rule.score || 0)),
165
+ embeddingScore: rule.embeddingScore,
166
+ reasons: rule.reasons || []
167
+ }))
168
+ .filter((skill) => Number(skill.keywordScore || 0) >= 0.35 || Number(skill.embeddingScore || 0) >= 0.62)
169
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
170
+ .slice(0, limit);
171
+ }
172
+
173
+ function selectEmbeddingCandidates(skills) {
174
+ if (skills.length <= DEFAULT_EMBEDDING_CANDIDATES) return skills;
175
+ return [...skills]
176
+ .filter((skill) => Number(skill.keywordScore || 0) > 0)
177
+ .sort((a, b) => Number(b.keywordScore || 0) - Number(a.keywordScore || 0) || a.name.localeCompare(b.name))
178
+ .slice(0, DEFAULT_EMBEDDING_CANDIDATES);
179
+ }
180
+
181
+ export async function warmSkillEmbeddings({
182
+ cwd = process.cwd(),
183
+ dataDir,
184
+ allowRemote = true,
185
+ skills = scanSkills({ cwd })
186
+ } = {}) {
187
+ if (!dataDir || !skills.length) return { count: 0, cachePath: null };
188
+ return warmRuleEmbeddings({
189
+ rules: skills.map((skill) => ({ content: `${skill.name} ${skill.description}` })),
190
+ task: "skill discovery semantic retrieval",
191
+ dataDir,
192
+ sources: skills.map((skill) => skill.path).filter(Boolean),
193
+ allowRemote
194
+ });
195
+ }
196
+
197
+ function scoreSkillsByKeyword({ prompt, skills }) {
198
+ const normalizedPrompt = normalize(prompt);
199
+ const promptTokens = new Set(normalizedPrompt.split(/\s+/).filter(Boolean));
200
+ return skills.map((skill, index) => {
201
+ const name = String(skill.name || "");
202
+ const description = String(skill.description || "");
203
+ const content = `${name} ${description}`;
204
+ const skillTokens = new Set(normalize(content).split(/\s+/).filter(Boolean));
205
+ const matches = [...skillTokens].filter((token) => promptTokens.has(token) && token.length > 2);
206
+ const normalizedName = normalize(name);
207
+ const nameTokens = normalizedName.split(/\s+/).filter((token) => token.length > 2);
208
+ const nameHit = normalizedPrompt.includes(normalizedName);
209
+ const nameTokenHit = nameTokens.length > 1 && nameTokens.every((token) => promptTokens.has(token));
210
+ const scopeBonus = skill.scope === "project" ? 0.08 : 0;
211
+ const score = Math.min(1, (matches.length ? 0.25 + matches.length * 0.08 : 0) + (nameHit ? 0.2 : 0) + (nameTokenHit ? 0.18 : 0) + scopeBonus);
212
+ return {
213
+ id: `skill-${index + 1}`,
214
+ name,
215
+ description,
216
+ path: skill.path,
217
+ scope: skill.scope,
218
+ content,
219
+ score,
220
+ keywordScore: score,
221
+ reasons: [
222
+ ...(matches.length ? [`keyword:${matches.slice(0, 4).join(",")}`] : []),
223
+ ...(nameHit || nameTokenHit ? ["name-match"] : [])
224
+ ],
225
+ originalOrder: index
226
+ };
227
+ });
228
+ }
229
+
230
+ function normalize(value) {
231
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
232
+ }
@@ -0,0 +1,237 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import readline from "node:readline/promises";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import { execFileSync, execSync } from "node:child_process";
7
+
8
+ const DEFAULT_AGENTS = ["codex", "claude", "antigravity"];
9
+ const INSTALL_SH_URL = "https://raw.githubusercontent.com/runkids/skillshare/main/install.sh";
10
+ const INSTALL_PS_URL = "https://raw.githubusercontent.com/runkids/skillshare/main/install.ps1";
11
+
12
+ function statusLine(label, value) {
13
+ return `[ctx] ${label.padEnd(38)} ${value}`;
14
+ }
15
+
16
+ function runCommand(command, args = [], { cwd = process.cwd(), stdio = "pipe", dryRun = false } = {}) {
17
+ if (dryRun) return { stdout: "", skipped: true };
18
+ const stdout = execFileSync(command, args, { cwd, stdio, encoding: "utf8" });
19
+ return { stdout: stdout || "" };
20
+ }
21
+
22
+ function runShell(command, { cwd = process.cwd(), stdio = "inherit", dryRun = false } = {}) {
23
+ if (dryRun) return { stdout: "", skipped: true };
24
+ const stdout = execSync(command, { cwd, stdio, encoding: "utf8" });
25
+ return { stdout: stdout || "" };
26
+ }
27
+
28
+ export function parseSyncSkillsArgs(args = []) {
29
+ const agentsFlag = args.indexOf("--agents");
30
+ const agents = agentsFlag >= 0
31
+ ? String(args[agentsFlag + 1] || "").split(",").map((item) => item.trim()).filter(Boolean)
32
+ : DEFAULT_AGENTS;
33
+ return {
34
+ skills: args.includes("--skills"),
35
+ agents,
36
+ dryRun: args.includes("--dry-run"),
37
+ noCollect: args.includes("--no-collect"),
38
+ yes: args.includes("--yes") || args.includes("-y")
39
+ };
40
+ }
41
+
42
+ export function detectOS(platform = process.platform) {
43
+ if (platform === "darwin") return "mac";
44
+ if (platform === "win32") return "windows";
45
+ return "linux";
46
+ }
47
+
48
+ export function skillshareConfigDir({ home = os.homedir() } = {}) {
49
+ return path.join(home, ".config", "skillshare");
50
+ }
51
+
52
+ export function skillshareSourceDir({ home = os.homedir() } = {}) {
53
+ return path.join(skillshareConfigDir({ home }), "skills");
54
+ }
55
+
56
+ export function checkSkillshareInstalled({ run = runCommand } = {}) {
57
+ try {
58
+ const result = run("skillshare", ["--version"]);
59
+ return { installed: true, version: result.stdout.trim() || "installed" };
60
+ } catch {
61
+ return { installed: false, version: "" };
62
+ }
63
+ }
64
+
65
+ async function shouldInstallSkillshare({ yes = false } = {}) {
66
+ if (yes) return true;
67
+ if (!process.stdin.isTTY) return false;
68
+ const rl = readline.createInterface({ input, output });
69
+ try {
70
+ const answer = await rl.question("[ctx] skillshare is not installed. Install now? [Y/n] ");
71
+ return !/^n(o)?$/i.test(answer.trim());
72
+ } finally {
73
+ rl.close();
74
+ }
75
+ }
76
+
77
+ export async function installSkillshare({
78
+ run = runCommand,
79
+ runShellCommand = runShell,
80
+ yes = false,
81
+ dryRun = false,
82
+ platform = process.platform
83
+ } = {}) {
84
+ const accepted = dryRun || await shouldInstallSkillshare({ yes });
85
+ if (!accepted) {
86
+ throw new Error("skillshare is required for ctx sync --skills. Install it manually with `curl -fsSL https://raw.githubusercontent.com/runkids/skillshare/main/install.sh | sh` or rerun with --yes.");
87
+ }
88
+
89
+ const osName = detectOS(platform);
90
+ if (osName === "windows") {
91
+ run("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", `irm ${INSTALL_PS_URL} | iex`], { stdio: "inherit", dryRun });
92
+ } else {
93
+ runShellCommand(`curl -fsSL ${INSTALL_SH_URL} | sh`, { stdio: "inherit", dryRun });
94
+ }
95
+
96
+ const check = checkSkillshareInstalled({ run });
97
+ if (!dryRun && !check.installed) {
98
+ throw new Error("skillshare install finished but `skillshare --version` still failed. Check PATH or install skillshare manually.");
99
+ }
100
+ return check;
101
+ }
102
+
103
+ export function detectExistingSkills({ cwd = process.cwd(), home = os.homedir() } = {}) {
104
+ return skillRoots({ cwd, home })
105
+ .map((root) => ({ path: root, count: countSkillFiles(root) }))
106
+ .filter((entry) => entry.count > 0);
107
+ }
108
+
109
+ function skillRoots({ cwd, home }) {
110
+ return [
111
+ path.join(home, ".claude", "skills"),
112
+ path.join(home, ".codex", "skills"),
113
+ path.join(home, ".gemini", "antigravity", "skills"),
114
+ path.join(home, ".gemini", "antigravity-cli", "skills"),
115
+ path.join(cwd, ".claude", "skills"),
116
+ path.join(cwd, ".codex", "skills"),
117
+ path.join(cwd, ".gemini", "antigravity", "skills"),
118
+ path.join(cwd, ".gemini", "antigravity-cli", "skills")
119
+ ];
120
+ }
121
+
122
+ function countSkillFiles(root) {
123
+ return findSkillFiles(root).length;
124
+ }
125
+
126
+ function findSkillFiles(root) {
127
+ const files = [];
128
+ walk(root, 0, files);
129
+ return files;
130
+ }
131
+
132
+ function walk(directory, depth, files) {
133
+ if (depth > 4) return;
134
+ let entries = [];
135
+ try {
136
+ entries = fs.readdirSync(directory, { withFileTypes: true });
137
+ } catch {
138
+ return;
139
+ }
140
+ for (const entry of entries) {
141
+ const fullPath = path.join(directory, entry.name);
142
+ if (entry.isSymbolicLink()) {
143
+ const stat = safeStat(fullPath);
144
+ if (stat?.isDirectory()) walk(fullPath, depth + 1, files);
145
+ } else if (entry.isDirectory()) {
146
+ walk(fullPath, depth + 1, files);
147
+ } else if (entry.isFile() && entry.name === "SKILL.md") {
148
+ files.push(fullPath);
149
+ }
150
+ }
151
+ }
152
+
153
+ function safeStat(filePath) {
154
+ try {
155
+ return fs.statSync(filePath);
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ export function isSkillshareInitialized({ home = os.homedir() } = {}) {
162
+ return fs.existsSync(skillshareConfigDir({ home }));
163
+ }
164
+
165
+ export async function syncSkills({
166
+ cwd = process.cwd(),
167
+ home = os.homedir(),
168
+ args = [],
169
+ run = runCommand,
170
+ runShellCommand = runShell,
171
+ logger = console.log,
172
+ rebuildSkillEmbeddings = async () => ({ count: 0, cachePath: null })
173
+ } = {}) {
174
+ const options = parseSyncSkillsArgs(args);
175
+ if (!options.skills) throw new Error("Usage: ctx sync --skills [--dry-run] [--no-collect] [--agents codex,claude,antigravity]");
176
+
177
+ const installed = checkSkillshareInstalled({ run });
178
+ logger(statusLine("Checking skillshare installation...", installed.installed ? `✓ ${installed.version}` : "not found"));
179
+ if (!installed.installed) {
180
+ logger("");
181
+ logger("skillshare is required to sync skills across agents.");
182
+ const postInstall = await installSkillshare({
183
+ run,
184
+ runShellCommand,
185
+ yes: options.yes,
186
+ dryRun: options.dryRun,
187
+ platform: process.platform
188
+ });
189
+ logger(statusLine("Installing skillshare...", options.dryRun ? "dry-run" : `✓ ${postInstall.version}`));
190
+ }
191
+
192
+ const initialized = isSkillshareInitialized({ home });
193
+ logger(statusLine("Checking skillshare config...", initialized ? "✓ initialized" : "not initialized"));
194
+
195
+ if (!initialized) {
196
+ const existing = detectExistingSkills({ cwd, home });
197
+ if (existing.length) {
198
+ logger("[ctx] Found existing skills:");
199
+ for (const entry of existing) {
200
+ logger(` ${entry.path.padEnd(44)} ${entry.count} skills`);
201
+ }
202
+ } else {
203
+ logger("[ctx] No existing skills found.");
204
+ }
205
+
206
+ run("skillshare", ["init"], { cwd, stdio: "inherit", dryRun: options.dryRun });
207
+ logger(statusLine("Initializing skillshare...", options.dryRun ? "dry-run" : "✓ initialized"));
208
+
209
+ if (existing.length && !options.noCollect) {
210
+ run("skillshare", ["backup"], { cwd, stdio: "inherit", dryRun: options.dryRun });
211
+ logger(statusLine("Backing up...", options.dryRun ? "dry-run" : "✓ backup created"));
212
+ run("skillshare", ["collect", "--all"], { cwd, stdio: "inherit", dryRun: options.dryRun });
213
+ const collected = countSkillFiles(skillshareSourceDir({ home }));
214
+ logger(statusLine("Collecting from all agents...", options.dryRun ? "dry-run" : `✓ ${collected} skills collected`));
215
+ }
216
+ }
217
+
218
+ const syncArgs = ["sync"];
219
+ if (options.dryRun) syncArgs.push("--dry-run");
220
+ if (options.agents.length) syncArgs.push("--agents", options.agents.join(","));
221
+ run("skillshare", syncArgs, { cwd, stdio: "inherit", dryRun: false });
222
+ const syncedCount = countSkillFiles(skillshareSourceDir({ home }));
223
+ logger(statusLine("Running skillshare sync...", options.dryRun ? "dry-run" : `✓ ${syncedCount} skills → ${options.agents.join(", ")}`));
224
+
225
+ let embeddings = { count: 0, cachePath: null, skipped: options.dryRun };
226
+ if (!options.dryRun) {
227
+ embeddings = await rebuildSkillEmbeddings({ cwd, home, sourceDir: skillshareSourceDir({ home }) });
228
+ logger(statusLine("Rebuilding skill embeddings...", `✓ ${embeddings.count || 0} skills indexed`));
229
+ } else {
230
+ logger(statusLine("Rebuilding skill embeddings...", "skipped in dry-run"));
231
+ }
232
+
233
+ logger("");
234
+ logger("Done. Skills are now synced.");
235
+ logger(`Source: ${skillshareSourceDir({ home })}`);
236
+ return { options, initialized, sourceDir: skillshareSourceDir({ home }), syncedCount, embeddings };
237
+ }
@@ -11,16 +11,23 @@ export function createContextOSMcpServer({ dataDir }) {
11
11
 
12
12
  server.registerTool("ctx_score_context", {
13
13
  title: "Score ContextOS prompt context",
14
- description: "Scores AGENTS.md rules and suggests files for a Codex prompt.",
14
+ description: "Scores AGENTS.md rules and suggests files/skills for an agent prompt.",
15
15
  inputSchema: {
16
16
  cwd: z.string().optional(),
17
17
  prompt: z.string(),
18
18
  openFiles: z.array(z.string()).optional(),
19
- maxFiles: z.number().int().positive().max(20).optional()
19
+ maxFiles: z.number().int().positive().max(20).optional(),
20
+ maxSkills: z.number().int().positive().max(10).optional(),
21
+ skills: z.array(z.object({
22
+ name: z.string(),
23
+ description: z.string(),
24
+ path: z.string().optional()
25
+ })).optional()
20
26
  },
21
27
  outputSchema: {
22
28
  scoredRules: z.array(z.any()),
23
29
  suggestedFiles: z.array(z.any()),
30
+ suggestedSkills: z.array(z.any()),
24
31
  telemetry: z.record(z.string(), z.any())
25
32
  }
26
33
  }, async (args) => {
@@ -29,7 +36,9 @@ export function createContextOSMcpServer({ dataDir }) {
29
36
  prompt: args.prompt || "",
30
37
  openFiles: args.openFiles || [],
31
38
  dataDir,
32
- maxFiles: args.maxFiles || 5
39
+ maxFiles: args.maxFiles || 5,
40
+ maxSkills: args.maxSkills || 3,
41
+ skills: args.skills
33
42
  });
34
43
  return {
35
44
  content: [
@@ -41,6 +50,7 @@ export function createContextOSMcpServer({ dataDir }) {
41
50
  structuredContent: {
42
51
  scoredRules: result.scoredRules,
43
52
  suggestedFiles: result.suggestedFiles,
53
+ suggestedSkills: result.suggestedSkills,
44
54
  telemetry: result.telemetry
45
55
  }
46
56
  };
@@ -68,7 +68,9 @@ async function handleBridgeRequest(socket, raw) {
68
68
  prompt: payload.prompt || "",
69
69
  openFiles: payload.openFiles || [],
70
70
  dataDir,
71
- maxFiles: payload.maxFiles || 5
71
+ maxFiles: payload.maxFiles || 5,
72
+ maxSkills: payload.maxSkills || 3,
73
+ skills: payload.skills
72
74
  });
73
75
  socket.end(JSON.stringify(result));
74
76
  } catch (error) {
@@ -76,6 +78,7 @@ async function handleBridgeRequest(socket, raw) {
76
78
  error: error?.message || String(error),
77
79
  scoredRules: [],
78
80
  suggestedFiles: [],
81
+ suggestedSkills: [],
79
82
  telemetry: { elapsedMs: 0, modelStatus: "error" }
80
83
  }));
81
84
  }