selftune 0.2.21 → 0.2.23

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 (108) hide show
  1. package/README.md +15 -8
  2. package/apps/local-dashboard/dist/assets/index-CwOtTrUS.css +1 -0
  3. package/apps/local-dashboard/dist/assets/index-f1HQpbeH.js +59 -0
  4. package/apps/local-dashboard/dist/assets/vendor-ui-jVSaIZey.js +12 -0
  5. package/apps/local-dashboard/dist/index.html +3 -3
  6. package/cli/selftune/adapters/cline/hook.ts +167 -0
  7. package/cli/selftune/adapters/cline/install.ts +197 -0
  8. package/cli/selftune/adapters/codex/hook.ts +296 -0
  9. package/cli/selftune/adapters/codex/install.ts +289 -0
  10. package/cli/selftune/adapters/opencode/hook.ts +222 -0
  11. package/cli/selftune/adapters/opencode/install.ts +543 -0
  12. package/cli/selftune/adapters/pi/hook.ts +273 -0
  13. package/cli/selftune/adapters/pi/install.ts +207 -0
  14. package/cli/selftune/constants.ts +10 -1
  15. package/cli/selftune/dashboard-contract.ts +14 -0
  16. package/cli/selftune/evolution/engines/judge-engine.ts +96 -0
  17. package/cli/selftune/evolution/engines/replay-engine.ts +158 -0
  18. package/cli/selftune/evolution/evidence.ts +2 -6
  19. package/cli/selftune/evolution/evolve-body.ts +73 -20
  20. package/cli/selftune/evolution/validate-body.ts +78 -42
  21. package/cli/selftune/evolution/validate-routing.ts +45 -104
  22. package/cli/selftune/hooks/auto-activate.ts +43 -37
  23. package/cli/selftune/hooks/skill-eval.ts +2 -1
  24. package/cli/selftune/hooks-shared/git-metadata.ts +149 -0
  25. package/cli/selftune/hooks-shared/hook-output.ts +105 -0
  26. package/cli/selftune/hooks-shared/normalize.ts +196 -0
  27. package/cli/selftune/hooks-shared/session-state.ts +76 -0
  28. package/cli/selftune/hooks-shared/skill-paths.ts +50 -0
  29. package/cli/selftune/hooks-shared/stdin-dispatch.ts +59 -0
  30. package/cli/selftune/hooks-shared/types.ts +91 -0
  31. package/cli/selftune/index.ts +76 -6
  32. package/cli/selftune/ingestors/pi-ingest.ts +726 -0
  33. package/cli/selftune/init.ts +11 -1
  34. package/cli/selftune/localdb/direct-write.ts +85 -0
  35. package/cli/selftune/localdb/materialize.ts +6 -7
  36. package/cli/selftune/localdb/queries.ts +126 -0
  37. package/cli/selftune/localdb/schema.ts +38 -0
  38. package/cli/selftune/observability.ts +8 -1
  39. package/cli/selftune/orchestrate.ts +43 -0
  40. package/cli/selftune/registry/client.ts +74 -0
  41. package/cli/selftune/registry/history.ts +54 -0
  42. package/cli/selftune/registry/index.ts +90 -0
  43. package/cli/selftune/registry/install.ts +141 -0
  44. package/cli/selftune/registry/list.ts +44 -0
  45. package/cli/selftune/registry/push.ts +171 -0
  46. package/cli/selftune/registry/rollback.ts +49 -0
  47. package/cli/selftune/registry/status.ts +62 -0
  48. package/cli/selftune/registry/sync.ts +125 -0
  49. package/cli/selftune/repair/skill-usage.ts +4 -1
  50. package/cli/selftune/status.ts +31 -0
  51. package/cli/selftune/sync.ts +127 -23
  52. package/cli/selftune/types.ts +2 -1
  53. package/cli/selftune/utils/jsonl.ts +1 -30
  54. package/cli/selftune/utils/llm-call.ts +99 -34
  55. package/cli/selftune/utils/skill-discovery.ts +22 -0
  56. package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
  57. package/node_modules/@selftune/telemetry-contract/fixtures/golden.test.ts +0 -1
  58. package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
  59. package/node_modules/@selftune/telemetry-contract/package.json +1 -1
  60. package/node_modules/@selftune/telemetry-contract/src/index.ts +1 -0
  61. package/node_modules/@selftune/telemetry-contract/src/schemas.ts +22 -4
  62. package/node_modules/@selftune/telemetry-contract/src/types.ts +1 -12
  63. package/node_modules/@selftune/telemetry-contract/tests/compatibility.test.ts +0 -1
  64. package/package.json +1 -1
  65. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
  66. package/packages/telemetry-contract/fixtures/golden.test.ts +0 -1
  67. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
  68. package/packages/telemetry-contract/package.json +1 -1
  69. package/packages/telemetry-contract/src/index.ts +1 -0
  70. package/packages/telemetry-contract/src/schemas.ts +22 -4
  71. package/packages/telemetry-contract/src/types.ts +1 -12
  72. package/packages/telemetry-contract/tests/compatibility.test.ts +0 -1
  73. package/packages/ui/AGENTS.md +16 -0
  74. package/packages/ui/README.md +1 -1
  75. package/packages/ui/package.json +1 -1
  76. package/packages/ui/src/components/ActivityTimeline.tsx +152 -168
  77. package/packages/ui/src/components/AnalyticsCharts.tsx +344 -0
  78. package/packages/ui/src/components/EvidenceViewer.tsx +153 -443
  79. package/packages/ui/src/components/EvolutionTimeline.tsx +34 -87
  80. package/packages/ui/src/components/InfoTip.tsx +1 -2
  81. package/packages/ui/src/components/InvocationsPanel.tsx +413 -0
  82. package/packages/ui/src/components/JobHistoryTimeline.tsx +156 -0
  83. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +18 -36
  84. package/packages/ui/src/components/OverviewPanels.tsx +652 -0
  85. package/packages/ui/src/components/PipelineStatusBar.tsx +65 -0
  86. package/packages/ui/src/components/SkillReportGuide.tsx +215 -0
  87. package/packages/ui/src/components/SkillReportPanels.tsx +919 -0
  88. package/packages/ui/src/components/SkillsLibrary.tsx +437 -0
  89. package/packages/ui/src/components/index.ts +56 -1
  90. package/packages/ui/src/components/section-cards.tsx +18 -35
  91. package/packages/ui/src/components/skill-health-grid.tsx +47 -37
  92. package/packages/ui/src/lib/constants.tsx +0 -1
  93. package/packages/ui/src/primitives/card.tsx +1 -1
  94. package/packages/ui/src/primitives/checkbox.tsx +1 -1
  95. package/packages/ui/src/primitives/dropdown-menu.tsx +2 -2
  96. package/packages/ui/src/primitives/select.tsx +2 -2
  97. package/packages/ui/src/types.ts +172 -4
  98. package/skill/SKILL.md +26 -2
  99. package/skill/Workflows/Ingest.md +60 -2
  100. package/skill/Workflows/Initialize.md +54 -9
  101. package/skill/Workflows/PlatformHooks.md +109 -0
  102. package/skill/Workflows/Registry.md +99 -0
  103. package/skill/Workflows/Sync.md +3 -1
  104. package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +0 -60
  105. package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +0 -1
  106. package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +0 -12
  107. package/cli/selftune/utils/html.ts +0 -27
  108. package/packages/ui/src/components/RecentActivityFeed.tsx +0 -117
@@ -0,0 +1,543 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Install selftune hooks into OpenCode environment.
4
+ *
5
+ * OpenCode uses a plugin system for hooks and a strict config schema.
6
+ * This installer:
7
+ * 1. Writes a plugin file (selftune-opencode-plugin.ts) into the
8
+ * plugins directory (auto-discovered by OpenCode at startup)
9
+ * 2. Registers selftune agents in the `agent` config key
10
+ *
11
+ * Plugin locations (OpenCode auto-discovers these):
12
+ * - ~/.config/opencode/plugins/ (global)
13
+ * - ./.opencode/plugins/ (project-level)
14
+ *
15
+ * Config locations (checked in order):
16
+ * 1. ./opencode.json (project-level)
17
+ * 2. ~/.config/opencode/opencode.json (user-level)
18
+ *
19
+ * Usage: selftune opencode install [--dry-run] [--uninstall]
20
+ */
21
+
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ readdirSync,
27
+ unlinkSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { dirname, join, resolve } from "node:path";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Constants
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const PLUGIN_FILENAME = "selftune-opencode-plugin.ts";
38
+ const SELFTUNE_AGENT_PREFIX = "[selftune]";
39
+
40
+ function getProjectConfigPath(): string {
41
+ return join(process.cwd(), "opencode.json");
42
+ }
43
+
44
+ function getUserConfigPath(): string {
45
+ return join(process.env.HOME ?? homedir(), ".config", "opencode", "opencode.json");
46
+ }
47
+
48
+ /** Global plugins directory — OpenCode auto-discovers plugins here. */
49
+ function getGlobalPluginsDir(): string {
50
+ return join(process.env.HOME ?? homedir(), ".config", "opencode", "plugins");
51
+ }
52
+
53
+ /** Project-level plugins directory — OpenCode auto-discovers plugins here. */
54
+ function getProjectPluginsDir(): string {
55
+ return join(process.cwd(), ".opencode", "plugins");
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Plugin content
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function buildPluginContent(): string {
63
+ return `// selftune-managed — Written by selftune. Do not edit.
64
+ // OpenCode plugin that pipes hook events to selftune for processing.
65
+ // Auto-discovered from plugins/ directory by OpenCode at startup.
66
+
67
+ export const SelftunePlugin = async ({ $ }) => {
68
+ /** Resolve the selftune CLI as an argv array for Bun.spawn. */
69
+ const resolveSelftune = () => {
70
+ if (process.env.SELFTUNE_CLI_PATH) return [process.env.SELFTUNE_CLI_PATH];
71
+ try {
72
+ const result = Bun.spawnSync(["which", "selftune"]);
73
+ const path = result.stdout?.toString().trim();
74
+ if (path) return [path];
75
+ } catch {}
76
+ return ["npx", "-y", "selftune@latest"];
77
+ };
78
+
79
+ const selftuneCmd = resolveSelftune();
80
+
81
+ /** Pipe a JSON payload to \`selftune opencode hook\` via Bun.spawn. */
82
+ const runHook = async (payload) => {
83
+ try {
84
+ const proc = Bun.spawn([...selftuneCmd, "opencode", "hook"], {
85
+ stdin: "pipe",
86
+ stdout: "ignore",
87
+ stderr: "ignore",
88
+ });
89
+ proc.stdin.write(payload);
90
+ proc.stdin.end();
91
+ await proc.exited;
92
+ } catch {}
93
+ };
94
+
95
+ return {
96
+ "tool.execute.before": async (input, output) => {
97
+ await runHook(JSON.stringify({
98
+ event: "tool.execute.before",
99
+ session_id: input.metadata?.sessionId ?? "unknown",
100
+ tool: { name: input.tool, args: output.args },
101
+ cwd: input.metadata?.cwd,
102
+ }));
103
+ },
104
+
105
+ "tool.execute.after": async (input, output) => {
106
+ await runHook(JSON.stringify({
107
+ event: "tool.execute.after",
108
+ session_id: input.metadata?.sessionId ?? "unknown",
109
+ tool: { name: input.tool, args: input.args, result: output.result },
110
+ cwd: input.metadata?.cwd,
111
+ }));
112
+ },
113
+
114
+ event: async ({ event }) => {
115
+ if (event.type === "session.idle") {
116
+ await runHook(JSON.stringify({
117
+ event: "session.idle",
118
+ session_id: event.properties?.sessionId ?? "unknown",
119
+ cwd: event.properties?.cwd,
120
+ }));
121
+ }
122
+ },
123
+ };
124
+ };
125
+ `;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Config helpers
130
+ // ---------------------------------------------------------------------------
131
+
132
+ interface OpenCodeAgentConfig {
133
+ description?: string;
134
+ name?: string;
135
+ mode?: string;
136
+ model?: string;
137
+ prompt?: string;
138
+ tools?: Record<string, boolean>;
139
+ }
140
+
141
+ interface OpenCodeConfig {
142
+ agent?: Record<string, OpenCodeAgentConfig>;
143
+ [key: string]: unknown;
144
+ }
145
+
146
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
147
+ return typeof value === "object" && value !== null && !Array.isArray(value);
148
+ }
149
+
150
+ function detectConfigPath(): string {
151
+ const projectConfig = getProjectConfigPath();
152
+ if (existsSync(projectConfig)) return projectConfig;
153
+ return getUserConfigPath();
154
+ }
155
+
156
+ function readConfig(configPath: string): OpenCodeConfig {
157
+ if (!existsSync(configPath)) return {};
158
+ let parsed: unknown;
159
+ try {
160
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
161
+ } catch {
162
+ throw new Error(
163
+ `OpenCode config at ${configPath} is not valid JSON; refusing to overwrite it.`,
164
+ );
165
+ }
166
+
167
+ if (!isPlainRecord(parsed)) {
168
+ throw new Error(
169
+ `OpenCode config at ${configPath} must be a JSON object; refusing to overwrite it.`,
170
+ );
171
+ }
172
+
173
+ return parsed as OpenCodeConfig;
174
+ }
175
+
176
+ function writeConfig(configPath: string, config: OpenCodeConfig): void {
177
+ const dir = dirname(configPath);
178
+ if (!existsSync(dir)) {
179
+ mkdirSync(dir, { recursive: true });
180
+ }
181
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Install logic
186
+ // ---------------------------------------------------------------------------
187
+
188
+ interface InstallOptions {
189
+ dryRun: boolean;
190
+ uninstall: boolean;
191
+ }
192
+
193
+ const KNOWN_FLAGS = new Set(["--dry-run", "--uninstall", "--help", "-h"]);
194
+
195
+ function parseFlags(args: string[]): InstallOptions | null {
196
+ if (args.includes("--help") || args.includes("-h")) {
197
+ console.log(`Usage: selftune opencode install [--dry-run] [--uninstall]
198
+
199
+ Options:
200
+ --dry-run Preview changes without writing to disk
201
+ --uninstall Remove selftune plugin and agents from OpenCode config
202
+ --help, -h Show this help message`);
203
+ return null;
204
+ }
205
+
206
+ const unknown = args.filter((a) => a.startsWith("-") && !KNOWN_FLAGS.has(a));
207
+ if (unknown.length > 0) {
208
+ console.error(`[selftune] Unknown flag(s): ${unknown.join(", ")}`);
209
+ console.error(`Run 'selftune opencode install --help' for usage.`);
210
+ process.exit(1);
211
+ }
212
+
213
+ return {
214
+ dryRun: args.includes("--dry-run"),
215
+ uninstall: args.includes("--uninstall"),
216
+ };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Agent registration
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /** Map Claude Code tool names to OpenCode tool permissions. */
224
+ function mapToolPermissions(tools?: string[], disallowed?: string[]): Record<string, boolean> {
225
+ const defaults: Record<string, boolean> = {
226
+ write: false,
227
+ edit: false,
228
+ bash: true,
229
+ };
230
+
231
+ if (tools) {
232
+ if (tools.includes("Write")) defaults.write = true;
233
+ if (tools.includes("Edit")) defaults.edit = true;
234
+ if (!tools.includes("Bash")) defaults.bash = false;
235
+ }
236
+
237
+ if (disallowed) {
238
+ if (disallowed.includes("Write")) defaults.write = false;
239
+ if (disallowed.includes("Edit")) defaults.edit = false;
240
+ if (disallowed.includes("Bash")) defaults.bash = false;
241
+ }
242
+
243
+ return defaults;
244
+ }
245
+
246
+ /** OpenCode model format (provider/model). */
247
+ const OPENCODE_MODEL_MAP: Record<string, string> = {
248
+ haiku: "anthropic/claude-haiku-4-5-20251001",
249
+ sonnet: "anthropic/claude-sonnet-4-20250514",
250
+ opus: "anthropic/claude-opus-4-20250514",
251
+ };
252
+
253
+ interface AgentFrontmatter {
254
+ name: string;
255
+ description?: string;
256
+ tools?: string[];
257
+ disallowedTools?: string[];
258
+ model?: string;
259
+ }
260
+
261
+ /** Parse YAML-like frontmatter from agent markdown files. */
262
+ function parseFrontmatter(content: string): AgentFrontmatter | null {
263
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
264
+ if (!match) return null;
265
+
266
+ const fm: Record<string, string> = {};
267
+ for (const line of match[1].split("\n")) {
268
+ const colonIdx = line.indexOf(":");
269
+ if (colonIdx === -1) continue;
270
+ const key = line.slice(0, colonIdx).trim();
271
+ const value = line.slice(colonIdx + 1).trim();
272
+ fm[key] = value;
273
+ }
274
+
275
+ if (!fm.name) return null;
276
+
277
+ return {
278
+ name: fm.name,
279
+ description: fm.description,
280
+ tools: fm.tools ? fm.tools.split(",").map((t) => t.trim()) : undefined,
281
+ disallowedTools: fm.disallowedTools
282
+ ? fm.disallowedTools.split(",").map((t) => t.trim())
283
+ : undefined,
284
+ model: fm.model,
285
+ };
286
+ }
287
+
288
+ const BUNDLED_AGENT_DIR = resolve(
289
+ dirname(import.meta.path),
290
+ "..",
291
+ "..",
292
+ "..",
293
+ "..",
294
+ "skill",
295
+ "agents",
296
+ );
297
+
298
+ /** Check if an agent entry was created by selftune. */
299
+ function isSelftuneAgent(entry: OpenCodeAgentConfig): boolean {
300
+ return (
301
+ typeof entry.description === "string" && entry.description.startsWith(SELFTUNE_AGENT_PREFIX)
302
+ );
303
+ }
304
+
305
+ /** Discover agent definitions from skill/agents/ and build OpenCode agent config entries. */
306
+ export function buildAgentEntries(
307
+ agentsDir: string = BUNDLED_AGENT_DIR,
308
+ ): Record<string, OpenCodeAgentConfig> {
309
+ const entries: Record<string, OpenCodeAgentConfig> = {};
310
+
311
+ if (!existsSync(agentsDir)) return entries;
312
+
313
+ const files = readdirSync(agentsDir).filter((f: string) => f.endsWith(".md"));
314
+
315
+ for (const file of files) {
316
+ const filePath = join(agentsDir, file);
317
+ const content = readFileSync(filePath, "utf-8");
318
+ const fm = parseFrontmatter(content);
319
+ if (!fm) continue;
320
+
321
+ // Strip frontmatter to get the body as the prompt
322
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
323
+
324
+ entries[fm.name] = {
325
+ description: `${SELFTUNE_AGENT_PREFIX} ${fm.description ?? fm.name}`,
326
+ mode: "subagent",
327
+ model: fm.model ? (OPENCODE_MODEL_MAP[fm.model] ?? fm.model) : undefined,
328
+ prompt: body,
329
+ tools: mapToolPermissions(fm.tools, fm.disallowedTools),
330
+ };
331
+ }
332
+
333
+ return entries;
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Plugin path helpers
338
+ // ---------------------------------------------------------------------------
339
+
340
+ /**
341
+ * Determine where to write the plugin file.
342
+ * Uses the global plugins dir (~/.config/opencode/plugins/) since it
343
+ * works regardless of which project the user is in.
344
+ */
345
+ function getPluginInstallPath(): string {
346
+ return join(getGlobalPluginsDir(), PLUGIN_FILENAME);
347
+ }
348
+
349
+ /** All candidate plugin locations to check during uninstall. */
350
+ function getPluginCandidatePaths(): string[] {
351
+ return [
352
+ join(getGlobalPluginsDir(), PLUGIN_FILENAME),
353
+ join(getProjectPluginsDir(), PLUGIN_FILENAME),
354
+ // Legacy locations from previous installer versions
355
+ join(dirname(getUserConfigPath()), PLUGIN_FILENAME),
356
+ join(process.cwd(), PLUGIN_FILENAME),
357
+ ];
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // Install / Uninstall
362
+ // ---------------------------------------------------------------------------
363
+
364
+ function doInstall(options: InstallOptions): void {
365
+ const configPath = detectConfigPath();
366
+ const pluginPath = getPluginInstallPath();
367
+ const agentEntries = buildAgentEntries();
368
+
369
+ // Validate config before touching filesystem
370
+ const config = readConfig(configPath);
371
+
372
+ if (options.dryRun) {
373
+ console.log(`[selftune] dry-run: would write plugin to ${pluginPath}`);
374
+ console.log(`[selftune] dry-run: would update config at ${configPath}`);
375
+ for (const name of Object.keys(agentEntries)) {
376
+ console.log(`[selftune] dry-run: would register agent '${name}'`);
377
+ }
378
+ return;
379
+ }
380
+
381
+ // Write plugin file to plugins directory (auto-discovered by OpenCode)
382
+ const pluginDir = dirname(pluginPath);
383
+ if (!existsSync(pluginDir)) {
384
+ mkdirSync(pluginDir, { recursive: true });
385
+ }
386
+ writeFileSync(pluginPath, buildPluginContent(), { mode: 0o644 });
387
+
388
+ // Register agents in config (no plugin array entry needed — plugins dir is auto-discovered)
389
+ let configChanged = false;
390
+ if (Object.keys(agentEntries).length > 0) {
391
+ if (!config.agent) {
392
+ config.agent = {};
393
+ }
394
+ for (const [name, entry] of Object.entries(agentEntries)) {
395
+ const existing = config.agent[name];
396
+ if (existing && !isSelftuneAgent(existing)) {
397
+ console.log(`[selftune] Warning: agent '${name}' already configured by user; skipping.`);
398
+ continue;
399
+ }
400
+ config.agent[name] = entry;
401
+ configChanged = true;
402
+ }
403
+ }
404
+
405
+ // Clean up any legacy plugin array entries from previous installer versions
406
+ if (Array.isArray(config.plugin)) {
407
+ const before = config.plugin.length;
408
+ config.plugin = (config.plugin as string[]).filter((p: string) => !p.includes(PLUGIN_FILENAME));
409
+ if (config.plugin.length === 0) {
410
+ delete config.plugin;
411
+ }
412
+ if (config.plugin?.length !== before) {
413
+ configChanged = true;
414
+ }
415
+ }
416
+
417
+ if (configChanged) {
418
+ writeConfig(configPath, config);
419
+ }
420
+
421
+ console.log(`[selftune] Installed OpenCode plugin:`);
422
+ console.log(` plugin: ${pluginPath}`);
423
+ console.log(` config: ${configPath}`);
424
+ if (Object.keys(agentEntries).length > 0) {
425
+ console.log(`[selftune] Registered agents:`);
426
+ for (const name of Object.keys(agentEntries)) {
427
+ console.log(` ${name}`);
428
+ }
429
+ }
430
+ }
431
+
432
+ function doUninstall(options: InstallOptions): void {
433
+ const configPath = detectConfigPath();
434
+
435
+ if (options.dryRun) {
436
+ console.log(`[selftune] dry-run: would remove plugin from plugins directories`);
437
+ console.log(`[selftune] dry-run: would remove agent entries from ${configPath}`);
438
+ return;
439
+ }
440
+
441
+ // Update config first — remove agents and any legacy plugin array entries
442
+ if (existsSync(configPath)) {
443
+ const config = readConfig(configPath);
444
+ let changed = false;
445
+
446
+ // Remove legacy plugin array entries
447
+ if (Array.isArray(config.plugin)) {
448
+ const before = config.plugin.length;
449
+ config.plugin = (config.plugin as string[]).filter(
450
+ (p: string) => !p.includes(PLUGIN_FILENAME),
451
+ );
452
+ if (config.plugin.length === 0) {
453
+ delete config.plugin;
454
+ }
455
+ if (config.plugin?.length !== before) {
456
+ changed = true;
457
+ }
458
+ }
459
+
460
+ // Remove selftune-managed agents
461
+ if (config.agent) {
462
+ for (const [name, entry] of Object.entries(config.agent)) {
463
+ if (!isSelftuneAgent(entry)) continue;
464
+ delete config.agent[name];
465
+ changed = true;
466
+ }
467
+ if (Object.keys(config.agent).length === 0) {
468
+ delete config.agent;
469
+ }
470
+ }
471
+
472
+ if (changed) {
473
+ writeConfig(configPath, config);
474
+ console.log(`[selftune] Removed agent entries from: ${configPath}`);
475
+ }
476
+ }
477
+
478
+ // Remove plugin files from all candidate locations
479
+ for (const pluginPath of getPluginCandidatePaths()) {
480
+ if (existsSync(pluginPath)) {
481
+ unlinkSync(pluginPath);
482
+ console.log(`[selftune] Removed plugin: ${pluginPath}`);
483
+ }
484
+ }
485
+
486
+ // Clean up legacy shim if present
487
+ for (const dir of [dirname(getUserConfigPath()), process.cwd()]) {
488
+ const legacyShim = join(dir, "selftune-opencode-hook.sh");
489
+ if (existsSync(legacyShim)) {
490
+ unlinkSync(legacyShim);
491
+ console.log(`[selftune] Removed legacy shim: ${legacyShim}`);
492
+ }
493
+ }
494
+
495
+ // Clean up legacy config.json if it exists (old installer wrote to wrong filename)
496
+ const legacyConfig = join(dirname(getUserConfigPath()), "config.json");
497
+ if (existsSync(legacyConfig)) {
498
+ try {
499
+ const content = JSON.parse(readFileSync(legacyConfig, "utf-8"));
500
+ // Only remove if it looks like our leftover (tiny file with just autoupdate/schema)
501
+ const keys = Object.keys(content).filter((k) => k !== "$schema");
502
+ if (keys.length <= 1 && (keys[0] === "autoupdate" || keys.length === 0)) {
503
+ unlinkSync(legacyConfig);
504
+ console.log(`[selftune] Removed legacy config: ${legacyConfig}`);
505
+ }
506
+ } catch {
507
+ // Not valid JSON or can't read — leave it alone
508
+ }
509
+ }
510
+
511
+ console.log(`[selftune] OpenCode plugin and agents uninstalled.`);
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // CLI entry
516
+ // ---------------------------------------------------------------------------
517
+
518
+ export async function cliMain(): Promise<void> {
519
+ const args = process.argv.slice(2);
520
+ const options = parseFlags(args);
521
+ if (!options) return; // --help was shown
522
+
523
+ if (options.uninstall) {
524
+ doUninstall(options);
525
+ } else {
526
+ doInstall(options);
527
+ }
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // stdin main (only when executed directly, not when imported)
532
+ // ---------------------------------------------------------------------------
533
+
534
+ if (import.meta.main) {
535
+ try {
536
+ await cliMain();
537
+ } catch (err) {
538
+ console.error(
539
+ `[selftune] OpenCode install failed: ${err instanceof Error ? err.message : String(err)}`,
540
+ );
541
+ process.exit(1);
542
+ }
543
+ }