feat: add Homebrew Cask support and update SHA256 for macOS builds

fix: enhance slash command picker with loading state and tab filtering
fix: improve PDF preview button styling and functionality
fix: update PDF viewer layout for better responsiveness
fix: handle user cancellation in Claude event processing
chore: add cancelledByUser state to Claude chat store
docs: update README with installation instructions for macOS
This commit is contained in:
delibae 2026-03-02 14:11:28 +09:00
parent 6f82e67350
commit 480976c15a
9 changed files with 372 additions and 135 deletions

View file

@ -205,3 +205,39 @@ jobs:
gh release edit "$TAG" \
--repo "${{ github.repository }}" \
--notes-file release-body.md
- name: Update Homebrew Cask SHA256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
# Download DMG files
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern "*.dmg" \
--dir /tmp/dmg
# Calculate SHA256
SHA_ARM64=$(sha256sum /tmp/dmg/ClaudePrism_${VERSION}_aarch64.dmg | awk '{print $1}')
SHA_X64=$(sha256sum /tmp/dmg/ClaudePrism_${VERSION}_x64.dmg | awk '{print $1}')
echo "ARM64 SHA256: $SHA_ARM64"
echo "X64 SHA256: $SHA_X64"
# Update Cask file
CASK_FILE="homebrew/Casks/claude-prism.rb"
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$CASK_FILE"
# Update arm64 SHA256 (first sha256 occurrence)
sed -i "0,/sha256 \".*\"/{s/sha256 \".*\"/sha256 \"${SHA_ARM64}\"/}" "$CASK_FILE"
# Update x64 SHA256 (second sha256 occurrence)
sed -i "0,/sha256 \".*\"/! {0,/sha256 \".*\"/s/sha256 \".*\"/sha256 \"${SHA_X64}\"/}" "$CASK_FILE"
# Commit and push
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$CASK_FILE"
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "chore: update Homebrew Cask to ${TAG}"
git push origin HEAD:main

View file

@ -12,6 +12,15 @@ Open-source AI-powered LaTeX writing workspace with live preview.
- **Local Storage** - Documents saved in browser IndexedDB
- **Dark/Light Theme** - Automatic theme switching support
## Install Desktop App (macOS)
```bash
brew tap delibae/claude-prism
brew install --cask claude-prism
```
Or download `.dmg` directly from [GitHub Releases](https://github.com/delibae/claude-prism/releases).
## Quick Start
```bash

View file

@ -12,6 +12,9 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
import { cn } from "@/lib/utils";
import { SlashCommandPicker, type SlashCommand } from "./slash-command-picker";
// Re-export for other modules
export type { SlashCommand };
interface PinnedContext {
label: string; // @file:line:col-line:col
filePath: string;
@ -68,9 +71,8 @@ export const ChatComposer: FC = () => {
// / slash command state
const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const slashFilteredCountRef = useRef(0);
const composerRef = useRef<HTMLDivElement>(null);
// Watch selection changes to auto-pin context
const selectionRange = useDocumentStore((s) => s.selectionRange);
@ -152,26 +154,6 @@ export const ChatComposer: FC = () => {
.catch(() => setSlashCommands([]));
}, [slashQuery !== null, projectRoot]);
// Compute filtered slash command count for keyboard navigation bounds
useEffect(() => {
if (slashQuery === null || slashCommands.length === 0) {
slashFilteredCountRef.current = 0;
return;
}
const q = slashQuery.toLowerCase();
if (!q) {
slashFilteredCountRef.current = slashCommands.length;
} else {
slashFilteredCountRef.current = slashCommands.filter((cmd) => {
if (cmd.name.toLowerCase().includes(q)) return true;
if (cmd.full_command.toLowerCase().includes(q)) return true;
if (cmd.namespace && cmd.namespace.toLowerCase().includes(q)) return true;
if (cmd.description && cmd.description.toLowerCase().includes(q)) return true;
return false;
}).length;
}
}, [slashQuery, slashCommands]);
const selectMention = useCallback((file: ProjectFile) => {
// Replace @query with empty and pin the file as context
const textarea = textareaRef.current;
@ -203,14 +185,13 @@ export const ChatComposer: FC = () => {
}, [input]);
const selectSlashCommand = useCallback((command: SlashCommand) => {
// Insert command text into input (opcode-style: insert as template, don't send immediately)
// Insert command syntax into input (opcode-style)
const newInput = command.accepts_arguments
? `${command.full_command} `
: command.content;
: `${command.full_command} `;
setInput(newInput);
setSlashQuery(null);
setSlashIndex(0);
// Refocus and move cursor to end
setTimeout(() => {
@ -432,28 +413,11 @@ export const ChatComposer: FC = () => {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// / slash command navigation
if (slashQuery !== null && slashFilteredCountRef.current > 0) {
if (e.key === "ArrowDown") {
// Slash command picker is open — let the picker handle keyboard events
// (it uses window.addEventListener for ArrowUp/Down, Enter, Tab, Escape)
if (slashQuery !== null) {
if (e.key === "Enter" || e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab" || e.key === "Escape") {
e.preventDefault();
setSlashIndex((i) => Math.min(i + 1, slashFilteredCountRef.current - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === "Tab") {
e.preventDefault();
// Tab selects the command — trigger via a DOM query for the active item
// We'll let the picker handle it via onSelect
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashQuery(null);
setSlashIndex(0);
return;
}
}
@ -505,14 +469,12 @@ export const ChatComposer: FC = () => {
const slashMatchWithArgs = value.match(/^\/(\S+)\s/);
if (slashMatch) {
setSlashQuery(slashMatch[1]);
setSlashIndex(0);
setMentionQuery(null);
} else if (slashMatchWithArgs) {
// Keep slash picker open while typing args after command name
setSlashQuery(slashMatchWithArgs[1]);
} else if (!value.startsWith("/")) {
setSlashQuery(null);
setSlashIndex(0);
}
// Detect @ mention trigger (only when not in slash command mode)
@ -561,17 +523,16 @@ export const ChatComposer: FC = () => {
}, [modelPickerOpen]);
return (
<div className="relative shrink-0 p-3">
{/* / slash command picker */}
<div ref={composerRef} className="relative shrink-0 p-3">
{/* / slash command picker — portal to body to escape all stacking contexts */}
{slashQuery !== null && (
<SlashCommandPicker
projectPath={projectRoot}
query={slashQuery}
selectedIndex={slashIndex}
anchorRef={composerRef}
onSelect={selectSlashCommand}
onClose={() => {
setSlashQuery(null);
setSlashIndex(0);
}}
/>
)}

View file

@ -1,6 +1,7 @@
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import { type FC, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon } from "lucide-react";
import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon, XIcon, SearchIcon, UserIcon, Building2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SlashCommand {
@ -21,43 +22,74 @@ export interface SlashCommand {
interface SlashCommandPickerProps {
projectPath: string | null;
query: string;
selectedIndex: number;
anchorRef: RefObject<HTMLDivElement | null>;
onSelect: (command: SlashCommand) => void;
onClose: () => void;
}
function getCommandIcon(command: SlashCommand) {
if (command.has_bash_commands) return <TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.has_file_references) return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.accepts_arguments) return <ZapIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "project") return <FolderOpenIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "user") return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.has_bash_commands) return <TerminalIcon className="size-4 shrink-0 text-muted-foreground" />;
if (command.has_file_references) return <FileCodeIcon className="size-4 shrink-0 text-muted-foreground" />;
if (command.accepts_arguments) return <ZapIcon className="size-4 shrink-0 text-muted-foreground" />;
if (command.scope === "project") return <FolderOpenIcon className="size-4 shrink-0 text-muted-foreground" />;
if (command.scope === "user") return <GlobeIcon className="size-4 shrink-0 text-muted-foreground" />;
return <CommandIcon className="size-4 shrink-0 text-muted-foreground" />;
}
export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
projectPath,
query,
selectedIndex,
anchorRef,
onSelect,
onClose,
}) => {
const [commands, setCommands] = useState<SlashCommand[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedIndex, setSelectedIndex] = useState(0);
const [activeTab, setActiveTab] = useState<"default" | "custom">("default");
const listRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; right: number; bottom: number }>({ left: 0, right: 0, bottom: 0 });
// Compute fixed position from anchor element
useLayoutEffect(() => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPos({
left: rect.left,
right: window.innerWidth - rect.right,
bottom: window.innerHeight - rect.top + 4,
});
}, [anchorRef]);
// Load commands on mount
useEffect(() => {
setIsLoading(true);
invoke<SlashCommand[]>("slash_commands_list", {
projectPath: projectPath ?? undefined,
})
.then(setCommands)
.catch(() => setCommands([]));
.then((cmds) => {
setCommands(cmds);
setIsLoading(false);
})
.catch(() => {
setCommands([]);
setIsLoading(false);
});
}, [projectPath]);
// Filter by tab and query
const filtered = useMemo(() => {
const q = query.toLowerCase();
if (!q) return commands;
let byTab: SlashCommand[];
if (activeTab === "default") {
byTab = commands.filter((cmd) => cmd.scope === "default");
} else {
byTab = commands.filter((cmd) => cmd.scope !== "default");
}
const matched = commands.filter((cmd) => {
const q = query.toLowerCase();
if (!q) return byTab;
const matched = byTab.filter((cmd) => {
if (cmd.name.toLowerCase().includes(q)) return true;
if (cmd.full_command.toLowerCase().includes(q)) return true;
if (cmd.namespace && cmd.namespace.toLowerCase().includes(q)) return true;
@ -78,82 +110,242 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
});
return matched;
}, [query, commands]);
}, [query, commands, activeTab]);
// Scroll active item into view
// Group commands by scope/namespace for the custom tab
const groupedCommands = useMemo(() => {
return filtered.reduce((acc, cmd) => {
let key: string;
if (cmd.scope === "user") {
key = cmd.namespace ? `User Commands: ${cmd.namespace}` : "User Commands";
} else if (cmd.scope === "project") {
key = cmd.namespace ? `Project Commands: ${cmd.namespace}` : "Project Commands";
} else {
key = cmd.namespace || "Commands";
}
if (!acc[key]) acc[key] = [];
acc[key].push(cmd);
return acc;
}, {} as Record<string, SlashCommand[]>);
}, [filtered]);
// Reset selected index when filtered list changes
useEffect(() => {
setSelectedIndex(0);
}, [query, activeTab]);
// Keyboard navigation via window listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "Escape":
e.preventDefault();
onClose();
break;
case "Enter":
e.preventDefault();
if (filtered.length > 0 && selectedIndex < filtered.length) {
onSelect(filtered[selectedIndex]);
}
break;
case "Tab":
e.preventDefault();
if (filtered.length > 0 && selectedIndex < filtered.length) {
onSelect(filtered[selectedIndex]);
}
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => Math.max(0, prev - 1));
break;
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => Math.min(filtered.length - 1, prev + 1));
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [filtered, selectedIndex, onSelect, onClose]);
// Scroll selected item into view
useEffect(() => {
if (listRef.current) {
const active = listRef.current.querySelector("[data-active=true]");
active?.scrollIntoView({ block: "nearest" });
const selected = listRef.current.querySelector(`[data-index="${selectedIndex}"]`);
selected?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedIndex]);
// Expose filtered list length and items for parent keyboard handling
// Parent controls selectedIndex, so we just need to render
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
if (filtered.length === 0) return null;
return (
<div
ref={listRef}
className="absolute bottom-full left-3 right-3 mb-1 max-h-64 overflow-y-auto rounded-lg border border-border bg-background shadow-lg"
>
<div className="px-3 py-1.5 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">Slash Commands</span>
</div>
{filtered.map((cmd, i) => (
<button
key={cmd.id}
data-active={i === clampedIndex}
className={cn(
"flex w-full items-start gap-2 px-3 py-1.5 text-left transition-colors",
i === clampedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted",
const renderCommandItem = (cmd: SlashCommand, index: number) => {
const isSelected = index === selectedIndex;
return (
<button
key={cmd.id}
data-index={index}
className={cn(
"flex w-full items-start gap-3 px-3 py-2 rounded-md text-left transition-colors",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(cmd);
}}
onMouseEnter={() => setSelectedIndex(index)}
>
{getCommandIcon(cmd)}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-mono text-sm">{cmd.full_command}</span>
{cmd.accepts_arguments && (
<span className="text-xs text-muted-foreground">[args]</span>
)}
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{cmd.scope}
</span>
</div>
{cmd.description && (
<p className="truncate text-xs text-muted-foreground mt-0.5">
{cmd.description}
</p>
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(cmd);
}}
>
{getCommandIcon(cmd)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{cmd.full_command}</span>
{cmd.accepts_arguments && (
<span className="text-xs text-muted-foreground">[args]</span>
)}
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{cmd.scope}
<div className="flex items-center gap-3 mt-1">
{cmd.allowed_tools.length > 0 && (
<span className="text-xs text-muted-foreground">
{cmd.allowed_tools.length} tool{cmd.allowed_tools.length === 1 ? "" : "s"}
</span>
</div>
{cmd.description && (
<p className="truncate text-xs text-muted-foreground">{cmd.description}</p>
)}
{cmd.has_bash_commands && (
<span className="text-xs text-blue-600 dark:text-blue-400">Bash</span>
)}
{cmd.has_file_references && (
<span className="text-xs text-green-600 dark:text-green-400">Files</span>
)}
</div>
</button>
))}
<div className="border-t border-border px-3 py-1">
</div>
</button>
);
};
return createPortal(
<div
className="fixed flex flex-col overflow-hidden rounded-lg border border-border bg-background shadow-lg"
style={{ left: pos.left, right: pos.right, bottom: pos.bottom, maxHeight: "400px", zIndex: 9999 }}
>
{/* Header */}
<div className="border-b border-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CommandIcon className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">Slash Commands</span>
{query && (
<span className="text-xs text-muted-foreground">
Searching: "{query}"
</span>
)}
</div>
<button
onMouseDown={(e) => {
e.preventDefault();
onClose();
}}
className="rounded-md p-1 transition-colors hover:bg-muted"
>
<XIcon className="size-4" />
</button>
</div>
{/* Tabs */}
<div className="mt-2 flex gap-1">
<button
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-center text-xs font-medium transition-colors",
activeTab === "default"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
)}
onMouseDown={(e) => {
e.preventDefault();
setActiveTab("default");
}}
>
Default
</button>
<button
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-center text-xs font-medium transition-colors",
activeTab === "custom"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
)}
onMouseDown={(e) => {
e.preventDefault();
setActiveTab("custom");
}}
>
Custom
</button>
</div>
</div>
{/* Command List */}
<div className="flex-1 overflow-y-auto" ref={listRef}>
{isLoading && (
<div className="flex items-center justify-center py-8">
<span className="text-sm text-muted-foreground">Loading commands...</span>
</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-8">
<SearchIcon className="size-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">
{query ? "No commands found" : "No commands available"}
</span>
{!query && activeTab === "custom" && (
<p className="text-xs text-muted-foreground mt-2 text-center px-4">
Create commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code>
</p>
)}
</div>
)}
{!isLoading && filtered.length > 0 && (
<div className="p-2">
{activeTab === "default" || Object.keys(groupedCommands).length <= 1 ? (
<div className="space-y-0.5">
{filtered.map((cmd, i) => renderCommandItem(cmd, i))}
</div>
) : (
<div className="space-y-4">
{Object.entries(groupedCommands).map(([groupKey, groupCmds]) => (
<div key={groupKey}>
<h3 className="flex items-center gap-2 px-3 mb-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{groupKey.startsWith("User Commands") && <UserIcon className="size-3" />}
{groupKey.startsWith("Project Commands") && <Building2Icon className="size-3" />}
{groupKey}
</h3>
<div className="space-y-0.5">
{groupCmds.map((cmd) => {
const globalIndex = filtered.indexOf(cmd);
return renderCommandItem(cmd, globalIndex);
})}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-border px-3 py-1.5">
<span className="text-xs text-muted-foreground">
Navigate &middot; Enter Select &middot; Esc Close
</span>
</div>
</div>
</div>,
document.body,
);
};
// Helper to get filtered count for parent component
export function getFilteredSlashCommands(
commands: SlashCommand[],
query: string,
): SlashCommand[] {
const q = query.toLowerCase();
if (!q) return commands;
return commands.filter((cmd) => {
if (cmd.name.toLowerCase().includes(q)) return true;
if (cmd.full_command.toLowerCase().includes(q)) return true;
if (cmd.namespace && cmd.namespace.toLowerCase().includes(q)) return true;
if (cmd.description && cmd.description.toLowerCase().includes(q)) return true;
return false;
});
}

View file

@ -515,13 +515,20 @@ export function PdfPreview() {
<div className="mx-1 h-4 w-px bg-border" />
{/* Capture mode */}
<Button
variant={captureMode ? "default" : "ghost"}
variant={captureMode ? "default" : "secondary"}
size="sm"
className={`h-7 gap-1.5 px-2.5 text-xs ${captureMode ? "ring-2 ring-primary/30" : ""}`}
className={`h-7 gap-1.5 px-2.5 text-xs ${
captureMode
? "ring-2 ring-primary/30"
: "bg-foreground text-background hover:bg-foreground/90"
}`}
onClick={() => setCaptureMode(!captureMode)}
>
<CrosshairIcon className="size-3.5" />
Capture
Capture & Ask
<kbd className="pointer-events-none ml-0.5 rounded border border-background/30 bg-background/20 px-1.5 py-0.5 text-xs font-medium leading-none text-background">
{navigator.userAgent.includes("Mac") ? "⌘X" : "Ctrl+X"}
</kbd>
</Button>
<div className="mx-1 h-4 w-px bg-border" />
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={handleExport} title="Export PDF">

View file

@ -556,7 +556,7 @@ export function PdfViewer({
>
<div
ref={contentRef}
className="flex flex-col items-center gap-4 p-4"
className="flex min-w-fit flex-col items-center gap-4 p-4"
onClick={handleTextLayerClick}
>
{loading && numPages === 0 && (

View file

@ -222,7 +222,7 @@ export function useClaudeEvents() {
return;
}
if (!success && msgCount > 0 && !chatStore.error && !cancelledForAskRef.current) {
if (!success && msgCount > 0 && !chatStore.error && !cancelledForAskRef.current && !chatStore._cancelledByUser) {
// Process failed but we received some messages — likely rate limit or API error
chatStore._setError("Claude process exited unexpectedly. This may be due to rate limiting or an API error.");
}

View file

@ -90,6 +90,7 @@ interface ClaudeChatState {
_setSessionId: (id: string) => void;
_setStreaming: (streaming: boolean) => void;
_setError: (error: string | null) => void;
_cancelledByUser: boolean;
}
// ─── Store ───
@ -99,6 +100,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
sessionId: null,
isStreaming: false,
error: null,
_cancelledByUser: false,
totalInputTokens: 0,
totalOutputTokens: 0,
@ -177,6 +179,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
messages: [...state.messages, userMessage],
isStreaming: true,
error: null,
_cancelledByUser: false,
}));
// Flush unsaved edits to disk so Claude reads the latest content
@ -250,6 +253,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
},
cancelExecution: async () => {
set({ _cancelledByUser: true });
try {
await invoke("cancel_claude_execution");
} catch {

View file

@ -0,0 +1,28 @@
cask "claude-prism" do
version "0.0.1"
on_arm do
sha256 "PLACEHOLDER_ARM64_SHA256"
url "https://github.com/delibae/claude-prism/releases/download/v#{version}/ClaudePrism_#{version}_aarch64.dmg"
end
on_intel do
sha256 "PLACEHOLDER_X64_SHA256"
url "https://github.com/delibae/claude-prism/releases/download/v#{version}/ClaudePrism_#{version}_x64.dmg"
end
name "ClaudePrism"
desc "Desktop app for Claude-powered academic research workflows"
homepage "https://github.com/delibae/claude-prism"
depends_on macos: ">= :big_sur"
app "ClaudePrism.app"
zap trash: [
"~/Library/Application Support/com.claude-prism.desktop",
"~/Library/Caches/com.claude-prism.desktop",
"~/Library/Preferences/com.claude-prism.desktop.plist",
"~/Library/Saved Application State/com.claude-prism.desktop.savedState",
]
end