From 3731d1837efe0ba9627c2685cf7b4bd25e5b3969 Mon Sep 17 00:00:00 2001 From: corentin Date: Sun, 14 Jun 2026 01:04:34 +0200 Subject: [PATCH 1/2] feat(list): interactive removal with selectable skills --- README.md | 2 + src/commands/list.ts | 172 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 25264eb..6747374 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ engram remove code-review # global: use the skill ### `list` — show installed skills +In an interactive terminal, `engram list` opens a selectable list grouped by `Global` and `Project`. Toggle skills with space, confirm with enter, then confirm the removal. In a non-TTY environment (scripts, CI), it falls back to the read-only listing. + ```sh engram list engram list --scope project diff --git a/src/commands/list.ts b/src/commands/list.ts index a048584..cf393d5 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,15 +1,63 @@ import { Console, Effect, Option } from "effect"; import { FileSystem } from "@effect/platform"; import * as path from "node:path"; -import { loadManifest } from "../manifest.js"; +import { styleText } from "node:util"; +import { confirm, groupMultiselect, isCancel, multiselect } from "@clack/prompts"; +import { loadManifest, type SkillEntry } from "../manifest.js"; import { ALL_PROVIDERS, globalSkillsDir, parseProvider, projectSkillsDir } from "../providers/index.js"; import { EngramError } from "../errors.js"; import { extractDescription } from "../skill.js"; +import * as RemoveCmd from "./remove.js"; export const run = ( scopeFilter: string | undefined, ): Effect.Effect => Effect.gen(function* () { + if (!isInteractive()) { + yield* runReadOnly(scopeFilter); + return; + } + + const fs = yield* FileSystem.FileSystem; + const globalSkills = scopeFilter === undefined || scopeFilter === "global" + ? yield* listGlobalSkills(fs) + : []; + const projectEntries = scopeFilter === undefined || scopeFilter === "project" + ? yield* listProjectSkills() + : []; + + const options = buildOptions(globalSkills, projectEntries); + if (options.length === 0) { + yield* Console.log("No skills installed. Use `engram add owner/repo` to install one."); + return; + } + + const selected = yield* selectSkills(options); + if (selected.length === 0) { + yield* Console.log("No skills selected."); + return; + } + + const shouldRemove = yield* confirmRemoval(selected.length); + if (!shouldRemove) { + yield* Console.log("Cancelled."); + return; + } + + yield* Effect.forEach(selected, (value) => removeSkill(value), { discard: true }); + yield* Console.log(`✓ Removed ${String(selected.length)} skill(s).`); + }); + +function isInteractive(): boolean { + return typeof process.stdin.isTTY === "boolean" && process.stdin.isTTY; +} + +// ── read-only listing (non-TTY fallback) ────────────────────────────────────── + +function runReadOnly( + scopeFilter: string | undefined, +): Effect.Effect { + return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const showGlobal = scopeFilter === undefined || scopeFilter === "global"; const showProject = scopeFilter === undefined || scopeFilter === "project"; @@ -29,8 +77,7 @@ export const run = ( if (showProject) { const cwd = process.cwd(); - const manifest = yield* loadManifest(cwd); - const entries = Object.entries(manifest.skills); + const entries = yield* listProjectSkills(); if (entries.length > 0) { yield* Console.log("Project skills:"); for (const [id, entry] of entries) { @@ -48,6 +95,9 @@ export const run = ( yield* Console.log("No skills installed. Use `engram add owner/repo` to install one."); } }); +} + +// ── skill discovery ─────────────────────────────────────────────────────────── interface SkillListing { name: string @@ -79,6 +129,14 @@ function listGlobalSkills(fs: FileSystem.FileSystem): Effect.Effect max ? `${text.slice(0, max - 1)}…` : text; +} + +function buildOptions( + globalSkills: SkillListing[], + projectEntries: Array<[string, SkillEntry]>, +): SelectableOption[] { + const options: SelectableOption[] = []; + + for (const skill of globalSkills) { + options.push({ + value: `global:${skill.name}`, + label: skill.name, + group: "Global", + ...(skill.description !== undefined && { hint: truncate(skill.description) }), + }); + } + + for (const [id, entry] of projectEntries) { + const providers = (entry.providers ?? []).join(", "); + const branchHint = entry.branch ? ` (${entry.branch})` : ""; + options.push({ + value: `project:${id}`, + label: `${id}${branchHint}`, + group: "Project", + ...(providers !== "" && { hint: providers }), + }); + } + + return options; +} + +function selectSkills(options: SelectableOption[]): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const hasGroups = options.some((o) => o.group !== options[0]?.group); + const hint = styleText("dim", "↑↓ navigate · space toggle · enter confirm"); + const result = hasGroups + ? await groupMultiselect({ + message: `Select skills to remove\n ${hint}`, + options: buildGroupedOptions(options), + required: false, + }) + : await multiselect({ + message: `Select skills to remove\n ${hint}`, + options: options.map((o) => ({ + value: o.value, + label: o.label, + ...(o.hint !== undefined && { hint: o.hint }), + })), + required: false, + }); + if (isCancel(result)) return []; + return result; + }, + catch: (e) => new EngramError({ message: String(e) }), + }); +} + +function buildGroupedOptions( + options: SelectableOption[], +): Record> { + const groups: Record> = {}; + for (const option of options) { + (groups[option.group] ??= []).push({ + value: option.value, + label: option.label, + ...(option.hint !== undefined && { hint: option.hint }), + }); + } + return groups; +} + +function confirmRemoval(count: number): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const result = await confirm({ + message: `Remove ${String(count)} skill${count === 1 ? "" : "s"}?`, + }); + if (isCancel(result)) return false; + return result; + }, + catch: (e) => new EngramError({ message: String(e) }), + }); +} + +function removeSkill(value: string): Effect.Effect { + const colonIndex = value.indexOf(":"); + if (colonIndex === -1) { + return Effect.fail(new EngramError({ message: `invalid skill selection: ${value}` })); + } + const scope = value.slice(0, colonIndex); + const ref = value.slice(colonIndex + 1); + if (scope !== "global" && scope !== "project") { + return Effect.fail(new EngramError({ message: `invalid skill scope: ${scope}` })); + } + return RemoveCmd.run(ref, scope, false); +} From d162f030856d653c3a201a02944c7378be243620 Mon Sep 17 00:00:00 2001 From: corentin Date: Sun, 14 Jun 2026 01:11:16 +0200 Subject: [PATCH 2/2] feat(errors): pretty validation errors without structured dump --- src/main.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main.ts b/src/main.ts index c310fb0..24e84c3 100755 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ #!/usr/bin/env tsx import { createRequire } from "node:module"; import { Args, Command, Options } from "@effect/cli"; +import * as ValidationError from "@effect/cli/ValidationError"; // Import via subpaths so we don't pull @effect/platform-node's cluster barrel // (NodeClusterHttp/Socket → @effect/cluster, @effect/rpc, @effect/sql), which we don't use. import * as NodeContext from "@effect/platform-node/NodeContext"; @@ -107,6 +108,15 @@ const { version } = createRequire(import.meta.url)("../package.json") as { versi const cli = Command.run(engramCmd, { name: "engram", version }); Effect.suspend(() => cli(process.argv)).pipe( + Effect.catchAll((error) => { + if (ValidationError.isValidationError(error)) { + // CliApp.run already rendered the validation error to stderr. + return Effect.sync(() => process.exit(1)); + } + return Console.error(`unexpected error: ${String(error)}`).pipe( + Effect.flatMap(() => Effect.sync(() => process.exit(1))), + ); + }), Effect.provide(NodeContext.layer), NodeRuntime.runMain, );