SLOPSHOPPER

slicer-handoff

Hands models to the right slicer: full-spectrum (Snapmaker U1) files Claude tries to open in Bambu Studio go to Snapmaker Orca instead, and previous slicer…

newguardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-07joeldg/claude-mods/slicer-handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slicer-handoff
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /slice ⎿ slicer-handoff: Usage: /slice <file> [bambu|snapmaker|orca]. Full-spectrum files always open in Snapmaker Orca; others in Bamb ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.

Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).

git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods

dev-servers

A pane of your project's running dev servers, so you don't have to ask Claude to restart them.

  • /servers opens the Servers pane:
  • running servers whose working folder is in this repo: name, port, pid and uptime, with Restart, Stop and Log
  • known start commands that aren't running, with Start: package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile
  • a count of other listeners on the Mac
  • Only button presses start or stop anything:
  • Start runs the command detached, logging to ~/.claude/dev-servers/<project>/.
  • Stop sends SIGTERM. If the server ignores it, pressing again within 10s force-stops it.
  • Restart stops the server, waits for the port to free up, then starts it.
  • Before any signal, it checks the pid still runs the same command.
  • When a command fails with "address already in use", Claude gets a note (and you a toast) naming the holder, e.g. Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).
  • Status line: servers: :4000 :5173.
  • Makes no model calls: lsof and ps every 15s.

downloads-drop

Puts files you just downloaded into your prompt with one click.

  • Watches ~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].
  • Attach puts @"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files.
  • Waits until a file has finished downloading (skips partial downloads and files still growing), and ignores hidden and zero-byte files.
  • /downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.
  • Stacks with other mods' bands (repo-brief, standing-orders, secret-guard) instead of hiding them.
  • Makes no model calls.

Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).

effort-router

Sets effort per message, so you don't have to switch it by hand.

  • Git chores ("merged", "#219 merged", "commit and push", "push it", "open a PR", "close the issue") run at low effort and come back faster.
  • Deep asks (audit, review, plan, design, investigate, root cause, "why does…", "figure out") run at max.
  • Everything else, including approvals like "yes", "go ahead" and "continue" and anything that starts new work ("merged 219, go ahead with #214"), keeps your session's own effort.
  • Prompt cache: changing effort makes the whole conversation get cached again. So it never switches mid-turn, raises effort at once, and over a large, warm cache lowers it only after 2 routine turns in a row. In small contexts, or once the cache has lapsed, it switches right away.
  • Model guard: set avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included.
  • /route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.
  • Status line while a turn is routed: effort: low (routine).
  • Makes no model calls.

Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).

job-watch

A Jobs pane for long-running work: training runs, downloads, extractions.

  • Picks up background Bash tasks and detached nohup … > log & launches by itself.
  • /watch <log> [label] adds any other log file.
  • Shows progress, ETA and the last log line, and flags a job as stalled when its log goes quiet.
  • Shows free space on / and /Volumes/* (the NAS).
  • Toasts when a job finishes or stalls. The status line shows jobs: 2 running · 1 stalled.
  • /jobs opens the pane, /unwatch <label|done|all> removes jobs.
  • Makes no model calls: it reads logs with tail, checks processes with ps, and runs df.

Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.

machine-guard

Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.

  • Status line: RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure, or
  • the Mac is reserved with /busy.

When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.

  • /busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``
  • /guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.
  • Remote runs (modal run, ssh), tests (pytest) and installs are never treated as heavy.
  • Add your own heavy commands with the "Also heavy" setting (a regex), e.g. overnight_|nightly_run\.sh.

mod-monitor

Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.

  • Failures: any mod hook that throws, times out or rejects, read from the hook chain's results (next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast.
  • What each mod did: its toasts ("#219 merged → …", "Blocked: …"), status-line changes, the mod commands you used (never their arguments), and failed subprocesses (a burst of 5 in 10 minutes raises a toast). Git checks run outside a repository are logged as expected, not as failures. It also records model calls (the only usage the mods cost: /second-opinion, /recall ask) and file writes (folders only, never contents).
  • /mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it.
  • /mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read.
  • /mods failures [7d]: failures and failed subprocesses only.
  • Logs: ~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.
  • Makes no model calls and adds no measurable latency.
  • Error lines mods log themselves ($.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged.
  • Transcript-row hooks (secret-guard masks /secrets records there) are watched for failures and slow runs, but not counted per run.

Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).

modal-meter

Keeps an eye on Modal so idle GPU containers don't burn credits.

  • Status line while containers run: Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.
  • A toast when an app has had containers up longer than alertMinutes (30), repeated at most every 30 minutes.
  • /modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.
  • Shows today's spend and alerts on a budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.
  • Finds the CLI as modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.
  • Makes no model calls: only the Modal CLI, every 60s.

pr-autopilot

Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.

  • Watches your open PRs in the session's repo: it adopts them at session start, and picks up every gh pr create Claude runs. It polls gh pr view every 60s.
  • Status line: PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.
  • When a PR merges, it cleans up with plain local git, then toasts the outcome and suggests carrying on (Tab to accept):
  • git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.
  • Never with uncommitted changes, never --force, never other branches.
  • Deletes the local branch only if it points at exactly the commit GitHub merged, so nothing local is lost. It also leaves a branch checked out in another worktree alone.
  • A merge seen mid-turn is cleaned up when the turn ends, so git never races Claude.
  • When you mention failing CI ("#258 is failing", "CI failed, fix it"), your message goes to Claude with gh pr checks and the tail of the failed log attached, so you don't paste it.
  • /prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.
  • Makes no model calls: only gh and git, at about one GitHub API call per open PR per minute.

Settings:

  • pollSeconds (60)
  • attachCiLogs (on)
  • logLines (120)
  • deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.

It never closes issues; put "Closes #N" in PR bodies for that.

recall

Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.

  • What it searches: Claude Code sessions, Codex sessions, subagent and workflow runs, Claude's memory files, standing orders, second-opinion reviews, and your /remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.
  • What it keeps: prompts, answers, compaction summaries, session titles, commands, files touched, commits, PRs, issues, URLs, tasks and decisions (what you approved or ruled out). Read-only look-ups like grep and cat are kept but ranked low.
  • History survives cleanup: extracts stay searchable after Claude Code deletes old transcripts.
  • Claude searches it itself with four read-only tools, search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled.
  • Commands:
  • /recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>.
  • /recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it.
  • /recall timeline [7d|30d|90d] [all]
  • /recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]
  • /recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model.
  • /recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help.
  • /remember <fact>, /remember list, /remember forget <ref>.
  • Bands:
  • Once per session: Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].
  • When a prompt mentions #214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.
  • Query syntax: words must all match. OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.
  • Privacy:
  • The index lives at ~/.claude/recall/index.db, readable only by you, and never goes in a repo.
  • Secrets are masked before anything is stored: known token shapes, labelled values ("password: …"), the values of secret-named exports in ~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update.
  • Cost: no model calls except /recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes.
  • Requires macOS's /usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.

Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).

repo-brief

Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".

  • Gathers in the background at session start:
  • branch, ahead/behind and uncommitted files
  • the last 8 commits
  • open PRs with CI ✓/✗/…
  • issues labelled owner, todo, P0 or blocked
  • stale branches (merged, or upstream gone)
  • A one-line band above the prompt, e.g. main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.
  • Claude gets the same summary once, in its first message, so the prompt cache stays warm. It refreshes after compaction.
  • /brief re-gathers now and prints the full summary.
  • Makes no model calls: only git and gh. The band refreshes after a turn at most every 2 minutes.

Settings: focus labels, refresh minutes, and whether to brief Claude.

routine-watch

Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.

  • Knows a session is a routine from its scheduled-task prompt, and does nothing in your other sessions.
  • When a routine stops to wait for your OK on a permission prompt or an AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.
  • When a turn ends in an error, or the routine finishes, you get a notification: Routine daily-report finished after 23m · waited on you 2 times.
  • Phone push (optional): notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.
  • allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed.
  • /routine shows the routine's name, how long it has run, its waits, and the settings.
  • Makes no model calls.

second-opinion

A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.

  • /second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters.
  • Other forms:
  • /second-opinion commits 5
  • /second-opinion diff (uncommitted changes)
  • /second-opinion file docs/ADR-007.md
  • /second-opinion <question>: adds a question for Fable to answer first.
  • The command returns at once, and the status line shows second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.
  • Send to Claude attaches the review to your next prompt (once) and drafts "What do you agree with, and what would you act on?".
  • Reviews are saved in ~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.

Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).

standing-orders

Keeps your "always / never / don't / from now on" instructions alive across compaction.

  • When you write an instruction like "never open bambu with full spectrum files", a band asks: Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.
  • Project orders live in ~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session.
  • Claude gets them at the start of every conversation and again after each compaction or /clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message.
  • /orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).
  • Makes no model calls.

secret-guard

Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.

  • Catches known token shapes: AWS, GitHub, Anthropic, OpenAI, Slack, Google, Hugging Face, GitLab, npm, Stripe, private keys and bearer tokens.
  • Also catches labelled values ("password: …", "api key = …", "the wifi password is …") and the two-line "Access Key ID / Secret Access Key" paste.
  • Leaves alone $NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.
  • On a hit, the prompt isn't sent and goes back in the box. A band shows the secret masked (…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:
  • Save as env var appends export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME.
  • Send anyway lets exactly that text through once.
  • Edit dismisses the band.
  • The value is never shown in toasts, status, state or the transcript, and /secrets test <text> output is masked too.
  • /secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.
  • Makes no model calls.

Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).

slicer-handoff

Makes Claude's open commands hand 3D files to the right slicer.

  • Full-spectrum files go to Snapmaker Orca. Bambu Studio and OrcaSlicer can't open them. A file counts as full-spectrum when:
  • its name or folder matches full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], or
  • its 3MF names a Full Spectrum filament profile.

An open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.

  • Earlier windows close first. Before opening a file, it asks the running slicer to quit (a normal quit, never forced), so windows don't pile up. If one won't close, for example because it's waiting on a save prompt, it stops trying and tells Claude to leave it alone.
  • /slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.
  • Recognizes open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.
  • Makes no model calls.

Settings:

  • closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.
  • fullSpectrumPattern (the regex above)
  • checkContents (on)

The quit request goes out when Claude issues the command, before any permission prompt for it.

Loading

  • One session from a terminal: pass --plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot
  • Every session, including the desktop app: add to ~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``

Checking

Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.

claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)
Source 2 files
hooks/register.ts 219 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4  closedToast,
5  contentPaths,
6  countPids,
7  fileName,
8  findSlicerOpens,
9  FULL_SPECTRUM_MARKER,
10  fullSpectrumPattern,
11  mentionsOpen,
12  nameSaysFullSpectrum,
13  parseSliceArgs,
14  quitScript,
15  rerouteNote,
16  rerouteToast,
17  rewriteOpens,
18  SLICERS,
19  stillOpenNote,
20} from './handoff'
21import type { OpenFile, Place, Slicer, SlicerOpen } from './handoff'
22
23type Engine = EngineInterface
24
25type Config = { closePrevious: boolean; pattern: RegExp; checkContents: boolean }
26
27let config: Config = { closePrevious: true, pattern: fullSpectrumPattern(undefined), checkContents: true }
28
29/** Quit requests sent to one slicer before giving up (Snapmaker Orca can run several instances). */
30const MAX_QUITS = 5
31/** The wait after each quit request before counting the instances again. */
32const QUIT_WAIT_MS = 1_000
33/** A quit that waits on a save dialog holds `osascript`; past this it counts as refused. */
34const QUIT_TIMEOUT_MS = 5_000
35const PROBE_TIMEOUT_MS = 5_000
36const OPEN_TIMEOUT_MS = 15_000
37
38const USAGE = 'Usage: /slice <file> [bambu|snapmaker|orca]. Full-spectrum files always open in Snapmaker Orca; others in Bambu Studio unless you name a slicer.'
39
40async function placeOf($: Engine): Promise<Place> {
41  const [cwd, home] = await Promise.all([
42    $.session.cwd().catch(() => null),
43    $.env.get('HOME').catch(() => undefined),
44  ])
45  return { cwd: cwd || null, home: home || null }
46}
47
48/** Whether a resolved 3MF's project settings name a Full Spectrum filament profile. */
49async function contentsSayFullSpectrum($: Engine, file: OpenFile): Promise<boolean> {
50  for (const path of contentPaths(file)) {
51    const out = await $.process
52      .run(['unzip', '-p', path, 'Metadata/project_settings.config'], { timeoutMs: PROBE_TIMEOUT_MS })
53      .catch(() => null)
54    if (out && out.exitCode === 0) {
55      return out.stdout.includes(FULL_SPECTRUM_MARKER)
56    }
57  }
58  return false
59}
60
61async function isFullSpectrum($: Engine, file: OpenFile): Promise<boolean> {
62  return nameSaysFullSpectrum(file, config.pattern) || (config.checkContents && (await contentsSayFullSpectrum($, file)))
63}
64
65async function countRunning($: Engine, slicer: Slicer): Promise<number> {
66  const out = await $.process.run(['pgrep', '-x', SLICERS[slicer].process], { timeoutMs: PROBE_TIMEOUT_MS }).catch(() => null)
67  return out && out.exitCode === 0 ? countPids(out.stdout) : 0
68}
69
70/** One normal quit request (never a kill); false when it was refused or is still waiting on a dialog. */
71async function askToQuit($: Engine, slicer: Slicer): Promise<boolean> {
72  const out = await $.process.run(['osascript', '-e', quitScript(slicer)], { timeoutMs: QUIT_TIMEOUT_MS }).catch(() => null)
73  return out !== null && out.exitCode === 0
74}
75
76/**
77 * Gently quits a slicer's running instances: up to MAX_QUITS normal quit requests while any
78 * remain, a second apart, stopping early at one that refuses (its save dialog was cancelled or is
79 * still up). Answers how many went away and how many are left.
80 */
81async function closeRunning($: Engine, slicer: Slicer): Promise<{ closed: number; left: number }> {
82  const before = await countRunning($, slicer)
83  let running = before
84  for (let attempt = 0; attempt < MAX_QUITS && running > 0; attempt++) {
85    const isAccepted = await askToQuit($, slicer)
86    await $.clock.sleep(QUIT_WAIT_MS)
87    running = await countRunning($, slicer)
88    if (!isAccepted && running > 0) {
89      break
90    }
91  }
92  return { closed: Math.max(0, before - running), left: running }
93}
94
95/** Closes a slicer's previous instances before a file opens in it; the note for the model when some stay open. */
96async function closeBeforeOpening($: Engine, slicer: Slicer, name: string): Promise<string | null> {
97  const { closed, left } = await closeRunning($, slicer)
98  if (closed > 0) {
99    $.ui.toast(closedToast(slicer, closed, name))
100  }
101  return left > 0 ? stillOpenNote(slicer, left) : null
102}
103
104/** The full-spectrum files among an open's, by name first and then by contents. */
105async function fullSpectrumFiles($: Engine, open: SlicerOpen): Promise<OpenFile[]> {
106  const found: OpenFile[] = []
107  for (const file of open.files) {
108    if (await isFullSpectrum($, file)) {
109      found.push(file)
110    }
111  }
112  return found
113}
114
115async function slice($: Engine, args: string): Promise<string> {
116  const { file, wanted } = parseSliceArgs(args, await placeOf($))
117  if (!file) {
118    return USAGE
119  }
120  if (!file.path) {
121    return `slicer-handoff: could not resolve ${file.written} to a file path. ${USAGE}`
122  }
123  if (!(await $.fs.exists(file.path).catch(() => false))) {
124    return `slicer-handoff: no such file: ${file.path}`
125  }
126  const name = fileName(file)
127  const isFs = await isFullSpectrum($, file)
128  const slicer: Slicer = isFs ? 'snapmaker' : (wanted ?? 'bambu')
129  const { label, bundle } = SLICERS[slicer]
130  const lines: string[] = []
131  if (isFs) {
132    lines.push(
133      wanted && wanted !== 'snapmaker'
134        ? `${name} is a full-spectrum file, which ${SLICERS[wanted].label} cannot open: using Snapmaker Orca.`
135        : `${name} is a full-spectrum file: using Snapmaker Orca.`,
136    )
137  }
138  if (config.closePrevious) {
139    const { closed, left } = await closeRunning($, slicer)
140    if (closed > 0) {
141      lines.push(`Closed ${closed} previous ${label} window(s).`)
142    }
143    if (left > 0) {
144      lines.push(`${left} ${label} instance(s) still open, probably asking to save; left as they are.`)
145    }
146  }
147  const out = await $.process.run(['open', '-b', bundle, file.path], { timeoutMs: OPEN_TIMEOUT_MS }).catch(() => null)
148  lines.push(
149    out && out.exitCode === 0
150      ? `Opened ${file.path} in ${label}.`
151      : `Could not open ${file.path} in ${label}: ${out?.stderr.trim() || 'open failed'}`,
152  )
153  return lines.join('\n')
154}
155
156export const register: Register = (on, options) => {
157  config = {
158    closePrevious: options.closePrevious !== false,
159    pattern: fullSpectrumPattern(options.fullSpectrumPattern),
160    checkContents: options.checkContents !== false,
161  }
162
163  on('session.start', async ($, e, next) => {
164    await $.command.register({
165      name: 'slice',
166      description: 'Open a model in the right slicer: Snapmaker Orca for full-spectrum files, else Bambu Studio',
167      argumentHint: '<file> [bambu|snapmaker|orca]',
168    })
169    return next(e)
170  })
171
172  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
173    if (!mentionsOpen(e.command)) {
174      return next(e)
175    }
176    const opens = findSlicerOpens(e.command, await placeOf($))
177    if (opens.length === 0) {
178      return next(e)
179    }
180    const notes: string[] = []
181    const rerouted: SlicerOpen[] = []
182    for (const open of opens) {
183      if (open.slicer === 'snapmaker') {
184        continue
185      }
186      const found = await fullSpectrumFiles($, open)
187      if (found.length > 0) {
188        const names = found.map(fileName)
189        rerouted.push(open)
190        $.ui.toast(rerouteToast(open.slicer, names))
191        notes.push(rerouteNote(open, names))
192      }
193    }
194    if (config.closePrevious) {
195      const targets = new Map<Slicer, string>()
196      for (const open of opens) {
197        const target = rerouted.includes(open) ? 'snapmaker' : open.slicer
198        if (!targets.has(target) && open.files[0]) {
199          targets.set(target, fileName(open.files[0]))
200        }
201      }
202      for (const [slicer, name] of targets) {
203        const note = await closeBeforeOpening($, slicer, name)
204        if (note) {
205          notes.push(note)
206        }
207      }
208    }
209    const command = rewriteOpens(e.command, rerouted, 'snapmaker')
210    const ran = await next(command === e.command ? e : { ...e, command })
211    if (ran.deny !== undefined || notes.length === 0) {
212      return ran
213    }
214    return { ...ran, context: [...(ran.context ?? []), ...notes] }
215  })
216
217  on('command.run', { command: 'slice' }, async ($, e) => ({ text: await slice($, e.args) }))
218}
219
hooks/handoff.ts 717 lines
1/** The slicers on this Mac that files are handed to. */
2export type Slicer = 'bambu' | 'snapmaker' | 'orca'
3
4export type SlicerInfo = {
5  /** What `open -b` and `quit app id` take. */
6  bundle: string
7  /** The executable's name, as `pgrep -x` matches it. */
8  process: string
9  /** The app's name as the person knows it. */
10  label: string
11}
12
13export const SLICERS: Readonly<Record<Slicer, SlicerInfo>> = {
14  bambu: { bundle: 'com.bambulab.bambu-studio', process: 'BambuStudio', label: 'Bambu Studio' },
15  snapmaker: { bundle: 'com.snapmaker.snapmaker-orca', process: 'Snapmaker_Orca', label: 'Snapmaker Orca' },
16  orca: { bundle: 'com.orcaslicer.OrcaSlicer', process: 'OrcaSlicer', label: 'OrcaSlicer' },
17}
18
19export const DEFAULT_FULL_SPECTRUM = 'full.?spectrum|snapmaker-only|-fs\\.3mf$|-u1[-.]'
20
21/** What a Snapmaker U1 full-spectrum project's settings name: its `Snapmaker PLA Full Spectrum @U1` profiles. */
22export const FULL_SPECTRUM_MARKER = 'Full Spectrum'
23
24/** The full-spectrum path pattern from the option, case-insensitive; the default when it is empty or not a valid regex. */
25export const fullSpectrumPattern = (source: unknown): RegExp => {
26  const text = typeof source === 'string' && source.trim() !== '' ? source : DEFAULT_FULL_SPECTRUM
27  try {
28    return new RegExp(text, 'i')
29  } catch {
30    return new RegExp(DEFAULT_FULL_SPECTRUM, 'i')
31  }
32}
33
34// ---------------------------------------------------------------------------------------------
35// Splitting a command into simple commands and words
36
37/** One shell word as written: its source text and where it sits in the command. */
38export type Word = { raw: string; start: number; end: number }
39
40function skipSingle(s: string, i: number): number {
41  const close = s.indexOf("'", i)
42  return close < 0 ? s.length : close + 1
43}
44
45function skipBacktick(s: string, i: number): number {
46  while (i < s.length) {
47    if (s[i] === '\\') {
48      i += 2
49    } else if (s[i] === '`') {
50      return i + 1
51    } else {
52      i++
53    }
54  }
55  return s.length
56}
57
58/** Index just past the `"` closing a double-quoted string whose body starts at `i`. */
59function skipDouble(s: string, i: number): number {
60  while (i < s.length) {
61    const ch = s[i]
62    if (ch === '\\') {
63      i += 2
64    } else if (ch === '"') {
65      return i + 1
66    } else if (ch === '$' && s[i + 1] === '(') {
67      i = skipParens(s, i + 2)
68    } else if (ch === '`') {
69      i = skipBacktick(s, i + 1)
70    } else {
71      i++
72    }
73  }
74  return s.length
75}
76
77/** Index just past the `)` closing a `$(` whose body starts at `i`. */
78function skipParens(s: string, i: number): number {
79  let depth = 1
80  while (i < s.length) {
81    const ch = s[i]
82    if (ch === '\\') {
83      i += 2
84    } else if (ch === "'") {
85      i = skipSingle(s, i + 1)
86    } else if (ch === '"') {
87      i = skipDouble(s, i + 1)
88    } else if (ch === '`') {
89      i = skipBacktick(s, i + 1)
90    } else if (ch === '(') {
91      depth++
92      i++
93    } else if (ch === ')') {
94      depth--
95      i++
96      if (depth === 0) {
97        return i
98      }
99    } else {
100      i++
101    }
102  }
103  return s.length
104}
105
106/** Index just past a heredoc's body, which starts at `i`, and its terminator line. */
107function skipHeredoc(s: string, i: number, doc: { tag: string; strip: boolean }): number {
108  while (i < s.length) {
109    const nl = s.indexOf('\n', i)
110    const line = s.slice(i, nl < 0 ? s.length : nl).replace(/\r$/, '')
111    i = nl < 0 ? s.length : nl + 1
112    if ((doc.strip ? line.replace(/^\t+/, '') : line) === doc.tag) {
113      break
114    }
115  }
116  return i
117}
118
119/**
120 * The simple commands of a Bash command line, each as its words: split on `&&`, `||`, `;`, `|`,
121 * `&`, newlines and parentheses outside quotes, with comments and heredoc bodies left out.
122 * Quotes, `$( )` and backticks stay inside the word they belong to; offsets are the command's.
123 */
124export const splitCommand = (command: string): Word[][] => {
125  const segments: Word[][] = []
126  let words: Word[] = []
127  let wordStart = -1
128  let pending: { tag: string; strip: boolean }[] = []
129  const s = command
130  const endWord = (at: number) => {
131    if (wordStart >= 0) {
132      words.push({ raw: s.slice(wordStart, at), start: wordStart, end: at })
133      wordStart = -1
134    }
135  }
136  const endSegment = () => {
137    if (words.length > 0) {
138      segments.push(words)
139    }
140    words = []
141  }
142  const begin = (at: number) => {
143    if (wordStart < 0) {
144      wordStart = at
145    }
146  }
147  let i = 0
148  while (i < s.length) {
149    const ch = s[i] ?? ''
150    if (ch === '\\') {
151      if (s[i + 1] === '\n' && wordStart < 0) {
152        i += 2
153        continue
154      }
155      begin(i)
156      i += 2
157    } else if (ch === "'") {
158      begin(i)
159      i = skipSingle(s, i + 1)
160    } else if (ch === '"') {
161      begin(i)
162      i = skipDouble(s, i + 1)
163    } else if (ch === '`') {
164      begin(i)
165      i = skipBacktick(s, i + 1)
166    } else if (ch === '$' && s[i + 1] === '(') {
167      begin(i)
168      i = skipParens(s, i + 2)
169    } else if (ch === '#' && wordStart < 0) {
170      const nl = s.indexOf('\n', i)
171      i = nl < 0 ? s.length : nl
172    } else if (ch === '\n') {
173      endWord(i)
174      endSegment()
175      i++
176      for (const doc of pending) {
177        i = skipHeredoc(s, i, doc)
178      }
179      pending = []
180    } else if (ch === ' ' || ch === '\t' || ch === '\r') {
181      endWord(i)
182      i++
183    } else if (ch === ';' || ch === '|' || ch === '(' || ch === ')') {
184      endWord(i)
185      endSegment()
186      i++
187    } else if (ch === '&') {
188      if (s[i - 1] === '>' || s[i - 1] === '<' || s[i + 1] === '>') {
189        begin(i)
190        i++
191      } else {
192        endWord(i)
193        endSegment()
194        i++
195      }
196    } else if (ch === '<' && s[i + 1] === '<' && s[i + 2] !== '<') {
197      endWord(i)
198      wordStart = i
199      let j = i + 2
200      const strip = s[j] === '-'
201      if (strip) {
202        j++
203      }
204      while (s[j] === ' ' || s[j] === '\t') {
205        j++
206      }
207      let tag = ''
208      while (j < s.length && !/[\s;|&<>()]/.test(s[j] ?? '')) {
209        const c = s[j] ?? ''
210        if (c === "'" || c === '"') {
211          const close = c === "'" ? skipSingle(s, j + 1) : skipDouble(s, j + 1)
212          tag += s.slice(j + 1, close - 1)
213          j = close
214        } else if (c === '\\') {
215          tag += s[j + 1] ?? ''
216          j += 2
217        } else {
218          tag += c
219          j++
220        }
221      }
222      if (tag !== '') {
223        pending.push({ tag, strip })
224      }
225      endWord(j)
226      i = j
227    } else if (ch === '<' || ch === '>') {
228      // A redirection starts its own word unless an fd number (`2>`) or `&` (`&>`) leads it.
229      if (wordStart >= 0 && !/^(?:\d+|&)$/.test(s.slice(wordStart, i))) {
230        endWord(i)
231      }
232      begin(i)
233      i++
234    } else {
235      begin(i)
236      i++
237    }
238  }
239  endWord(Math.min(i, s.length))
240  endSegment()
241  return segments
242}
243
244// ---------------------------------------------------------------------------------------------
245// Expanding words and paths
246
247/** Variables the command assigned before a point: a value, or null when it is not knowable here. */
248export type Vars = ReadonlyMap<string, string | null>
249
250export type Expanded = {
251  /** The word with quotes removed and what is known expanded; what is not stays as written. */
252  value: string
253  /** False when an unknown variable, command substitution or glob leaves the value open. */
254  isResolved: boolean
255}
256
257const NAME = /^[A-Za-z_][A-Za-z0-9_]*/
258
259/** A word's value as the shell would read it: quotes removed, `~`, `$HOME` and the command's own variables expanded. */
260export const expandWord = (raw: string, vars: Vars, home: string | null): Expanded => {
261  let value = ''
262  let isResolved = true
263  let i = 0
264  if (raw === '~' || raw.startsWith('~/')) {
265    if (home) {
266      value = home
267      i = 1
268    } else {
269      isResolved = false
270    }
271  }
272  const substitute = (name: string, written: string) => {
273    const known = !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
274      ? null
275      : vars.has(name)
276        ? (vars.get(name) ?? null)
277        : name === 'HOME'
278          ? home
279          : null
280    if (known === null) {
281      value += written
282      isResolved = false
283    } else {
284      value += known
285    }
286  }
287  /** Expands the `$` at `j`; returns the index past what it took. */
288  const dollar = (j: number): number => {
289    const next = raw[j + 1] ?? ''
290    if (next === '{') {
291      const close = raw.indexOf('}', j + 2)
292      const end = close < 0 ? raw.length : close + 1
293      substitute(raw.slice(j + 2, close < 0 ? raw.length : close), raw.slice(j, end))
294      return end
295    }
296    if (next === '(') {
297      const end = skipParens(raw, j + 2)
298      value += raw.slice(j, end)
299      isResolved = false
300      return end
301    }
302    const name = NAME.exec(raw.slice(j + 1))?.[0]
303    if (name) {
304      substitute(name, `$${name}`)
305      return j + 1 + name.length
306    }
307    if (/[0-9@*#?$!-]/.test(next)) {
308      value += raw.slice(j, j + 2)
309      isResolved = false
310      return j + 2
311    }
312    value += '$'
313    return j + 1
314  }
315  while (i < raw.length) {
316    const ch = raw[i] ?? ''
317    if (ch === "'") {
318      const close = raw.indexOf("'", i + 1)
319      value += raw.slice(i + 1, close < 0 ? raw.length : close)
320      i = close < 0 ? raw.length : close + 1
321    } else if (ch === '"') {
322      i++
323      while (i < raw.length && raw[i] !== '"') {
324        const c = raw[i] ?? ''
325        if (c === '\\' && '"\\$`\n'.includes(raw[i + 1] ?? 'x')) {
326          value += raw[i + 1] === '\n' ? '' : raw[i + 1]
327          i += 2
328        } else if (c === '$') {
329          i = dollar(i)
330        } else if (c === '`') {
331          const end = skipBacktick(raw, i + 1)
332          value += raw.slice(i, end)
333          isResolved = false
334          i = end
335        } else {
336          value += c
337          i++
338        }
339      }
340      i++
341    } else if (ch === '\\') {
342      value += raw[i + 1] === '\n' ? '' : (raw[i + 1] ?? '')
343      i += 2
344    } else if (ch === '$') {
345      i = dollar(i)
346    } else if (ch === '`') {
347      const end = skipBacktick(raw, i + 1)
348      value += raw.slice(i, end)
349      isResolved = false
350      i = end
351    } else {
352      if (ch === '*' || ch === '?' || ch === '[') {
353        isResolved = false
354      }
355      value += ch
356      i++
357    }
358  }
359  return { value, isResolved }
360}
361
362/** An absolute path with `.`, `..` and repeated slashes folded away. */
363export const normalizePath = (path: string): string => {
364  const parts: string[] = []
365  for (const part of path.split('/')) {
366    if (part === '' || part === '.') {
367      continue
368    }
369    if (part === '..') {
370      parts.pop()
371    } else {
372      parts.push(part)
373    }
374  }
375  return `/${parts.join('/')}`
376}
377
378/** The absolute path a value names from `cwd`; null when it is relative and the directory is unknown. */
379export const resolvePath = (value: string, cwd: string | null): string | null =>
380  value.startsWith('/') ? normalizePath(value) : cwd ? normalizePath(`${cwd}/${value}`) : null
381
382export const baseName = (path: string): string => path.replace(/\/+$/, '').split('/').pop() || path
383
384// ---------------------------------------------------------------------------------------------
385// Finding the slicer opens
386
387/** A file an `open` hands to the slicer. */
388export type OpenFile = {
389  /** The argument as written in the command. */
390  raw: string
391  /** Its value: quotes removed, and what could be expanded expanded. */
392  written: string
393  /** The absolute path it names, or null when a variable, glob or unknown directory leaves it open. */
394  path: string | null
395  /** What an earlier `cp` or `mv` in the same command put there, when one did. */
396  copiedFrom: { written: string; path: string | null } | null
397}
398
399/** One `open` of a slicer in a command. */
400export type SlicerOpen = {
401  slicer: Slicer
402  /** Where the words that choose the app sit (`-a BambuStudio`, `-b <id>`), and the other short flags sharing them. */
403  selector: { start: number; end: number; keep: string }
404  /** Those words as written. */
405  selectorText: string
406  files: OpenFile[]
407}
408
409/** Where a command starts: the session's directory and the home directory, when known. */
410export type Place = { cwd: string | null; home: string | null }
411
412const APPS: Readonly<Record<string, Slicer>> = {
413  bambustudio: 'bambu',
414  snapmakerorca: 'snapmaker',
415  orcaslicer: 'orca',
416}
417
418/** The slicer an `open -a` value names: a name with or without spaces, or an `.app` path. */
419export const slicerOfApp = (app: string): Slicer | null => {
420  const key = baseName(app)
421    .replace(/\.app$/i, '')
422    .toLowerCase()
423    .replace(/[\s_-]+/g, '')
424  return APPS[key] ?? null
425}
426
427/** The slicer an `open -b` bundle id names. */
428export const slicerOfBundle = (bundle: string): Slicer | null => {
429  const id = bundle.trim().toLowerCase()
430  const found = (Object.keys(SLICERS) as Slicer[]).find(slicer => SLICERS[slicer].bundle.toLowerCase() === id)
431  return found ?? null
432}
433
434const KEYWORDS = new Set(['{', '}', '!', 'then', 'do', 'else', 'elif', 'if', 'while', 'until', 'time'])
435const WRAPPERS = new Set(['command', 'exec', 'nohup', 'builtin'])
436const DECLARES = new Set(['export', 'declare', 'readonly', 'local', 'typeset'])
437const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
438/** A redirection word: `>x`, `2>&1`, `&>x`, `<x`, `<<EOF`. */
439const REDIRECT = /^(?:\d+|&)?(?:>>?|<<?<?|>&|<&|>\|)/
440/** A redirection whose target is the next word. */
441const REDIRECT_ALONE = /^(?:\d+|&)?(?:>>?|<|<<<|>\|)$/
442/** Long `open` flags that take the next word. */
443const OPEN_VALUE_FLAGS = new Set(['--env', '--stdin', '--stdout', '--stderr', '--arch'])
444
445type Scope = {
446  vars: Map<string, string | null>
447  cwd: string | null
448  home: string | null
449  copies: Map<string, { written: string; path: string | null }>
450}
451
452function assign(scope: Scope, raw: string) {
453  const eq = raw.indexOf('=')
454  const expanded = expandWord(raw.slice(eq + 1), scope.vars, scope.home)
455  scope.vars.set(raw.slice(0, eq), expanded.isResolved ? expanded.value : null)
456}
457
458const operands = (words: readonly Word[]): Word[] => {
459  const kept: Word[] = []
460  for (let i = 0; i < words.length; i++) {
461    const raw = words[i]?.raw ?? ''
462    if (REDIRECT.test(raw)) {
463      i += REDIRECT_ALONE.test(raw) ? 1 : 0
464    } else if (!raw.startsWith('-')) {
465      kept.push(words[i] as Word)
466    }
467  }
468  return kept
469}
470
471function changeDir(scope: Scope, args: readonly Word[]) {
472  const [target] = operands(args)
473  if (!target) {
474    scope.cwd = scope.home
475    scope.vars.set('PWD', scope.cwd)
476    return
477  }
478  const expanded = expandWord(target.raw, scope.vars, scope.home)
479  scope.cwd = expanded.isResolved && expanded.value !== '-' ? resolvePath(expanded.value, scope.cwd) : null
480  scope.vars.set('PWD', scope.cwd)
481}
482
483function noteCopy(scope: Scope, args: readonly Word[]) {
484  const words = operands(args)
485  const dest = words.pop()
486  if (!dest || words.length === 0) {
487    return
488  }
489  const to = expandWord(dest.raw, scope.vars, scope.home)
490  const destKey = (to.isResolved ? resolvePath(to.value, scope.cwd) : null) ?? to.value
491  for (const source of words) {
492    const from = expandWord(source.raw, scope.vars, scope.home)
493    const copied = { written: from.value, path: from.isResolved ? resolvePath(from.value, scope.cwd) : null }
494    scope.copies.set(`${destKey.replace(/\/+$/, '')}/${baseName(from.value)}`, copied)
495    if (words.length === 1) {
496      scope.copies.set(destKey, copied)
497    }
498  }
499}
500
501function parseOpen(command: string, scope: Scope, args: readonly Word[]): SlicerOpen | null {
502  let selector: { kind: 'a' | 'b'; value: string; start: number; end: number; keep: string } | null = null
503  const fileWords: Word[] = []
504  let isOnlyFiles = false
505  for (let i = 0; i < args.length; i++) {
506    const word = args[i] as Word
507    const raw = word.raw
508    if (raw === '--args') {
509      break
510    }
511    if (REDIRECT.test(raw)) {
512      i += REDIRECT_ALONE.test(raw) ? 1 : 0
513      continue
514    }
515    if (!isOnlyFiles && raw === '--') {
516      isOnlyFiles = true
517      continue
518    }
519    if (!isOnlyFiles && raw.startsWith('--')) {
520      i += OPEN_VALUE_FLAGS.has(raw) ? 1 : 0
521      continue
522    }
523    if (!isOnlyFiles && /^-[A-Za-z]/.test(raw)) {
524      const group = raw.slice(1)
525      for (let k = 0; k < group.length; k++) {
526        const flag = group[k]
527        if (flag === 'a' || flag === 'b') {
528          const attached = group.slice(k + 1)
529          const valueWord = attached ? undefined : args[i + 1]
530          if (attached || valueWord) {
531            selector = {
532              kind: flag,
533              value: expandWord(attached || (valueWord as Word).raw, scope.vars, scope.home).value,
534              start: word.start,
535              end: (valueWord ?? word).end,
536              keep: group.slice(0, k),
537            }
538            i += valueWord ? 1 : 0
539          }
540          break
541        }
542        if (flag === 's') {
543          i += group.slice(k + 1) ? 0 : 1
544          break
545        }
546      }
547      continue
548    }
549    fileWords.push(word)
550  }
551  if (!selector || fileWords.length === 0) {
552    return null
553  }
554  const slicer = selector.kind === 'a' ? slicerOfApp(selector.value) : slicerOfBundle(selector.value)
555  if (!slicer) {
556    return null
557  }
558  const files = fileWords.map((word): OpenFile => {
559    const expanded = expandWord(word.raw, scope.vars, scope.home)
560    const path = expanded.isResolved ? resolvePath(expanded.value, scope.cwd) : null
561    return { raw: word.raw, written: expanded.value, path, copiedFrom: scope.copies.get(path ?? expanded.value) ?? null }
562  })
563  return {
564    slicer,
565    selector: { start: selector.start, end: selector.end, keep: selector.keep },
566    selectorText: command.slice(selector.start, selector.end),
567    files,
568  }
569}
570
571/**
572 * Every `open` of a slicer in a Bash command (`open -a <app>` or `open -b <bundle id>` with one or
573 * more files), in order, with each file resolved as far as the command allows: `~`, `$HOME`, `$PWD`,
574 * variables assigned earlier in it, a preceding `cd`, and what an earlier `cp`/`mv` copied there.
575 */
576export const findSlicerOpens = (command: string, place: Place): SlicerOpen[] => {
577  const scope: Scope = { vars: new Map([['PWD', place.cwd]]), cwd: place.cwd, home: place.home, copies: new Map() }
578  const opens: SlicerOpen[] = []
579  for (const segment of splitCommand(command)) {
580    let words = segment
581    while (words[0] && KEYWORDS.has(words[0].raw)) {
582      words = words.slice(1)
583    }
584    let leading = 0
585    while (words[leading] && ASSIGNMENT.test(words[leading]?.raw ?? '')) {
586      leading++
587    }
588    if (leading === words.length) {
589      for (const word of words) {
590        assign(scope, word.raw)
591      }
592      continue
593    }
594    words = words.slice(leading)
595    while (words[0] && WRAPPERS.has(words[0].raw)) {
596      words = words.slice(1)
597    }
598    const [head, ...args] = words
599    if (!head) {
600      continue
601    }
602    const name = expandWord(head.raw, scope.vars, scope.home).value
603    if (DECLARES.has(name)) {
604      for (const word of args) {
605        if (ASSIGNMENT.test(word.raw)) {
606          assign(scope, word.raw)
607        }
608      }
609    } else if (name === 'cd' || name === 'pushd') {
610      changeDir(scope, args)
611    } else if (name === 'cp' || name === 'mv') {
612      noteCopy(scope, args)
613    } else if (name === 'open' || name === '/usr/bin/open') {
614      const found = parseOpen(command, scope, args)
615      if (found) {
616        opens.push(found)
617      }
618    }
619  }
620  return opens
621}
622
623/** Whether a command could hold an `open` at all: the cheap test before parsing it. */
624export const mentionsOpen = (command: string): boolean => /(?:^|[\s;&|(/])open\s/.test(command)
625
626/**
627 * The command with each of these opens pointed at another slicer: only the words choosing the
628 * app change (`-a BambuStudio` becomes `-b com.snapmaker.snapmaker-orca`); every other byte stays.
629 */
630export const rewriteOpens = (command: string, opens: readonly SlicerOpen[], to: Slicer): string =>
631  [...opens]
632    .sort((a, b) => b.selector.start - a.selector.start)
633    .reduce(
634      (text, open) =>
635        `${text.slice(0, open.selector.start)}${open.selector.keep ? `-${open.selector.keep} ` : ''}-b ${SLICERS[to].bundle}${text.slice(open.selector.end)}`,
636      command,
637    )
638
639// ---------------------------------------------------------------------------------------------
640// Full-spectrum files
641
642/** Whether a file's path (its name or a folder), or the path it was copied from, says full-spectrum. */
643export const nameSaysFullSpectrum = (file: OpenFile, pattern: RegExp): boolean =>
644  [file.written, file.path, file.copiedFrom?.written, file.copiedFrom?.path].some(
645    text => typeof text === 'string' && text !== '' && pattern.test(text),
646  )
647
648/** The resolved 3MF paths whose contents can say: the file itself, then what was copied to it. */
649export const contentPaths = (file: OpenFile): string[] =>
650  [...new Set([file.path, file.copiedFrom?.path])].filter(
651    (path): path is string => typeof path === 'string' && /\.3mf$/i.test(path),
652  )
653
654export const fileName = (file: OpenFile): string => baseName(file.path ?? file.written)
655
656// ---------------------------------------------------------------------------------------------
657// Closing previous instances
658
659/** How many processes `pgrep` listed. */
660export const countPids = (stdout: string): number => stdout.split('\n').filter(line => /^\s*\d+\s*$/.test(line)).length
661
662/** The AppleScript for a normal quit, the same as Cmd-Q: the app still asks to save unsaved work. */
663export const quitScript = (slicer: Slicer): string => `quit app id "${SLICERS[slicer].bundle}"`
664
665// ---------------------------------------------------------------------------------------------
666// /slice
667
668const SLICE_WORDS: Readonly<Record<string, Slicer>> = { bambu: 'bambu', snapmaker: 'snapmaker', snap: 'snapmaker', orca: 'orca' }
669
670/** `/slice <file> [bambu|snapmaker|orca]`: the file, resolved, and the slicer asked for, if one was. */
671export const parseSliceArgs = (args: string, place: Place): { file: OpenFile | null; wanted: Slicer | null } => {
672  const words = splitCommand(args).flat()
673  const last = words.at(-1)
674  const lastValue = last ? expandWord(last.raw, new Map(), place.home).value : ''
675  const wanted = words.length > 1 ? (SLICE_WORDS[lastValue.toLowerCase()] ?? slicerOfApp(lastValue)) : null
676  const fileWords = wanted ? words.slice(0, -1) : words
677  if (fileWords.length === 0) {
678    return { file: null, wanted }
679  }
680  const expanded = fileWords.map(word => expandWord(word.raw, new Map(), place.home))
681  const written = expanded.map(part => part.value).join(' ')
682  const isResolved = expanded.every(part => part.isResolved)
683  return {
684    file: {
685      raw: args.slice(fileWords[0]?.start ?? 0, fileWords.at(-1)?.end ?? args.length),
686      written,
687      path: isResolved ? resolvePath(written, place.cwd) : null,
688      copiedFrom: null,
689    },
690    wanted,
691  }
692}
693
694// ---------------------------------------------------------------------------------------------
695// What the person and the model are told
696
697const shortName = (slicer: Slicer): string => (slicer === 'bambu' ? 'Bambu' : SLICERS[slicer].label)
698
699const listed = (names: readonly string[]): string => names.join(', ')
700
701export const rerouteToast = (from: Slicer, names: readonly string[]): string =>
702  `${shortName(from)} can't open full-spectrum files: opened ${listed(names)} in Snapmaker Orca instead`
703
704export const rerouteNote = (open: SlicerOpen, names: readonly string[]): string =>
705  `slicer-handoff: ${listed(names)} ${names.length === 1 ? 'is a full-spectrum (Snapmaker U1) file' : 'are full-spectrum (Snapmaker U1) files'}, ` +
706  `which ${SLICERS[open.slicer].label} cannot open (it crashes), so \`${open.selectorText}\` was changed to ` +
707  `\`-b ${SLICERS.snapmaker.bundle}\` and the open went to Snapmaker Orca instead. ` +
708  `Full-spectrum files must always be opened in Snapmaker Orca (\`open -b ${SLICERS.snapmaker.bundle} <file>\`), never in Bambu Studio or OrcaSlicer.`
709
710export const closedToast = (slicer: Slicer, closed: number, name: string): string =>
711  `Closed ${closed} ${SLICERS[slicer].label} window(s) before opening ${name}`
712
713export const stillOpenNote = (slicer: Slicer, left: number): string =>
714  `slicer-handoff: ${left === 1 ? 'an instance' : `${left} instances`} of ${SLICERS[slicer].label} ${left === 1 ? 'is' : 'are'} still open after a normal quit request, ` +
715  'most likely asking whether to save unsaved changes. Do not open more windows of it and do not quit or kill it yourself; ' +
716  'tell the user it is waiting for them.'
717