diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..f3fa385
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,91 @@
+# ssync
+
+ssync is a local workflow surface for monitoring and operating SLURM work across configured HPC clusters. The language below keeps the user-facing concepts precise across the CLI, web UI, mobile app, VS Code extension, and Raycast extension.
+
+## Language
+
+**Host**:
+A configured HPC cluster endpoint that can report and operate on SLURM jobs. Jobs are grouped by host when showing cross-cluster status.
+_Avoid_: Backend, cluster when referring to the configured endpoint
+
+**ssync API Server**:
+The local service that exposes ssync status, job detail, output, launch, watcher, and configuration endpoints to clients.
+_Avoid_: Backend
+
+**ssync API URL**:
+The URL where a client reaches the ssync API server.
+_Avoid_: Server URL
+
+**ssync API Key**:
+The credential used by a client when the ssync API server requires authentication.
+_Avoid_: Token, password
+
+**ssync Connection**:
+A client configuration consisting of an ssync API URL and an optional ssync API key that has passed a connection test.
+_Avoid_: Configuration when specifically referring to the tested client connection
+
+**Job**:
+A SLURM workload tracked by ssync on a host. A job may be active, waiting, completed, failed, cancelled, timed out, or unknown.
+_Avoid_: Task, run when referring to the scheduler object
+
+**Running Job**:
+A job that is currently executing on allocated resources.
+_Avoid_: Active job when specifically meaning running only
+
+**Pending Job**:
+A job that is waiting for scheduling or resources before execution.
+_Avoid_: Queued task
+
+**Historical Job**:
+A job that is no longer running or pending, such as completed, failed, cancelled, timed out, or unknown work.
+_Avoid_: The rest, old job
+
+**Historical Job Window**:
+The time range used when loading historical jobs for a client view.
+_Avoid_: Completed jobs window
+
+**Job Detail**:
+The focused view of one job, including its scheduler metadata, resource allocation, timing, paths, outputs, script, manifest, and related watcher information when available.
+_Avoid_: Job page when speaking across multiple clients
+
+**Job Output**:
+The stdout and stderr content associated with a job.
+_Avoid_: Logs when specifically referring to scheduler output files
+
+**Local Job Output Copy**:
+A desktop-local copy of Job Output used when a client opens the output in an external application.
+_Avoid_: Remote output file
+
+**Job Script**:
+The submitted batch script associated with a job.
+_Avoid_: Launch script
+
+**Manual Relaunch**:
+A user-initiated launch of a new job based on a previous job's script or stored launch request.
+_Avoid_: Resubmit when the user is manually starting a new job
+
+**Watcher**:
+A persistent rule attached to a job or workflow that observes job output or state and can perform follow-up behavior.
+_Avoid_: Monitor, daemon when referring to the rule itself
+
+**Watcher Event**:
+A recorded occurrence from watcher evaluation or action execution.
+_Avoid_: Notification event, log line
+
+**Watcher Resubmission**:
+A watcher-initiated launch of a new job from cached job script or manifest context.
+_Avoid_: Manual relaunch
+
+## Example Dialogue
+
+Dev: Should the Raycast extension show every job in one flat list?
+
+Domain expert: No. Group jobs by host, show running jobs first, then pending jobs, then historical jobs.
+
+Dev: When a user opens a job, should watcher information live somewhere else?
+
+Domain expert: No. Job detail should include related watchers and watcher events so the user can understand what follow-up behavior is attached to that job.
+
+Dev: Should a user action and a watcher action both be called resubmit?
+
+Domain expert: No. A user starts a manual relaunch; a watcher performs watcher resubmission.
diff --git a/docs/raycast-extension-plan.md b/docs/raycast-extension-plan.md
new file mode 100644
index 0000000..79fc003
--- /dev/null
+++ b/docs/raycast-extension-plan.md
@@ -0,0 +1,60 @@
+# Raycast Extension Plan
+
+## Scope
+
+The first Raycast extension release is a monitoring surface for ssync jobs. It should make running and pending work easy to inspect without adding background load to the ssync API server.
+
+## V1 Features
+
+- Configure and test an ssync connection before first use.
+- Load jobs cache-first, then revalidate when the cached snapshot is older than 60 seconds.
+- Show the main Jobs command as lifecycle-first sections:
+ - Running Jobs
+ - Pending Jobs
+ - Historical Jobs
+- Default historical job window: `3d`.
+- Default job limit: `50`.
+- Search locally across the loaded job snapshot.
+- Show Job Detail as a sectioned read-only inspector list with lazy secondary actions.
+- Fetch Job Output only when requested.
+- Default Job Output to stdout, tail-limited.
+- Allow switching from stdout to stderr when needed.
+- Allow opening a refreshed Local Job Output Copy in a configured external editor.
+- Fetch Job Script only when requested.
+- Fetch Watchers and Watcher Events only when requested.
+- Keep watchers read-only in v1.
+- Include a menu-bar command with running jobs first and pending jobs second.
+- Keep menu-bar background refresh conservative, defaulting to 5 minutes.
+- Allow blank API keys when the ssync API server accepts the connection.
+- Guard cancellation behind confirmation.
+
+## Deferred
+
+- Manual relaunch.
+- Watcher edit, delete, attach, pause, resume, or trigger actions.
+- WebSocket support.
+- Launch recipe browsing or submission.
+- Historical jobs in the menu-bar dropdown.
+
+## Backend Load Rules
+
+- Do not poll on every search keystroke.
+- Do not fetch output, script, manifest, watchers, or watcher events while rendering the main Jobs list.
+- Do not fetch both stdout and stderr by default.
+- Do not force-refresh main job status by default.
+- Manual refresh may ask the ssync API server for fresh job status.
+- Background menu-bar refresh must only fetch job status.
+- Opening Job Output should force-refresh the selected stream once, then perform at most one delayed follow-up read if the ssync API server queued a background output refresh.
+
+## Output And Script Views
+
+- Job Detail uses a Raycast `List` inspector so status, placement, timing, paths, and related views are selectable with native keyboard navigation.
+- Job Output defaults to `output_type=stdout&lines=300`.
+- Stderr is a secondary action, not loaded by default.
+- Full output is a deliberate secondary action, not the default.
+- Opening a Local Job Output Copy downloads the full selected stream and stores it under the system temporary directory before opening it externally.
+- The external output editor is user-configurable: system default, Visual Studio Code, Cursor, Neovim in Ghostty, or a custom Raycast app picker value.
+- Job Script opens as a separate formatted detail view.
+- Output and script text should be displayed in a monospace code block with job metadata in Raycast metadata sidebars.
+- Avoid markdown tables in Raycast views; use `Detail.Metadata` or `List.Item.Detail.Metadata` for structured facts.
+- Raycast action shortcuts may support view-level commands such as refresh, switch to stderr, copy, and load full output. Custom vim-style single-key scrolling is not assumed to be available inside Raycast extension views.
diff --git a/raycast-extension/.prettierrc b/raycast-extension/.prettierrc
new file mode 100644
index 0000000..f3bcd4c
--- /dev/null
+++ b/raycast-extension/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": false,
+ "trailingComma": "all"
+}
diff --git a/raycast-extension/assets/icon.png b/raycast-extension/assets/icon.png
new file mode 100644
index 0000000..c1c19cd
Binary files /dev/null and b/raycast-extension/assets/icon.png differ
diff --git a/raycast-extension/assets/icon.svg b/raycast-extension/assets/icon.svg
new file mode 100644
index 0000000..0dd0e09
--- /dev/null
+++ b/raycast-extension/assets/icon.svg
@@ -0,0 +1,28 @@
+
diff --git a/raycast-extension/package-lock.json b/raycast-extension/package-lock.json
new file mode 100644
index 0000000..38de2a5
--- /dev/null
+++ b/raycast-extension/package-lock.json
@@ -0,0 +1,1545 @@
+{
+ "name": "ssync",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ssync",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@raycast/api": "^1.104.19",
+ "react": "19.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "22.19.17",
+ "@types/react": "19.0.10",
+ "typescript": "^5.9.3"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/ansi": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
+ "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/checkbox": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz",
+ "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "5.1.21",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz",
+ "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "10.3.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz",
+ "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "cli-width": "^4.1.0",
+ "mute-stream": "^2.0.0",
+ "signal-exit": "^4.1.0",
+ "wrap-ansi": "^6.2.0",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@inquirer/editor": {
+ "version": "4.2.23",
+ "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz",
+ "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/external-editor": "^1.0.3",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/expand": {
+ "version": "4.0.23",
+ "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz",
+ "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/external-editor": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz",
+ "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==",
+ "license": "MIT",
+ "dependencies": {
+ "chardet": "^2.1.1",
+ "iconv-lite": "^0.7.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "1.0.15",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz",
+ "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/input": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz",
+ "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/number": {
+ "version": "3.0.23",
+ "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz",
+ "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/password": {
+ "version": "4.0.23",
+ "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz",
+ "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/prompts": {
+ "version": "7.10.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz",
+ "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/checkbox": "^4.3.2",
+ "@inquirer/confirm": "^5.1.21",
+ "@inquirer/editor": "^4.2.23",
+ "@inquirer/expand": "^4.0.23",
+ "@inquirer/input": "^4.3.1",
+ "@inquirer/number": "^3.0.23",
+ "@inquirer/password": "^4.0.23",
+ "@inquirer/rawlist": "^4.1.11",
+ "@inquirer/search": "^3.2.2",
+ "@inquirer/select": "^4.4.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/rawlist": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz",
+ "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/search": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz",
+ "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/select": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz",
+ "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz",
+ "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@oclif/core": {
+ "version": "4.11.4",
+ "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.11.4.tgz",
+ "integrity": "sha512-URwiQ5ALx/sJ2iH4vzXEd+H4K6NAI7LRs6Jag3hrgKEpGmaE6alfRC8qjO4GIgb6A3ACaJumqP9twi/M9ywdHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-escapes": "^4.3.2",
+ "ansis": "^3.17.0",
+ "clean-stack": "^3.0.1",
+ "cli-spinners": "^2.9.2",
+ "debug": "^4.4.3",
+ "ejs": "^3.1.10",
+ "get-package-type": "^0.1.0",
+ "indent-string": "^4.0.0",
+ "is-wsl": "^2.2.0",
+ "lilconfig": "^3.1.3",
+ "minimatch": "^10.2.5",
+ "semver": "^7.8.1",
+ "string-width": "^4.2.3",
+ "supports-color": "^8",
+ "tinyglobby": "^0.2.16",
+ "widest-line": "^3.1.0",
+ "wordwrap": "^1.0.0",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@oclif/plugin-autocomplete": {
+ "version": "3.2.50",
+ "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-3.2.50.tgz",
+ "integrity": "sha512-SQRIJSYue/1tIn7X55W/97gTb8UkSoHeFAcBng2r2YMJyWj8uB1DtFl28D8BDXPQXPTiPK89hQGejoT7RdkR2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@oclif/core": "^4",
+ "ansis": "^3.16.0",
+ "debug": "^4.4.1",
+ "ejs": "^3.1.10"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@oclif/plugin-help": {
+ "version": "6.2.50",
+ "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.2.50.tgz",
+ "integrity": "sha512-rNCG4hUm+kPXFbhJfAVk/fZ3OdWJYwBDASlyX8CqOLP0MssjIGl7iEgfZz7TMuZFa+KucupKU5NRSc0KWfPTQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@oclif/core": "^4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@oclif/plugin-not-found": {
+ "version": "3.2.87",
+ "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-3.2.87.tgz",
+ "integrity": "sha512-lKyZ4INrx5vB14HNWIkM6Vla/4rWVhOA2U7uCAj6gEBg36/KVmwYXxpZ9ckzZS0+jtLE84TVqS8NCYEhQiQojw==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/prompts": "^7.10.1",
+ "@oclif/core": "^4.11.4",
+ "ansis": "^3.17.0",
+ "fast-levenshtein": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@raycast/api": {
+ "version": "1.104.19",
+ "resolved": "https://registry.npmjs.org/@raycast/api/-/api-1.104.19.tgz",
+ "integrity": "sha512-SAVg56BAzxZGy/OPQ0jekUG3pJaoX5pCqleALvFo9JRE7P2tvKoglWnYRcIJArRhLlvV8FtFNMDafd+NNwXXCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@oclif/core": "^4.8.4",
+ "@oclif/plugin-autocomplete": "^3.2.40",
+ "@oclif/plugin-help": "^6.2.37",
+ "@oclif/plugin-not-found": "^3.2.74",
+ "@types/node": "22.19.17",
+ "@types/react": "19.0.10",
+ "esbuild": "^0.27.3",
+ "react": "19.0.0"
+ },
+ "bin": {
+ "ray": "bin/run.js"
+ },
+ "engines": {
+ "node": ">=22.22.2"
+ },
+ "peerDependencies": {
+ "@types/node": "22.19.17",
+ "@types/react": "19.0.10",
+ "react-devtools": "6.1.1"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ },
+ "react-devtools": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.17",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
+ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.0.10",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
+ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+ "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.21.3"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/ansis": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz",
+ "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/chardet": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz",
+ "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==",
+ "license": "MIT"
+ },
+ "node_modules/clean-stack": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz",
+ "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-spinners": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
+ "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz",
+ "integrity": "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fastest-levenshtein": "^1.0.7"
+ }
+ },
+ "node_modules/fastest-levenshtein": {
+ "version": "1.0.16",
+ "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
+ "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.9.1"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/filelist": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/filelist/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/filelist/node_modules/brace-expansion": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mute-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
+ "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
+ "license": "ISC",
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.0.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
+ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/widest-line": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
+ "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
+ "license": "MIT",
+ "dependencies": {
+ "string-width": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/yoctocolors-cjs": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz",
+ "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/raycast-extension/package.json b/raycast-extension/package.json
new file mode 100644
index 0000000..4f99297
--- /dev/null
+++ b/raycast-extension/package.json
@@ -0,0 +1,95 @@
+{
+ "name": "ssync",
+ "title": "Ssync",
+ "description": "Monitor ssync SLURM jobs from Raycast",
+ "icon": "icon.png",
+ "author": "ramlaoui",
+ "license": "MIT",
+ "categories": [
+ "Developer Tools"
+ ],
+ "platforms": [
+ "macOS"
+ ],
+ "preferences": [
+ {
+ "name": "outputEditor",
+ "type": "dropdown",
+ "required": false,
+ "title": "Output Editor",
+ "description": "Application used when opening downloaded ssync job output files.",
+ "default": "default",
+ "data": [
+ {
+ "title": "System Default",
+ "value": "default"
+ },
+ {
+ "title": "Visual Studio Code",
+ "value": "vscode"
+ },
+ {
+ "title": "Cursor",
+ "value": "cursor"
+ },
+ {
+ "title": "Neovim in Ghostty",
+ "value": "ghostty-nvim"
+ },
+ {
+ "title": "Custom Application",
+ "value": "custom"
+ }
+ ]
+ },
+ {
+ "name": "outputEditorApplication",
+ "type": "appPicker",
+ "required": false,
+ "title": "Custom Output Editor",
+ "description": "Application used when Output Editor is set to Custom Application."
+ }
+ ],
+ "commands": [
+ {
+ "name": "jobs",
+ "title": "Jobs",
+ "subtitle": "ssync",
+ "description": "Monitor running, pending, and historical ssync jobs",
+ "mode": "view",
+ "keywords": [
+ "slurm",
+ "hpc",
+ "cluster"
+ ]
+ },
+ {
+ "name": "menu-bar",
+ "title": "Job Summary",
+ "subtitle": "ssync",
+ "description": "Show running and pending ssync jobs in the menu bar",
+ "mode": "menu-bar",
+ "interval": "5m",
+ "keywords": [
+ "slurm",
+ "hpc",
+ "cluster"
+ ]
+ }
+ ],
+ "dependencies": {
+ "@raycast/api": "^1.104.19",
+ "react": "19.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "22.19.17",
+ "@types/react": "19.0.10",
+ "typescript": "^5.9.3"
+ },
+ "scripts": {
+ "dev": "ray develop",
+ "build": "ray build",
+ "lint": "ray lint",
+ "fix-lint": "ray lint --fix"
+ }
+}
diff --git a/raycast-extension/raycast-env.d.ts b/raycast-extension/raycast-env.d.ts
new file mode 100644
index 0000000..22a0148
--- /dev/null
+++ b/raycast-extension/raycast-env.d.ts
@@ -0,0 +1,32 @@
+///
+
+/* 馃毀 馃毀 馃毀
+ * This file is auto-generated from the extension's manifest.
+ * Do not modify manually. Instead, update the `package.json` file.
+ * 馃毀 馃毀 馃毀 */
+
+/* eslint-disable @typescript-eslint/ban-types */
+
+type ExtensionPreferences = {
+ /** Output Editor - Application used when opening downloaded ssync job output files. */
+ "outputEditor": "default" | "vscode" | "cursor" | "ghostty-nvim" | "custom",
+ /** Custom Output Editor - Application used when Output Editor is set to Custom Application. */
+ "outputEditorApplication"?: import("@raycast/api").Application
+}
+
+/** Preferences accessible in all the extension's commands */
+declare type Preferences = ExtensionPreferences
+
+declare namespace Preferences {
+ /** Preferences accessible in the `jobs` command */
+ export type Jobs = ExtensionPreferences & {}
+ /** Preferences accessible in the `menu-bar` command */
+ export type MenuBar = ExtensionPreferences & {}
+}
+
+declare namespace Arguments {
+ /** Arguments passed to the `jobs` command */
+ export type Jobs = {}
+ /** Arguments passed to the `menu-bar` command */
+ export type MenuBar = {}
+}
diff --git a/raycast-extension/src/api/client.ts b/raycast-extension/src/api/client.ts
new file mode 100644
index 0000000..02317bc
--- /dev/null
+++ b/raycast-extension/src/api/client.ts
@@ -0,0 +1,276 @@
+import http from "node:http";
+import https from "node:https";
+import type {
+ ConnectionSettings,
+ JobInfo,
+ JobOutputResponse,
+ JobScriptResponse,
+ JobStatusResponse,
+ WatcherEventsResponse,
+ WatchersResponse,
+} from "../types/ssync";
+
+type RequestOptions = {
+ method?: "GET" | "POST";
+ params?: Record;
+ body?: unknown;
+ timeoutMs?: number;
+};
+
+export type DownloadedOutput = {
+ filename: string;
+ content: Buffer;
+};
+
+export class SsyncApiError extends Error {
+ constructor(
+ message: string,
+ public readonly statusCode?: number,
+ ) {
+ super(message);
+ this.name = "SsyncApiError";
+ }
+}
+
+export class SsyncClient {
+ constructor(private readonly connection: Pick) {}
+
+ async testConnection(): Promise {
+ await this.request>("/api/info", { timeoutMs: 10_000 });
+ }
+
+ async getStatus(options: {
+ since?: string;
+ limit?: number;
+ forceRefresh?: boolean;
+ }): Promise {
+ return this.request("/api/status", {
+ params: {
+ since: options.since,
+ limit: options.limit,
+ group_array_jobs: false,
+ force_refresh: options.forceRefresh || undefined,
+ },
+ timeoutMs: 45_000,
+ });
+ }
+
+ async getJob(job: JobInfo, forceRefresh = false): Promise {
+ return this.request(`/api/jobs/${encodeURIComponent(job.job_id)}`, {
+ params: {
+ host: job.hostname,
+ cache_first: true,
+ force_refresh: forceRefresh || undefined,
+ },
+ timeoutMs: 30_000,
+ });
+ }
+
+ async getOutput(options: {
+ job: JobInfo;
+ outputType: "stdout" | "stderr";
+ lines?: number;
+ fullOutput?: boolean;
+ forceRefresh?: boolean;
+ }): Promise {
+ return this.request(`/api/jobs/${encodeURIComponent(options.job.job_id)}/output`, {
+ params: {
+ host: options.job.hostname,
+ output_type: options.outputType,
+ lines: options.fullOutput ? undefined : options.lines,
+ all: options.fullOutput || undefined,
+ force_refresh: options.forceRefresh || undefined,
+ },
+ timeoutMs: options.fullOutput ? 90_000 : 45_000,
+ });
+ }
+
+ async downloadOutput(options: {
+ job: JobInfo;
+ outputType: "stdout" | "stderr";
+ forceRefresh?: boolean;
+ }): Promise {
+ return this.requestBuffer(`/api/jobs/${encodeURIComponent(options.job.job_id)}/output/download`, {
+ params: {
+ host: options.job.hostname,
+ output_type: options.outputType,
+ compressed: false,
+ force_refresh: options.forceRefresh || undefined,
+ },
+ timeoutMs: 120_000,
+ fallbackFilename: `job_${options.job.job_id}_${options.outputType}.log`,
+ });
+ }
+
+ async getScript(job: JobInfo): Promise {
+ return this.request(`/api/jobs/${encodeURIComponent(job.job_id)}/script`, {
+ params: { host: job.hostname },
+ timeoutMs: 45_000,
+ });
+ }
+
+ async getWatchers(job: JobInfo): Promise {
+ return this.request(`/api/jobs/${encodeURIComponent(job.job_id)}/watchers`, {
+ params: { host: job.hostname },
+ timeoutMs: 30_000,
+ });
+ }
+
+ async getWatcherEvents(job: JobInfo, limit = 100): Promise {
+ return this.request("/api/watchers/events", {
+ params: {
+ job_id: job.job_id,
+ limit,
+ },
+ timeoutMs: 30_000,
+ });
+ }
+
+ async cancelJob(job: JobInfo): Promise {
+ await this.request>(`/api/jobs/${encodeURIComponent(job.job_id)}/cancel`, {
+ method: "POST",
+ params: { host: job.hostname },
+ timeoutMs: 30_000,
+ });
+ }
+
+ private request(path: string, options: RequestOptions = {}): Promise {
+ const url = this.buildUrl(path, options.params);
+ const isHttps = url.protocol === "https:";
+ const transport = isHttps ? https : http;
+
+ return new Promise((resolve, reject) => {
+ const request = transport.request(
+ url,
+ {
+ method: options.method || "GET",
+ headers: this.requestHeaders("application/json"),
+ timeout: options.timeoutMs || 30_000,
+ ...(isHttps ? { rejectUnauthorized: false } : {}),
+ },
+ (response) => {
+ const chunks: Buffer[] = [];
+ response.on("data", (chunk: Buffer) => chunks.push(chunk));
+ response.on("end", () => {
+ const text = Buffer.concat(chunks).toString("utf8");
+ const statusCode = response.statusCode || 0;
+
+ if (statusCode >= 400) {
+ reject(new SsyncApiError(errorMessage(statusCode, text), statusCode));
+ return;
+ }
+
+ if (!text) {
+ resolve(undefined as T);
+ return;
+ }
+
+ try {
+ resolve(JSON.parse(text) as T);
+ } catch {
+ reject(new SsyncApiError(`Invalid JSON response from ssync API: ${text.slice(0, 120)}`, statusCode));
+ }
+ });
+ },
+ );
+
+ request.on("timeout", () => {
+ request.destroy(new SsyncApiError("Request timed out"));
+ });
+ request.on("error", (error) => {
+ reject(error instanceof SsyncApiError ? error : new SsyncApiError(error.message));
+ });
+
+ if (options.body !== undefined) request.write(JSON.stringify(options.body));
+ request.end();
+ });
+ }
+
+ private requestBuffer(
+ path: string,
+ options: RequestOptions & { fallbackFilename: string },
+ ): Promise {
+ const url = this.buildUrl(path, options.params);
+ const isHttps = url.protocol === "https:";
+ const transport = isHttps ? https : http;
+
+ return new Promise((resolve, reject) => {
+ const request = transport.request(
+ url,
+ {
+ method: options.method || "GET",
+ headers: this.requestHeaders("text/plain"),
+ timeout: options.timeoutMs || 60_000,
+ ...(isHttps ? { rejectUnauthorized: false } : {}),
+ },
+ (response) => {
+ const chunks: Buffer[] = [];
+ response.on("data", (chunk: Buffer) => chunks.push(chunk));
+ response.on("end", () => {
+ const content = Buffer.concat(chunks);
+ const statusCode = response.statusCode || 0;
+
+ if (statusCode >= 400) {
+ reject(new SsyncApiError(errorMessage(statusCode, content.toString("utf8")), statusCode));
+ return;
+ }
+
+ resolve({
+ filename: filenameFromContentDisposition(response.headers["content-disposition"], options.fallbackFilename),
+ content,
+ });
+ });
+ },
+ );
+
+ request.on("timeout", () => {
+ request.destroy(new SsyncApiError("Request timed out"));
+ });
+ request.on("error", (error) => {
+ reject(error instanceof SsyncApiError ? error : new SsyncApiError(error.message));
+ });
+ request.end();
+ });
+ }
+
+ private buildUrl(path: string, params?: RequestOptions["params"]): URL {
+ const base = this.connection.apiUrl.replace(/\/+$/, "");
+ const url = new URL(path, base);
+ for (const [key, value] of Object.entries(params || {})) {
+ if (value === undefined || value === null || value === "") continue;
+ url.searchParams.set(key, String(value));
+ }
+ return url;
+ }
+
+ private requestHeaders(accept: string): Record {
+ const headers: Record = {
+ Accept: accept,
+ "Content-Type": "application/json",
+ };
+ if (this.connection.apiKey) headers["X-API-Key"] = this.connection.apiKey;
+ return headers;
+ }
+}
+
+function errorMessage(statusCode: number, text: string): string {
+ if (!text) return `ssync API returned HTTP ${statusCode}`;
+ try {
+ const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
+ const detail = parsed.detail || parsed.message;
+ if (typeof detail === "string") return detail;
+ } catch {
+ // Fall through to raw body preview.
+ }
+ return `ssync API returned HTTP ${statusCode}: ${text.slice(0, 200)}`;
+}
+
+function filenameFromContentDisposition(header: string | string[] | undefined, fallback: string): string {
+ const raw = Array.isArray(header) ? header[0] : header;
+ const match = raw?.match(/filename="?([^";]+)"?/);
+ return sanitizeFilename(match?.[1] || fallback);
+}
+
+function sanitizeFilename(value: string): string {
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "output.log";
+}
diff --git a/raycast-extension/src/api/storage.ts b/raycast-extension/src/api/storage.ts
new file mode 100644
index 0000000..4b1a1cf
--- /dev/null
+++ b/raycast-extension/src/api/storage.ts
@@ -0,0 +1,127 @@
+import { LocalStorage, OAuth } from "@raycast/api";
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { homedir } from "node:os";
+import type { ConnectionSettings, JobCache } from "../types/ssync";
+
+const CONNECTION_KEY = "ssync.connection.v1";
+const JOB_CACHE_KEY = "ssync.jobs.cache.v1";
+const oauthClient = new OAuth.PKCEClient({
+ redirectMethod: OAuth.RedirectMethod.App,
+ providerName: "ssync",
+ providerId: "ssync-api-key",
+});
+
+export const DEFAULT_API_URL = "https://localhost:8042";
+export const DEFAULT_HISTORY_WINDOW = "3d";
+export const DEFAULT_JOB_LIMIT = 50;
+export const STALE_JOB_CACHE_MS = 60_000;
+
+export async function getConnection(): Promise {
+ const raw = await LocalStorage.getItem(CONNECTION_KEY);
+ if (!raw) return undefined;
+ try {
+ const parsed = JSON.parse(raw) as ConnectionSettings;
+ if (!parsed.apiUrl) return undefined;
+ if (parsed.apiKey) {
+ await saveApiKey(parsed.apiKey);
+ delete parsed.apiKey;
+ await LocalStorage.setItem(CONNECTION_KEY, JSON.stringify(parsed));
+ }
+ return {
+ apiUrl: parsed.apiUrl,
+ apiKey: await getStoredApiKey(),
+ historyWindow: parsed.historyWindow || DEFAULT_HISTORY_WINDOW,
+ jobLimit: Number(parsed.jobLimit) || DEFAULT_JOB_LIMIT,
+ updatedAt: parsed.updatedAt || Date.now(),
+ };
+ } catch {
+ return undefined;
+ }
+}
+
+export async function saveConnection(settings: Omit): Promise {
+ const next: ConnectionSettings = {
+ ...settings,
+ apiUrl: settings.apiUrl.trim().replace(/\/+$/, ""),
+ apiKey: settings.apiKey?.trim() || "",
+ historyWindow: settings.historyWindow || DEFAULT_HISTORY_WINDOW,
+ jobLimit: Number(settings.jobLimit) || DEFAULT_JOB_LIMIT,
+ updatedAt: Date.now(),
+ };
+ if (next.apiKey) {
+ await saveApiKey(next.apiKey);
+ } else {
+ await clearApiKey();
+ }
+ const { apiKey: _apiKey, ...nonSecretSettings } = next;
+ await LocalStorage.setItem(CONNECTION_KEY, JSON.stringify(nonSecretSettings));
+ return next;
+}
+
+export async function clearConnection(): Promise {
+ await LocalStorage.removeItem(CONNECTION_KEY);
+ await clearApiKey();
+}
+
+export async function getJobCache(): Promise {
+ const raw = await LocalStorage.getItem(JOB_CACHE_KEY);
+ if (!raw) return undefined;
+ try {
+ const parsed = JSON.parse(raw) as JobCache;
+ if (!Array.isArray(parsed.responses) || typeof parsed.loadedAt !== "number") return undefined;
+ return parsed;
+ } catch {
+ return undefined;
+ }
+}
+
+export async function saveJobCache(cache: JobCache): Promise {
+ await LocalStorage.setItem(JOB_CACHE_KEY, JSON.stringify(cache));
+}
+
+export async function clearJobCache(): Promise {
+ await LocalStorage.removeItem(JOB_CACHE_KEY);
+}
+
+export function readLocalApiKey(): string {
+ const keyPath = join(homedir(), ".config", "ssync", ".api_key");
+ if (!existsSync(keyPath)) return "";
+
+ try {
+ const raw = readFileSync(keyPath, "utf8").trim();
+ if (!raw) return "";
+
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ if (typeof parsed === "string") return parsed;
+ if (parsed && typeof parsed === "object") {
+ const record = parsed as Record;
+ const values = Object.values(record);
+ const stringValue = values.find((value): value is string => typeof value === "string" && value.length > 0);
+ if (stringValue) return stringValue;
+ const firstKey = Object.keys(record)[0];
+ if (firstKey) return firstKey;
+ }
+ } catch {
+ return raw;
+ }
+
+ return raw;
+ } catch {
+ return "";
+ }
+}
+
+async function getStoredApiKey(): Promise {
+ const tokenSet = await oauthClient.getTokens();
+ return tokenSet?.accessToken || "";
+}
+
+async function saveApiKey(apiKey: string): Promise {
+ await oauthClient.setTokens({ accessToken: apiKey });
+}
+
+async function clearApiKey(): Promise {
+ await oauthClient.removeTokens();
+}
diff --git a/raycast-extension/src/components/ConnectionForm.tsx b/raycast-extension/src/components/ConnectionForm.tsx
new file mode 100644
index 0000000..de96807
--- /dev/null
+++ b/raycast-extension/src/components/ConnectionForm.tsx
@@ -0,0 +1,100 @@
+import { Action, ActionPanel, Form, Icon, Toast, showToast } from "@raycast/api";
+import { useMemo, useState } from "react";
+import { SsyncClient } from "../api/client";
+import {
+ DEFAULT_API_URL,
+ DEFAULT_HISTORY_WINDOW,
+ DEFAULT_JOB_LIMIT,
+ readLocalApiKey,
+ saveConnection,
+} from "../api/storage";
+import type { ConnectionSettings } from "../types/ssync";
+
+type Values = {
+ apiUrl: string;
+ apiKey: string;
+ historyWindow: string;
+ jobLimit: string;
+};
+
+type Props = {
+ initial?: ConnectionSettings;
+ onConfigured: (connection: ConnectionSettings) => void;
+};
+
+export function ConnectionForm({ initial, onConfigured }: Props) {
+ const [isTesting, setIsTesting] = useState(false);
+ const detectedKey = useMemo(() => readLocalApiKey(), []);
+
+ async function submit(values: Values) {
+ const apiUrl = values.apiUrl.trim().replace(/\/+$/, "");
+ const apiKey = values.apiKey.trim();
+ const jobLimit = Number(values.jobLimit || DEFAULT_JOB_LIMIT);
+
+ if (!apiUrl) {
+ await showToast({ style: Toast.Style.Failure, title: "ssync API URL is required" });
+ return;
+ }
+ if (!Number.isFinite(jobLimit) || jobLimit <= 0) {
+ await showToast({ style: Toast.Style.Failure, title: "Job limit must be a positive number" });
+ return;
+ }
+
+ setIsTesting(true);
+ const toast = await showToast({ style: Toast.Style.Animated, title: "Testing ssync connection" });
+ try {
+ const candidate = {
+ apiUrl,
+ apiKey,
+ historyWindow: values.historyWindow || DEFAULT_HISTORY_WINDOW,
+ jobLimit,
+ };
+ await new SsyncClient(candidate).testConnection();
+ const saved = await saveConnection(candidate);
+ toast.style = Toast.Style.Success;
+ toast.title = "Connected to ssync";
+ onConfigured(saved);
+ } catch (error) {
+ toast.style = Toast.Style.Failure;
+ toast.title = "Connection failed";
+ toast.message = error instanceof Error ? error.message : String(error);
+ } finally {
+ setIsTesting(false);
+ }
+ }
+
+ const defaultKey = initial?.apiKey || detectedKey;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/raycast-extension/src/components/JobDetail.tsx b/raycast-extension/src/components/JobDetail.tsx
new file mode 100644
index 0000000..be3f5e7
--- /dev/null
+++ b/raycast-extension/src/components/JobDetail.tsx
@@ -0,0 +1,550 @@
+import { Action, ActionPanel, Alert, Icon, Keyboard, List, Toast, confirmAlert, open, showToast } from "@raycast/api";
+import type { ReactNode } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { SsyncClient } from "../api/client";
+import {
+ compactJobSubtitle,
+ formatDate,
+ isPending,
+ isRunning,
+ metadataText,
+ stateColor,
+ stateIcon,
+ stateLabel,
+ webJobUrl,
+} from "../lib/format";
+import { bulletList, codeBlock, escapeMarkdown } from "../lib/markdown";
+import { openJobOutputFile } from "../lib/output-file";
+import type { ConnectionSettings, JobInfo } from "../types/ssync";
+import { OutputView } from "./OutputView";
+import { ScriptView } from "./ScriptView";
+import { WatchersView } from "./WatchersView";
+
+type Props = {
+ connection: ConnectionSettings;
+ job: JobInfo;
+ onJobUpdated?: (job: JobInfo) => void;
+};
+
+type InspectorItemProps = {
+ id: string;
+ title: string;
+ subtitle?: string | null;
+ icon?: List.Item.Props["icon"];
+ accessories?: List.Item.Props["accessories"];
+ keywords?: string[];
+ markdown: string;
+ metadata?: ReactNode;
+ actions: ReactNode;
+};
+
+type JobInspectorActionsProps = {
+ connection: ConnectionSettings;
+ job: JobInfo;
+ canCancel: boolean;
+ refreshJob: () => Promise;
+ cancelJob: () => Promise;
+ primary?: ReactNode;
+ includeRelatedViews?: boolean;
+};
+
+export function JobDetail({ connection, job, onJobUpdated }: Props) {
+ const client = useMemo(() => new SsyncClient(connection), [connection]);
+ const [currentJob, setCurrentJob] = useState(job);
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ setCurrentJob(job);
+ }, [job]);
+
+ async function refreshJob(forceRefresh = false) {
+ setIsLoading(true);
+ try {
+ const next = await client.getJob(currentJob, forceRefresh);
+ setCurrentJob(next);
+ onJobUpdated?.(next);
+ await showToast({ style: Toast.Style.Success, title: "Job refreshed" });
+ } catch (error) {
+ await showToast({
+ style: Toast.Style.Failure,
+ title: "Failed to refresh job",
+ message: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ async function cancelJob() {
+ const confirmed = await confirmAlert({
+ title: `Cancel job ${currentJob.job_id}?`,
+ message: `${currentJob.name || "This job"} on ${currentJob.hostname} will be cancelled with scancel.`,
+ primaryAction: {
+ title: "Cancel Job",
+ style: Alert.ActionStyle.Destructive,
+ },
+ });
+ if (!confirmed) return;
+
+ const toast = await showToast({ style: Toast.Style.Animated, title: "Cancelling job" });
+ try {
+ await client.cancelJob(currentJob);
+ toast.style = Toast.Style.Success;
+ toast.title = "Job cancelled";
+ await refreshJob();
+ } catch (error) {
+ toast.style = Toast.Style.Failure;
+ toast.title = "Failed to cancel job";
+ toast.message = error instanceof Error ? error.message : String(error);
+ }
+ }
+
+ const canCancel = isRunning(currentJob) || isPending(currentJob);
+ const actions = {
+ connection,
+ job: currentJob,
+ canCancel,
+ refreshJob: () => refreshJob(),
+ cancelJob,
+ };
+
+ return (
+
+
+ }
+ actions={
+ }
+ />
+ }
+ />
+ }
+ actions={
+ }
+ />
+ }
+ />
+
+
+
+ }
+ actions={} />}
+ />
+ }
+ actions={}
+ />
+
+
+
+ }
+ actions={}
+ />
+
+
+
+ }
+ actions={
+ : undefined}
+ />
+ }
+ />
+ }
+ actions={
+
+ } />
+ {currentJob.stdout_file ? (
+ openJobOutputFile({ client, job: currentJob, outputType: "stdout" })}
+ />
+ ) : null}
+ {currentJob.stdout_file ? : null}
+ >
+ }
+ />
+ }
+ />
+ }
+ actions={
+
+ } />
+ {currentJob.stderr_file ? (
+ openJobOutputFile({ client, job: currentJob, outputType: "stderr" })}
+ />
+ ) : null}
+ {currentJob.stderr_file ? : null}
+ >
+ }
+ />
+ }
+ />
+
+
+
+ }
+ actions={
+ } />}
+ />
+ }
+ />
+ }
+ actions={
+ } />}
+ />
+ }
+ />
+ }
+ actions={
+ } />}
+ />
+ }
+ />
+ }
+ actions={
+ open(webJobUrl(connection.apiUrl, currentJob))} />}
+ />
+ }
+ />
+
+
+ );
+}
+
+function InspectorItem({ id, title, subtitle, icon, accessories, keywords, markdown, metadata, actions }: InspectorItemProps) {
+ return (
+ }
+ actions={actions}
+ />
+ );
+}
+
+function JobInspectorActions({
+ connection,
+ job,
+ canCancel,
+ refreshJob,
+ cancelJob,
+ primary,
+ includeRelatedViews = true,
+}: JobInspectorActionsProps) {
+ return (
+
+ {primary ? {primary} : null}
+ {includeRelatedViews ? (
+
+ } />
+ } />
+ } />
+
+ ) : null}
+
+
+ {canCancel ? : null}
+ open(webJobUrl(connection.apiUrl, job))} />
+
+
+
+ {job.work_dir ? : null}
+ {job.stdout_file ? : null}
+ {job.stderr_file ? : null}
+
+
+ );
+}
+
+function StatusMetadata({ job }: { job: JobInfo }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function IdentityMetadata({ job }: { job: JobInfo }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function PlacementMetadata({ job }: { job: JobInfo }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function ResourcesMetadata({ job }: { job: JobInfo }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function TimingMetadata({ job }: { job: JobInfo }) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function PathMetadata({ title, value, job }: { title: string; value?: string | null; job: JobInfo }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function RelatedViewMetadata({ job, kind }: { job: JobInfo; kind: string }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function statusMarkdown(job: JobInfo): string {
+ return [
+ `# ${escapeMarkdown(stateLabel(job.state))}`,
+ "",
+ bulletList([
+ ["Raw state", job.state],
+ ["Reason", job.reason],
+ ["Exit code", job.exit_code],
+ ]),
+ ].join("\n");
+}
+
+function identityMarkdown(job: JobInfo): string {
+ return [
+ `# ${escapeMarkdown(job.name || `Job ${job.job_id}`)}`,
+ "",
+ bulletList([
+ ["Job ID", job.job_id],
+ ["User", job.user],
+ ["Host", job.hostname],
+ ]),
+ ].join("\n");
+}
+
+function placementMarkdown(job: JobInfo): string {
+ return [
+ "# Placement",
+ "",
+ bulletList([
+ ["Host", job.hostname],
+ ["Partition", job.partition],
+ ["Account", job.account],
+ ["QoS", job.qos],
+ ]),
+ ].join("\n");
+}
+
+function resourcesMarkdown(job: JobInfo): string {
+ return [
+ "# Resources",
+ "",
+ bulletList([
+ ["Nodes", job.nodes],
+ ["CPUs", job.cpus],
+ ["Memory", job.memory],
+ ]),
+ ].join("\n");
+}
+
+function timingMarkdown(job: JobInfo): string {
+ return [
+ "# Timing",
+ "",
+ bulletList([
+ ["Submitted", formatDate(job.submit_time)],
+ ["Started", formatDate(job.start_time)],
+ ["Ended", formatDate(job.end_time)],
+ ["Runtime", job.runtime],
+ ["Time limit", job.time_limit],
+ ]),
+ ].join("\n");
+}
+
+function pathMarkdown(title: string, value?: string | null): string {
+ return [`# ${escapeMarkdown(title)}`, "", codeBlock(value, "text")].join("\n");
+}
+
+function relatedViewMarkdown(title: string, description: string): string {
+ return [`# ${escapeMarkdown(title)}`, "", escapeMarkdown(description)].join("\n");
+}
+
+function statusSubtitle(job: JobInfo): string {
+ const parts = [stateLabel(job.state)];
+ if (job.reason) parts.push(job.reason);
+ if (job.exit_code) parts.push(`exit ${job.exit_code}`);
+ return parts.join(" 路 ");
+}
+
+function placementSubtitle(job: JobInfo): string {
+ return [job.hostname, job.partition, job.account, job.qos].filter(Boolean).join(" 路 ") || "n/a";
+}
+
+function resourcesSubtitle(job: JobInfo): string {
+ return [`${metadataText(job.nodes)} nodes`, `${metadataText(job.cpus)} CPUs`, metadataText(job.memory)].join(" 路 ");
+}
+
+function timingSubtitle(job: JobInfo): string {
+ return [job.runtime, job.time_limit ? `limit ${job.time_limit}` : undefined].filter(Boolean).join(" 路 ") || "n/a";
+}
+
+function keywords(...values: (string | number | null | undefined)[]): string[] {
+ return values
+ .filter((value): value is string | number => value !== undefined && value !== null && value !== "")
+ .map((value) => String(value));
+}
diff --git a/raycast-extension/src/components/OutputView.tsx b/raycast-extension/src/components/OutputView.tsx
new file mode 100644
index 0000000..190ab25
--- /dev/null
+++ b/raycast-extension/src/components/OutputView.tsx
@@ -0,0 +1,125 @@
+import { Action, ActionPanel, Detail, Icon, Keyboard, Toast, showToast } from "@raycast/api";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { SsyncClient } from "../api/client";
+import { bytesLabel, formatDate, metadataText } from "../lib/format";
+import { codeBlock, escapeMarkdown } from "../lib/markdown";
+import { openJobOutputFile } from "../lib/output-file";
+import type { ConnectionSettings, JobInfo, JobOutputResponse } from "../types/ssync";
+
+type Props = {
+ connection: ConnectionSettings;
+ job: JobInfo;
+ initialOutputType?: OutputType;
+};
+
+type OutputType = "stdout" | "stderr";
+
+export function OutputView({ connection, job, initialOutputType = "stdout" }: Props) {
+ const client = useMemo(() => new SsyncClient(connection), [connection]);
+ const [outputType, setOutputType] = useState(initialOutputType);
+ const [lines, setLines] = useState(300);
+ const [fullOutput, setFullOutput] = useState(false);
+ const [output, setOutput] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const queuedRefreshTimer = useRef(null);
+
+ function clearQueuedRefresh() {
+ if (!queuedRefreshTimer.current) return;
+ clearTimeout(queuedRefreshTimer.current);
+ queuedRefreshTimer.current = null;
+ }
+
+ async function load(
+ next?: { outputType?: OutputType; lines?: number; fullOutput?: boolean; forceRefresh?: boolean },
+ options?: { followQueuedRefresh?: boolean },
+ ) {
+ clearQueuedRefresh();
+ const requestedType = next?.outputType || outputType;
+ const requestedFull = next?.fullOutput ?? fullOutput;
+ const requestedLines = requestedFull ? undefined : next?.lines ?? lines;
+ setOutputType(requestedType);
+ setFullOutput(requestedFull);
+ setLines(requestedLines);
+ setIsLoading(true);
+ setError(null);
+ try {
+ const data = await client.getOutput({
+ job,
+ outputType: requestedType,
+ lines: requestedLines,
+ fullOutput: requestedFull,
+ forceRefresh: next?.forceRefresh,
+ });
+ setOutput(data);
+ if (data.refresh_queued && options?.followQueuedRefresh) {
+ queuedRefreshTimer.current = setTimeout(() => {
+ void load({ outputType: requestedType, lines: requestedLines, fullOutput: requestedFull });
+ }, 2_500);
+ }
+ } catch (loadError) {
+ setError(loadError instanceof Error ? loadError.message : String(loadError));
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ void load({ outputType: initialOutputType, lines: 300, fullOutput: false, forceRefresh: true }, { followQueuedRefresh: true });
+ return clearQueuedRefresh;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [job.job_id, job.hostname, initialOutputType]);
+
+ async function refresh() {
+ await load({ forceRefresh: true }, { followQueuedRefresh: true });
+ await showToast({ style: Toast.Style.Success, title: "Output refreshed" });
+ }
+
+ const content = outputType === "stdout" ? output?.stdout : output?.stderr;
+ const metadata = outputType === "stdout" ? output?.stdout_metadata : output?.stderr_metadata;
+ const title = `${outputType} 路 ${job.job_id} @ ${job.hostname}`;
+ const markdown = error ? `# ${escapeMarkdown(outputType)}\n\n**Error:** ${escapeMarkdown(error)}` : codeBlock(content, "text");
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ actions={
+
+
+
+ load({ outputType: outputType === "stdout" ? "stderr" : "stdout", lines: 300, fullOutput: false, forceRefresh: true }, { followQueuedRefresh: true })} />
+ load({ lines: 1000, fullOutput: false })} />
+ load({ fullOutput: true })} />
+ openJobOutputFile({ client, job, outputType })}
+ />
+
+
+
+ {metadata?.path ? : null}
+
+
+ }
+ />
+ );
+}
diff --git a/raycast-extension/src/components/ScriptView.tsx b/raycast-extension/src/components/ScriptView.tsx
new file mode 100644
index 0000000..22feb7a
--- /dev/null
+++ b/raycast-extension/src/components/ScriptView.tsx
@@ -0,0 +1,70 @@
+import { Action, ActionPanel, Detail, Icon, Keyboard, Toast, showToast } from "@raycast/api";
+import { useEffect, useMemo, useState } from "react";
+import { SsyncClient } from "../api/client";
+import { metadataText } from "../lib/format";
+import { codeBlock, escapeMarkdown } from "../lib/markdown";
+import type { ConnectionSettings, JobInfo, JobScriptResponse } from "../types/ssync";
+
+type Props = {
+ connection: ConnectionSettings;
+ job: JobInfo;
+};
+
+export function ScriptView({ connection, job }: Props) {
+ const client = useMemo(() => new SsyncClient(connection), [connection]);
+ const [script, setScript] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ async function load() {
+ setIsLoading(true);
+ setError(null);
+ try {
+ setScript(await client.getScript(job));
+ } catch (loadError) {
+ setError(loadError instanceof Error ? loadError.message : String(loadError));
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ void load();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [job.job_id, job.hostname]);
+
+ async function refresh() {
+ await load();
+ await showToast({ style: Toast.Style.Success, title: "Script refreshed" });
+ }
+
+ const markdown = error
+ ? `# Script for ${escapeMarkdown(job.name || job.job_id)}\n\n**Error:** ${escapeMarkdown(error)}`
+ : codeBlock(script?.script_content, "bash");
+
+ return (
+
+
+
+
+
+
+
+ }
+ actions={
+
+
+
+
+ {script?.local_source_dir ? : null}
+
+
+ }
+ />
+ );
+}
diff --git a/raycast-extension/src/components/WatchersView.tsx b/raycast-extension/src/components/WatchersView.tsx
new file mode 100644
index 0000000..425820d
--- /dev/null
+++ b/raycast-extension/src/components/WatchersView.tsx
@@ -0,0 +1,247 @@
+import { Action, ActionPanel, Color, Icon, Keyboard, List, Toast, showToast } from "@raycast/api";
+import { useEffect, useMemo, useState } from "react";
+import { SsyncClient } from "../api/client";
+import { formatDate, metadataText } from "../lib/format";
+import { codeBlock, escapeMarkdown } from "../lib/markdown";
+import type { ConnectionSettings, JobInfo, Watcher, WatcherAction, WatcherEvent } from "../types/ssync";
+
+type Props = {
+ connection: ConnectionSettings;
+ job: JobInfo;
+};
+
+export function WatchersView({ connection, job }: Props) {
+ const client = useMemo(() => new SsyncClient(connection), [connection]);
+ const [watchers, setWatchers] = useState([]);
+ const [events, setEvents] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ async function load() {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const [watcherPayload, eventPayload] = await Promise.all([client.getWatchers(job), client.getWatcherEvents(job, 100)]);
+ setWatchers(watcherPayload.watchers || []);
+ setEvents(eventPayload.events || []);
+ } catch (loadError) {
+ setError(loadError instanceof Error ? loadError.message : String(loadError));
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ void load();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [job.job_id, job.hostname]);
+
+ async function refresh() {
+ await load();
+ await showToast({ style: Toast.Style.Success, title: "Watchers refreshed" });
+ }
+
+ return (
+
+ {error ? (
+
+
+
+ }
+ />
+ ) : null}
+ {!error && watchers.length === 0 && events.length === 0 ? (
+
+ ) : null}
+ {watchers.length > 0 ? (
+
+ {watchers.map((watcher) => (
+ } />}
+ actions={}
+ />
+ ))}
+
+ ) : null}
+ {events.length > 0 ? (
+
+ {events.map((event) => (
+ } />}
+ actions={}
+ />
+ ))}
+
+ ) : null}
+
+ );
+}
+
+function WatcherActions({ watcher, onRefresh }: { watcher: Watcher; onRefresh: () => void }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function EventActions({ event, onRefresh }: { event: WatcherEvent; onRefresh: () => void }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function WatcherMetadata({ watcher }: { watcher: Watcher }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function EventMetadata({ event }: { event: WatcherEvent }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function watcherMarkdown(watcher: Watcher): string {
+ const lines = [`# ${escapeMarkdown(watcher.name)}`];
+ if (watcher.condition) {
+ lines.push("", `**Condition:** ${escapeMarkdown(watcher.condition)}`);
+ }
+ lines.push(
+ "",
+ "## Pattern",
+ "",
+ codeBlock(watcher.pattern, "text"),
+ "",
+ "## Captures",
+ "",
+ stringList(watcher.captures),
+ "",
+ "## Captured Variables",
+ "",
+ objectList(watcher.variables),
+ "",
+ "## Actions",
+ "",
+ watcher.actions.length > 0 ? watcher.actions.map(actionMarkdown).join("\n\n") : "_No actions configured._",
+ );
+ return lines.join("\n");
+}
+
+function eventMarkdown(event: WatcherEvent): string {
+ return [
+ `# ${escapeMarkdown(event.action_type)} event`,
+ "",
+ "## Matched Text",
+ "",
+ codeBlock(event.matched_text, "text"),
+ "",
+ "## Captured Variables",
+ "",
+ objectList(event.captured_vars),
+ ...(event.action_result ? ["", "## Action Result", "", codeBlock(event.action_result, "text")] : []),
+ ].join("\n");
+}
+
+function stringList(values?: string[]): string {
+ if (!values || values.length === 0) return "_No captures configured._";
+ return values.map((value) => `- ${escapeMarkdown(value)}`).join("\n");
+}
+
+function objectList(values?: Record): string {
+ const entries = Object.entries(values || {});
+ if (entries.length === 0) return "_No values captured._";
+ return entries.map(([key, value]) => `- **${escapeMarkdown(key)}:** ${escapeMarkdown(formatUnknown(value))}`).join("\n");
+}
+
+function actionMarkdown(action: WatcherAction, index: number): string {
+ const lines = [`### ${index + 1}. ${escapeMarkdown(action.type)}`];
+ if (action.condition) lines.push("", `**Condition:** ${escapeMarkdown(action.condition)}`);
+ if (action.params && Object.keys(action.params).length > 0) lines.push("", "**Params**", "", objectList(action.params));
+ if (action.config && Object.keys(action.config).length > 0) lines.push("", "**Config**", "", objectList(action.config));
+ return lines.join("\n");
+}
+
+function formatUnknown(value: unknown): string {
+ if (value === undefined || value === null || value === "") return "n/a";
+ if (typeof value === "string") return value;
+ if (typeof value === "number" || typeof value === "boolean") return metadataText(value);
+ return JSON.stringify(value);
+}
+
+function watcherStateColor(state: string): Color {
+ switch (state) {
+ case "active":
+ return Color.Green;
+ case "paused":
+ return Color.Yellow;
+ case "failed":
+ return Color.Red;
+ case "completed":
+ return Color.Blue;
+ default:
+ return Color.SecondaryText;
+ }
+}
diff --git a/raycast-extension/src/jobs.tsx b/raycast-extension/src/jobs.tsx
new file mode 100644
index 0000000..988a121
--- /dev/null
+++ b/raycast-extension/src/jobs.tsx
@@ -0,0 +1,375 @@
+import {
+ Action,
+ ActionPanel,
+ Alert,
+ Icon,
+ Keyboard,
+ LaunchProps,
+ List,
+ Toast,
+ confirmAlert,
+ open,
+ showToast,
+ useNavigation,
+} from "@raycast/api";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { SsyncClient } from "./api/client";
+import {
+ STALE_JOB_CACHE_MS,
+ clearJobCache,
+ getConnection,
+ getJobCache,
+ saveJobCache,
+} from "./api/storage";
+import { ConnectionForm } from "./components/ConnectionForm";
+import { JobDetail } from "./components/JobDetail";
+import { OutputView } from "./components/OutputView";
+import { ScriptView } from "./components/ScriptView";
+import { WatchersView } from "./components/WatchersView";
+import {
+ compactJobSubtitle,
+ flattenJobs,
+ formatRelativeAge,
+ isHistorical,
+ isPending,
+ isRunning,
+ jobTitle,
+ sortJobs,
+ stateColor,
+ stateIcon,
+ stateLabel,
+ webJobUrl,
+} from "./lib/format";
+import type { ConnectionSettings, JobCache, JobInfo, JobsLaunchContext } from "./types/ssync";
+
+type Props = LaunchProps<{ launchContext?: JobsLaunchContext }>;
+
+export default function Command(props: Props) {
+ const [connection, setConnection] = useState();
+ const [didLoadConnection, setDidLoadConnection] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ void getConnection().then((loaded) => {
+ if (cancelled) return;
+ setConnection(loaded);
+ setDidLoadConnection(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (!didLoadConnection) {
+ return
;
+ }
+
+ if (!connection) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+function JobsList({
+ connection,
+ initialContext,
+ onConnectionChanged,
+}: {
+ connection: ConnectionSettings;
+ initialContext?: JobsLaunchContext;
+ onConnectionChanged: (connection: ConnectionSettings) => void;
+}) {
+ const client = useMemo(() => new SsyncClient(connection), [connection]);
+ const { push, pop } = useNavigation();
+ const didPushInitialContext = useRef(false);
+ const [cache, setCache] = useState();
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const job = initialContext?.job;
+ if (didPushInitialContext.current || !job) return;
+ didPushInitialContext.current = true;
+ push(targetForLaunchContext({ job, view: initialContext.view }, connection));
+ }, [connection, initialContext, push]);
+
+ useEffect(() => {
+ let cancelled = false;
+ async function loadInitial() {
+ setIsLoading(true);
+ const storedCache = await getJobCache();
+ if (cancelled) return;
+ if (storedCache) {
+ setCache(storedCache);
+ setIsLoading(false);
+ }
+ if (!storedCache || Date.now() - storedCache.loadedAt > STALE_JOB_CACHE_MS) {
+ await refreshJobs({ silent: Boolean(storedCache) });
+ } else {
+ setIsLoading(false);
+ }
+ }
+ void loadInitial();
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [connection.apiUrl, connection.apiKey, connection.historyWindow, connection.jobLimit]);
+
+ async function refreshJobs(options: { silent?: boolean; forceRefresh?: boolean } = {}) {
+ if (!options.silent) setIsLoading(true);
+ setError(null);
+ try {
+ const responses = await client.getStatus({
+ since: connection.historyWindow,
+ limit: connection.jobLimit,
+ forceRefresh: options.forceRefresh,
+ });
+ const nextCache = { loadedAt: Date.now(), responses };
+ setCache(nextCache);
+ await saveJobCache(nextCache);
+ } catch (refreshError) {
+ const message = refreshError instanceof Error ? refreshError.message : String(refreshError);
+ setError(message);
+ if (!cache) {
+ await showToast({ style: Toast.Style.Failure, title: "Failed to load jobs", message });
+ }
+ } finally {
+ if (!options.silent) setIsLoading(false);
+ }
+ }
+
+ async function cancelJob(job: JobInfo) {
+ const confirmed = await confirmAlert({
+ title: `Cancel job ${job.job_id}?`,
+ message: `${job.name || "This job"} on ${job.hostname} will be cancelled with scancel.`,
+ primaryAction: {
+ title: "Cancel Job",
+ style: Alert.ActionStyle.Destructive,
+ },
+ });
+ if (!confirmed) return;
+
+ const toast = await showToast({
+ style: Toast.Style.Animated,
+ title: "Cancelling job",
+ message: `${job.job_id} @ ${job.hostname}`,
+ });
+ try {
+ await client.cancelJob(job);
+ toast.style = Toast.Style.Success;
+ toast.title = "Job cancelled";
+ toast.message = `${job.job_id} @ ${job.hostname}`;
+ await refreshJobs({ silent: true, forceRefresh: true });
+ } catch (cancelError) {
+ toast.style = Toast.Style.Failure;
+ toast.title = "Failed to cancel job";
+ toast.message = cancelError instanceof Error ? cancelError.message : String(cancelError);
+ }
+ }
+
+ const jobs = useMemo(() => sortJobs(flattenJobs(cache?.responses || [])), [cache]);
+ const runningJobs = jobs.filter(isRunning);
+ const pendingJobs = jobs.filter(isPending);
+ const historicalJobs = jobs.filter(isHistorical);
+ const cacheAge = cache ? formatRelativeAge(cache.loadedAt) : "never";
+
+ function configureConnection() {
+ push(
+ {
+ onConnectionChanged(nextConnection);
+ pop();
+ void clearJobCache();
+ setCache(undefined);
+ }}
+ />,
+ );
+ }
+
+ return (
+
+ refreshJobs()} />
+
+
+ }
+ >
+ {error ? (
+
+ refreshJobs()} />
+
+
+ }
+ />
+ ) : null}
+ {!error && jobs.length === 0 && !isLoading ? (
+
+ refreshJobs()} />
+
+
+ }
+ />
+ ) : null}
+ refreshJobs()}
+ onCancel={cancelJob}
+ onConfigure={configureConnection}
+ />
+ refreshJobs()}
+ onCancel={cancelJob}
+ onConfigure={configureConnection}
+ />
+ refreshJobs()}
+ onCancel={cancelJob}
+ onConfigure={configureConnection}
+ />
+
+ );
+}
+
+function targetForLaunchContext(context: { job: JobInfo; view?: JobsLaunchContext["view"] }, connection: ConnectionSettings) {
+ if (context.view === "output") {
+ return ;
+ }
+ if (context.view === "script") {
+ return ;
+ }
+ if (context.view === "watchers") {
+ return ;
+ }
+ return ;
+}
+
+function JobSection({
+ title,
+ jobs,
+ connection,
+ onRefresh,
+ onCancel,
+ onConfigure,
+}: {
+ title: string;
+ jobs: JobInfo[];
+ connection: ConnectionSettings;
+ onRefresh: () => void;
+ onCancel: (job: JobInfo) => Promise;
+ onConfigure: () => void;
+}) {
+ if (jobs.length === 0) return null;
+ return (
+
+ {jobs.map((job) => (
+
+ ))}
+
+ );
+}
+
+function JobListItem({
+ connection,
+ job,
+ onRefresh,
+ onCancel,
+ onConfigure,
+}: {
+ connection: ConnectionSettings;
+ job: JobInfo;
+ onRefresh: () => void;
+ onCancel: (job: JobInfo) => Promise;
+ onConfigure: () => void;
+}) {
+ const accessories = [
+ { tag: { value: stateLabel(job.state), color: stateColor(job.state) } },
+ { text: job.hostname, tooltip: "Host" },
+ job.runtime ? { text: job.runtime, tooltip: "Runtime" } : undefined,
+ job.partition ? { text: job.partition, tooltip: "Partition" } : undefined,
+ ].filter((item): item is NonNullable => Boolean(item));
+
+ const keywords = [
+ job.job_id,
+ job.hostname,
+ job.name,
+ job.state,
+ job.partition || "",
+ job.reason || "",
+ job.work_dir || "",
+ ].filter(Boolean);
+
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+ {isRunning(job) || isPending(job) ? (
+ onCancel(job)}
+ />
+ ) : null}
+ open(webJobUrl(connection.apiUrl, job))} />
+
+
+
+
+ {job.work_dir ? : null}
+
+
+ }
+ />
+ );
+}
diff --git a/raycast-extension/src/lib/format.ts b/raycast-extension/src/lib/format.ts
new file mode 100644
index 0000000..2a31b82
--- /dev/null
+++ b/raycast-extension/src/lib/format.ts
@@ -0,0 +1,153 @@
+import { Color, Icon } from "@raycast/api";
+import type { JobInfo, JobState } from "../types/ssync";
+
+export function stateLabel(state: JobState): string {
+ switch (state) {
+ case "PD":
+ return "Pending";
+ case "R":
+ return "Running";
+ case "CD":
+ return "Completed";
+ case "F":
+ return "Failed";
+ case "CA":
+ return "Cancelled";
+ case "TO":
+ return "Timed out";
+ case "UNKNOWN":
+ return "Unknown";
+ default:
+ return String(state);
+ }
+}
+
+export function stateIcon(state: JobState): Icon {
+ switch (state) {
+ case "R":
+ return Icon.Play;
+ case "PD":
+ return Icon.Clock;
+ case "CD":
+ return Icon.CheckCircle;
+ case "F":
+ return Icon.XmarkCircle;
+ case "CA":
+ return Icon.Stop;
+ case "TO":
+ return Icon.Hourglass;
+ default:
+ return Icon.QuestionMark;
+ }
+}
+
+export function stateColor(state: JobState): Color {
+ switch (state) {
+ case "R":
+ return Color.Green;
+ case "PD":
+ return Color.Yellow;
+ case "CD":
+ return Color.Blue;
+ case "F":
+ case "TO":
+ return Color.Red;
+ case "CA":
+ return Color.Orange;
+ default:
+ return Color.SecondaryText;
+ }
+}
+
+export function isRunning(job: JobInfo): boolean {
+ return job.state === "R";
+}
+
+export function isPending(job: JobInfo): boolean {
+ return job.state === "PD";
+}
+
+export function isHistorical(job: JobInfo): boolean {
+ return !isRunning(job) && !isPending(job);
+}
+
+export function jobSortTime(job: JobInfo): number {
+ const raw = job.start_time || job.submit_time || job.end_time || "";
+ const parsed = Date.parse(raw);
+ return Number.isNaN(parsed) ? 0 : parsed;
+}
+
+export function sortJobs(jobs: JobInfo[]): JobInfo[] {
+ return [...jobs].sort((left, right) => {
+ const byTime = jobSortTime(right) - jobSortTime(left);
+ if (byTime !== 0) return byTime;
+ return right.job_id.localeCompare(left.job_id);
+ });
+}
+
+export function flattenJobs(responses: { hostname: string; jobs: JobInfo[] }[]): JobInfo[] {
+ return responses.flatMap((response) =>
+ (response.jobs || []).map((job) => ({
+ ...job,
+ hostname: job.hostname || response.hostname,
+ })),
+ );
+}
+
+export function jobTitle(job: JobInfo): string {
+ return job.name || `Job ${job.job_id}`;
+}
+
+export function compactJobSubtitle(job: JobInfo): string {
+ const parts = [job.hostname, `#${job.job_id}`];
+ if (job.runtime) parts.push(job.runtime);
+ if (job.partition) parts.push(job.partition);
+ if (job.reason && job.state === "PD") parts.push(job.reason);
+ return parts.filter(Boolean).join(" 路 ");
+}
+
+export function formatDate(value?: string | null): string {
+ if (!value) return "n/a";
+ const parsed = Date.parse(value);
+ if (Number.isNaN(parsed)) return value;
+ return new Date(parsed).toLocaleString();
+}
+
+export function formatRelativeAge(timestamp: number): string {
+ const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.round(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.round(hours / 24)}d ago`;
+}
+
+export function bytesLabel(value?: number | null): string {
+ if (value === undefined || value === null) return "n/a";
+ if (value < 1024) return `${value} B`;
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
+ return `${(value / 1024 / 1024).toFixed(1)} MiB`;
+}
+
+export function metadataText(value?: string | number | boolean | null): string {
+ if (value === undefined || value === null || value === "") return "n/a";
+ if (typeof value === "boolean") return value ? "yes" : "no";
+ return String(value);
+}
+
+export function webJobUrl(apiUrl: string, job: JobInfo): string {
+ const base = apiUrl.replace(/\/+$/, "");
+ return `${base}/#/jobs/${encodeURIComponent(job.job_id)}/${encodeURIComponent(job.hostname)}`;
+}
+
+export function stateCountLabel(jobs: JobInfo[]): string {
+ const running = jobs.filter(isRunning).length;
+ const pending = jobs.filter(isPending).length;
+ const failed = jobs.filter((job) => job.state === "F" || job.state === "TO").length;
+ const parts = [];
+ if (running) parts.push(`${running}R`);
+ if (pending) parts.push(`${pending}PD`);
+ if (failed) parts.push(`${failed}F`);
+ return parts.join(" ");
+}
diff --git a/raycast-extension/src/lib/markdown.ts b/raycast-extension/src/lib/markdown.ts
new file mode 100644
index 0000000..73413bd
--- /dev/null
+++ b/raycast-extension/src/lib/markdown.ts
@@ -0,0 +1,21 @@
+export function escapeMarkdown(value: string): string {
+ return value.replace(/[\\`*_{}[\]()#+\-.!|]/g, "\\$&");
+}
+
+export function codeBlock(content?: string | null, language = ""): string {
+ const body = content && content.length > 0 ? content : "No content available.";
+ return `~~~${language}\n${body.replace(/\n?$/, "\n")}~~~`;
+}
+
+export function fieldLine(label: string, value?: string | number | null): string | null {
+ if (value === undefined || value === null || value === "") return null;
+ return `**${escapeMarkdown(label)}:** ${escapeMarkdown(String(value))}`;
+}
+
+export function bulletList(rows: [string, string | number | null | undefined][]): string {
+ const lines = rows
+ .map(([label, value]) => fieldLine(label, value))
+ .filter((line): line is string => Boolean(line))
+ .map((line) => `- ${line}`);
+ return lines.length > 0 ? lines.join("\n") : "_No values available._";
+}
diff --git a/raycast-extension/src/lib/output-file.ts b/raycast-extension/src/lib/output-file.ts
new file mode 100644
index 0000000..9a8524f
--- /dev/null
+++ b/raycast-extension/src/lib/output-file.ts
@@ -0,0 +1,117 @@
+import { type Application, Toast, getPreferenceValues, open, showToast } from "@raycast/api";
+import { execFile } from "node:child_process";
+import { mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { promisify } from "node:util";
+import { SsyncClient } from "../api/client";
+import type { JobInfo } from "../types/ssync";
+
+type OutputType = "stdout" | "stderr";
+
+type OutputEditor = "default" | "vscode" | "cursor" | "ghostty-nvim" | "custom";
+
+type Preferences = {
+ outputEditor?: OutputEditor;
+ outputEditorApplication?: Application | string;
+};
+
+const execFileAsync = promisify(execFile);
+
+export async function openJobOutputFile(options: {
+ client: SsyncClient;
+ job: JobInfo;
+ outputType: OutputType;
+}): Promise {
+ const toast = await showToast({
+ style: Toast.Style.Animated,
+ title: `Downloading ${options.outputType}`,
+ message: "Refreshing the full output file",
+ });
+
+ try {
+ const download = await options.client.downloadOutput({
+ job: options.job,
+ outputType: options.outputType,
+ forceRefresh: true,
+ });
+ const filePath = await writeOutputFile({
+ hostname: options.job.hostname,
+ jobId: options.job.job_id,
+ outputType: options.outputType,
+ filename: download.filename,
+ content: download.content,
+ });
+
+ toast.title = `Opening ${options.outputType}`;
+ toast.message = filePath;
+ await openWithConfiguredEditor(filePath);
+
+ toast.style = Toast.Style.Success;
+ toast.title = `${options.outputType} opened`;
+ toast.message = filePath;
+ } catch (error) {
+ toast.style = Toast.Style.Failure;
+ toast.title = `Failed to open ${options.outputType}`;
+ toast.message = error instanceof Error ? error.message : String(error);
+ }
+}
+
+async function writeOutputFile(options: {
+ hostname: string;
+ jobId: string;
+ outputType: OutputType;
+ filename: string;
+ content: Buffer;
+}): Promise {
+ const filePath = join(
+ tmpdir(),
+ "ssync-raycast-output",
+ safePathSegment(options.hostname),
+ safePathSegment(options.jobId),
+ safeOutputFilename(options.filename, options.outputType),
+ );
+ await mkdir(dirname(filePath), { recursive: true });
+ await writeFile(filePath, options.content);
+ return filePath;
+}
+
+async function openWithConfiguredEditor(filePath: string): Promise {
+ const preferences = getPreferenceValues();
+ switch (preferences.outputEditor || "default") {
+ case "vscode":
+ await open(filePath, "Visual Studio Code");
+ return;
+ case "cursor":
+ await open(filePath, "Cursor");
+ return;
+ case "ghostty-nvim":
+ await openInGhosttyNvim(filePath);
+ return;
+ case "custom":
+ await open(filePath, preferences.outputEditorApplication || undefined);
+ return;
+ case "default":
+ default:
+ await open(filePath);
+ }
+}
+
+async function openInGhosttyNvim(filePath: string): Promise {
+ const initialCommand = `direct:nvim ${filePath}`;
+ if (process.platform === "darwin") {
+ await execFileAsync("/usr/bin/open", ["-na", "Ghostty", "--args", `--initial-command=${initialCommand}`]);
+ return;
+ }
+ await execFileAsync("ghostty", [`--initial-command=${initialCommand}`]);
+}
+
+function safePathSegment(value: string): string {
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "unknown";
+}
+
+function safeOutputFilename(filename: string, outputType: OutputType): string {
+ const safe = safePathSegment(filename);
+ if (safe.endsWith(".log")) return safe;
+ return `${safe || outputType}.log`;
+}
diff --git a/raycast-extension/src/menu-bar.tsx b/raycast-extension/src/menu-bar.tsx
new file mode 100644
index 0000000..f6c5105
--- /dev/null
+++ b/raycast-extension/src/menu-bar.tsx
@@ -0,0 +1,137 @@
+import { Clipboard, Icon, LaunchType, MenuBarExtra, Toast, launchCommand, showToast } from "@raycast/api";
+import { useEffect, useMemo, useState } from "react";
+import { SsyncClient } from "./api/client";
+import { STALE_JOB_CACHE_MS, getConnection, getJobCache, saveJobCache } from "./api/storage";
+import {
+ compactJobSubtitle,
+ flattenJobs,
+ isPending,
+ isRunning,
+ jobTitle,
+ sortJobs,
+ stateColor,
+ stateCountLabel,
+ stateIcon,
+} from "./lib/format";
+import type { ConnectionSettings, JobCache, JobInfo, JobsLaunchContext } from "./types/ssync";
+
+export default function Command() {
+ const [connection, setConnection] = useState();
+ const [cache, setCache] = useState();
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ async function load() {
+ const loadedConnection = await getConnection();
+ if (cancelled) return;
+ setConnection(loadedConnection);
+ if (!loadedConnection) {
+ setIsLoading(false);
+ return;
+ }
+
+ const storedCache = await getJobCache();
+ if (cancelled) return;
+ if (storedCache) {
+ setCache(storedCache);
+ setIsLoading(false);
+ }
+ if (!storedCache || Date.now() - storedCache.loadedAt > STALE_JOB_CACHE_MS) {
+ await refresh(loadedConnection, { silent: Boolean(storedCache) });
+ } else {
+ setIsLoading(false);
+ }
+ }
+ void load();
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ async function refresh(connectionOverride?: ConnectionSettings, options: { silent?: boolean } = {}) {
+ const activeConnection = connectionOverride || connection;
+ if (!activeConnection) {
+ await openJobsCommand();
+ return;
+ }
+ const activeClient = new SsyncClient(activeConnection);
+ if (!options.silent) setIsLoading(true);
+ setError(null);
+ try {
+ const responses = await activeClient.getStatus({
+ since: activeConnection.historyWindow,
+ limit: activeConnection.jobLimit,
+ });
+ const nextCache = { loadedAt: Date.now(), responses };
+ setCache(nextCache);
+ await saveJobCache(nextCache);
+ } catch (refreshError) {
+ setError(refreshError instanceof Error ? refreshError.message : String(refreshError));
+ } finally {
+ if (!options.silent) setIsLoading(false);
+ }
+ }
+
+ const jobs = sortJobs(flattenJobs(cache?.responses || []));
+ const runningJobs = jobs.filter(isRunning);
+ const pendingJobs = jobs.filter(isPending);
+ const label = stateCountLabel(jobs);
+
+ if (!connection) {
+ return (
+
+ openJobsCommand()} />
+
+ );
+ }
+
+ return (
+
+ {error ? openJobsCommand()} /> : null}
+
+ {runningJobs.length ? runningJobs.map((job) => ) : }
+
+
+ {pendingJobs.length ? pendingJobs.map((job) => ) : }
+
+
+ openJobsCommand()} />
+ {
+ await refresh();
+ await showToast({ style: Toast.Style.Success, title: "ssync jobs refreshed" });
+ }}
+ />
+ openJobsCommand()} />
+
+
+ );
+}
+
+function JobMenuItem({ job }: { job: JobInfo }) {
+ return (
+
+
+ openJobsCommand({ job, view: "detail" })} />
+ openJobsCommand({ job, view: "output" })} />
+ openJobsCommand({ job, view: "script" })} />
+ Clipboard.copy(job.job_id)} />
+
+ );
+}
+
+async function openJobsCommand(context?: JobsLaunchContext) {
+ await launchCommand({
+ name: "jobs",
+ type: LaunchType.UserInitiated,
+ context,
+ });
+}
diff --git a/raycast-extension/src/types/ssync.ts b/raycast-extension/src/types/ssync.ts
new file mode 100644
index 0000000..2bb8de5
--- /dev/null
+++ b/raycast-extension/src/types/ssync.ts
@@ -0,0 +1,178 @@
+export type JobState = "PD" | "R" | "CD" | "F" | "CA" | "TO" | "UNKNOWN" | string;
+
+export interface ConnectionSettings {
+ apiUrl: string;
+ apiKey?: string;
+ historyWindow: string;
+ jobLimit: number;
+ updatedAt: number;
+}
+
+export interface JobInfo {
+ job_id: string;
+ name: string;
+ state: JobState;
+ hostname: string;
+ user?: string | null;
+ partition?: string | null;
+ nodes?: string | null;
+ cpus?: string | null;
+ memory?: string | null;
+ time_limit?: string | null;
+ runtime?: string | null;
+ reason?: string | null;
+ work_dir?: string | null;
+ stdout_file?: string | null;
+ stderr_file?: string | null;
+ submit_time?: string | null;
+ submit_line?: string | null;
+ start_time?: string | null;
+ end_time?: string | null;
+ node_list?: string | null;
+ node_hostnames?: string[] | null;
+ batch_host?: string | null;
+ exit_code?: string | null;
+ account?: string | null;
+ qos?: string | null;
+ priority?: string | null;
+ array_job_id?: string | null;
+ array_task_id?: string | null;
+ alloc_tres?: string | null;
+ req_tres?: string | null;
+ gres?: string | null;
+ tres_per_node?: string | null;
+ cpu_time?: string | null;
+ total_cpu?: string | null;
+ user_cpu?: string | null;
+ system_cpu?: string | null;
+ ave_cpu?: string | null;
+ ave_cpu_freq?: string | null;
+ req_cpu_freq_min?: string | null;
+ req_cpu_freq_max?: string | null;
+ max_rss?: string | null;
+ ave_rss?: string | null;
+ max_vmsize?: string | null;
+ ave_vmsize?: string | null;
+ max_disk_read?: string | null;
+ max_disk_write?: string | null;
+ ave_disk_read?: string | null;
+ ave_disk_write?: string | null;
+ consumed_energy?: string | null;
+ cached?: boolean;
+ stale?: boolean;
+ refresh_queued?: boolean;
+}
+
+export interface JobStatusResponse {
+ hostname: string;
+ jobs: JobInfo[];
+ total_jobs: number;
+ query_time: string;
+ cached?: boolean;
+ group_array_jobs?: boolean;
+}
+
+export interface JobCache {
+ loadedAt: number;
+ responses: JobStatusResponse[];
+}
+
+export interface FileMetadata {
+ path: string;
+ exists: boolean;
+ size_bytes: number | null;
+ last_modified: string | null;
+ access_path: string | null;
+}
+
+export interface JobOutputResponse {
+ job_id: string;
+ hostname: string;
+ output_type: "stdout" | "stderr" | "both";
+ stdout: string | null;
+ stderr: string | null;
+ stdout_metadata: FileMetadata | null;
+ stderr_metadata: FileMetadata | null;
+ content_truncated?: boolean;
+ content_limit_bytes?: number | null;
+ cached?: boolean;
+ stale?: boolean;
+ refresh_queued?: boolean;
+}
+
+export interface JobScriptResponse {
+ job_id: string;
+ hostname: string;
+ script_content: string;
+ content_length: number;
+ local_source_dir?: string | null;
+}
+
+export interface WatcherAction {
+ type: string;
+ params?: Record;
+ config?: Record;
+ condition?: string;
+}
+
+export interface Watcher {
+ id: number;
+ job_id: string;
+ hostname: string;
+ name: string;
+ job_name?: string | null;
+ pattern: string;
+ interval_seconds: number;
+ captures: string[];
+ condition?: string;
+ actions: WatcherAction[];
+ state: string;
+ trigger_count: number;
+ failure_count?: number;
+ max_failures?: number | null;
+ last_check?: string | null;
+ last_position?: number | null;
+ created_at?: string | null;
+ timer_mode_enabled?: boolean;
+ timer_interval_seconds?: number;
+ timer_mode_active?: boolean;
+ trigger_on_job_end?: boolean;
+ trigger_job_states?: string[];
+ variables?: Record;
+ remaining_resubmits?: number;
+ is_array_template?: boolean;
+ array_spec?: string | null;
+ parent_watcher_id?: number | null;
+ discovered_task_count?: number;
+ expected_task_count?: number | null;
+}
+
+export interface WatcherEvent {
+ id: number;
+ watcher_id: number;
+ watcher_name: string;
+ job_id: string;
+ hostname: string;
+ timestamp: string;
+ matched_text: string;
+ captured_vars: Record;
+ action_type: string;
+ action_result?: string | null;
+ success: boolean;
+}
+
+export interface WatchersResponse {
+ job_id?: string;
+ watchers: Watcher[];
+ count?: number;
+}
+
+export interface WatcherEventsResponse {
+ events: WatcherEvent[];
+ count: number;
+}
+
+export interface JobsLaunchContext {
+ job?: JobInfo;
+ view?: "detail" | "output" | "script" | "watchers";
+}
diff --git a/raycast-extension/tsconfig.json b/raycast-extension/tsconfig.json
new file mode 100644
index 0000000..92a8193
--- /dev/null
+++ b/raycast-extension/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "commonjs",
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "types": ["node", "react"]
+ },
+ "include": ["src"]
+}
diff --git a/src/ssync/api/client.py b/src/ssync/api/client.py
index f250af1..2f42a88 100644
--- a/src/ssync/api/client.py
+++ b/src/ssync/api/client.py
@@ -184,6 +184,7 @@ def download_job_output(
host: str,
output_type: str = "stdout",
compressed: bool = False,
+ force_refresh: bool = False,
timeout: int = 60,
) -> tuple[str, bytes]:
"""Download a job output file from the API.
@@ -197,6 +198,7 @@ def download_job_output(
"host": host,
"output_type": output_type,
"compressed": "true" if compressed else "false",
+ "force_refresh": "true" if force_refresh else "false",
},
headers=self._get_headers(),
timeout=timeout,
diff --git a/src/ssync/web/api/job.py b/src/ssync/web/api/job.py
index ed4c36f..a907877 100644
--- a/src/ssync/web/api/job.py
+++ b/src/ssync/web/api/job.py
@@ -226,6 +226,9 @@ async def download_job_output(
host: str = Query(..., description="Host where job is running"),
output_type: str = Query("stdout", regex="^(stdout|stderr)$"),
compressed: bool = Query(default=False, description="Download as gzip"),
+ force_refresh: bool = Query(
+ False, description="Force refresh from SSH even if cached"
+ ),
_authenticated: bool = Depends(verify_api_key_dependency),
):
"""Download job output file, optionally compressed."""
@@ -236,6 +239,7 @@ async def download_job_output(
host=host,
output_type=output_type,
compressed=compressed,
+ force_refresh=force_refresh,
get_slurm_manager=get_slurm_manager,
)
diff --git a/src/ssync/web/services/jobs.py b/src/ssync/web/services/jobs.py
index 3b9d6da..eddea57 100644
--- a/src/ssync/web/services/jobs.py
+++ b/src/ssync/web/services/jobs.py
@@ -713,6 +713,7 @@ async def build_download_job_output_response(
host: str,
output_type: str,
compressed: bool,
+ force_refresh: bool = False,
get_slurm_manager,
) -> StreamingResponse:
cache = get_cache()
@@ -722,7 +723,7 @@ async def build_download_job_output_response(
content = None
compression = "none"
original_size = 0
- if cached_job:
+ if cached_job and not force_refresh:
content, compression, original_size = get_cached_output_payload(
cached_job,
output_type,
diff --git a/tests/unit/test_api_client_output.py b/tests/unit/test_api_client_output.py
index 5e4daef..e4308aa 100644
--- a/tests/unit/test_api_client_output.py
+++ b/tests/unit/test_api_client_output.py
@@ -37,3 +37,38 @@ def fake_get(url, **kwargs):
"output_type": "stdout",
"all": "true",
}
+
+
+@pytest.mark.unit
+def test_download_job_output_sends_force_refresh_query_param(monkeypatch):
+ calls = {}
+
+ class _Response:
+ headers = {"Content-Disposition": 'attachment; filename="job_1234_stdout.log"'}
+ content = b"full-output"
+
+ def raise_for_status(self):
+ return None
+
+ def fake_get(url, **kwargs):
+ calls["url"] = url
+ calls["kwargs"] = kwargs
+ return _Response()
+
+ monkeypatch.setattr("ssync.api.client.requests.get", fake_get)
+
+ client = APIClient(base_url="https://ssync.test", api_key="secret")
+ assert client.download_job_output(
+ job_id="1234",
+ host="entalpic",
+ output_type="stdout",
+ force_refresh=True,
+ ) == ("job_1234_stdout.log", b"full-output")
+
+ assert calls["url"] == "https://ssync.test/api/jobs/1234/output/download"
+ assert calls["kwargs"]["params"] == {
+ "host": "entalpic",
+ "output_type": "stdout",
+ "compressed": "false",
+ "force_refresh": "true",
+ }