From fcc8708dd50cad07b5e899e8a6be094a2df44642 Mon Sep 17 00:00:00 2001 From: jknapp Date: Sun, 17 May 2026 06:48:00 -0700 Subject: [PATCH] feat(snippets): named reusable artifacts with scope mirroring memories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snippets are exact-name-keyed templates (PR formats, checklists, style rules) — prescriptive artifacts the user wants applied consistently. Distinct from memories, which are searched descriptive prose. - Migration 0002 adds scope/project_id/deleted_at to snippets, partial unique indexes per scope, and a scope/project CHECK constraint mirroring memories_scope_project_chk. - New @shared-memory/schemas: SnippetName/Put/Get/List/Delete inputs with shared scope-project refinement. - lib/snippets.ts: get/put/list/softDelete helpers used by both the Web Server Actions and the MCP tool handlers. - Four new MCP tools (snippet.put/get/list/delete) with directive descriptions contrasting against memory.* (exact-name lookup vs search; templates vs facts). - /snippets pages: list, new, [name] with edit + delete and a scope-picker when a name lives in multiple scopes. - Nav: Snippets link between Memories and Projects. Design note: when a name exists in both user and project scope, snippet.get without an explicit scope prefers project (if project key given) then falls back to user. The detail page shows a picker when the name is ambiguous and no scope query param was supplied. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/(authed)/_nav.tsx | 1 + .../web/app/(authed)/snippets/[name]/page.tsx | 251 +++++++++++++++ apps/web/app/(authed)/snippets/new/page.tsx | 137 +++++++++ apps/web/app/(authed)/snippets/page.tsx | 157 ++++++++++ apps/web/drizzle/0002_snippets_scope.sql | 39 +++ apps/web/lib/db/schema.ts | 9 +- apps/web/lib/mcp/tools.ts | 244 +++++++++++++++ apps/web/lib/snippet-actions.ts | 174 +++++++++++ apps/web/lib/snippets.ts | 285 ++++++++++++++++++ packages/schemas/src/index.ts | 72 +++++ 10 files changed, 1368 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/(authed)/snippets/[name]/page.tsx create mode 100644 apps/web/app/(authed)/snippets/new/page.tsx create mode 100644 apps/web/app/(authed)/snippets/page.tsx create mode 100644 apps/web/drizzle/0002_snippets_scope.sql create mode 100644 apps/web/lib/snippet-actions.ts create mode 100644 apps/web/lib/snippets.ts diff --git a/apps/web/app/(authed)/_nav.tsx b/apps/web/app/(authed)/_nav.tsx index 5168782..c7a687d 100644 --- a/apps/web/app/(authed)/_nav.tsx +++ b/apps/web/app/(authed)/_nav.tsx @@ -18,6 +18,7 @@ export function Nav({ user }: { user: Session["user"] }) { diff --git a/apps/web/app/(authed)/snippets/[name]/page.tsx b/apps/web/app/(authed)/snippets/[name]/page.tsx new file mode 100644 index 0000000..71f9600 --- /dev/null +++ b/apps/web/app/(authed)/snippets/[name]/page.tsx @@ -0,0 +1,251 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { and, eq, isNull } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/lib/db/client"; +import { snippets, projects } from "@/lib/db/schema"; +import { updateSnippetAction, deleteSnippetAction } from "@/lib/snippet-actions"; +import { getSnippet, type SnippetWithProjectKey } from "@/lib/snippets"; +import { Container, PageHeader } from "@/app/_components/ui/container"; +import { Card, CardBody, CardHeader } from "@/app/_components/ui/card"; +import { Input, Textarea, Label } from "@/app/_components/ui/input"; +import { Button } from "@/app/_components/ui/button"; +import { Badge } from "@/app/_components/ui/badge"; + +export const dynamic = "force-dynamic"; + +interface SiblingHit { + scope: "project" | "user"; + projectKey: string | null; +} + +/** + * When a snippet name exists in more than one scope (e.g. a user-scope + * default plus one or more project-scope variants), we need to either + * disambiguate by query string or, if no hint is given, show a picker. + */ +async function findAllMatches(userId: string, name: string): Promise { + const rows = await db + .select({ + scope: snippets.scope, + projectKey: projects.key, + }) + .from(snippets) + .leftJoin(projects, eq(snippets.projectId, projects.id)) + .where(and(eq(snippets.userId, userId), eq(snippets.name, name), isNull(snippets.deletedAt))); + return rows as SiblingHit[]; +} + +export default async function SnippetDetailPage({ + params, + searchParams, +}: { + params: Promise<{ name: string }>; + searchParams: Promise<{ scope?: string; project?: string; edit?: string }>; +}) { + const session = await auth(); + const userId = session!.user.id; + const { name: rawName } = await params; + const name = decodeURIComponent(rawName); + const sp = await searchParams; + const scope: "project" | "user" | undefined = + sp.scope === "user" || sp.scope === "project" ? sp.scope : undefined; + const project = sp.project?.trim() || undefined; + const isEditing = sp.edit === "1"; + + const siblings = await findAllMatches(userId, name); + if (siblings.length === 0) notFound(); + + // If multiple matches and the user hasn't disambiguated, show a picker. + if (!scope && siblings.length > 1) { + return ( + + + + + } + /> + + {siblings.map((s, i) => { + const params = new URLSearchParams({ scope: s.scope }); + if (s.scope === "project" && s.projectKey) { + params.set("project", s.projectKey); + } + return ( + 0 ? "border-t border-border" : ""}`} + > +
+ {s.scope} + {s.projectKey ? ( + {s.projectKey} + ) : ( + applies everywhere + )} +
+ + ); + })} +
+
+ ); + } + + const snippet: SnippetWithProjectKey | null = await getSnippet(userId, { + name, + scope, + projectKey: project, + }); + + if (!snippet) notFound(); + + return ( + + + {snippet.scope} + {snippet.projectKey ? ` · ${snippet.projectKey}` : ""} + + } + actions={ + <> + + + + {!isEditing ? ( + + + + ) : null} + + } + /> + + + + {snippet.scope} + {snippet.projectKey ? {snippet.projectKey} : null} + · Created {new Date(snippet.createdAt).toLocaleString()} + {snippet.updatedAt.getTime() !== snippet.createdAt.getTime() ? ( + · Updated {new Date(snippet.updatedAt).toLocaleString()} + ) : null} + + + {isEditing ? ( + +
+ + + {snippet.scope === "project" && snippet.projectKey ? ( + + ) : null} + +
+ + +
+ +
+ +