Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/desktop-update-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@selftune/desktop": patch
---

Allow macOS updater-driven window closure before the normal shutdown event so Restart Now does not hide the app and leave the update waiting.
5 changes: 5 additions & 0 deletions .changeset/local-skill-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@selftune/desktop": patch
---

Add read-only local BM25 skill search through `selftune skills search`, including inactive Library packages and collection metadata.
5 changes: 5 additions & 0 deletions .changeset/sidebar-desktop-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@selftune/desktop": patch
---

Show native update progress and a download icon that reveals Update on hover or keyboard focus in the sidebar footer.
7 changes: 7 additions & 0 deletions .changeset/temporary-skill-receipts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@selftune/desktop": patch
---

Support task-owned temporary skill selections with receipt-backed cleanup, overlapping-task guards, and a read-only cleanup preview.

Expose `skills load`, `activate`, `active`, and `deactivate` through the agent-facing CLI workflow. Activation and cleanup preview by default; approved changes require `--yes`.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ selftune is empirical. It observes real sessions, grades execution quality, dete

## Commands

Find local skills without installing them:
`selftune skills search "task or collection" --json`.
Search includes inactive Library packages and returns up to five matches by default.

For temporary project use, your agent selects results, previews `skills activate`,
loads the chosen instructions, then runs `skills deactivate` for its task. Local
Library copies and pre-existing project skills stay intact.

Your agent runs these — you just say what you want ("improve my skills", "show the dashboard").

| Group | Command | What it does |
Expand Down
125 changes: 125 additions & 0 deletions apps/cli/src/effect-cli/commands/skills-activation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Command from "effect/unstable/cli/Command";
import * as Flag from "effect/unstable/cli/Flag";
import { LibraryError } from "@selftune/library";
import { CLIError } from "@selftune/runtime/utils/cli-error";
import {
activateSkills,
activeSkills,
deactivateSkills,
loadSkill,
previewActivation,
previewDeactivation,
type SkillSelection,
} from "@selftune/runtime/skill-search/activation";

const execute = Effect.fn("selftune.skills.activation")(function* <A>(run: () => A) {
const result = yield* Effect.try({
try: run,
catch: (error) =>
error instanceof CLIError
? error
: error instanceof LibraryError
? new CLIError(error.message, error.code, error.suggestion, error.exitCode)
: new CLIError(
error instanceof Error ? error.message : String(error),
"OPERATION_FAILED",
"selftune skills active --json",
),
});
yield* Console.log(JSON.stringify(result, null, 2));
});

export function makeSkillsActivationCommands() {
const project = Flag.string("project").pipe(Flag.withDefault("."));
const json = Flag.boolean("json");
const task = Flag.string("task");
const approval = { yes: Flag.boolean("yes"), dryRun: Flag.boolean("dry-run") };
const lookup = { searchDirs: Flag.string("search-dir").pipe(Flag.atLeast(0)) };
const activate = Command.make(
"activate",
{
project,
task,
json,
...approval,
...lookup,
ids: Flag.string("id").pipe(Flag.atLeast(0)),
set: Flag.string("set").pipe(Flag.optional),
harness: Flag.choice("harness", ["codex", "claude_code", "opencode", "openclaw", "pi"]),
},
(input) =>
execute(() => {
const setId = Option.getOrUndefined(input.set);
if ((setId !== undefined) === input.ids.length > 0)
throw new CLIError(
"Choose --id (repeatable) or --set, but not both.",
"INVALID_ARGUMENT",
);
const selection: SkillSelection = setId === undefined ? { ids: input.ids } : { setId };
const options = {
project: input.project,
task: input.task,
harness: input.harness,
selection,
searchDirs: input.searchDirs.length ? input.searchDirs : undefined,
};
return !input.yes || input.dryRun
? { status: "preview", plan: previewActivation(options) }
: { status: "active", receipt: activateSkills(options) };
}),
).pipe(
Command.withDescription(
"Temporarily activate selected local skills for a task; previews unless --yes.",
),
);
const active = Command.make(
"active",
{ project, json, task: task.pipe(Flag.optional) },
(input) =>
execute(() => ({
activations: activeSkills({
project: input.project,
task: Option.getOrUndefined(input.task),
}),
})),
).pipe(Command.withDescription("List unfinished temporary skill activations in this project."));
const deactivate = Command.make(
"deactivate",
{
project,
json,
...approval,
task: task.pipe(Flag.optional),
receipt: Flag.string("receipt").pipe(Flag.optional),
},
(input) =>
execute(() => {
const taskId = Option.getOrUndefined(input.task);
const receiptId = Option.getOrUndefined(input.receipt);
if ((taskId === undefined) === (receiptId === undefined))
throw new CLIError("Choose --task or --receipt, but not both.", "INVALID_ARGUMENT");
const owner = taskId !== undefined ? { task: taskId } : { receipt: receiptId ?? "" };
const options = { project: input.project, owner };
return !input.yes || input.dryRun
? { status: "preview", plans: previewDeactivation(options) }
: { status: "cleaned", receipts: deactivateSkills(options) };
}),
).pipe(
Command.withDescription(
"Remove only this task's temporary project paths; previews unless --yes.",
),
);
const load = Command.make("load", { id: Flag.string("id"), json, ...lookup }, (input) =>
execute(() =>
loadSkill(input.id, { searchDirs: input.searchDirs.length ? input.searchDirs : undefined }),
),
).pipe(
Command.withDescription(
"Read one exact local skill revision without installing or executing it.",
),
);
return [activate, active, deactivate, load];
}
40 changes: 40 additions & 0 deletions apps/cli/src/effect-cli/commands/skills-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as Argument from "effect/unstable/cli/Argument";
import * as Command from "effect/unstable/cli/Command";
import * as Flag from "effect/unstable/cli/Flag";
import { searchLocalSkillsEffect } from "@selftune/runtime/skill-search/search";

export function makeSkillsSearchCommand() {
return Command.make(
"search",
{
query: Argument.string("query"),
limit: Flag.integer("limit").pipe(Flag.withDefault(5)),
searchDirs: Flag.string("search-dir").pipe(Flag.atLeast(0)),
json: Flag.boolean("json"),
},
Effect.fn(function* ({ query, limit, searchDirs, json }) {
const result = yield* searchLocalSkillsEffect({
query,
limit,
searchDirs: searchDirs.length ? searchDirs : undefined,
});
yield* Console.log(
json
? JSON.stringify(result, null, 2)
: [
...result.results.map(
(hit) => `${hit.name} (${hit.id})\n ${hit.description}\n ${hit.skill_path}`,
),
...(result.results.length ? [] : ["No matching local skills."]),
...result.warnings.map((warning) => `Warning: ${warning.path}: ${warning.message}`),
].join("\n"),
);
}),
).pipe(
Command.withDescription(
"Search local skills and inactive Library packages with BM25; does not install or execute.",
),
);
}
4 changes: 4 additions & 0 deletions apps/cli/src/effect-cli/commands/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Command from "effect/unstable/cli/Command";
import * as Flag from "effect/unstable/cli/Flag";
import { makeSkillsSearchCommand } from "./skills-search.js";
import { makeSkillsActivationCommands } from "./skills-activation.js";

import { SKILLS_HELP } from "@selftune/runtime/skill-portfolio/help";
import type {
Expand Down Expand Up @@ -345,6 +347,8 @@ export function makeSkillsCommand(actions: SkillsCommandActions = makeLiveSkills
({ internalHelp }) => (internalHelp ? Console.log(SKILLS_HELP) : Effect.void),
).pipe(
Command.withSubcommands([
makeSkillsSearchCommand(),
...makeSkillsActivationCommands(),
audit,
quarantined,
quarantine,
Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/effect-cli/compatibility/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ function prepareConsolidationRollback(args: ReadonlyArray<string>): ReadonlyArra

function prepareLeaf(subcommand: string, args: ReadonlyArray<string>): ReadonlyArray<string> {
switch (subcommand) {
case "search":
case "activate":
case "active":
case "deactivate":
case "load":
return [subcommand, ...args];
case "audit":
return prepareAudit(args);
case "quarantined":
Expand Down
14 changes: 11 additions & 3 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ process.on("unhandledRejection", handleCLIError);

const originalArgv = process.argv.slice(2);
const command = process.argv[2];
const isSkillSearch =
command === "skills" &&
["search", "load", "activate", "active", "deactivate"].includes(process.argv[3] ?? "");
const internalCommand =
command === INTERNAL_PACKAGE_COLLECTOR_COMMAND ||
command === INTERNAL_PACKAGE_BUNDLE_SMOKE_COMMAND;
Expand Down Expand Up @@ -90,7 +93,7 @@ Usage:

Primary Lifecycle:
status Show skill health summary
skills <sub> Audit, consolidate, archive, and restore installed skills
skills <sub> Search, audit, consolidate, archive, and restore skills
library Reconcile all installed, cached, draft, and archived skills
sets <sub> Build and materialize reusable project Skill Sets
verify Verify a draft skill package
Expand Down Expand Up @@ -154,6 +157,7 @@ const FAST_COMMANDS: ReadonlySet<string> = new Set([
// Track command usage (lazy import — skip for hooks and --help to avoid loading crypto/os)
if (
!internalCommand &&
!isSkillSearch &&
command &&
!FAST_COMMANDS.has(command) &&
command !== "--help" &&
Expand All @@ -167,6 +171,7 @@ if (
// Advisory update check (skip for hooks and platform hook commands — they must be fast — and --help)
if (
!internalCommand &&
!isSkillSearch &&
command &&
!FAST_COMMANDS.has(command) &&
command !== "--help" &&
Expand All @@ -181,8 +186,11 @@ if (!internalCommand && !command) {
const { runEffectCliMain } = await import("./effect-cli/runtime.js");
runEffectCliMain("status", []);
} else if (!internalCommand) {
const { startDashboardActionStream } = await import("@selftune/runtime/dashboard-action-stream");
startDashboardActionStream(originalArgv);
if (!isSkillSearch) {
const { startDashboardActionStream } =
await import("@selftune/runtime/dashboard-action-stream");
startDashboardActionStream(originalArgv);
}

// Route to the appropriate subcommand module.
// We use dynamic imports so only the needed module is loaded.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@selftune/desktop",
"version": "0.4.9",
"version": "0.4.10",
"private": true,
"description": "Native desktop control plane for SelfTune skill observability and improvement",
"homepage": "https://selftune.dev",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/desktop-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const IPC_CHANNELS = [
"selftune:choose-folder",
"selftune:restart-service",
"selftune:check-for-updates",
"selftune:update-status",
"selftune:background-service",
"selftune:set-background-service",
"selftune:export-diagnostics",
Expand Down Expand Up @@ -110,6 +111,11 @@ export function registerDesktopIpc(options: DesktopIpcOptions): DesktopIpcContro
options.window.assertTrustedIpc(event);
return options.shell.checkForUpdates(true);
});
ipcMain.handle("selftune:update-status", (event, ...input: unknown[]) => {
options.window.assertTrustedIpc(event);
decodeDesktopBootstrapNoInput(input);
return options.shell.getUpdateStatus();
});
ipcMain.handle("selftune:background-service", (event) => {
options.window.assertTrustedIpc(event);
return options.runRuntime(options.runtime.backgroundServiceState);
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/desktop-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type TraySettingsResponse,
} from "./tray-state";
import { createDesktopUpdater, type DesktopUpdaterController } from "./updater";
import type { DesktopUpdateStatus } from "./update-state";

const SyncResultSchema = Schema.Struct({
success: Schema.Boolean,
Expand All @@ -33,6 +34,7 @@ export interface DesktopShellOptions {
}

export interface DesktopShellController {
readonly getUpdateStatus: () => DesktopUpdateStatus;
readonly checkForUpdates: (interactive: boolean) => Promise<void>;
readonly createTray: () => Promise<void>;
readonly destroy: () => void;
Expand Down Expand Up @@ -138,6 +140,7 @@ export function createDesktopShell(options: DesktopShellOptions): DesktopShellCo
updaterController = createDesktopUpdater(
(status) => trayController?.setUpdateStatus(status),
() => options.runRuntime(options.runtime.prepareForUpdate),
options.window.beginShutdown,
);
installApplicationMenu();
}
Expand Down Expand Up @@ -191,6 +194,7 @@ export function createDesktopShell(options: DesktopShellOptions): DesktopShellCo
updaterController = null;
Menu.setApplicationMenu(null);
},
getUpdateStatus: () => updaterController?.getStatus() ?? { state: "idle" },
installUpdate: () => updaterController?.install() ?? Promise.resolve(),
refreshTray: () => trayController?.refresh() ?? Promise.resolve(),
start,
Expand Down
Loading
Loading