@minhpnq1807/contextos 0.5.39 → 0.5.41
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 +20 -0
- package/bin/ctx.js +170 -1
- package/package.json +1 -1
- package/plugins/ctx/.codex-plugin/plugin.json +1 -1
- package/plugins/ctx/lib/multi-select.js +113 -0
- package/plugins/ctx/lib/skill-library.js +364 -0
- package/plugins/ctx/lib/update-notifier.js +129 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.41
|
|
4
|
+
|
|
5
|
+
- **Interactive community skill installer (`ctx skills`):** Replaced the info-only display with a fully functional multi-select installer. Users can now toggle multiple community library sources with `Space`, confirm with `Enter`, and the CLI automatically runs the appropriate install command for each selected source (`npx`, `git clone`, etc.).
|
|
6
|
+
- **Compact selection UI with URL hints:** The skill source picker now shows a clean box-styled header and each option displays its GitHub URL as a dimmed sub-line hint, matching the `◇ / │` visual language used throughout contextOS.
|
|
7
|
+
- **Prefixed install output (`runPrefixed`):** All child-process output during installation (including interactive npx prompts like "Ok to proceed?") is piped through a line-by-line prefixer that prepends `│ ` to every line. This keeps the visual box style consistent and prevents raw command output from breaking the layout. `stdin` remains inherited so users can still answer interactive prompts.
|
|
8
|
+
- **`multiSelect` hint support:** The multi-select component now accepts an optional `hint` property on each option, rendered as a dimmed indented line below the label. Used for URLs but available for any contextual sub-text.
|
|
9
|
+
- **Library install metadata:** `skill-library.js` now exports `getInstallCommands(libraryId)` returning structured install info (command, verify step, type) for each library source, keeping install logic out of the main CLI.
|
|
10
|
+
|
|
11
|
+
## 0.5.40
|
|
12
|
+
|
|
13
|
+
- **Update notifier:** `ctx` now checks npm for newer versions in the background (once per day, 3s timeout). If a newer release exists, a boxed notice is printed at the very end of any command: `Update available: 0.5.39 → 0.5.40`. Check result is cached in `$CONTEXTOS_DATA/.update-check.json` to avoid repeated network calls.
|
|
14
|
+
- **Community skill library browser (`ctx skills`):** New command to browse curated skill libraries from the community. Fetches and parses README files from 4 sources:
|
|
15
|
+
- [antigravity-awesome-skills](https://github.com/sickn33/antigravity-awesome-skills) — 1,400+ universal skills
|
|
16
|
+
- [awesome-claude-skills](https://github.com/ComposioHQ/awesome-claude-skills) — Claude Code skills & workflows
|
|
17
|
+
- [awesome-codex-skills](https://github.com/ComposioHQ/awesome-codex-skills) — Codex CLI skills & automations
|
|
18
|
+
- [awesome-copilot](https://github.com/github/awesome-copilot) — GitHub Copilot instructions & agents
|
|
19
|
+
Results are cached for 24 hours. Use `--agents <names>` to filter and `--refresh` to force refetch.
|
|
20
|
+
- **Post-install skill recommendations:** After interactive `ctx install` or `ctx setup`, a styled recommendation panel shows top 5 skills from each relevant library with descriptions and repo URLs. This guides new users toward useful community skills immediately after setup.
|
|
21
|
+
- **Test stability:** Increased timeout for MCP bridge fallback test to prevent CI flakiness.
|
|
22
|
+
|
|
3
23
|
## 0.5.39
|
|
4
24
|
|
|
5
25
|
- **Report layout fix:** Replaced ASCII table formatting with clean markdown output. Reports now render correctly in all agent UIs (Antigravity, Claude Code, Codex) without truncation or line-wrapping issues.
|
package/bin/ctx.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import readline from "node:readline/promises";
|
|
6
6
|
import { stdin as input, stdout as output } from "node:process";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
9
9
|
|
|
10
10
|
import { readAgentsChain } from "../plugins/ctx/lib/reader.js";
|
|
11
11
|
import { filterActionableRules, parseRules, scoreRules } from "../plugins/ctx/lib/analyzer.js";
|
|
@@ -34,6 +34,38 @@ import { parsePassthroughArgs, runPassthrough } from "../plugins/ctx/lib/passthr
|
|
|
34
34
|
import { parseAgentList, parseSetupArgs, setupSummaryLines } from "../plugins/ctx/lib/setup-wizard.js";
|
|
35
35
|
import { multiSelect } from "../plugins/ctx/lib/multi-select.js";
|
|
36
36
|
import { syncWorkflows, warmWorkflowEmbeddings } from "../plugins/ctx/lib/workflow-discoverer.js";
|
|
37
|
+
import { checkForUpdate } from "../plugins/ctx/lib/update-notifier.js";
|
|
38
|
+
import { fetchSkillsForAgents, printSkillRecommendations, getAllLibraries, getInstallCommands } from "../plugins/ctx/lib/skill-library.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run a shell command with all output lines prefixed by │
|
|
42
|
+
* Keeps the visual box style consistent during child-process output.
|
|
43
|
+
* stdin is inherited so interactive prompts (e.g. npx "Ok to proceed?") still work.
|
|
44
|
+
*/
|
|
45
|
+
function runPrefixed(cmd) {
|
|
46
|
+
const DIM = "\x1B[2m";
|
|
47
|
+
const RST = "\x1B[0m";
|
|
48
|
+
const pfx = `${DIM}│${RST} `;
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const child = spawn("sh", ["-c", cmd], { stdio: ["inherit", "pipe", "pipe"] });
|
|
51
|
+
function pipe(stream, target) {
|
|
52
|
+
let needPrefix = true;
|
|
53
|
+
stream.on("data", (buf) => {
|
|
54
|
+
const str = buf.toString();
|
|
55
|
+
let out = "";
|
|
56
|
+
for (const ch of str) {
|
|
57
|
+
if (needPrefix) { out += pfx; needPrefix = false; }
|
|
58
|
+
out += ch;
|
|
59
|
+
if (ch === "\n") needPrefix = true;
|
|
60
|
+
}
|
|
61
|
+
target.write(out);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
pipe(child.stdout, process.stdout);
|
|
65
|
+
pipe(child.stderr, process.stderr);
|
|
66
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`exit code ${code}`)));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
37
69
|
|
|
38
70
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
71
|
const rootDir = path.resolve(__dirname, "..");
|
|
@@ -70,6 +102,9 @@ Usage:
|
|
|
70
102
|
ctx sync --workflows Sync workflows across agents
|
|
71
103
|
ctx sync --workflows --agents <names> Sync workflows to specific agents
|
|
72
104
|
ctx sync --workflows --dry-run Preview workflow sync without writing
|
|
105
|
+
ctx skills Browse community skill libraries
|
|
106
|
+
ctx skills --agents <names> Filter skills for specific agents
|
|
107
|
+
ctx skills --refresh Force refresh skill library cache
|
|
73
108
|
ctx embeddings warm -- "task" Pre-warm embedding caches for a task
|
|
74
109
|
ctx ruler -- <ruler args> Passthrough to ruler CLI
|
|
75
110
|
ctx skillshare -- <skillshare args> Passthrough to skillshare CLI
|
|
@@ -608,6 +643,12 @@ async function setup({ args = [], cwd = process.cwd() } = {}) {
|
|
|
608
643
|
console.log("│ Next: restart/open your agent from this project directory.");
|
|
609
644
|
console.log("│ Try: ctx debug -- \"Recheck authen flow\"");
|
|
610
645
|
console.log("");
|
|
646
|
+
|
|
647
|
+
// Recommend community skills based on selected agents
|
|
648
|
+
try {
|
|
649
|
+
const libraryResults = await fetchSkillsForAgents(options.agents, { dataDir: contextOSDataDir() });
|
|
650
|
+
printSkillRecommendations(libraryResults);
|
|
651
|
+
} catch { /* skill library is best-effort */ }
|
|
611
652
|
}
|
|
612
653
|
|
|
613
654
|
const args = process.argv.slice(2);
|
|
@@ -622,6 +663,8 @@ function installAgentsFromArgs(args) {
|
|
|
622
663
|
return null; // no flag → interactive selection
|
|
623
664
|
}
|
|
624
665
|
|
|
666
|
+
const notifyUpdate = checkForUpdate({ currentVersion: packageVersion(), dataDir: contextOSDataDir() });
|
|
667
|
+
|
|
625
668
|
try {
|
|
626
669
|
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
627
670
|
console.log(usage());
|
|
@@ -654,6 +697,11 @@ try {
|
|
|
654
697
|
await streamSetupOutput(() => install({ copy, agent }));
|
|
655
698
|
console.log("");
|
|
656
699
|
}
|
|
700
|
+
// Recommend community skills based on selected agents
|
|
701
|
+
try {
|
|
702
|
+
const libraryResults = await fetchSkillsForAgents(selected, { dataDir: contextOSDataDir() });
|
|
703
|
+
printSkillRecommendations(libraryResults);
|
|
704
|
+
} catch { /* skill library is best-effort */ }
|
|
657
705
|
}
|
|
658
706
|
}
|
|
659
707
|
} else if (command === "setup") {
|
|
@@ -682,6 +730,125 @@ try {
|
|
|
682
730
|
const task = marker >= 0 ? args.slice(marker + 1).join(" ") : args.slice(1).join(" ");
|
|
683
731
|
if (!task.trim()) throw new Error('Usage: ctx benchmark -- "task"');
|
|
684
732
|
console.log(formatBenchmark(benchmarkWorkspace({ cwd: process.cwd(), task })));
|
|
733
|
+
} else if (command === "skills") {
|
|
734
|
+
// Interactive community skill library selector + installer
|
|
735
|
+
const agentsFlag = args.indexOf("--agents");
|
|
736
|
+
const forceRefresh = args.includes("--refresh");
|
|
737
|
+
let agents;
|
|
738
|
+
if (agentsFlag >= 0 && args[agentsFlag + 1]) {
|
|
739
|
+
agents = args[agentsFlag + 1].split(",").map((a) => a.trim()).filter(Boolean);
|
|
740
|
+
} else {
|
|
741
|
+
agents = ["codex", "claude", "agy", "copilot"];
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const DIM = "\x1B[2m";
|
|
745
|
+
const RESET = "\x1B[0m";
|
|
746
|
+
const CYAN = "\x1B[36m";
|
|
747
|
+
const GREEN = "\x1B[32m";
|
|
748
|
+
const YELLOW = "\x1B[33m";
|
|
749
|
+
const BOLD = "\x1B[1m";
|
|
750
|
+
|
|
751
|
+
console.log("Fetching community skill libraries...\n");
|
|
752
|
+
const libraryResults = await fetchSkillsForAgents(agents, {
|
|
753
|
+
dataDir: contextOSDataDir()
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const totalSkills = libraryResults.reduce((sum, r) => sum + r.count, 0);
|
|
757
|
+
if (totalSkills === 0) {
|
|
758
|
+
console.log("No skills found. Check your network connection or try --refresh.");
|
|
759
|
+
process.exit(1);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Compact header
|
|
763
|
+
console.log(`${CYAN}◇${RESET} ${BOLD}Community skill libraries available:${RESET}`);
|
|
764
|
+
console.log(`${DIM}│${RESET} Browse and install curated skills from the community.`);
|
|
765
|
+
console.log(`${DIM}│${RESET}`);
|
|
766
|
+
|
|
767
|
+
// Multi-select which sources to install from
|
|
768
|
+
const allLibs = getAllLibraries();
|
|
769
|
+
const availableLibs = allLibs.filter((lib) => {
|
|
770
|
+
const result = libraryResults.find((r) => r.library.id === lib.id);
|
|
771
|
+
return result && result.count > 0;
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
if (availableLibs.length === 0) {
|
|
775
|
+
console.log("No installable libraries available.");
|
|
776
|
+
process.exit(0);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const selectedSources = await multiSelect({
|
|
780
|
+
message: "Select skill sources to install:",
|
|
781
|
+
options: availableLibs.map((lib) => {
|
|
782
|
+
const result = libraryResults.find((r) => r.library.id === lib.id);
|
|
783
|
+
return {
|
|
784
|
+
label: `${lib.name} (${result?.count || 0} skills)`,
|
|
785
|
+
value: lib.id,
|
|
786
|
+
hint: lib.url,
|
|
787
|
+
selected: false
|
|
788
|
+
};
|
|
789
|
+
})
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
if (!selectedSources || selectedSources.length === 0) {
|
|
793
|
+
console.log(`\n${DIM}No sources selected.${RESET}`);
|
|
794
|
+
process.exit(0);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Install each selected source using its provided commands
|
|
798
|
+
let successCount = 0;
|
|
799
|
+
for (const libId of selectedSources) {
|
|
800
|
+
const lib = allLibs.find((l) => l.id === libId);
|
|
801
|
+
if (!lib) continue;
|
|
802
|
+
|
|
803
|
+
const installInfo = getInstallCommands(libId);
|
|
804
|
+
if (!installInfo) {
|
|
805
|
+
console.log(`${YELLOW}⚠${RESET} No install info for ${lib.name}. Visit: ${lib.url}`);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
console.log("");
|
|
810
|
+
console.log(`${CYAN}◇${RESET} ${BOLD}Installing from ${lib.name}${RESET}`);
|
|
811
|
+
|
|
812
|
+
if (installInfo.type === "manual") {
|
|
813
|
+
console.log(`${DIM}│${RESET} ${installInfo.instructions}`);
|
|
814
|
+
console.log(`${DIM}│${RESET} ${DIM}URL: ${lib.url}${RESET}`);
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const installCmd = installInfo.fullInstall;
|
|
819
|
+
if (installCmd) {
|
|
820
|
+
console.log(`${DIM}│${RESET} ${GREEN}$ ${installCmd}${RESET}`);
|
|
821
|
+
console.log(`${DIM}│${RESET}`);
|
|
822
|
+
|
|
823
|
+
try {
|
|
824
|
+
await runPrefixed(installCmd);
|
|
825
|
+
successCount++;
|
|
826
|
+
|
|
827
|
+
// Run verify command if available
|
|
828
|
+
if (installInfo.verify) {
|
|
829
|
+
try {
|
|
830
|
+
await runPrefixed(installInfo.verify);
|
|
831
|
+
} catch { /* verify is best-effort */ }
|
|
832
|
+
}
|
|
833
|
+
console.log(`${DIM}│${RESET}`);
|
|
834
|
+
console.log(`${GREEN}✔${RESET} ${lib.name} installed successfully.`);
|
|
835
|
+
} catch (err) {
|
|
836
|
+
console.error(`${YELLOW}⚠${RESET} Install failed for ${lib.name}. Try manually:`);
|
|
837
|
+
console.error(` ${installCmd}`);
|
|
838
|
+
console.error(` ${DIM}${err.message}${RESET}`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Final summary
|
|
844
|
+
console.log("");
|
|
845
|
+
if (successCount > 0) {
|
|
846
|
+
console.log(`${GREEN}✔${RESET} ${BOLD}${successCount} source${successCount > 1 ? "s" : ""} installed.${RESET}`);
|
|
847
|
+
console.log(`${DIM}│${RESET} Restart your agent to pick up new skills.`);
|
|
848
|
+
} else {
|
|
849
|
+
console.log(`${DIM}No installations were completed.${RESET}`);
|
|
850
|
+
}
|
|
851
|
+
console.log("");
|
|
685
852
|
} else if (command === "sync") {
|
|
686
853
|
if (args.includes("--workflows")) {
|
|
687
854
|
await syncWorkflows({
|
|
@@ -719,4 +886,6 @@ try {
|
|
|
719
886
|
} catch (error) {
|
|
720
887
|
console.error(error.message);
|
|
721
888
|
process.exitCode = 1;
|
|
889
|
+
} finally {
|
|
890
|
+
await notifyUpdate();
|
|
722
891
|
}
|
package/package.json
CHANGED
|
@@ -62,6 +62,9 @@ export function multiSelect({ message, options }) {
|
|
|
62
62
|
const label = isCursor ? `${BOLD}${CYAN}${options[i].label}${RESET}` : options[i].label;
|
|
63
63
|
const pointer = isCursor ? `${CYAN}❯${RESET}` : " ";
|
|
64
64
|
lines.push(` ${pointer} ${checkbox} ${label}`);
|
|
65
|
+
if (options[i].hint) {
|
|
66
|
+
lines.push(` ${DIM}${options[i].hint}${RESET}`);
|
|
67
|
+
}
|
|
65
68
|
}
|
|
66
69
|
return lines;
|
|
67
70
|
}
|
|
@@ -140,3 +143,113 @@ export function multiSelect({ message, options }) {
|
|
|
140
143
|
draw();
|
|
141
144
|
});
|
|
142
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Interactive single-select prompt (radio-button style).
|
|
149
|
+
*
|
|
150
|
+
* @param {{ message: string, options: Array<{label: string, value: string, disabled?: boolean, isHeader?: boolean}> }} config
|
|
151
|
+
* @returns {Promise<string|null>} selected value or null
|
|
152
|
+
*/
|
|
153
|
+
export function singleSelect({ message, options }) {
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
if (!process.stdin.isTTY) {
|
|
156
|
+
// Non-interactive: return first non-disabled
|
|
157
|
+
const first = options.find((o) => !o.disabled);
|
|
158
|
+
resolve(first ? first.value : null);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Filter to only selectable items, but keep the full list for rendering
|
|
163
|
+
const selectableIndices = options
|
|
164
|
+
.map((o, i) => (o.disabled ? -1 : i))
|
|
165
|
+
.filter((i) => i >= 0);
|
|
166
|
+
if (selectableIndices.length === 0) { resolve(null); return; }
|
|
167
|
+
|
|
168
|
+
let cursorIdx = 0; // index into selectableIndices
|
|
169
|
+
let cursor = selectableIndices[0]; // actual index in options
|
|
170
|
+
|
|
171
|
+
function render() {
|
|
172
|
+
const lines = [];
|
|
173
|
+
lines.push(`${CYAN}◇${RESET} ${message}`);
|
|
174
|
+
lines.push(`${DIM} Use ↑/↓ to navigate, Enter to select${RESET}`);
|
|
175
|
+
for (let i = 0; i < options.length; i++) {
|
|
176
|
+
const opt = options[i];
|
|
177
|
+
if (opt.disabled || opt.isHeader) {
|
|
178
|
+
lines.push(` ${DIM}${opt.label}${RESET}`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const isCursor = i === cursor;
|
|
182
|
+
const radio = isCursor ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`;
|
|
183
|
+
const label = isCursor ? `${BOLD}${CYAN}${opt.label}${RESET}` : opt.label;
|
|
184
|
+
const pointer = isCursor ? `${CYAN}❯${RESET}` : " ";
|
|
185
|
+
lines.push(` ${pointer} ${radio} ${label}`);
|
|
186
|
+
}
|
|
187
|
+
return lines;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let prevLineCount = 0;
|
|
191
|
+
|
|
192
|
+
function draw() {
|
|
193
|
+
const lines = render();
|
|
194
|
+
if (prevLineCount > 0) {
|
|
195
|
+
process.stdout.write(`\x1B[${prevLineCount}A`);
|
|
196
|
+
for (let i = 0; i < prevLineCount; i++) {
|
|
197
|
+
process.stdout.write("\x1B[2K");
|
|
198
|
+
if (i < prevLineCount - 1) process.stdout.write("\x1B[1B");
|
|
199
|
+
}
|
|
200
|
+
process.stdout.write(`\x1B[${prevLineCount - 1}A`);
|
|
201
|
+
}
|
|
202
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
203
|
+
prevLineCount = lines.length;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
process.stdout.write(HIDE_CURSOR);
|
|
207
|
+
|
|
208
|
+
const wasRaw = process.stdin.isRaw;
|
|
209
|
+
process.stdin.setRawMode(true);
|
|
210
|
+
process.stdin.resume();
|
|
211
|
+
process.stdin.setEncoding("utf8");
|
|
212
|
+
|
|
213
|
+
function cleanup() {
|
|
214
|
+
process.stdin.setRawMode(wasRaw || false);
|
|
215
|
+
process.stdin.pause();
|
|
216
|
+
process.stdin.removeListener("data", onData);
|
|
217
|
+
process.stdout.write(SHOW_CURSOR);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function onData(data) {
|
|
221
|
+
if (matchKey(data, KEYS.CTRL_C)) {
|
|
222
|
+
cleanup();
|
|
223
|
+
process.exit(0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (matchKey(data, KEYS.UP) || matchKey(data, KEYS.K)) {
|
|
227
|
+
cursorIdx = (cursorIdx - 1 + selectableIndices.length) % selectableIndices.length;
|
|
228
|
+
cursor = selectableIndices[cursorIdx];
|
|
229
|
+
draw();
|
|
230
|
+
} else if (matchKey(data, KEYS.DOWN) || matchKey(data, KEYS.J)) {
|
|
231
|
+
cursorIdx = (cursorIdx + 1) % selectableIndices.length;
|
|
232
|
+
cursor = selectableIndices[cursorIdx];
|
|
233
|
+
draw();
|
|
234
|
+
} else if (matchKey(data, KEYS.ENTER)) {
|
|
235
|
+
cleanup();
|
|
236
|
+
const selected = options[cursor];
|
|
237
|
+
// Clear and show result
|
|
238
|
+
if (prevLineCount > 0) {
|
|
239
|
+
process.stdout.write(`\x1B[${prevLineCount}A`);
|
|
240
|
+
for (let i = 0; i < prevLineCount; i++) {
|
|
241
|
+
process.stdout.write("\x1B[2K");
|
|
242
|
+
if (i < prevLineCount - 1) process.stdout.write("\x1B[1B");
|
|
243
|
+
}
|
|
244
|
+
process.stdout.write(`\x1B[${prevLineCount - 1}A`);
|
|
245
|
+
}
|
|
246
|
+
process.stdout.write(`${CYAN}◇${RESET} ${message}\n`);
|
|
247
|
+
process.stdout.write(`${DIM}│${RESET} ${GREEN}${selected.label}${RESET}\n`);
|
|
248
|
+
resolve(selected.value);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
process.stdin.on("data", onData);
|
|
253
|
+
draw();
|
|
254
|
+
});
|
|
255
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Curated skill library sources, mapped by agent.
|
|
8
|
+
*
|
|
9
|
+
* Each source provides a GitHub repo with a README that lists available skills.
|
|
10
|
+
* We fetch the README, parse skill entries, and let the user pick from a
|
|
11
|
+
* multi-select panel after agent selection in `ctx install` or `ctx setup`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SKILL_LIBRARIES = [
|
|
15
|
+
{
|
|
16
|
+
id: "antigravity-awesome",
|
|
17
|
+
name: "Antigravity Awesome Skills",
|
|
18
|
+
repo: "sickn33/antigravity-awesome-skills",
|
|
19
|
+
url: "https://github.com/sickn33/antigravity-awesome-skills",
|
|
20
|
+
rawReadmeUrl: "https://raw.githubusercontent.com/sickn33/antigravity-awesome-skills/main/README.md",
|
|
21
|
+
agents: ["agy", "claude", "codex", "copilot"], // universal library
|
|
22
|
+
description: "1,400+ agentic skills for all agents",
|
|
23
|
+
install: {
|
|
24
|
+
type: "npx",
|
|
25
|
+
fullInstall: "npx antigravity-awesome-skills",
|
|
26
|
+
agentFlags: {
|
|
27
|
+
codex: "npx antigravity-awesome-skills --codex",
|
|
28
|
+
claude: "npx antigravity-awesome-skills --claude",
|
|
29
|
+
agy: "npx antigravity-awesome-skills --antigravity",
|
|
30
|
+
copilot: "npx antigravity-awesome-skills --cursor",
|
|
31
|
+
gemini: "npx antigravity-awesome-skills --gemini"
|
|
32
|
+
},
|
|
33
|
+
verify: 'test -d ~/.agents/skills && echo "Skills installed in ~/.agents/skills"'
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "awesome-claude",
|
|
38
|
+
name: "Awesome Claude Skills",
|
|
39
|
+
repo: "ComposioHQ/awesome-claude-skills",
|
|
40
|
+
url: "https://github.com/ComposioHQ/awesome-claude-skills",
|
|
41
|
+
rawReadmeUrl: "https://raw.githubusercontent.com/ComposioHQ/awesome-claude-skills/master/README.md",
|
|
42
|
+
agents: ["claude"],
|
|
43
|
+
description: "Curated Claude Code skills & workflows",
|
|
44
|
+
install: {
|
|
45
|
+
type: "git-clone",
|
|
46
|
+
clone: "git clone https://github.com/ComposioHQ/awesome-claude-skills.git",
|
|
47
|
+
skillDir: "~/.claude/skills",
|
|
48
|
+
copyCommand: (skillName) =>
|
|
49
|
+
`cp -r awesome-claude-skills/${skillName} ~/.claude/skills/${skillName}`,
|
|
50
|
+
fullInstall: [
|
|
51
|
+
"git clone https://github.com/ComposioHQ/awesome-claude-skills.git",
|
|
52
|
+
"mkdir -p ~/.claude/skills",
|
|
53
|
+
'for d in awesome-claude-skills/*/; do [ -f "$d/SKILL.md" ] && cp -r "$d" ~/.claude/skills/; done'
|
|
54
|
+
].join(" && "),
|
|
55
|
+
verify: 'ls ~/.claude/skills/'
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "awesome-codex",
|
|
60
|
+
name: "Awesome Codex Skills",
|
|
61
|
+
repo: "ComposioHQ/awesome-codex-skills",
|
|
62
|
+
url: "https://github.com/ComposioHQ/awesome-codex-skills",
|
|
63
|
+
rawReadmeUrl: "https://raw.githubusercontent.com/ComposioHQ/awesome-codex-skills/master/README.md",
|
|
64
|
+
agents: ["codex"],
|
|
65
|
+
description: "Practical Codex CLI skills & automations",
|
|
66
|
+
install: {
|
|
67
|
+
type: "git-clone-python",
|
|
68
|
+
clone: "git clone https://github.com/ComposioHQ/awesome-codex-skills.git",
|
|
69
|
+
skillDir: "~/.codex/skills",
|
|
70
|
+
installScript: (skillName) =>
|
|
71
|
+
`python awesome-codex-skills/skill-installer/scripts/install-skill-from-github.py --repo ComposioHQ/awesome-codex-skills --path ${skillName}`,
|
|
72
|
+
fullInstall: [
|
|
73
|
+
"git clone https://github.com/ComposioHQ/awesome-codex-skills.git",
|
|
74
|
+
"cd awesome-codex-skills",
|
|
75
|
+
"mkdir -p ~/.codex/skills",
|
|
76
|
+
'for d in */; do [ -f "$d/SKILL.md" ] && [ "$d" != "skill-installer/" ] && cp -r "$d" ~/.codex/skills/; done'
|
|
77
|
+
].join(" && "),
|
|
78
|
+
verify: 'ls ~/.codex/skills/'
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "awesome-copilot",
|
|
83
|
+
name: "Awesome Copilot",
|
|
84
|
+
repo: "github/awesome-copilot",
|
|
85
|
+
url: "https://github.com/github/awesome-copilot",
|
|
86
|
+
rawReadmeUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/README.md",
|
|
87
|
+
agents: ["copilot"],
|
|
88
|
+
description: "Community GitHub Copilot instructions & agents",
|
|
89
|
+
install: {
|
|
90
|
+
type: "manual",
|
|
91
|
+
fullInstall: null,
|
|
92
|
+
instructions: "Browse the repository and copy relevant .instructions.md files to your .github/ directory.",
|
|
93
|
+
url: "https://github.com/github/awesome-copilot"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
|
99
|
+
|
|
100
|
+
// ────────────────────────────── HTTP fetch ────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function fetchUrl(url, timeoutMs = 10000) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const req = https.get(url, { timeout: timeoutMs }, (res) => {
|
|
105
|
+
// Follow redirects (301/302/307/308)
|
|
106
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
107
|
+
fetchUrl(res.headers.location, timeoutMs).then(resolve, reject);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (res.statusCode !== 200) {
|
|
111
|
+
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
112
|
+
res.resume();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const chunks = [];
|
|
116
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
117
|
+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
118
|
+
res.on("error", reject);
|
|
119
|
+
});
|
|
120
|
+
req.on("timeout", () => { req.destroy(); reject(new Error(`Timeout fetching ${url}`)); });
|
|
121
|
+
req.on("error", reject);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ────────────────────────────── README parser ─────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse a GitHub awesome-list README and extract skill entries.
|
|
129
|
+
*
|
|
130
|
+
* Looks for markdown links in list items:
|
|
131
|
+
* - [Skill Name](url) - Description
|
|
132
|
+
* - **[Skill Name](url)** — Description
|
|
133
|
+
*
|
|
134
|
+
* Returns: Array<{ name, url, description }>
|
|
135
|
+
*/
|
|
136
|
+
export function parseSkillEntries(readmeContent) {
|
|
137
|
+
const entries = [];
|
|
138
|
+
const lines = readmeContent.split("\n");
|
|
139
|
+
// Match list items with markdown links
|
|
140
|
+
const linkPattern = /^[-*]\s+\**\[([^\]]+)\]\(([^)]+)\)\**\s*[-–—:]*\s*(.*)/;
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
const match = line.match(linkPattern);
|
|
143
|
+
if (!match) continue;
|
|
144
|
+
const [, name, url, description] = match;
|
|
145
|
+
// Skip non-skill links (images, badges, section headers)
|
|
146
|
+
if (/\.(png|jpg|svg|gif)$/i.test(url)) continue;
|
|
147
|
+
if (url.startsWith("#")) continue;
|
|
148
|
+
entries.push({
|
|
149
|
+
name: name.trim(),
|
|
150
|
+
url: url.trim(),
|
|
151
|
+
description: (description || "").replace(/^\s*[-–—:]+\s*/, "").trim()
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return entries;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ────────────────────────────── Cache layer ────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function cacheDir(dataDir) {
|
|
160
|
+
return path.join(dataDir, "skill-library-cache");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cacheFilePath(dataDir, libraryId) {
|
|
164
|
+
return path.join(cacheDir(dataDir), `${libraryId}.json`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readCache(dataDir, libraryId) {
|
|
168
|
+
const filePath = cacheFilePath(dataDir, libraryId);
|
|
169
|
+
try {
|
|
170
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
171
|
+
const data = JSON.parse(raw);
|
|
172
|
+
if (Date.now() - (data.fetchedAt || 0) < CACHE_TTL_MS) {
|
|
173
|
+
return data.entries || [];
|
|
174
|
+
}
|
|
175
|
+
} catch { /* cache miss */ }
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function writeCache(dataDir, libraryId, entries) {
|
|
180
|
+
const dir = cacheDir(dataDir);
|
|
181
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
182
|
+
const data = { fetchedAt: Date.now(), entries };
|
|
183
|
+
fs.writeFileSync(cacheFilePath(dataDir, libraryId), JSON.stringify(data, null, 2));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ────────────────────────────── Public API ─────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get libraries relevant to selected agents.
|
|
190
|
+
*/
|
|
191
|
+
export function getLibrariesForAgents(selectedAgents) {
|
|
192
|
+
const normalized = selectedAgents.map((a) => a.toLowerCase().replace("antigravity", "agy"));
|
|
193
|
+
return SKILL_LIBRARIES.filter((lib) =>
|
|
194
|
+
lib.agents.some((agent) => normalized.includes(agent))
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch skill entries from a library (with caching).
|
|
200
|
+
*/
|
|
201
|
+
export async function fetchLibrarySkills(library, { dataDir, forceRefresh = false } = {}) {
|
|
202
|
+
if (dataDir && !forceRefresh) {
|
|
203
|
+
const cached = readCache(dataDir, library.id);
|
|
204
|
+
if (cached) return { entries: cached, source: "cache" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const readme = await fetchUrl(library.rawReadmeUrl);
|
|
209
|
+
const entries = parseSkillEntries(readme);
|
|
210
|
+
if (dataDir && entries.length > 0) {
|
|
211
|
+
writeCache(dataDir, library.id, entries);
|
|
212
|
+
}
|
|
213
|
+
return { entries, source: "network" };
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Try cache even if expired on network failure
|
|
216
|
+
if (dataDir) {
|
|
217
|
+
try {
|
|
218
|
+
const filePath = cacheFilePath(dataDir, library.id);
|
|
219
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
220
|
+
const data = JSON.parse(raw);
|
|
221
|
+
return { entries: data.entries || [], source: "stale-cache" };
|
|
222
|
+
} catch { /* no cache at all */ }
|
|
223
|
+
}
|
|
224
|
+
return { entries: [], source: "error", error: error.message };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Fetch skills from all relevant libraries for selected agents.
|
|
230
|
+
* Returns a flat list grouped by library.
|
|
231
|
+
*/
|
|
232
|
+
export async function fetchSkillsForAgents(selectedAgents, { dataDir } = {}) {
|
|
233
|
+
const libraries = getLibrariesForAgents(selectedAgents);
|
|
234
|
+
const results = [];
|
|
235
|
+
|
|
236
|
+
for (const lib of libraries) {
|
|
237
|
+
const { entries, source } = await fetchLibrarySkills(lib, { dataDir });
|
|
238
|
+
results.push({
|
|
239
|
+
library: lib,
|
|
240
|
+
entries,
|
|
241
|
+
source,
|
|
242
|
+
count: entries.length
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return results;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Format skill library results as multi-select options.
|
|
251
|
+
* Groups by library with a header option (disabled).
|
|
252
|
+
*/
|
|
253
|
+
export function formatAsSelectOptions(libraryResults) {
|
|
254
|
+
const options = [];
|
|
255
|
+
for (const result of libraryResults) {
|
|
256
|
+
if (result.entries.length === 0) continue;
|
|
257
|
+
// Add library header as separator
|
|
258
|
+
options.push({
|
|
259
|
+
label: `── ${result.library.name} (${result.count} skills) ──`,
|
|
260
|
+
value: `__header__${result.library.id}`,
|
|
261
|
+
disabled: true,
|
|
262
|
+
isHeader: true
|
|
263
|
+
});
|
|
264
|
+
// Add top skills (limit to prevent overwhelming the user)
|
|
265
|
+
const topEntries = result.entries.slice(0, 30);
|
|
266
|
+
for (const entry of topEntries) {
|
|
267
|
+
const desc = entry.description ? ` — ${entry.description.slice(0, 60)}` : "";
|
|
268
|
+
options.push({
|
|
269
|
+
label: `${entry.name}${desc}`,
|
|
270
|
+
value: entry.url,
|
|
271
|
+
selected: false,
|
|
272
|
+
libraryId: result.library.id,
|
|
273
|
+
skillName: entry.name
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (result.entries.length > 30) {
|
|
277
|
+
options.push({
|
|
278
|
+
label: ` ... and ${result.entries.length - 30} more at ${result.library.url}`,
|
|
279
|
+
value: `__more__${result.library.id}`,
|
|
280
|
+
disabled: true,
|
|
281
|
+
isHeader: true
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return options;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Print a compact recommendation panel to the terminal.
|
|
290
|
+
* Used after agent selection in `ctx install` and `ctx setup`.
|
|
291
|
+
*/
|
|
292
|
+
export function printSkillRecommendations(libraryResults, { logger = console.log } = {}) {
|
|
293
|
+
const DIM = "\x1B[2m";
|
|
294
|
+
const RESET = "\x1B[0m";
|
|
295
|
+
const CYAN = "\x1B[36m";
|
|
296
|
+
const GREEN = "\x1B[32m";
|
|
297
|
+
const YELLOW = "\x1B[33m";
|
|
298
|
+
const BOLD = "\x1B[1m";
|
|
299
|
+
|
|
300
|
+
const hasSkills = libraryResults.some((r) => r.entries.length > 0);
|
|
301
|
+
if (!hasSkills) return;
|
|
302
|
+
|
|
303
|
+
logger("");
|
|
304
|
+
logger(`${CYAN}◇${RESET} ${BOLD}Community skill libraries available:${RESET}`);
|
|
305
|
+
logger(`${DIM}│${RESET} Browse and install curated skills from the community.`);
|
|
306
|
+
logger(`${DIM}│${RESET}`);
|
|
307
|
+
|
|
308
|
+
for (const result of libraryResults) {
|
|
309
|
+
if (result.entries.length === 0) continue;
|
|
310
|
+
const badge = result.source === "cache" ? `${DIM}(cached)${RESET}` : "";
|
|
311
|
+
logger(`${DIM}│${RESET} ${GREEN}●${RESET} ${BOLD}${result.library.name}${RESET} ${badge}`);
|
|
312
|
+
logger(`${DIM}│${RESET} ${result.count} skills · ${result.library.description}`);
|
|
313
|
+
logger(`${DIM}│${RESET} ${DIM}${result.library.url}${RESET}`);
|
|
314
|
+
|
|
315
|
+
// Show top 5 skills as preview
|
|
316
|
+
const preview = result.entries.slice(0, 5);
|
|
317
|
+
for (const skill of preview) {
|
|
318
|
+
const desc = skill.description ? ` ${DIM}— ${skill.description.slice(0, 50)}${RESET}` : "";
|
|
319
|
+
logger(`${DIM}│${RESET} ${YELLOW}▸${RESET} ${skill.name}${desc}`);
|
|
320
|
+
}
|
|
321
|
+
if (result.entries.length > 5) {
|
|
322
|
+
logger(`${DIM}│${RESET} ${DIM}... and ${result.entries.length - 5} more${RESET}`);
|
|
323
|
+
}
|
|
324
|
+
logger(`${DIM}│${RESET}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
logger(`${DIM}│${RESET} ${DIM}Run ${CYAN}ctx skills${RESET}${DIM} to browse and install community skills.${RESET}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get all library definitions.
|
|
332
|
+
*/
|
|
333
|
+
export function getAllLibraries() {
|
|
334
|
+
return SKILL_LIBRARIES;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Get install commands for a specific library.
|
|
339
|
+
* @param {string} libraryId
|
|
340
|
+
* @param {string} [agent] - optional agent to target
|
|
341
|
+
* @returns {{ fullInstall: string|null, agentInstall: string|null, verify: string|null, instructions: string|null, type: string }}
|
|
342
|
+
*/
|
|
343
|
+
export function getInstallCommands(libraryId, agent) {
|
|
344
|
+
const lib = SKILL_LIBRARIES.find((l) => l.id === libraryId);
|
|
345
|
+
if (!lib || !lib.install) return null;
|
|
346
|
+
|
|
347
|
+
const inst = lib.install;
|
|
348
|
+
const result = {
|
|
349
|
+
type: inst.type,
|
|
350
|
+
fullInstall: typeof inst.fullInstall === "string" ? inst.fullInstall : null,
|
|
351
|
+
agentInstall: null,
|
|
352
|
+
verify: inst.verify || null,
|
|
353
|
+
instructions: inst.instructions || null,
|
|
354
|
+
url: inst.url || lib.url
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Agent-specific install (only for npx-based libs)
|
|
358
|
+
if (agent && inst.agentFlags) {
|
|
359
|
+
const normalized = agent.toLowerCase().replace("antigravity", "agy");
|
|
360
|
+
result.agentInstall = inst.agentFlags[normalized] || null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const PACKAGE_NAME = "@minhpnq1807/contextos";
|
|
5
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 1 day
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 3000;
|
|
7
|
+
|
|
8
|
+
function cacheFilePath(dataDir) {
|
|
9
|
+
return path.join(dataDir, ".update-check.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readCache(dataDir) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(cacheFilePath(dataDir), "utf8"));
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeCache(dataDir, data) {
|
|
21
|
+
try {
|
|
22
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
23
|
+
fs.writeFileSync(cacheFilePath(dataDir), JSON.stringify(data), "utf8");
|
|
24
|
+
} catch {
|
|
25
|
+
// best-effort
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fetchLatestVersion() {
|
|
30
|
+
const https = await import("node:https");
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const url = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
33
|
+
const req = https.get(url, { timeout: REQUEST_TIMEOUT_MS, headers: { accept: "application/json" } }, (res) => {
|
|
34
|
+
if (res.statusCode !== 200) {
|
|
35
|
+
res.resume();
|
|
36
|
+
return resolve(null);
|
|
37
|
+
}
|
|
38
|
+
let body = "";
|
|
39
|
+
res.on("data", (chunk) => { body += chunk; });
|
|
40
|
+
res.on("end", () => {
|
|
41
|
+
try {
|
|
42
|
+
resolve(JSON.parse(body).version || null);
|
|
43
|
+
} catch {
|
|
44
|
+
resolve(null);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
req.on("error", () => resolve(null));
|
|
49
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function compareVersions(current, latest) {
|
|
54
|
+
const parse = (v) => String(v || "").replace(/^v/, "").split(".").map(Number);
|
|
55
|
+
const c = parse(current);
|
|
56
|
+
const l = parse(latest);
|
|
57
|
+
for (let i = 0; i < 3; i++) {
|
|
58
|
+
if ((l[i] || 0) > (c[i] || 0)) return 1;
|
|
59
|
+
if ((l[i] || 0) < (c[i] || 0)) return -1;
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check for updates in background (non-blocking).
|
|
66
|
+
* Returns a function that, when called, prints the update message if available.
|
|
67
|
+
*
|
|
68
|
+
* Usage:
|
|
69
|
+
* const notify = checkForUpdate({ currentVersion, dataDir });
|
|
70
|
+
* // ... run CLI command ...
|
|
71
|
+
* await notify(); // prints update message at the very end
|
|
72
|
+
*/
|
|
73
|
+
export function checkForUpdate({ currentVersion, dataDir }) {
|
|
74
|
+
let resultPromise = null;
|
|
75
|
+
|
|
76
|
+
// Start background check lazily
|
|
77
|
+
function startCheck() {
|
|
78
|
+
if (resultPromise) return resultPromise;
|
|
79
|
+
resultPromise = (async () => {
|
|
80
|
+
try {
|
|
81
|
+
const cache = readCache(dataDir);
|
|
82
|
+
const isFresh = cache && typeof cache.checkedAt === "number" && (Date.now() - cache.checkedAt < CHECK_INTERVAL_MS);
|
|
83
|
+
|
|
84
|
+
if (isFresh) {
|
|
85
|
+
return cache.latestVersion && compareVersions(currentVersion, cache.latestVersion) > 0
|
|
86
|
+
? cache.latestVersion
|
|
87
|
+
: null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const latestVersion = await fetchLatestVersion();
|
|
91
|
+
if (latestVersion) {
|
|
92
|
+
writeCache(dataDir, { checkedAt: Date.now(), latestVersion });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return latestVersion && compareVersions(currentVersion, latestVersion) > 0
|
|
96
|
+
? latestVersion
|
|
97
|
+
: null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
return resultPromise;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Start immediately (non-blocking)
|
|
106
|
+
startCheck();
|
|
107
|
+
|
|
108
|
+
return async () => {
|
|
109
|
+
const latestVersion = await startCheck();
|
|
110
|
+
if (latestVersion) {
|
|
111
|
+
console.error(formatUpdateBox(currentVersion, latestVersion));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatUpdateBox(currentVersion, latestVersion) {
|
|
117
|
+
const lines = [
|
|
118
|
+
`Update available: ${currentVersion} → ${latestVersion}`,
|
|
119
|
+
"",
|
|
120
|
+
`Run: npm install -g ${PACKAGE_NAME}`,
|
|
121
|
+
`Then run: ctx install --agents codex`,
|
|
122
|
+
];
|
|
123
|
+
const maxLen = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
124
|
+
const width = maxLen + 4;
|
|
125
|
+
const pad = (text) => `│ ${text.padEnd(width - 4)} │`;
|
|
126
|
+
const top = `╭${"─".repeat(width - 2)}╮`;
|
|
127
|
+
const bottom = `╰${"─".repeat(width - 2)}╯`;
|
|
128
|
+
return ["", top, ...lines.map(pad), bottom, ""].join("\n");
|
|
129
|
+
}
|