diff --git a/README.md b/README.md index 794f23a..7c03aa3 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,16 @@ Two minutes. No API keys. No external services. No configuration ceremony. Uses npx selftune@latest doctor ``` +## Updating + +The skill and CLI ship together as one npm package. To update: + +```bash +npx skills add selftune-dev/selftune +``` + +This reinstalls the latest version of both the skill (SKILL.md, workflows) and the CLI. `selftune doctor` will warn you when a newer version is available. + ## Before / After

diff --git a/biome.json b/biome.json index e2bf806..898b710 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, diff --git a/cli/selftune/badge/badge.ts b/cli/selftune/badge/badge.ts index 4843823..7a74b89 100644 --- a/cli/selftune/badge/badge.ts +++ b/cli/selftune/badge/badge.ts @@ -30,7 +30,7 @@ Options: const VALID_FORMATS = new Set(["svg", "markdown", "url"]); -export function cliMain(): void { +export async function cliMain(): Promise { const { values } = parseArgs({ args: process.argv.slice(2), options: { @@ -71,7 +71,7 @@ export function cliMain(): void { const auditEntries = readJsonl(EVOLUTION_AUDIT_LOG); // Run doctor for system health - const doctorResult = doctor(); + const doctorResult = await doctor(); // Compute status const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult); diff --git a/cli/selftune/dashboard-server.ts b/cli/selftune/dashboard-server.ts index b19e321..888156d 100644 --- a/cli/selftune/dashboard-server.ts +++ b/cli/selftune/dashboard-server.ts @@ -100,12 +100,12 @@ const MIME_TYPES: Record = { ".ico": "image/x-icon", }; -function computeStatusFromLogs(): StatusResult { +async function computeStatusFromLogs(): Promise { const telemetry = readJsonl(TELEMETRY_LOG); const skillRecords = readEffectiveSkillUsageRecords(); const queryRecords = readJsonl(QUERY_LOG); const auditEntries = readJsonl(EVOLUTION_AUDIT_LOG); - const doctorResult = doctor(); + const doctorResult = await doctor(); return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult); } @@ -531,7 +531,7 @@ export async function startDashboardServer( // ---- GET /api/v2/doctor ---- System health diagnostics if (url.pathname === "/api/v2/doctor" && req.method === "GET") { - const result = doctor(); + const result = await doctor(); return Response.json(result, { headers: corsHeaders() }); } diff --git a/cli/selftune/evolution/evolve-body.ts b/cli/selftune/evolution/evolve-body.ts index 9531220..1e77a5f 100644 --- a/cli/selftune/evolution/evolve-body.ts +++ b/cli/selftune/evolution/evolve-body.ts @@ -410,7 +410,7 @@ export async function evolveBody( }; } - if (lastProposal && lastValidation && lastValidation.improved) { + if (lastProposal && lastValidation?.improved) { // Deploy: write updated SKILL.md if (target === "routing") { const updatedContent = replaceSection( diff --git a/cli/selftune/evolution/evolve.ts b/cli/selftune/evolution/evolve.ts index 107d99b..04aa834 100644 --- a/cli/selftune/evolution/evolve.ts +++ b/cli/selftune/evolution/evolve.ts @@ -841,7 +841,7 @@ export async function evolve( // ----------------------------------------------------------------------- // Step 15: Update evolution memory // ----------------------------------------------------------------------- - const wasDeployed = lastProposal !== null && lastValidation !== null && lastValidation.improved; + const wasDeployed = lastProposal && lastValidation?.improved; const evolveResult: EvolveResult = withStats({ proposal: lastProposal, validation: lastValidation, diff --git a/cli/selftune/index.ts b/cli/selftune/index.ts index fbc20fb..ea01793 100644 --- a/cli/selftune/index.ts +++ b/cli/selftune/index.ts @@ -326,7 +326,7 @@ Run 'selftune eval --help' for action-specific options.`); } case "doctor": { const { doctor } = await import("./observability.js"); - const result = doctor(); + const result = await doctor(); console.log(JSON.stringify(result, null, 2)); process.exit(result.healthy ? 0 : 1); break; diff --git a/cli/selftune/init.ts b/cli/selftune/init.ts index 7b13440..49a8bf7 100644 --- a/cli/selftune/init.ts +++ b/cli/selftune/init.ts @@ -595,7 +595,7 @@ export async function cliMain(): Promise { // Run doctor as post-check const { doctor } = await import("./observability.js"); - const doctorResult = doctor(); + const doctorResult = await doctor(); console.log( JSON.stringify({ level: "info", diff --git a/cli/selftune/observability.ts b/cli/selftune/observability.ts index 2b46753..6c7d413 100644 --- a/cli/selftune/observability.ts +++ b/cli/selftune/observability.ts @@ -203,12 +203,73 @@ export function checkConfigHealth(): HealthCheck[] { return [check]; } -export function doctor(): DoctorResult { +/** + * Compare two semver strings. Returns: + * -1 if a < b, 0 if equal, 1 if a > b. + * Handles standard x.y.z versions; pre-release tags are not compared. + */ +function compareSemver(a: string, b: string): -1 | 0 | 1 { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + const va = pa[i] ?? 0; + const vb = pb[i] ?? 0; + if (va < vb) return -1; + if (va > vb) return 1; + } + return 0; +} + +/** Check if the installed version is the latest on npm. Non-blocking, warns on stale. */ +export async function checkVersionHealth(): Promise { + const check: HealthCheck = { + name: "version_up_to_date", + path: "package.json", + status: "pass", + message: "", + }; + + try { + const pkgPath = join(import.meta.dir, "../../package.json"); + const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + try { + const res = await fetch("https://registry.npmjs.org/selftune/latest", { + signal: controller.signal, + }); + + if (res.ok) { + const data = (await res.json()) as { version: string }; + const latestVersion = data.version; + const cmp = compareSemver(currentVersion, latestVersion); + if (cmp >= 0) { + check.message = `v${currentVersion} (latest)`; + } else { + check.status = "warn"; + check.message = `v${currentVersion} installed, v${latestVersion} available. Run: npx skills add selftune-dev/selftune`; + } + } else { + check.message = `v${currentVersion} (unable to check npm registry)`; + } + } finally { + clearTimeout(timeout); + } + } catch { + check.message = "Unable to check latest version (network unavailable)"; + } + + return [check]; +} + +export async function doctor(): Promise { const allChecks = [ ...checkConfigHealth(), ...checkLogHealth(), ...checkHookInstallation(), ...checkEvolutionHealth(), + ...(await checkVersionHealth()), ]; const passed = allChecks.filter((c) => c.status === "pass").length; const failed = allChecks.filter((c) => c.status === "fail").length; @@ -224,7 +285,7 @@ export function doctor(): DoctorResult { } if (import.meta.main) { - const result = doctor(); + const result = await doctor(); console.log(JSON.stringify(result, null, 2)); process.exit(result.healthy ? 0 : 1); } diff --git a/cli/selftune/orchestrate.ts b/cli/selftune/orchestrate.ts index 78b6d7e..ba8bf6c 100644 --- a/cli/selftune/orchestrate.ts +++ b/cli/selftune/orchestrate.ts @@ -652,7 +652,7 @@ export async function orchestrate( const skillRecords = _readSkillRecords(); const queryRecords = _readQueryRecords(); const auditEntries = _readAuditEntries(); - const doctorResult = _doctor(); + const doctorResult = await _doctor(); const statusResult = _computeStatus( telemetry, diff --git a/cli/selftune/quickstart.ts b/cli/selftune/quickstart.ts index bef54ae..55721f0 100644 --- a/cli/selftune/quickstart.ts +++ b/cli/selftune/quickstart.ts @@ -115,7 +115,7 @@ export async function quickstart(): Promise { try { const auditEntries = readJsonl(EVOLUTION_AUDIT_LOG); - const doctorResult = doctor(); + const doctorResult = await doctor(); const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult); const output = formatStatus(result); diff --git a/cli/selftune/status.ts b/cli/selftune/status.ts index af8df4f..0edf802 100644 --- a/cli/selftune/status.ts +++ b/cli/selftune/status.ts @@ -324,13 +324,13 @@ function colorize(text: string, hex: string): string { // cliMain — reads logs, runs doctor, prints output // --------------------------------------------------------------------------- -export function cliMain(): void { +export async function cliMain(): Promise { try { const telemetry = readJsonl(TELEMETRY_LOG); const skillRecords = readEffectiveSkillUsageRecords(); const queryRecords = readJsonl(QUERY_LOG); const auditEntries = readJsonl(EVOLUTION_AUDIT_LOG); - const doctorResult = doctor(); + const doctorResult = await doctor(); const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult); const output = formatStatus(result); diff --git a/package.json b/package.json index d6255e8..ebaa19d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selftune", - "version": "0.2.3", + "version": "0.2.4", "description": "Self-improving skills CLI for AI agents", "type": "module", "license": "MIT", @@ -69,7 +69,7 @@ "@selftune/telemetry-contract": "workspace:*" }, "devDependencies": { - "@biomejs/biome": "2.4.7", + "@biomejs/biome": "^2.4.7", "@types/bun": "^1.1.0" } } diff --git a/tests/observability.test.ts b/tests/observability.test.ts index 542e455..d314ef9 100644 --- a/tests/observability.test.ts +++ b/tests/observability.test.ts @@ -151,8 +151,8 @@ describe("checkConfigHealth", () => { }); describe("doctor", () => { - test("returns structured result", () => { - const result = doctor(); + test("returns structured result", async () => { + const result = await doctor(); expect(result.command).toBe("doctor"); expect(result).toHaveProperty("timestamp"); expect(result).toHaveProperty("checks"); @@ -165,16 +165,16 @@ describe("doctor", () => { ); }); - test("includes evolution health checks", () => { - const result = doctor(); + test("includes evolution health checks", async () => { + const result = await doctor(); const evolutionChecks = result.checks.filter( (c) => c.name === "evolution_audit" || c.name === "log_evolution_audit", ); expect(evolutionChecks.length).toBeGreaterThanOrEqual(1); }); - test("doctor does not produce false positives from git hook checks", () => { - const result = doctor(); + test("doctor does not produce false positives from git hook checks", async () => { + const result = await doctor(); // With the git hook checks removed, doctor should not produce false // positives from missing .git/hooks/ files const gitHookChecks = result.checks.filter((c) => c.path?.includes(".git/hooks/"));