selftune 0.2.30 → 0.2.32

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 (102) hide show
  1. package/README.md +83 -56
  2. package/apps/local-dashboard/dist/assets/index-B-ut4w0B.js +15 -0
  3. package/apps/local-dashboard/dist/assets/index-BFGfCVrL.css +1 -0
  4. package/apps/local-dashboard/dist/assets/vendor-ui-DfowE3Hu.js +1 -0
  5. package/apps/local-dashboard/dist/index.html +3 -3
  6. package/cli/selftune/command-surface.ts +613 -2
  7. package/cli/selftune/create/baseline.ts +429 -0
  8. package/cli/selftune/create/check.ts +35 -0
  9. package/cli/selftune/create/init.ts +115 -0
  10. package/cli/selftune/create/package-candidate-state.ts +771 -0
  11. package/cli/selftune/create/package-evaluator.ts +710 -0
  12. package/cli/selftune/create/package-fingerprint.ts +142 -0
  13. package/cli/selftune/create/package-search.ts +377 -0
  14. package/cli/selftune/create/publish.ts +431 -0
  15. package/cli/selftune/create/readiness.ts +495 -0
  16. package/cli/selftune/create/replay.ts +330 -0
  17. package/cli/selftune/create/report.ts +74 -0
  18. package/cli/selftune/create/scaffold.ts +121 -0
  19. package/cli/selftune/create/skills-ref-adapter.ts +177 -0
  20. package/cli/selftune/create/status.ts +33 -0
  21. package/cli/selftune/create/templates.ts +249 -0
  22. package/cli/selftune/cron/setup.ts +1 -1
  23. package/cli/selftune/dashboard-action-events.ts +4 -1
  24. package/cli/selftune/dashboard-action-result.ts +789 -24
  25. package/cli/selftune/dashboard-action-stream.ts +80 -0
  26. package/cli/selftune/dashboard-contract.ts +146 -3
  27. package/cli/selftune/dashboard-server.ts +5 -4
  28. package/cli/selftune/eval/hooks-to-evals.ts +58 -35
  29. package/cli/selftune/eval/synthetic-evals.ts +145 -17
  30. package/cli/selftune/evolution/bounded-mutations.ts +1045 -0
  31. package/cli/selftune/evolution/evolve-body.ts +9 -36
  32. package/cli/selftune/evolution/evolve.ts +8 -72
  33. package/cli/selftune/evolution/stopping-criteria.ts +5 -13
  34. package/cli/selftune/evolution/unblock-suggestions.ts +0 -16
  35. package/cli/selftune/evolution/validate-host-replay.ts +115 -15
  36. package/cli/selftune/improve.ts +206 -0
  37. package/cli/selftune/index.ts +123 -6
  38. package/cli/selftune/init.ts +1 -1
  39. package/cli/selftune/localdb/queries/dashboard.ts +30 -0
  40. package/cli/selftune/localdb/schema.ts +52 -0
  41. package/cli/selftune/monitoring/watch.ts +257 -23
  42. package/cli/selftune/orchestrate/execute.ts +300 -1
  43. package/cli/selftune/orchestrate/finalize.ts +14 -0
  44. package/cli/selftune/orchestrate/plan.ts +22 -5
  45. package/cli/selftune/orchestrate/prepare.ts +59 -4
  46. package/cli/selftune/orchestrate/report.ts +1 -1
  47. package/cli/selftune/orchestrate.ts +34 -1
  48. package/cli/selftune/publish.ts +35 -0
  49. package/cli/selftune/registry/github-install.ts +256 -0
  50. package/cli/selftune/registry/index.ts +1 -1
  51. package/cli/selftune/registry/install.ts +58 -7
  52. package/cli/selftune/routes/actions.ts +81 -15
  53. package/cli/selftune/routes/overview.ts +1 -1
  54. package/cli/selftune/routes/skill-report.ts +147 -2
  55. package/cli/selftune/run.ts +18 -0
  56. package/cli/selftune/schedule.ts +3 -3
  57. package/cli/selftune/search-run.ts +703 -0
  58. package/cli/selftune/status.ts +35 -11
  59. package/cli/selftune/testing-readiness.ts +431 -40
  60. package/cli/selftune/types.ts +316 -0
  61. package/cli/selftune/utils/eval-readiness.ts +1 -0
  62. package/cli/selftune/utils/json-output.ts +11 -0
  63. package/cli/selftune/utils/lifecycle-surface.ts +48 -0
  64. package/cli/selftune/utils/query-filter.ts +82 -1
  65. package/cli/selftune/utils/tui.ts +85 -2
  66. package/cli/selftune/verify.ts +205 -0
  67. package/cli/selftune/workflows/proposals.ts +1 -1
  68. package/cli/selftune/workflows/skill-scaffold.ts +141 -63
  69. package/cli/selftune/workflows/workflows.ts +4 -4
  70. package/package.json +1 -1
  71. package/packages/dashboard-core/src/routes/manifest.ts +2 -2
  72. package/packages/ui/src/components/SkillReportPanels.tsx +7 -7
  73. package/packages/ui/src/primitives/button.tsx +5 -0
  74. package/skill/SKILL.md +148 -85
  75. package/skill/references/cli-quick-reference.md +16 -1
  76. package/skill/references/creator-playbook.md +31 -10
  77. package/skill/workflows/Baseline.md +8 -9
  78. package/skill/workflows/Contributions.md +4 -4
  79. package/skill/workflows/Create.md +173 -0
  80. package/skill/workflows/CreateTestDeploy.md +34 -30
  81. package/skill/workflows/Cron.md +2 -2
  82. package/skill/workflows/Dashboard.md +3 -3
  83. package/skill/workflows/Evals.md +13 -7
  84. package/skill/workflows/Evolve.md +75 -32
  85. package/skill/workflows/EvolveBody.md +22 -15
  86. package/skill/workflows/Hook.md +1 -1
  87. package/skill/workflows/Improve.md +168 -0
  88. package/skill/workflows/Initialize.md +3 -3
  89. package/skill/workflows/Orchestrate.md +49 -12
  90. package/skill/workflows/Publish.md +100 -0
  91. package/skill/workflows/Registry.md +19 -13
  92. package/skill/workflows/Run.md +72 -0
  93. package/skill/workflows/Schedule.md +2 -2
  94. package/skill/workflows/SearchRun.md +89 -0
  95. package/skill/workflows/SignalsDashboard.md +2 -2
  96. package/skill/workflows/UnitTest.md +13 -4
  97. package/skill/workflows/Verify.md +136 -0
  98. package/skill/workflows/Watch.md +114 -47
  99. package/skill/workflows/Workflows.md +13 -8
  100. package/apps/local-dashboard/dist/assets/index-BcXquWFB.css +0 -1
  101. package/apps/local-dashboard/dist/assets/index-Coq42hE4.js +0 -15
  102. package/apps/local-dashboard/dist/assets/vendor-ui-B0H8s1mP.js +0 -1
@@ -16,6 +16,8 @@ interface OrchestrateFinalTotals {
16
16
  watched: number;
17
17
  skipped: number;
18
18
  autoGraded: number;
19
+ packageSearched: number;
20
+ packageImproved: number;
19
21
  freshlyWatchedSkills: string[];
20
22
  }
21
23
 
@@ -27,6 +29,8 @@ export interface FinalizeOrchestrateRunInput {
27
29
  dryRun: boolean;
28
30
  approvalMode: "auto" | "review";
29
31
  autoGradedCount: number;
32
+ packageSearched: number;
33
+ packageImproved: number;
30
34
  freshlyWatchedSkills: string[];
31
35
  pendingSignals: ImprovementSignalRecord[];
32
36
  elapsedMs: number;
@@ -36,6 +40,8 @@ function buildFinalTotals(
36
40
  skills: SkillStatus[],
37
41
  candidates: SkillAction[],
38
42
  autoGradedCount: number,
43
+ packageSearched: number,
44
+ packageImproved: number,
39
45
  freshlyWatchedSkills: string[],
40
46
  ): OrchestrateFinalTotals {
41
47
  return {
@@ -50,6 +56,8 @@ function buildFinalTotals(
50
56
  freshlyWatchedSkills.length,
51
57
  skipped: candidates.filter((candidate) => candidate.action === "skip").length,
52
58
  autoGraded: autoGradedCount,
59
+ packageSearched,
60
+ packageImproved,
53
61
  freshlyWatchedSkills,
54
62
  };
55
63
  }
@@ -63,6 +71,8 @@ export function finalizeOrchestrateRun(input: FinalizeOrchestrateRunInput): Orch
63
71
  dryRun,
64
72
  approvalMode,
65
73
  autoGradedCount,
74
+ packageSearched,
75
+ packageImproved,
66
76
  freshlyWatchedSkills,
67
77
  pendingSignals,
68
78
  elapsedMs,
@@ -72,6 +82,8 @@ export function finalizeOrchestrateRun(input: FinalizeOrchestrateRunInput): Orch
72
82
  statusResult.skills,
73
83
  candidates,
74
84
  autoGradedCount,
85
+ packageSearched,
86
+ packageImproved,
75
87
  freshlyWatchedSkills,
76
88
  );
77
89
 
@@ -106,6 +118,8 @@ export function finalizeOrchestrateRun(input: FinalizeOrchestrateRunInput): Orch
106
118
  watched: finalTotals.watched,
107
119
  skipped: finalTotals.skipped,
108
120
  auto_graded: finalTotals.autoGraded,
121
+ package_searched: finalTotals.packageSearched,
122
+ package_improved: finalTotals.packageImproved,
109
123
  skill_actions: candidates.map(
110
124
  (candidate): OrchestrateRunSkillAction => ({
111
125
  skill: candidate.skill,
@@ -2,6 +2,22 @@ import type { CandidateContext, SkillAction } from "../orchestrate.js";
2
2
  import type { SkillStatus } from "../status.js";
3
3
  import type { EvolutionAuditEntry } from "../types.js";
4
4
 
5
+ /**
6
+ * Determines whether a skill should use package search instead of standard
7
+ * evolution. Returns true when the skill has package-level evidence:
8
+ * a frontier candidate exists or a canonical evaluation record is present.
9
+ *
10
+ * This gates the package-search path so only skills with sufficient
11
+ * package-level signal enter the bounded search flow.
12
+ */
13
+ export function shouldSelectPackageSearch(skill: SkillStatus, context: CandidateContext): boolean {
14
+ // Package search requires that the candidate context carries a
15
+ // packageFrontierSkills set (populated from package-candidate-state).
16
+ // When present and the skill is listed, we route through package search.
17
+ if (!context.packageFrontierSkills) return false;
18
+ return context.packageFrontierSkills.has(skill.name);
19
+ }
20
+
5
21
  /** Candidate selection criteria. */
6
22
  const CANDIDATE_STATUSES = new Set(["CRITICAL", "WARNING", "UNGRADED"]);
7
23
 
@@ -109,18 +125,19 @@ export function selectCandidates(skills: SkillStatus[], options: CandidateContex
109
125
  continue;
110
126
  }
111
127
 
128
+ const action = shouldSelectPackageSearch(skill, options) ? "package-search" : "evolve";
112
129
  actions.push({
113
130
  skill: skill.name,
114
- action: "evolve",
131
+ action,
115
132
  reason: `status=${skill.status}, passRate=${skill.passRate !== null ? `${(skill.passRate * 100).toFixed(0)}%` : "—"}, missed=${skill.missedQueries}, trend=${skill.trend}`,
116
133
  });
117
134
  }
118
135
 
119
- let evolveCount = 0;
136
+ let activeCount = 0;
120
137
  for (const action of actions) {
121
- if (action.action === "evolve") {
122
- evolveCount++;
123
- if (evolveCount > options.maxSkills) {
138
+ if (action.action === "evolve" || action.action === "package-search") {
139
+ activeCount++;
140
+ if (activeCount > options.maxSkills) {
124
141
  action.action = "skip";
125
142
  action.reason = `capped by --max-skills ${options.maxSkills}`;
126
143
  }
@@ -1,5 +1,5 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
 
4
4
  import {
5
5
  buildDefaultGradingOutputPath,
@@ -7,8 +7,11 @@ import {
7
7
  gradeSession,
8
8
  resolveLatestSessionForSkill,
9
9
  } from "../grading/grade-session.js";
10
+ import { selectAcceptedPackageFrontierCandidate } from "../create/package-candidate-state.js";
10
11
  import { writeGradingResultToDb } from "../localdb/direct-write.js";
11
12
  import { createDefaultSyncOptions } from "../sync.js";
13
+ import { getDb } from "../localdb/db.js";
14
+ import { readCanonicalPackageEvaluationArtifact } from "../testing-readiness.js";
12
15
  import type {
13
16
  ImprovementSignalRecord,
14
17
  QueryLogRecord,
@@ -17,7 +20,7 @@ import type {
17
20
  } from "../types.js";
18
21
  import { readExcerpt } from "../utils/transcript.js";
19
22
  import type { OrchestrateOptions, SkillAction } from "../orchestrate.js";
20
- import { selectCandidates } from "./plan.js";
23
+ import { MIN_CANDIDATE_EVIDENCE, selectCandidates } from "./plan.js";
21
24
  import { groupSignalsBySkill, readPendingSignals } from "./signals.js";
22
25
  import type { ResolvedOrchestrateRuntime } from "./runtime.js";
23
26
 
@@ -33,6 +36,50 @@ export interface PreparedOrchestrateRun {
33
36
  autoGradedCount: number;
34
37
  }
35
38
 
39
+ export function collectPackageSearchEligibleSkills(
40
+ skillNames: string[],
41
+ options?: {
42
+ db?: import("bun:sqlite").Database;
43
+ resolveSkillPath?: (skillName: string) => string | undefined;
44
+ },
45
+ ): Set<string> {
46
+ const eligible = new Set<string>();
47
+
48
+ for (const skillName of skillNames) {
49
+ if (
50
+ selectAcceptedPackageFrontierCandidate(skillName) != null ||
51
+ readCanonicalPackageEvaluationArtifact(skillName) != null
52
+ ) {
53
+ eligible.add(skillName);
54
+ continue;
55
+ }
56
+
57
+ // Second tier: skills with a draft package and sufficient grading evidence
58
+ if (!options?.db || !options?.resolveSkillPath) continue;
59
+
60
+ const skillPath = options.resolveSkillPath(skillName);
61
+ if (!skillPath) continue;
62
+
63
+ const hasDraft = existsSync(join(dirname(skillPath), "selftune.create.json"));
64
+ if (!hasDraft) continue;
65
+
66
+ try {
67
+ const row = options.db
68
+ .query<{ count: number }, [string]>(
69
+ "SELECT COUNT(*) as count FROM grading_results WHERE skill_name = ?",
70
+ )
71
+ .get(skillName);
72
+ if (row && row.count >= MIN_CANDIDATE_EVIDENCE) {
73
+ eligible.add(skillName);
74
+ }
75
+ } catch {
76
+ // Fail-open: table may not exist yet
77
+ }
78
+ }
79
+
80
+ return eligible;
81
+ }
82
+
36
83
  /**
37
84
  * Detects significant overlap between the positive eval sets of evolution
38
85
  * candidates. When two skills share >30% of their positive queries, it
@@ -270,6 +317,10 @@ export async function prepareOrchestrateRun(
270
317
 
271
318
  const pendingSignals = readPendingSignals(runtime.readSignals);
272
319
  const signaledSkills = groupSignalsBySkill(pendingSignals);
320
+ const packageFrontierSkills = collectPackageSearchEligibleSkills(
321
+ statusResult.skills.map((skill) => skill.name),
322
+ { db: getDb(), resolveSkillPath: runtime.resolveSkillPath },
323
+ );
273
324
  if (signaledSkills.size > 0) {
274
325
  console.error(
275
326
  `[orchestrate] Improvement signals: ${pendingSignals.length} pending for ${signaledSkills.size} skill(s)`,
@@ -281,12 +332,16 @@ export async function prepareOrchestrateRun(
281
332
  maxSkills: options.maxSkills,
282
333
  auditEntries,
283
334
  signaledSkills,
335
+ packageFrontierSkills,
284
336
  });
285
337
 
286
338
  const evolveCandidates = candidates.filter((candidate) => candidate.action === "evolve");
339
+ const packageSearchCount = candidates.filter(
340
+ (candidate) => candidate.action === "package-search",
341
+ ).length;
287
342
  const skipCount = candidates.filter((candidate) => candidate.action === "skip").length;
288
343
  console.error(
289
- `[orchestrate] Candidates: ${evolveCandidates.length} to evolve, ${skipCount} skipped`,
344
+ `[orchestrate] Candidates: ${evolveCandidates.length} to evolve, ${packageSearchCount} to package-search, ${skipCount} skipped`,
290
345
  );
291
346
  for (const candidate of candidates) {
292
347
  console.error(
@@ -123,7 +123,7 @@ export function formatOrchestrateReport(result: OrchestrateResult): string {
123
123
  const lines: string[] = [];
124
124
 
125
125
  lines.push(separator);
126
- lines.push("selftune orchestrate — decision report");
126
+ lines.push("selftune run — decision report");
127
127
  lines.push(separator);
128
128
  lines.push("");
129
129
 
@@ -24,8 +24,11 @@ import {
24
24
  autoGradeFreshDeploys,
25
25
  buildReplayValidationOptions,
26
26
  runEvolutionPhase,
27
+ runPackageSearchPhase,
27
28
  watchRecentDeploys,
28
29
  } from "./orchestrate/execute.js";
30
+ export { runPackageSearchPhase } from "./orchestrate/execute.js";
31
+ export type { RunPackageSearchPhaseInput } from "./orchestrate/execute.js";
29
32
  import { finalizeOrchestrateRun } from "./orchestrate/finalize.js";
30
33
  import { acquireLock, releaseLock } from "./orchestrate/locks.js";
31
34
  import { runPostOrchestrateSideEffects } from "./orchestrate/post-run.js";
@@ -67,6 +70,7 @@ export {
67
70
  DEFAULT_COOLDOWN_HOURS,
68
71
  MIN_CANDIDATE_EVIDENCE,
69
72
  selectCandidates,
73
+ shouldSelectPackageSearch,
70
74
  } from "./orchestrate/plan.js";
71
75
  export { autoGradeTopUngraded, detectCrossSkillOverlap } from "./orchestrate/prepare.js";
72
76
  export { formatOrchestrateReport } from "./orchestrate/report.js";
@@ -93,12 +97,20 @@ export interface OrchestrateOptions {
93
97
  maxAutoGrade: number;
94
98
  }
95
99
 
100
+ export interface PackageSearchResult {
101
+ searched: boolean;
102
+ winnerApplied: boolean;
103
+ candidateCount: number;
104
+ winnerCandidateId?: string;
105
+ }
106
+
96
107
  export interface SkillAction {
97
108
  skill: string;
98
- action: "evolve" | "watch" | "skip";
109
+ action: "evolve" | "package-search" | "watch" | "skip";
99
110
  reason: string;
100
111
  evolveResult?: EvolveResult;
101
112
  watchResult?: WatchResult;
113
+ packageSearchResult?: PackageSearchResult;
102
114
  }
103
115
 
104
116
  /** Context for candidate selection beyond simple status checks. */
@@ -110,6 +122,8 @@ export interface CandidateContext {
110
122
  cooldownHours?: number;
111
123
  /** Skill name (lowercase) to improvement signal count. */
112
124
  signaledSkills?: Map<string, number>;
125
+ /** Skills with an accepted package frontier candidate (eligible for package search). */
126
+ packageFrontierSkills?: Set<string>;
113
127
  }
114
128
 
115
129
  export interface OrchestrateResult {
@@ -127,6 +141,8 @@ export interface OrchestrateResult {
127
141
  watched: number;
128
142
  skipped: number;
129
143
  autoGraded: number;
144
+ packageSearched: number;
145
+ packageImproved: number;
130
146
  freshlyWatchedSkills: string[];
131
147
  dryRun: boolean;
132
148
  approvalMode: "auto" | "review";
@@ -240,6 +256,8 @@ export async function orchestrate(
240
256
  watched: 0,
241
257
  skipped: 0,
242
258
  autoGraded: 0,
259
+ packageSearched: 0,
260
+ packageImproved: 0,
243
261
  freshlyWatchedSkills: [],
244
262
  dryRun: options.dryRun,
245
263
  approvalMode: options.approvalMode,
@@ -288,6 +306,19 @@ export async function orchestrate(
288
306
  readSkillRecords: runtime.readSkillRecords,
289
307
  });
290
308
 
309
+ // -------------------------------------------------------------------------
310
+ // Step 5c: Package search for candidates tagged with action "package-search"
311
+ // -------------------------------------------------------------------------
312
+ const packageSearchCandidates = candidates.filter(
313
+ (candidate) => candidate.action === "package-search",
314
+ );
315
+ const packageSearchImproved = await runPackageSearchPhase({
316
+ packageSearchCandidates,
317
+ dryRun: options.dryRun,
318
+ agent,
319
+ resolveSkillPath: runtime.resolveSkillPath,
320
+ });
321
+
291
322
  // -------------------------------------------------------------------------
292
323
  // Step 6: Watch recently evolved skills (including freshly deployed in this run)
293
324
  // -------------------------------------------------------------------------
@@ -336,6 +367,8 @@ export async function orchestrate(
336
367
  dryRun: options.dryRun,
337
368
  approvalMode: options.approvalMode,
338
369
  autoGradedCount,
370
+ packageSearched: packageSearchCandidates.length,
371
+ packageImproved: packageSearchImproved.length,
339
372
  freshlyWatchedSkills,
340
373
  pendingSignals,
341
374
  elapsedMs: Date.now() - startTime,
@@ -0,0 +1,35 @@
1
+ import { PUBLIC_COMMAND_SURFACES, renderCommandHelp } from "./command-surface.js";
2
+ import { cliMain as createPublishCliMain } from "./create/publish.js";
3
+ import { CLIError, handleCLIError } from "./utils/cli-error.js";
4
+
5
+ export async function cliMain(): Promise<void> {
6
+ const rawArgs = process.argv.slice(2);
7
+
8
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
9
+ console.log(renderCommandHelp(PUBLIC_COMMAND_SURFACES.publish));
10
+ process.exit(0);
11
+ }
12
+
13
+ const hasWatch = rawArgs.includes("--watch") || rawArgs.some((arg) => arg.startsWith("--watch="));
14
+ const hasNoWatch = rawArgs.includes("--no-watch");
15
+
16
+ if (hasWatch && hasNoWatch) {
17
+ throw new CLIError(
18
+ "Use either --watch or --no-watch, not both.",
19
+ "INVALID_FLAG",
20
+ "selftune publish --skill-path <path> [--no-watch]",
21
+ );
22
+ }
23
+
24
+ const delegatedArgs = rawArgs.filter((arg) => arg !== "--no-watch");
25
+ if (!hasWatch && !hasNoWatch) {
26
+ delegatedArgs.push("--watch");
27
+ }
28
+
29
+ process.argv = [process.argv[0], process.argv[1], ...delegatedArgs];
30
+ await createPublishCliMain();
31
+ }
32
+
33
+ if (import.meta.main) {
34
+ cliMain().catch(handleCLIError);
35
+ }
@@ -0,0 +1,256 @@
1
+ import { execFile } from "node:child_process";
2
+ import { cp, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { parseFrontmatter } from "../utils/frontmatter.js";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export interface GithubRegistryInstallTarget {
11
+ owner: string;
12
+ repo: string;
13
+ repoFullName: string;
14
+ ref: string | null;
15
+ skillPath: string | null;
16
+ }
17
+
18
+ function normalizeGithubSkillPath(skillPath: string): string {
19
+ const trimmed = skillPath.trim().replace(/\\/g, "/");
20
+ if (!trimmed || trimmed === ".") {
21
+ return ".";
22
+ }
23
+
24
+ const segments = trimmed.split("/").filter(Boolean);
25
+ if (segments.includes("..")) {
26
+ throw new Error("GitHub skill path must stay within the repository");
27
+ }
28
+
29
+ const normalized = path.posix.normalize(trimmed).replace(/^\/+|\/+$/g, "");
30
+ return normalized || ".";
31
+ }
32
+
33
+ export function parseGithubRegistryInstallTarget(
34
+ rawTarget: string,
35
+ ): GithubRegistryInstallTarget | null {
36
+ if (!rawTarget.startsWith("github:")) {
37
+ return null;
38
+ }
39
+
40
+ const spec = rawTarget.slice("github:".length).trim();
41
+ if (!spec) {
42
+ throw new Error("GitHub install target must be github:owner/repo[@ref][//path]");
43
+ }
44
+
45
+ const pathSeparatorIndex = spec.indexOf("//");
46
+ const repoWithMaybeRef = pathSeparatorIndex === -1 ? spec : spec.slice(0, pathSeparatorIndex);
47
+ const pathWithMaybeRef = pathSeparatorIndex === -1 ? null : spec.slice(pathSeparatorIndex + 2);
48
+
49
+ let ref: string | null = null;
50
+ let repoSpec = repoWithMaybeRef;
51
+
52
+ const repoRefIndex = repoWithMaybeRef.lastIndexOf("@");
53
+ if (repoRefIndex !== -1) {
54
+ repoSpec = repoWithMaybeRef.slice(0, repoRefIndex);
55
+ ref = repoWithMaybeRef.slice(repoRefIndex + 1) || null;
56
+ }
57
+
58
+ let skillPath: string | null = null;
59
+ if (pathWithMaybeRef != null) {
60
+ const pathRefIndex = pathWithMaybeRef.lastIndexOf("@");
61
+ if (pathRefIndex !== -1) {
62
+ skillPath = pathWithMaybeRef.slice(0, pathRefIndex) || ".";
63
+ ref = pathWithMaybeRef.slice(pathRefIndex + 1) || ref;
64
+ } else {
65
+ skillPath = pathWithMaybeRef || ".";
66
+ }
67
+ }
68
+
69
+ const match = repoSpec.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
70
+ if (!match) {
71
+ throw new Error("GitHub install target must look like github:owner/repo[@ref][//path]");
72
+ }
73
+
74
+ return {
75
+ owner: match[1],
76
+ repo: match[2],
77
+ repoFullName: `${match[1]}/${match[2]}`,
78
+ ref,
79
+ skillPath: skillPath ? normalizeGithubSkillPath(skillPath) : null,
80
+ };
81
+ }
82
+
83
+ function isExcludedEntry(name: string): boolean {
84
+ return name === ".git" || name === "node_modules" || name === ".env" || name.startsWith(".env.");
85
+ }
86
+
87
+ export async function discoverLocalSkillPaths(rootDir: string): Promise<string[]> {
88
+ async function walk(currentDir: string, basePath: string): Promise<string[]> {
89
+ const entries = await readdir(currentDir, { withFileTypes: true });
90
+ const discovered: string[] = [];
91
+
92
+ for (const entry of entries) {
93
+ if (isExcludedEntry(entry.name)) {
94
+ continue;
95
+ }
96
+
97
+ const fullPath = path.join(currentDir, entry.name);
98
+ const relativePath = basePath ? path.join(basePath, entry.name) : entry.name;
99
+
100
+ if (entry.isDirectory()) {
101
+ discovered.push(...(await walk(fullPath, relativePath)));
102
+ continue;
103
+ }
104
+
105
+ if (entry.isFile() && entry.name === "SKILL.md") {
106
+ discovered.push(basePath ? basePath.split(path.sep).join("/") : ".");
107
+ }
108
+ }
109
+
110
+ return discovered;
111
+ }
112
+
113
+ const discovered = await walk(rootDir, "");
114
+ return [...new Set(discovered)].sort((a, b) => a.localeCompare(b));
115
+ }
116
+
117
+ export async function resolveGithubSkillPath(
118
+ repoDir: string,
119
+ requestedSkillPath: string | null,
120
+ ): Promise<{ skillPath: string; availablePaths: string[] }> {
121
+ const availablePaths = await discoverLocalSkillPaths(repoDir);
122
+
123
+ if (requestedSkillPath) {
124
+ const normalized = normalizeGithubSkillPath(requestedSkillPath);
125
+ const skillMdPath =
126
+ normalized === "."
127
+ ? path.join(repoDir, "SKILL.md")
128
+ : path.join(repoDir, ...normalized.split("/"), "SKILL.md");
129
+ await stat(skillMdPath);
130
+ return { skillPath: normalized, availablePaths };
131
+ }
132
+
133
+ if (availablePaths.length === 1) {
134
+ return { skillPath: availablePaths[0] ?? ".", availablePaths };
135
+ }
136
+
137
+ if (availablePaths.length === 0) {
138
+ throw new Error("No SKILL.md found in the GitHub repository");
139
+ }
140
+
141
+ throw new Error(
142
+ `Multiple skills found in the GitHub repository. Choose one with github:owner/repo//path (available: ${availablePaths.join(", ")})`,
143
+ );
144
+ }
145
+
146
+ export function deriveGithubInstallSkillName(
147
+ frontmatterName: string,
148
+ skillPath: string,
149
+ skillDir: string,
150
+ repoName: string,
151
+ ): string {
152
+ const trimmedName = frontmatterName.trim();
153
+ if (trimmedName) {
154
+ return trimmedName;
155
+ }
156
+
157
+ return skillPath === "." ? repoName : path.basename(skillDir);
158
+ }
159
+
160
+ async function cloneGithubRepository(
161
+ target: GithubRegistryInstallTarget,
162
+ cloneDir: string,
163
+ ): Promise<void> {
164
+ const repoUrl = `https://github.com/${target.repoFullName}.git`;
165
+ const args = ["clone", "--depth=1"];
166
+
167
+ if (target.ref) {
168
+ args.push("--branch", target.ref);
169
+ }
170
+
171
+ args.push(repoUrl, cloneDir);
172
+
173
+ await execFileAsync("git", args);
174
+ }
175
+
176
+ async function copySkillDirectory(sourceDir: string, targetDir: string): Promise<void> {
177
+ await rm(targetDir, { recursive: true, force: true });
178
+ await mkdir(path.dirname(targetDir), { recursive: true });
179
+
180
+ await cp(sourceDir, targetDir, {
181
+ recursive: true,
182
+ filter: (entryPath) => {
183
+ const basename = path.basename(entryPath);
184
+ return !isExcludedEntry(basename);
185
+ },
186
+ });
187
+ }
188
+
189
+ export async function installFromGithubTarget(
190
+ rawTarget: string,
191
+ globalFlag: boolean,
192
+ ): Promise<void> {
193
+ const target = parseGithubRegistryInstallTarget(rawTarget);
194
+ if (!target) {
195
+ throw new Error("GitHub install target must start with github:");
196
+ }
197
+
198
+ const tempRoot = await mkdtemp(path.join(tmpdir(), "selftune-github-install-"));
199
+
200
+ try {
201
+ const cloneDir = path.join(tempRoot, "repo");
202
+ await cloneGithubRepository(target, cloneDir);
203
+
204
+ const { skillPath, availablePaths } = await resolveGithubSkillPath(cloneDir, target.skillPath);
205
+ const skillDir = skillPath === "." ? cloneDir : path.join(cloneDir, ...skillPath.split("/"));
206
+ const skillContent = await readFile(path.join(skillDir, "SKILL.md"), "utf-8");
207
+ const frontmatter = parseFrontmatter(skillContent);
208
+ const skillName = deriveGithubInstallSkillName(
209
+ frontmatter.name,
210
+ skillPath,
211
+ skillDir,
212
+ target.repo,
213
+ );
214
+ const resolvedCommit = (
215
+ await execFileAsync("git", ["-C", cloneDir, "rev-parse", "HEAD"])
216
+ ).stdout.trim();
217
+
218
+ const targetBase = globalFlag
219
+ ? path.join(process.env.HOME || "~", ".claude", "skills")
220
+ : path.join(process.cwd(), ".claude", "skills");
221
+ const targetDir = path.join(targetBase, skillName);
222
+
223
+ await copySkillDirectory(skillDir, targetDir);
224
+ await writeFile(
225
+ path.join(targetDir, ".selftune-source.json"),
226
+ JSON.stringify(
227
+ {
228
+ source: "github-direct",
229
+ repo: target.repoFullName,
230
+ ref: target.ref ?? "HEAD",
231
+ commit: resolvedCommit,
232
+ skill_path: skillPath,
233
+ available_paths: availablePaths,
234
+ },
235
+ null,
236
+ 2,
237
+ ),
238
+ );
239
+
240
+ console.log(
241
+ JSON.stringify({
242
+ success: true,
243
+ source: "github-direct",
244
+ name: skillName,
245
+ repo: target.repoFullName,
246
+ ref: target.ref ?? "HEAD",
247
+ commit: resolvedCommit,
248
+ skill_path: skillPath,
249
+ path: targetDir,
250
+ global: globalFlag,
251
+ }),
252
+ );
253
+ } finally {
254
+ await rm(tempRoot, { recursive: true, force: true });
255
+ }
256
+ }
@@ -24,7 +24,7 @@ Usage:
24
24
 
25
25
  Subcommands:
26
26
  push [name] Push current skill folder as a new version
27
- install <name> Download and install a skill from the registry
27
+ install <name> Download from the registry or install github:owner/repo[@ref][//path]
28
28
  sync Check for updates and pull latest versions
29
29
  status Show installed entries and version drift
30
30
  rollback <name> Rollback to a previous version