Compare commits

...

29 Commits

Author SHA1 Message Date
Alan Wizemann e8fcd699f2 Merge pull request #38 from awizemann/claude/beautiful-goldstine-6aa6c5
refactor(settings): remove unused providers list
2026-04-23 01:15:17 +01:00
Alan Wizemann d82b28258d refactor(settings): remove unused providers list
The hardcoded `providers` array in SettingsViewModel was never referenced — no view reads `viewModel.providers`; the Model picker uses the models.dev catalog via `ModelCatalogService.loadProviders()` and Provider is shown as a `ReadOnlyRow` in the General tab. Leaving the dead list around makes issues like #33 look plausible (users reasonably guess a stale enum is normalising `openai-codex` → `openai` on save, which the code does not actually do).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:13:49 +02:00
Alan Wizemann 323a1e55f4 Merge pull request #34 from awizemann/fix/code-review-apr22
fix: address code-review findings from Apr 22 commits
2026-04-23 00:03:18 +01:00
Alan Wizemann 959a68b707 fix: address code-review findings from Apr 22 commits
Three follow-ups from reviewing 1989fee (sidebar-width persist) and
4163595 (default server on launch):

- `SplitViewAutosaveFinder` hardcoded `"ScarfMainSidebar"` for every
  window. Since Scarf's `WindowGroup` spawns one window per `ServerID`,
  all windows shared the same `NSSplitView.autosaveName` — AppKit
  documents that name as required-unique, and in practice per-window
  widths collapsed onto a single UserDefaults key. Thread the window's
  `ServerContext` in through `@Environment(\.serverContext)` (already
  wired at `WindowGroup` construction) and suffix the name with the
  server UUID.
- `setDefaultServer` fired `onEntriesChanged`, whose sole consumer is
  `ServerLiveStatusRegistry.rebuild()` for menu-bar fanout. Flipping a
  default flag doesn't change the set of servers; the callback was
  semantic noise. Drop the call — SwiftUI views still redraw on the
  flag flip via `@Observable`'s tracking of `entries`.
- The filled-yellow star in `ManageServersView` had a no-op action
  inside `if !isDefault { ... }` but still animated its pressed state
  on click. Replace the conditional with `.disabled(isDefault)` so the
  row is visually inert when it already is the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:59:58 +02:00
Alan Wizemann e76fbf9937 chore: audit follow-ups from plan review
Four small fixes surfaced by a side-by-side plan-vs-shipped pass:

- README.md: adds the Template Catalog section the plan called out —
  links to the live site URL, the install flows (web / file / Finder),
  and templates/CONTRIBUTING.md for authors. Placed right before the
  existing Contributing section, with a catalog-specific cross-link at
  the end of that section too.
- CLAUDE.md: adds the Template Catalog section so future agent sessions
  know the regenerator pipeline exists, how it relates to release.sh +
  wiki.sh, and what the schema-sync rule is when DashboardWidget or
  ProjectTemplateManifest change.
- scarf/scarfTests/ProjectTemplateTests.swift: fixes the stale
  ProjectTemplateExampleTemplateTests docstring still referencing
  `examples/templates/` (the example moved to `templates/awizemann/`
  in 70f7cea).
- .github/workflows/validate-template-pr.yml: untangles the self-
  contradictory Python-version comment. The validator is 3.9+
  compatible; CI uses 3.11 for faster runner caching. Same stdlib
  surface, same code paths — just clearer about why.

All tests still green: 22 Swift tests in 7 suites, 16 Python tests,
catalog check passes on the site-status-checker example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann c9b8da9ec5 feat(ci): validate template submissions on PR + tailored checklist
Adds the CI gate that runs on every PR touching templates/, the catalog
validator, or its tests. The Action:
- runs tools/test_build_catalog.py (catches drift between validator +
  its own test suite on the same PR that introduces the drift)
- runs tools/build-catalog.py --check (validates every shipped .scarftemplate
  against the same invariants ProjectTemplateService.verifyClaims enforces
  at install time)
- posts a PR comment with the last 3 KB of the validator log on failure,
  so contributors see the specific mismatch without hunting through the
  Actions UI

.github/PULL_REQUEST_TEMPLATE/template-submission.md is the author-facing
checklist that mirrors templates/CONTRIBUTING.md. Opt-in via the
?template=template-submission.md compare URL (documented in the
contribution guide). CONTRIBUTING.md now links both the PR template and
the workflow file so authors know what to expect.

Phase 4 closes the community loop — from this commit on, a stranger can
fork the repo, follow templates/CONTRIBUTING.md, push a PR, and get
deterministic green/red feedback before a maintainer ever looks at it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 6175bee27d feat(site): dogfood the Scarf dashboard format as the catalog website
Adds site/ with vanilla HTML + CSS + ~300 lines of JavaScript that
renders ProjectDashboard JSON directly in the browser. Each template's
detail page shows a live preview of the exact dashboard the user will
get post-install — the catalog IS the dogfood.

site/widgets.js mirrors the Swift widget dispatcher:
- stat (big number + colored icon + optional subtitle)
- progress (0..1 bar)
- text with inline markdown subset (headings, bold/italic, inline code,
  code fences, bullet + numbered lists, links)
- table (plain HTML)
- list (with up/down/unknown status badges)
- chart (SVG line + bar — no Chart.js dependency)
- webview (sandboxed iframe)
- unknown (placeholder so the page doesn't silently omit widgets)

Plus the renderMarkdown helper used by the template detail page to
display the bundle's README.

site/index.html.tmpl + site/template.html.tmpl are substitution-only —
the Python regenerator swaps {{CARDS}}, {{COUNT}}, {{COUNT_PLURAL}},
{{NAME}}, {{DESC}}, {{VERSION}}, {{AUTHOR_HTML}}, {{TAGS_HTML}},
{{INSTALL_URL_ENCODED}}, {{SCARF_INSTALL_URL}}. The detail page fetches
dashboard.json + README.md at page load and hands them to widgets.js.
No client-side framework, no bundler, no npm.

site/styles.css: minimal CSS with scarf green accent, prefers-color-
scheme dark support, responsive at 680px. One file, ~280 lines.

build-catalog.py extended to copy dashboard.json + README.md out of each
bundle into its detail dir so widgets.js can fetch them without
reaching across directories (and so gh-pages doesn't need to serve zip
contents at request time).

Two new Python tests: end-to-end site rendering (both cards, install
URL wiring, static asset copy, per-template dashboard + README copy)
and the {{COUNT_PLURAL}} singular-vs-plural flip. 16/16 Python tests
green.

Smoke-tested locally with python3 -m http.server: every endpoint
(index, catalog.json, detail HTML, per-template dashboard.json + README,
widgets.js) returns 200. The .gh-pages-worktree/appcast.xml +
.gh-pages-worktree/index.html are untouched — the catalog is purely
additive under /templates/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 11732baa3c feat(catalog): stdlib-only Python validator + regenerator for templates/
Adds the catalog pipeline without introducing any external dependencies.
tools/build-catalog.py walks templates/<author>/<name>/, validates every
shipped .scarftemplate against its manifest (same invariants Swift's
ProjectTemplateService.verifyClaims enforces at install time), and emits
templates/catalog.json for the frontend to read.

Validator invariants:
- Required bundle files: template.json, README.md, AGENTS.md, dashboard.json
- contents claim cross-checked against actual zip entries (instructions,
  skills, cron count, memory appendix)
- dashboard.json widget types restricted to the vocabulary the Swift
  renderer knows
- Manifest id author component must match the template directory
- 5 MB bundle-size cap on submissions (installer's own cap is 50 MB)
- High-confidence secret patterns (private keys, GitHub PATs, Slack tokens,
  AWS access keys, OpenAI/Anthropic keys) block the bundle
- staging/ source tree must match the built bundle byte-for-byte — catches
  the common failure mode of editing staging/ but forgetting to rebuild

scripts/catalog.sh wraps the Python script with check/build/preview/serve/
publish subcommands, mirroring the scripts/wiki.sh shape. publish adds a
second-pass hard-pattern secret scan on the rendered gh-pages output so
template prose can't leak credentials even if the Python scan missed them.

tools/test_build_catalog.py has 14 unit tests covering the main validator
paths (minimal-valid, missing-AGENTS, content-claim mismatch, author
mismatch, oversized bundle, unknown widget type, secret detection,
staging-drift detection, missing bundle, catalog.json shape, and a real-
bundle end-to-end check against templates/awizemann/site-status-checker).
Python 3.9 compatible (Xcode's bundled python3), so no runtime needs
installing.

templates/catalog.json committed as the first generated aggregate index;
maintainers regenerate on merge by running `./scripts/catalog.sh build`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann d8a0a89db2 feat(templates): promote examples/ to templates/<author>/<name>/ catalog layout
Set up the catalog directory structure this branch will fill with
community templates. The existing site-status-checker example moves
from examples/templates/ to templates/awizemann/site-status-checker/
(tracked by git as a rename so history is preserved). The examples/
directory is removed.

New top-level docs:
- templates/README.md — landing for folks browsing the catalog on
  github.com. Lists the current templates and points at the live site.
- templates/CONTRIBUTING.md — author-facing submission walkthrough.
  Requires AGENTS.md, pre-flight with tools/build-catalog.py --check
  (added in the next commit), one template per PR, don't edit
  catalog.json (maintainer regenerates it post-merge).

ProjectTemplateExampleTemplateTests.locateExample updated to search
templates/<author>/<name>/ instead of examples/templates/ — the test
still walks up from #filePath to find the repo root so it works in
both xcodebuild and Xcode IDE test runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 38c075d61d docs: ship site-status-checker example template + v2.2.0 release notes
First installable template demonstrating the format:
- Dashboard with stat widgets (up/down/last-checked) + configured-sites
  list + quick-start markdown.
- Cross-agent AGENTS.md with the full cron-prompt contract so any agent
  that reads agents.md (Claude Code, Cursor, Codex, Aider, Jules,
  Copilot, Zed, …) picks up the behavior on first run.
- Cron job (0 9 * * *) that ships paused with the [tmpl:…] tag, pinging
  a user-editable sites.txt and writing results to status-log.md.
- First-run bootstrap logic in AGENTS.md: if sites.txt doesn't exist
  yet the agent creates it with two placeholder URLs, then proceeds.

Plus examples/templates/README.md explaining the staging/ layout,
authoring conventions, and how to rebuild a bundle after editing. CI
validates the bundle via ProjectTemplateExampleTemplateTests so drift
between staging/ and the built .scarftemplate fails on every build.

v2.2.0 release notes cover the full feature surface including the
install preview sheet, scarf:// + file:// URL handling, skills
namespacing, cron-job tagging, memory-block markers, and the
lock-driven uninstall flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann c800b93804 feat: project templates v1 (install + uninstall + export + URL handler)
Shareable `.scarftemplate` bundle format lets users package a project's
dashboard, cross-agent AGENTS.md, optional per-agent instruction shims,
optional namespaced skills, optional tagged cron jobs, and an optional
memory appendix into a single zip that anyone can install with one click.

Core:
- Bundle format + manifest schema v1 (template.json with contents claim
  cross-checked against zip entries to prevent hidden files).
- ProjectTemplateService inspects + validates + builds an install plan.
- ProjectTemplateInstaller executes plans with transport-routed I/O so
  the v1 local-only flow extends cleanly to remote ServerContexts later.
- ProjectTemplateExporter builds bundles from existing projects with
  user-selected skills + cron jobs.
- ProjectTemplateUninstaller reverses installs using template.lock.json.
  Only lock-tracked files are removed; user-added files are preserved.

UI:
- Templates menu in Projects toolbar: Install from File, Install from
  URL, Export as Template.
- Preview-and-confirm sheets for install, uninstall, and export with
  full diff of what will be written/removed before anything runs.
- Right-click context menu on project list + dashboard header button
  for uninstall (only shown when template.lock.json exists).

Deep link + file associations:
- scarf:// URL scheme registered; onOpenURL in scarfApp.swift routes
  scarf://install?url=https://... and file:// URLs for .scarftemplate
  files to the install sheet.
- Custom UTType com.scarf.template registered so Finder shows the file
  with a Scarf icon and double-click opens the install preview.
- Cold-launch race fix: .task picks up any URL staged on the router
  before the onChange observer was installed.

Safety:
- Never writes to config.yaml, auth.json, sessions, or credentials.
- Cron jobs ship paused with a [tmpl:<id>] name prefix.
- Skills install to a namespaced ~/.hermes/skills/templates/<slug>/ dir
  so they never collide with user-authored skills.
- Memory appendix is wrapped in scarf-template:<id>:begin/end markers
  for clean removal during uninstall.
- Download cap: 50 MB for URL-fetched templates, enforced on the actual
  on-disk file size after download so chunked transfers can't bypass it.

Tests: 22 tests in 7 suites cover manifest parsing, claim verification,
URL routing (scarf:// + file://), end-to-end install and uninstall
against a minimal bundle (projects registry is snapshotted + restored),
user-added file preservation, and exporter round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 7311320bfd Merge pull request #30 from awizemann/claude/issue-26-default-server
Let users pick the default server opened on launch (#26)
2026-04-22 22:52:33 +01:00
Alan Wizemann 4663697942 Merge pull request #29 from awizemann/claude/issue-26-sidebar-width
Persist sidebar width across launches (#26)
2026-04-22 22:44:57 +01:00
Claude 41635955b0 feat: let users pick the default server opened on launch (#26)
Repurposes the previously-unused ServerEntry.openOnLaunch flag so users
can nominate Local or any registered remote as the server Scarf opens
into when a fresh window has no prior binding (first launch or File →
New Window).

- ServerRegistry gains `defaultServerID` (returns the flagged entry's
  ID or falls back to Local) and `setDefaultServer(_:)` (flips the flag
  on the named entry and clears it elsewhere, then persists).
- ScarfApp's WindowGroup defaultValue closure now returns
  `registry.defaultServerID` instead of hardcoded `ServerContext.local.id`.
- ManageServersView gains a Local row at the top of the list plus a
  star button per row: filled yellow on the current default, outline on
  the others. Click to promote.

Backward compatible: the openOnLaunch field was already in the persisted
schema (default false), so existing servers.json files load unchanged —
Local remains the default until the user picks otherwise.

Refs #26
2026-04-22 11:00:32 +00:00
Claude 1989feee22 feat: persist sidebar width across launches (#26)
Wire an NSSplitView autosave name into NavigationSplitView's underlying
AppKit split view so the sidebar's drag-to-resize position is remembered
in UserDefaults and restored on next launch.

SplitViewAutosave.swift installs an invisible NSViewRepresentable that
walks up the view hierarchy from the sidebar, finds the enclosing
NSSplitView, and assigns autosaveName = "ScarfMainSidebar". AppKit
handles persistence from there — no manual UserDefaults or @AppStorage
plumbing needed.

ContentView also gets navigationSplitViewColumnWidth(min:ideal:max:)
bounds so first-launch (before any autosave exists) lands at a sensible
240pt ideal within a 180–360pt range.

Refs #26
2026-04-22 10:58:34 +00:00
Alan Wizemann 8773254d11 chore: accept safe parts of Xcode recommended-settings migration
Xcode 26.x suggested an upgrade pass that included a critical regression:
ENABLE_APP_SANDBOX = YES on the main app, which would silently break every
view that reads ~/.hermes/ (state.db, config.yaml, memory files, skills,
logs). Scarf is architected sandbox-off per CLAUDE.md — reverted.

Kept the benign pieces:

- DEAD_CODE_STRIPPING = YES on all targets (stock modern optimization)
- CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES at project level —
  static analyzer warning for un-localizable call sites; directly
  relevant to the i18n work in 2.1.0 and will flag regressions of the
  exact patterns just cleaned up
- STRING_CATALOG_GENERATE_SYMBOLS = YES hoisted to project level
  (was already set at target level; hoisting is a no-op functional
  change but Xcode prefers it inherited)
- Scheme file LastUpgradeVersion bumped to 2620 to match current Xcode

Rejected:
- ENABLE_APP_SANDBOX = YES (critical — would break app file access)
- ENABLE_RESOURCE_ACCESS_AUDIO_INPUT / RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION
  build settings (Xcode's new form replacing the entitlements file;
  keeping the entitlements file as the single source of truth since
  every release 1.x → 2.1.0 shipped and notarized with that form)
- LastUpgradeCheck = 2620 (Xcode dropped 2630 → 2620; cosmetic revert)

v2.1.0 was released before this Xcode pass so no rebuild needed — the
downloaded zips and Sparkle appcast entry are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:27:55 -07:00
Alan Wizemann a1aa653a33 chore: Bump version to 2.1.0 2026-04-20 18:46:47 -07:00
Alan Wizemann e256196397 chore: commit shared Xcode scheme
The scarf scheme existed in every local Xcode session (Xcode auto-creates
it from xcschememanagement's ^#shared#^ entry on first open), but was
never actually committed to the repo. Release v2.1.0 hit the resulting
"project contains no schemes" error on headless xcodebuild archive after
the build/ cache was cleaned. Committing the scheme itself so future
headless builds work from a fresh clone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:46:36 -07:00
Alan Wizemann 50880efe81 docs: prep v2.1.0 release notes + README language badge
Pre-release prep so that when `./scripts/release.sh 2.1.0` runs on main,
the notes file is already in tree (script's `git add` is then a no-op,
bump commit contains only the pbxproj version change).

- README gains a 2.1 "What's New" section covering translations + the
  chat slash-menu; 2.0 moves down to "Previously".
- Badge row gains a language list line.
- Full release notes at releases/v2.1.0/RELEASE_NOTES.md — covers the
  three stacked i18n PRs (infra, audit burn-down, translations) and the
  chat slash-menu work merged in parallel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:39:51 -07:00
Alan Wizemann b1bc7e8494 Merge pull request #25 from awizemann/translations-initial
feat(i18n): initial translations for 6 languages + contributor workflow
2026-04-20 18:37:06 -07:00
Alan Wizemann f47034d4ad fix(i18n): localize sidebar, settings tabs, and settings section titles
Three connected bugs where the Label/SettingsSection APIs took a `String`,
which routes through the StringProtocol overloads and bypasses localization
entirely. Identified by the user after testing zh-Hans / de / fr — the
sidebar menu items, Settings tab bar, and Settings section headers all
remained English under any App Language override.

- SidebarSection now exposes displayName: LocalizedStringResource; SidebarView
  builds Label via the Text/Image builders so the catalog key is actually
  used.
- SettingsTab gets the same displayName treatment; the .tabItem Label builds
  through the Text/Image builder too.
- SettingsSection.title changes from String → LocalizedStringKey so literal
  call sites (all ~20 of them) now extract into the catalog. Two call sites
  that were passing String variables (PlatformsView, CredentialPoolsView) are
  wrapped via LocalizedStringKey(...) — brand/provider names fall through to
  English as before. AuxiliaryTab's static task list gets a LocalizedStringKey
  column so its section titles extract too.

This change newly extracts 65 previously-invisible section-title keys into
the catalog; translations added for all six locales. Catalog: 575 → 644
source keys, each locale translated for 583 of them (brand names / protocol
names / format-only keys intentionally fall through).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 03:32:32 +02:00
Alan Wizemann 1726a613a5 feat(i18n): add translations for zh-Hans, de, fr, es, ja, pt-BR
Ships first-pass AI translations for six locales on top of the existing
English base, plus a simple JSON-per-locale contributor workflow so new
languages can land as a single PR.

- 518 keys translated per locale (proper nouns / brand names / format-
  only strings left to fall back to English by design — see the
  "Non-blocking (intentional verbatim)" section of scarf/docs/I18N.md).
- Per-locale source-of-truth lives in tools/translations/<locale>.json;
  tools/merge-translations.py writes them into Localizable.xcstrings
  and is idempotent (re-runnable as translators iterate).
- InfoPlist.xcstrings (macOS microphone permission prompt) translated
  for all six locales.
- knownRegions expanded: zh-Hans, de, fr now join by es, ja, pt-BR.
- CONTRIBUTING.md gains an "Adding a Language" section documenting the
  fork → JSON → merge → PR flow. Native-speaker reviews welcome.

Closes #13 (the original ask: Simplified Chinese support).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:16:41 -07:00
Alan Wizemann de34a80807 Merge pull request #24 from awizemann/multi-language
feat(i18n): close silently un-localizable sites (Phase 1b)
2026-04-20 17:52:18 -07:00
Alan Wizemann d9a25b3997 Merge pull request #22 from awizemann/claude/pedantic-kare-1edf13
feat(i18n): enable String Catalog + locale-aware numeric formatters
2026-04-20 17:51:09 -07:00
Alan Wizemann b40182f2da feat(i18n): close silently un-localizable sites from the audit
Burns down the follow-ups tracked in scarf/docs/I18N.md so that future
translation passes (Phase 2+) don't see English leak through ternary UI
copy, enum rawValue displays, or fixed-format strings.

- Ternary status copy: Text(cond ? "A" : "B") → cond ? Text("A") : Text("B")
  (each branch routes through LocalizedStringKey). Covers Health, Chat
  (voice/TTS/recording/ACP status), Profiles, MCPServer test result,
  SignalSetup, QuickCommands header.
- Enum .rawValue displays: LogFile, LogComponent, DashboardTab, Skills.Tab,
  InsightsPeriod, ToolKind, AuthType each expose a
  displayName: LocalizedStringResource. LogEntry.LogLevel stays verbatim
  (technical jargon — DEBUG/INFO/ERROR/… are industry-standard).
- displayName passthroughs: HermesToolPlatform, ServerRegistry.Entry,
  MCPServerPreset wrapped with Text(verbatim:) at call sites (brand names
  and user data, not UI chrome). MCPTransport.displayName promoted to
  LocalizedStringResource.
- Composite format strings: ModelPickerSheet "ctx" suffix, InsightsView
  "tokens" suffix and MCPServerTestResultView "%.1fs · %d tools" rewritten
  as Text("\(arg) suffix") LocalizedStringKey. Percent display uses
  .formatted(.percent) after /100.
- Day-of-week chart now sources from Calendar.current.shortWeekdaySymbols,
  re-indexed for the existing Mon=0 data model.
- ConnectionStatusPill's label + tooltip return Text (not String) so the
  .help(Text) / direct-render paths localize correctly.
- Catalog re-synced: 545 → 575 keys (+30 from new ternary branches and
  enum displayName values).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:40:56 -07:00
Alan Wizemann 6817c95681 chore(i18n): sync catalog after rebasing onto chat slash-menu work
Picks up 7 new Text("…") keys introduced by a68e0c5 and c8208de
(loading state copy, slash-menu empty states, argument-hint placeholder).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:25:16 -07:00
Alan Wizemann 89748fdfee feat(i18n): enable String Catalog + locale-aware numeric formatters
Lays the groundwork for zh-Hans / de / fr translations on an English base.
No user-visible English-locale behavior changes. See scarf/docs/I18N.md for
the full plan and remaining audit follow-ups.

- Localizable.xcstrings seeded with 538 keys auto-extracted via
  `xcstringstool sync` from the Swift sources
- InfoPlist.xcstrings carrying NSMicrophoneUsageDescription
- knownRegions += zh-Hans, de, fr
- Currency / byte-count / compact-number String(format:) sites migrated to
  Locale.current-aware .formatted() style (currency, byteCount(.file),
  compactName notation) — previously rendered POSIX separators + English
  unit names regardless of user locale

Refs #13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:24:29 -07:00
Alan Wizemann c8208dedb1 fix(chat): slash-menu filter, auto-scroll on send/complete, loading state
- Slash menu: filter at the parent and pass the pre-filtered list to
  SlashCommandMenu (pure-prefix match, no description fallback). Adds
  `.id(menuQuery)` to force a fresh view on every query so SwiftUI can't
  render stale props — this was the cause of "typing /mo still shows
  /help" (the old description fallback plus a cached child view kept
  /help pinned regardless of query).
- Auto-scroll to bottom when the user submits a message and again when
  the prompt completes. `.defaultScrollAnchor(.bottom)` handles slow
  streaming fine, but rapid slash-command responses outran the anchor
  and left the response off-screen.
- Loading state: add `ChatViewModel.isPreparingSession` (true during
  Starting / Creating / Loading / Reconnecting). While true, the message
  list swaps its placeholder for a ProgressView — non-blocking, just a
  view inside the ScrollView.
- Center the empty-state placeholder properly: replace
  `.padding(.vertical, 80)` with Spacers inside
  `.containerRelativeFrame(.vertical)` so the placeholder sits in the
  true vertical center of the chat pane at any window size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:15:46 -07:00
Alan Wizemann a68e0c5f42 feat(chat): slash-command menu + scroll/layout fixes
- Add floating slash-command menu driven by ACP available_commands_update
  and user-defined quick_commands from config.yaml. ↑/↓ navigate, Tab or
  Enter completes, Esc dismisses. Commands with argument hints insert a
  trailing space so the user can type the argument.
- New HermesSlashCommand model carries name/description/argumentHint/source;
  RichChatViewModel stores ACP + quick_commands separately and merges them
  for the menu. QuickCommandsViewModel exposes a reusable static loader.
- Menu renders as a sibling above the input HStack (not a popover or
  overlay) — guaranteed to render regardless of focus/z-order quirks.
- Hide the dedicated /compress button once the menu has more than one
  command; keep it as a fallback when only /compress is advertised.
- Fix long-standing "session loads with whitespace, must scroll up to see
  chat" bug by switching LazyVStack → VStack in RichChatMessageList.
  LazyVStack's estimated row heights were fooling .defaultScrollAnchor(.bottom)
  into overshooting real content; VStack measures every row upfront so the
  anchor has real heights to work with.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:35:53 -07:00
100 changed files with 35181 additions and 209 deletions
@@ -0,0 +1,42 @@
<!--
Use this template when submitting a new Scarf project template or updating
an existing one. For regular code/docs PRs, delete this template and write
your own summary.
Switch to this template by adding `?template=template-submission.md` to the
compare URL, or let GitHub pick it up automatically when you touch files
under templates/.
-->
## What's in this PR
- [ ] New template: `templates/<your-handle>/<your-template-name>/`
- [ ] Update to existing template: `templates/<author>/<name>/` (which one and why)
## One-line pitch
_What does this template do for its installers? Two sentences max._
## Checklist
- [ ] I wrote this template, or have the author's explicit permission to submit it.
- [ ] `AGENTS.md` is present and tells any cross-agent what the project does and how to run it.
- [ ] `README.md` includes install, customize, and uninstall instructions.
- [ ] The bundle's `template.json` `contents` claim matches what's actually in the zip.
- [ ] Cron jobs (if any) ship paused and use self-contained prompts.
- [ ] No secrets in any file (API keys, tokens, hostnames, IPs, credentials).
- [ ] No writes to `config.yaml`, `auth.json`, or credential paths — v1 installer will refuse.
- [ ] `python3 tools/build-catalog.py --check` passes locally.
- [ ] I installed + uninstalled this template on my machine and verified the `AGENTS.md` contract works end-to-end.
- [ ] I did **not** edit `templates/catalog.json` — the maintainer regenerates it post-merge.
## Testing notes
_What did you run, what did you see? Paste the log output of the cron job
firing once, or the chat transcript of asking the agent to do the main
thing. Reviewers don't have your machine — show, don't tell._
## Screenshots (optional)
_Drop screenshots of the installed dashboard, or the catalog detail page
rendered locally (`./scripts/catalog.sh preview && open /tmp/scarf-catalog-preview/templates/<slug>/index.html`)._
@@ -0,0 +1,74 @@
# Validates `.scarftemplate` bundles on PRs that touch templates/.
#
# Mirrors the invariants `ProjectTemplateService.verifyClaims` enforces at
# install time. Runs the same Python script the maintainer uses locally
# (tools/build-catalog.py --check) so a bundle can't reach main unless the
# validator is happy.
#
# Also runs tools/test_build_catalog.py so drift between the validator and
# its own test suite is caught on the same PR.
name: Validate template submissions
on:
pull_request:
paths:
- 'templates/**'
- 'tools/build-catalog.py'
- 'tools/test_build_catalog.py'
- '.github/workflows/validate-template-pr.yml'
permissions:
contents: read
pull-requests: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full clone so we can diff against the PR base and scope
# --only to just the changed templates if we want to later.
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
# The validator is stdlib-only and tested against 3.9+ (the
# system Python on current macOS, what most maintainers run
# locally). CI uses 3.11 for faster cold-cache times on
# GitHub Actions runners — same stdlib APIs, same code paths.
python-version: '3.11'
- name: Run validator unit tests
run: python3 tools/test_build_catalog.py -v
- name: Validate every template
id: validate
run: |
set -o pipefail
python3 tools/build-catalog.py --check 2>&1 | tee /tmp/validator.log
- name: Post failure comment
if: failure() && steps.validate.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let body = '## Template validation failed\n\n';
try {
const log = fs.readFileSync('/tmp/validator.log', 'utf8');
body += '```\n' + log.slice(-3000) + '\n```\n';
} catch (e) {
body += 'See the failed job log for details.\n';
}
body += '\nFix the issues above and push again — the check reruns automatically.\n';
body += '\nLocal reproduction: `python3 tools/build-catalog.py --check`\n';
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
+42
View File
@@ -85,3 +85,45 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
## Hermes Version
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
## Project Templates
Scarf ships a `.scarftemplate` format (v1 as of 2.2.0) for sharing pre-packaged projects across users and machines. A bundle is a zip containing:
- `template.json` — manifest (id, name, version, `contents` claim)
- `README.md` — shown in the install preview sheet
- `AGENTS.md` — required; the [Linux Foundation cross-agent instructions standard](https://agents.md/) — every template is agent-portable out of the box
- `dashboard.json` — copied to `<project>/.scarf/dashboard.json`
- `instructions/…` — optional per-agent shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`)
- `skills/<name>/…` — optional; installed to `~/.hermes/skills/templates/<slug>/` (namespaced so uninstall is `rm -rf` on one folder)
- `cron/jobs.json` — optional; registered via `hermes cron create` with a `[tmpl:<id>] …` name prefix and immediately paused
- `memory/append.md` — optional; appended to `~/.hermes/memories/MEMORY.md` between `<!-- scarf-template:<id>:begin/end -->` markers
Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTemplateService.swift) (inspect + validate + plan), [ProjectTemplateInstaller.swift](scarf/scarf/Core/Services/ProjectTemplateInstaller.swift) (execute a plan), [ProjectTemplateExporter.swift](scarf/scarf/Core/Services/ProjectTemplateExporter.swift) (build a bundle from a project), [ProjectTemplateUninstaller.swift](scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift) (reverse an install using the lock file). UI in [Features/Templates/](scarf/scarf/Features/Templates/). The `scarf://install?url=<https URL>` deep link + `file://` URLs for `.scarftemplate` files are handled by [TemplateURLRouter.swift](scarf/scarf/Core/Services/TemplateURLRouter.swift) and `onOpenURL` in `scarfApp.swift`. A `<project>/.scarf/template.lock.json` uninstall manifest is written after every install and drives the uninstall flow.
**Uninstall semantics:** driven by the lock file. Only files listed in `lock.projectFiles` are removed from the project dir; user-added files (e.g. a `sites.txt` created on first run) are preserved. If every file in the dir was installed by the template, the dir is removed too; otherwise the dir stays with just the user's files. Skills namespace is always removed wholesale (it's isolated). Cron jobs are removed via `hermes cron remove <id>` after resolving each lock-recorded name. Memory block is stripped between the `begin`/`end` markers, leaving the rest of MEMORY.md intact. No "undo" — uninstall is destructive; to re-install, run the install flow again. Uninstall UI lives on the project-list context menu and the dashboard header (only shown when the selected project has a lock file).
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
## Template Catalog
Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths.
Pipeline:
- **Validator + regenerator:** [tools/build-catalog.py](tools/build-catalog.py) is stdlib-only Python (3.9+). It walks `templates/*/*/`, validates every `.scarftemplate` against its manifest claim (mirrors the Swift `ProjectTemplateService.verifyClaims` invariants), enforces a 5 MB bundle-size cap, scans for high-confidence secret patterns, checks `staging/` matches the built bundle byte-for-byte, and emits `templates/catalog.json`. Tested by [tools/test_build_catalog.py](tools/test_build_catalog.py) — 16 tests covering every validation path.
- **Wrapper:** [scripts/catalog.sh](scripts/catalog.sh) mirrors the `scripts/wiki.sh` shape with `check / build / preview / serve / publish` subcommands. `publish` runs a second-pass secret-scan against the rendered site before committing + pushing `gh-pages`.
- **Site source:** `site/index.html.tmpl` + `site/template.html.tmpl` are `{{TOKEN}}`-substitution templates. `site/widgets.js` (~300 lines of vanilla JS) is the dogfood — renders a `ProjectDashboard` JSON into HTML using the same widget vocabulary the Swift app uses, so each template's detail page shows a live preview of its post-install dashboard.
- **Install-URL hosting:** raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template Releases ceremony.
- **CI gate:** [.github/workflows/validate-template-pr.yml](.github/workflows/validate-template-pr.yml) runs the Python validator + its own test suite on every PR that touches `templates/`, the validator, or its tests. Failures post a comment on the PR with the last 3 KB of the validator log.
Maintainer workflow on merge to main:
```bash
./scripts/catalog.sh build # regenerate templates/catalog.json + .gh-pages-worktree/templates/
./scripts/catalog.sh publish # secret-scan rendered output + commit + push gh-pages
```
Same cadence as `scripts/release.sh` (manual, auditable, no auto-deploy). Runs stay isolated: release.sh only touches `appcast.xml` on gh-pages; catalog.sh only touches `templates/` on gh-pages. Never push catalog output on a release cadence or vice versa.
**Schema is Swift-primary.** When `ProjectDashboardWidget.type` gains a new case or `ProjectTemplateManifest` adds a field, update Swift first, then mirror into `tools/build-catalog.py` (`SUPPORTED_WIDGET_TYPES`, `_validate_manifest`, `_validate_contents_claim`) so the web validator stays honest. The Python test suite's real-bundle test catches drift on the example template but not on the full widget vocabulary — add a synthetic fixture to `test_build_catalog.py` for any new widget type.
+17
View File
@@ -37,6 +37,23 @@ Rules:
Public docs live in the [GitHub wiki](https://github.com/awizemann/scarf/wiki). Small fixes (typos, clarifications) can be made via the "Edit" button on any wiki page — you need push access to the main repo. For larger changes, clone the wiki locally (`git clone git@github.com:awizemann/scarf.wiki.git`) or open an issue describing the proposed change.
## Adding a Language
Scarf ships with English + Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese. To add another locale (or improve an existing one):
1. **Fork** the repo and create a branch.
2. **Add the locale to `knownRegions`** in `scarf/scarf.xcodeproj/project.pbxproj` — follow the existing list (e.g. add `it` after `"pt-BR"`).
3. **Drop a new JSON file at `tools/translations/<locale>.json`** — copy an existing one (say `tools/translations/es.json`) as a starting point. Each entry maps the English source string to your translation. Keys you omit fall back to English at runtime — do that for proper nouns (Scarf, Hermes, Anthropic, OAuth, SSH, …) and for anything technical that shouldn't translate.
4. **Preserve format specifiers exactly**: `%@`, `%lld`, `%d`, positional `%1$@` / `%2$lld`, etc. If word order needs to change in your language, use positional forms (`%1$@ … %2$@`).
5. **Add your locale to `tools/merge-translations.py`'s `LOCALES` list** and run `python3 tools/merge-translations.py` — this writes your translations into `scarf/scarf/Localizable.xcstrings`.
6. **Translate `scarf/scarf/InfoPlist.xcstrings`** (the macOS microphone-permission prompt) for your locale. Add a new `stringUnit` under `localizations`.
7. **Build** (`xcodebuild -project scarf/scarf.xcodeproj -scheme scarf build`) and **sanity-check in Xcode**: Scheme → Run → App Language → your locale. Walk the main views (Dashboard, Chat, Settings) and look for clipping or obvious leaks.
8. **Open a PR** including the new JSON file, the updated catalog, and the pbxproj / script changes. Mention which routes you spot-checked.
AI translation is fine for the first pass — it's how the initial six locales landed. Native-speaker review improves quality and is always welcome, either as a follow-up PR or as review comments on the initial one.
See [scarf/docs/I18N.md](scarf/docs/I18N.md) for deeper context on the String Catalog setup and which strings are intentionally kept verbatim.
## Reporting Issues
Open an issue with:
+33 -2
View File
@@ -13,18 +13,37 @@
<img src="https://img.shields.io/badge/macOS-14.6+%20Sonoma-blue" alt="macOS">
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<em>Available in English, 简体中文, Deutsch, Français, Español, 日本語, and Português (Brasil).</em>
<br><br>
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
</p>
## What's New in 2.0
## What's New in 2.2
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, and cron jobs into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the new **Templates** menu in the Projects toolbar.
- **Preview-before-apply** — Every install shows a preview sheet listing the exact project directory that will be created, every file inside it, every skill that will be namespaced, every cron job that will be registered (paused by default), and a live diff of any memory appendix. Nothing writes until you click Install.
- **Safe-by-design** — Skills install into `~/.hermes/skills/templates/<slug>/` so they never collide with your own. Cron jobs carry a `[tmpl:<id>]` tag and start paused. A `template.lock.json` records everything written for easy uninstall. Templates **never** touch `config.yaml`, `auth.json`, sessions, or credentials.
See the full [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0).
### Previously, in 2.1
- **Seven languages** — Full UI translations for Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese on top of English. Scarf respects the system language by default; override per-app via **System Settings → Language & Region → Apps → Scarf**. Contributor workflow for adding more locales is documented in [CONTRIBUTING.md → Adding a Language](CONTRIBUTING.md#adding-a-language).
- **Locale-aware number formatting** — Currency, byte sizes, compact token counts (`15K`, `1.5M`), and day-of-week charts now follow the user's locale instead of POSIX / English defaults.
- **Chat slash-command menu** — Type `/` in Rich Chat to browse every command the agent has advertised plus any user-defined `quick_commands:` from config.yaml. ↑/↓ to navigate, Tab/Enter to complete, Esc to dismiss.
- **Chat polish** — Auto-scroll on send and on prompt completion, a non-blocking loading spinner during session reconnects, properly centered empty state, and the long-standing "session loads with whitespace" bug fixed (LazyVStack → VStack in the message list).
See the full [v2.1.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.1.0).
### Previously, in 2.0
- **Multi-server** — Manage multiple Hermes installations (local + any number of remotes) from one app. Each window binds to one server; open them side-by-side.
- **Remote Hermes over SSH** — Every feature that worked against your local `~/.hermes/` now works against a remote host. File I/O routes through `scp`/`sftp`; chat ACP runs over `ssh -T`; SQLite is served from atomic `.backup` snapshots pulled on file-watcher ticks.
- **Chat UX overhaul** — No more white-screen flash on first message, no more scroll jumping into whitespace during streaming, failed prompts explain themselves instead of silently spinning forever.
- **Correctness pass** — Fixed remote WAL error spam, stale-snapshot session resume, auto-resume of dead cron sessions, 230+ Swift 6 concurrency warnings.
See the full [v2.0.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.0.0).
See the [v2.0.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.0.0) for the full 2.0 series.
### Previously, in 1.6
@@ -376,6 +395,16 @@ Signing prerequisites (one-time):
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
## Template Catalog
Community-contributed Scarf project templates live under [`templates/`](templates/) in this repo and are browsable at **[awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/)** with live dashboard previews and one-click `scarf://install?url=…` links.
- **Install from the web** — click "Install with Scarf" on any template's detail page; the app takes over from there.
- **Install from a local file** — Scarf → Projects → Templates → Install from File…, or double-click any `.scarftemplate` in Finder.
- **Author a template** — see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) for the full walkthrough. Fork, drop a template under `templates/<your-github-handle>/<your-name>/`, open a PR; CI validates the bundle automatically.
The catalog's site is a static HTML + vanilla JS build generated by [`tools/build-catalog.py`](tools/build-catalog.py) and driven by [`scripts/catalog.sh`](scripts/catalog.sh) (check / build / preview / publish). Appcast and main landing page are independent — updating the catalog never disturbs Sparkle.
## Contributing
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
@@ -386,6 +415,8 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
4. Push to the branch (`git push origin feature/my-feature`)
5. Open a Pull Request
For template submissions, see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) — same flow, with a catalog-specific checklist + automated CI validation.
## Support
If you find Scarf useful, consider buying me a coffee.
+52
View File
@@ -0,0 +1,52 @@
## What's New in 2.1.0
Scarf now speaks seven languages and has a proper slash-command menu in the chat. The language work closes [#13](https://github.com/awizemann/scarf/issues/13) and opens the door for community contributions of additional locales.
### Multi-language support
The UI is now fully translated to **Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese** on top of the existing English. Scarf respects the system language by default; override per-app from **System Settings → Language & Region → Apps → Scarf**.
- **644 source strings** catalogued. **583 translated per locale** — the remaining ~60 are deliberate fall-throughs to English: proper nouns (Scarf, Hermes, OAuth, MCP, SSH), brand names (Docker, Daytona, Singularity, BlueBubbles), format-only tokens (`%lld`, `·`, `•`), and config-literal placeholders (`my_server`, `npx`, `sk-…`).
- **Locale-aware number and date formatting.** Previous builds hardcoded POSIX-style decimal separators (`$12.34`) and English unit names (`"MB"`, `"K"`, `"M"`). Currency now routes through `.formatted(.currency(code: "USD"))`, byte sizes through `.byteCount(style: .file)`, token counts through `.notation(.compactName)`, and the day-of-week chart through `Calendar.current.shortWeekdaySymbols` — so German users see `15,2 MB`, Japanese users see `15.5万 tokens`, and the activity heatmap starts on the locale's first weekday.
- **Microphone permission prompt localized** — the system dialog that appears the first time you enable voice chat now reads in the user's language.
#### How the translation work shipped
Three stacked PRs to keep each piece independently reviewable, all AI-translated with the bar explicitly set low so native speakers can iterate:
1. **[#22](https://github.com/awizemann/scarf/pull/22) — String Catalog infrastructure.** Added `Localizable.xcstrings` + `InfoPlist.xcstrings`, expanded `knownRegions` with the six new locales, and fixed the locale-aware number formatters mentioned above. No user-visible English-locale change; the groundwork only.
2. **[#24](https://github.com/awizemann/scarf/pull/24) — Audit burn-down.** Swept the codebase for "silently un-localizable" patterns that look fine in Xcode's catalog but leak English at runtime: `Text(cond ? "A" : "B")` routes through the String overload instead of `LocalizedStringKey`, as do `Label(stringVar, systemImage:)`, `.help(stringVar)`, and composite format strings with translatable text suffixes. ~40 sites refactored, covering Chat voice/TTS toggles, Logs pickers, Insights period + day names, MCPServer test result, Profiles, SignalSetup, QuickCommands, ConnectionStatusPill. Without this PR the translations would have landed but ~40 visible strings would still have rendered in English.
3. **[#25](https://github.com/awizemann/scarf/pull/25) — Translations + contributor path.** The six locale JSONs + a 90-line merge script + a "Adding a Language" section in `CONTRIBUTING.md`. The sidebar and Settings tab bar fix also shipped here after smoke-testing revealed they were still missed — `Label(section.rawValue, …)` goes to the String overload just like the audit cases.
#### Contributing a new language
Per-locale source of truth lives in [`tools/translations/<locale>.json`](https://github.com/awizemann/scarf/tree/main/tools/translations). Each entry is a plain `{ "English": "Translation" }` map — keys you omit fall through to English at runtime. Workflow is: fork, drop a JSON, run `python3 tools/merge-translations.py`, open a PR. The full bar is documented in [CONTRIBUTING.md → Adding a Language](https://github.com/awizemann/scarf/blob/main/CONTRIBUTING.md#adding-a-language).
Native-speaker review of the initial six locales is welcome — AI translation gets us most of the way, but idiom and tone are better with someone who actually uses the language. Post a PR against the relevant `<locale>.json` and it'll land as a follow-up.
### Chat slash-command menu
Type `/` in Rich Chat and a floating menu appears above the input with every command the connected agent has advertised via ACP's `available_commands_update`, plus any user-defined `quick_commands:` from `~/.hermes/config.yaml`. ↑/↓ to navigate, Tab or Enter to complete, Esc to dismiss. Commands with argument hints (e.g. `/compress <topic>`) insert a trailing space so you can start typing the argument immediately.
The filter uses pure-prefix match and re-renders on every query — the old menu had a description-fallback filter and a cached child view that together pinned `/help` on-screen regardless of what you typed. The dedicated `/compress` button is hidden once the menu has more than one command; it only surfaces when `/compress` is the single advertised slash command, preserving the v2.0 one-click compression flow for that case.
### Chat UX polish
- **Auto-scroll on send and on completion.** `.defaultScrollAnchor(.bottom)` handles slow streaming fine, but rapid slash-command responses (common once the menu lands) outran the anchor and left the reply off-screen. Now the list explicitly scrolls to the latest message when you submit and again when the prompt finishes.
- **Loading state.** `ChatViewModel.isPreparingSession` is true during Starting / Creating / Loading / Reconnecting. While true, the message list swaps its empty-state placeholder for a spinner — non-blocking, just a view inside the ScrollView.
- **Empty-state centering.** The "Start a new session or resume an existing one" placeholder was positioned with a fixed `.padding(.vertical, 80)` that looked wrong at extreme window sizes. Replaced with Spacers inside `.containerRelativeFrame(.vertical)` so it sits in the true vertical center of the chat pane.
- **Session-load whitespace bug.** Opening a session used to render a blank viewport you'd have to scroll up from — the fix was `LazyVStack``VStack` in `RichChatMessageList`. LazyVStack's estimated row heights were fooling `.defaultScrollAnchor(.bottom)` into overshooting real content; VStack measures every row upfront so the anchor has real heights to work with.
### Under the hood
- **String Catalog build pipeline.** `SWIFT_EMIT_LOC_STRINGS` + `STRING_CATALOG_GENERATE_SYMBOLS` are enabled; keys extract automatically on IDE build. Headless builds use `xcrun xcstringstool sync` to merge the per-source `.stringsdata` files into the catalog (wrapped by [`tools/merge-translations.py`](https://github.com/awizemann/scarf/blob/main/tools/merge-translations.py) when applying JSON translations).
- **New docs.** [`scarf/docs/I18N.md`](https://github.com/awizemann/scarf/blob/main/scarf/docs/I18N.md) covers the catalog setup, the patterns that silently bypass localization (and their fixes), and which strings are intentionally kept verbatim. Anyone adding UI copy should read the "Guardrails when writing new UI code" section to avoid re-introducing the leaks #24 cleaned up.
### Migrating from 2.0.x
Sparkle will offer the update automatically. No config migration needed. The first launch after update picks up the system locale — if you want English even on a non-English macOS, set **System Settings → Language & Region → Apps → Scarf → English**.
### Thanks
- [Onion3](https://github.com/Onion3) for filing [#13](https://github.com/awizemann/scarf/issues/13) back in April. The single-locale ask turned into a six-locale rollout.
- Future translators: if you spot a weird AI translation in your language, open a PR against `tools/translations/<locale>.json`. The bar is explicitly low — we'd rather have a 95%-correct translation shipped and iterated on than hold everything for perfection.
+45
View File
@@ -0,0 +1,45 @@
## What's New in 2.2.0
Scarf projects can now travel. This release introduces **Project Templates** — a shareable `.scarftemplate` bundle format that packages a project's dashboard, agent instructions, skills, and cron jobs into a single file anyone can install with one click from a local file or an `scarf://install?url=…` deep link.
### Project Templates
- **Bundle format: `.scarftemplate`.** A zip archive carrying a `template.json` manifest, the project's dashboard, a required `AGENTS.md` (the [Linux Foundation cross-agent instructions standard](https://agents.md/) — reads natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and more), a README shown in the installer, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), optional namespaced skills, optional cron job definitions, and an optional memory appendix. Every bundle is agent-portable out of the box.
- **Install preview sheet.** Before anything touches disk, Scarf shows you the exact project directory that will be created, every file inside it, every skill that will be namespaced under `~/.hermes/skills/templates/<slug>/`, every cron job that will be registered (always paused — you enable each one manually), and a live diff of the memory appendix against your existing `MEMORY.md`. The manifest's content claim is cross-checked against the actual zip entries so a bundle can't hide files from the preview.
- **`scarf://install?url=…` deep links.** Register Scarf as the handler for the `scarf` URL scheme so a future catalog site can link one-click installs straight into the app. Only `https://` payloads are accepted; `file://`, `javascript:`, and `http://` are refused on principle. A 50 MB size cap keeps a malicious link from exhausting disk. The URL never auto-installs — the preview sheet is always user-confirmed.
- **Export any project as a template.** Select a project, open the new Templates menu in the Projects toolbar, fill in a handful of fields (id, name, version, description, optional author + category + tags), tick the skills and cron jobs you want to include, optionally drop in a memory snippet, and save. The exporter builds the bundle and you can hand it to anyone.
- **No-overwrite, reversible by design.** Installed templates drop a `<project>/.scarf/template.lock.json` recording exactly what they wrote — every project file, skill path, cron job name, and memory block id. Installing the same template id twice is refused at the preview step so you don't accidentally double-append to `MEMORY.md`. Uninstalling by hand is a matter of deleting the project directory, the skills namespace folder, and any `[tmpl:<id>] …` cron jobs — no hidden state.
- **Safe globals.** Skills install to `~/.hermes/skills/templates/<slug>/<skill-name>/` so they never collide with your own skills. Cron jobs are prefixed with `[tmpl:<id>]` and start paused so nothing unexpected kicks off on install. The installer **never** touches `~/.hermes/config.yaml`, `auth.json`, sessions, or any credential-bearing path.
### Using templates
- **Install from file:** Projects → Templates → *Install from File…*, pick a `.scarftemplate` from disk.
- **Install from URL:** Projects → Templates → *Install from URL…*, paste an https URL.
- **Install from the web:** click any `scarf://install?url=…` link in a browser.
- **Export:** select a project → Projects → Templates → *Export "&lt;name&gt;" as Template…*, fill the form, save.
### Under the hood
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock, errors).
- `Core/Services/ProjectTemplateService.swift` unzips, parses, and validates; `ProjectTemplateInstaller.swift` executes the plan atomically-enough (pre-flights conflicts, then writes); `ProjectTemplateExporter.swift` builds bundles from a live project + selections.
- `Core/Services/TemplateURLRouter.swift` is the process-wide landing pad for `scarf://` URLs so a cold-launch browser click still reaches the install sheet.
- Installer dispatches cron creation via `hermes cron create` (there's no direct Scarf write path for `cron/jobs.json`), then diffs before/after to pause the newly-registered jobs.
- New Swift Testing suites: `ProjectTemplateServiceTests`, `TemplateURLRouterTests`, `ProjectTemplateExportTests`.
### Uninstall
- **One-click uninstall** driven by `template.lock.json`. Right-click any template-installed project in the sidebar → **Uninstall Template…**, or click the uninstall button in the dashboard header. A preview sheet lists every file, cron job, and memory block that will be removed, and every user-created file that will be preserved.
- **User content is never removed.** Files you (or the agent) added to the project dir after install — like a `sites.txt` or `status-log.md` — are detected and listed as "keep" in the preview. The project directory itself is removed only if nothing user-owned is left inside.
- **Clean global state.** The isolated `~/.hermes/skills/templates/<slug>/` namespace is removed wholesale. Tagged cron jobs are removed via `hermes cron remove`. The memory block between the `<!-- scarf-template:<id>:begin/end -->` markers is stripped, leaving the rest of MEMORY.md intact. The project registry entry is removed last.
- **No undo.** v1 uninstall is destructive — to reinstall, run the install flow again.
### Not in this release (planned for v2.3)
- In-app catalog browser backed by a GitHub Pages `templates.json`.
- EdDSA-signed bundles reusing the Sparkle key.
- Template updates (compare installed lock against a newer bundle's version, offer a diff).
- Installing into remote `ServerContext`s (v1 is local-only).
### Migrating from 2.1.x
Sparkle will offer the update automatically. No config migration needed. Existing projects are untouched — templates are additive.
+84
View File
@@ -0,0 +1,84 @@
# Internationalization (i18n)
Scarf uses Apple's modern **String Catalog** workflow. Source strings are auto-extracted from `Text("…")` and `String(localized: …)` literals into [`scarf/scarf/Localizable.xcstrings`](../scarf/Localizable.xcstrings) at build time (when built in Xcode.app; `xcodebuild` alone emits per-source `.stringsdata` but does not merge back into the catalog). Info.plist keys are localized via [`scarf/scarf/InfoPlist.xcstrings`](../scarf/InfoPlist.xcstrings).
## Languages
| Locale | Status |
|---|---|
| `en` (English) | Base / source |
| `zh-Hans` (Simplified Chinese) | AI-translated, native-speaker review welcome |
| `de` (German) | AI-translated, native-speaker review welcome |
| `fr` (French) | AI-translated, native-speaker review welcome |
| `es` (Spanish) | AI-translated, native-speaker review welcome |
| `ja` (Japanese) | AI-translated, native-speaker review welcome |
| `pt-BR` (Portuguese, Brazil) | AI-translated, native-speaker review welcome |
Canadian French users are served by base `fr`. `fr-CA` will be added only if a concrete Québec-specific bug is reported.
### Translation workflow
Source-of-truth per locale lives in `tools/translations/<locale>.json` — a flat `{ "English": "Translation" }` map. The merge step writes those into `scarf/scarf/Localizable.xcstrings` via:
```bash
python3 tools/merge-translations.py
```
Keys absent from a locale file fall back to English at runtime — this is deliberate for proper nouns (Scarf, Hermes, Anthropic, OAuth, SSH…) and format-only strings (`%lld`, `%@ → %@`, `•`). Re-running the merge is idempotent; iterate on a JSON and re-merge.
Contributor path for new languages is documented in the repo root [CONTRIBUTING.md](../../CONTRIBUTING.md#adding-a-language).
## Adding a new language
1. Xcode → Project → Info → Localizations → `+` (add locale).
2. Ensure the locale code is also listed in `knownRegions` of `scarf.xcodeproj/project.pbxproj`.
3. Open `Localizable.xcstrings` in Xcode; the new locale appears as an empty column — translate or use Xcode's AI suggestions.
4. Repeat for `InfoPlist.xcstrings` (microphone usage, etc.).
5. Smoke-test via scheme language override (Edit Scheme → Run → App Language).
## Adding translations (AI-first workflow)
For the three supported non-English locales we use Xcode's built-in AI translation:
1. Open `Localizable.xcstrings` in Xcode.
2. Select untranslated rows for a locale → right-click → **Translate** (Xcode 26+ provides GPT-backed suggestions with context from the surrounding code comment).
3. Review each suggestion before marking **Translated**.
4. For terms that should NOT translate (proper nouns like *Scarf*, *Hermes*, *Anthropic*; env var names; file paths), wrap the source site in `Text(verbatim: "…")` so the key never hits the catalog.
## Guardrails when writing new UI code
`Text("literal")` auto-localizes. These patterns **silently leak English** and need explicit handling:
| Pattern | Fix |
|---|---|
| `Text(someStringVar)` | `Text(LocalizedStringResource("key"))` or pass a `LocalizedStringKey` down the view tree |
| `"Hello " + name` | `String(localized: "Hello \(name)")` |
| `String(format: "$%.2f", cost)` | `cost.formatted(.currency(code: "USD").precision(.fractionLength(2)))` |
| `String(format: "%.1f MB", size)` | `Int64(size).formatted(.byteCount(style: .file))` |
| `String(format: "%.1fM", n)` | `n.formatted(.number.notation(.compactName))` |
| Custom `DateFormatter` with fixed `dateFormat` | `date.formatted(.dateTime.month().day().year())` |
| `.help(stringVar)` | Compute a `LocalizedStringKey` or use `.help(Text(…))` |
| `Button(stringVar)` | `Button(LocalizedStringResource("key")) { … }` |
Strings that are **user data** (session titles, memory file contents, log lines, shell commands shown in UI, file paths) should pass through without localization — this happens naturally when the value is a `String` variable, since those overloads skip the catalog.
## Audit status
Phase 1b (the `multi-language` PR) closed every tracked site from the original audit:
- **Category A high-priority (ternary UI copy)** — converted to `Text`-ternary form so each branch routes through `LocalizedStringKey`.
- **Category A medium-priority (enum `.rawValue` displays)** — each enum now exposes `displayName: LocalizedStringResource` and call sites use it. `LogEntry.LogLevel` (technical jargon) stays verbatim.
- **Category A lower-priority (displayName passthroughs)** — wrapped with `Text(verbatim:)` for proper nouns / user data (`HermesToolPlatform`, `ServerRegistry.Entry`, `MCPServerPreset`). `MCPTransport.displayName` promoted to `LocalizedStringResource`.
- **Category B (composite format strings)** — migrated to `Text("\(arg) suffix")` with `LocalizedStringKey` or to `.percent` / `.currency` FormatStyle.
- **Category C (hard-coded day names)** — replaced with `Calendar.current.shortWeekdaySymbols`, re-indexed to match the existing Mon=0 data model.
- **Category D (`.help(stringVar)` sites)** — `ConnectionStatusPill` now returns `Text` from its `labelText` / `tooltipText` properties.
If you spot a new silently-un-localizable site during translation review, prefer the patterns in the table above over one-off workarounds.
### Non-blocking (intentional verbatim)
The following are correct as-is because they pass user data or machine-readable content through to the UI:
- Session titles, message content, memory / skill / YAML file contents, log lines, shell commands, file paths, session IDs, model IDs, credential sources, URL strings.
If we later need to badge these (e.g. "(empty)" placeholder), the badge itself becomes a localizable key while the data passthrough stays verbatim.
+30 -12
View File
@@ -214,6 +214,12 @@
knownRegions = (
en,
Base,
"zh-Hans",
de,
fr,
es,
ja,
"pt-BR",
);
mainGroup = 534959372F7B83B600BD31AD;
minimizedProjectReferenceProxies = 1;
@@ -300,6 +306,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -329,6 +336,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -354,6 +362,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -364,6 +373,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -393,6 +403,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_NS_ASSERTIONS = NO;
@@ -411,6 +422,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
@@ -424,7 +436,8 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 22;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -436,7 +449,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.0.2;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -458,7 +471,8 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 22;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -470,7 +484,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.0.2;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -488,11 +502,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 22;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.0.2;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -509,11 +524,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 22;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.0.2;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -529,10 +545,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 22;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.0.2;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -548,10 +565,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 22;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.0.2;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5349593F2F7B83B600BD31AD"
BuildableName = "scarf.app"
BlueprintName = "scarf"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5349594E2F7B83B700BD31AD"
BuildableName = "scarfTests.xctest"
BlueprintName = "scarfTests"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "534959582F7B83B700BD31AD"
BuildableName = "scarfUITests.xctest"
BlueprintName = "scarfUITests"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5349593F2F7B83B600BD31AD"
BuildableName = "scarf.app"
BlueprintName = "scarf"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5349593F2F7B83B600BD31AD"
BuildableName = "scarf.app"
BlueprintName = "scarf"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+1
View File
@@ -14,6 +14,7 @@ struct ContentView: View {
var body: some View {
NavigationSplitView {
SidebarView()
.navigationSplitViewColumnWidth(min: 180, ideal: 240, max: 360)
} detail: {
detailView
.toolbar {
@@ -6,7 +6,7 @@ enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
var id: String { rawValue }
var displayName: String {
var displayName: LocalizedStringResource {
switch self {
case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)"
@@ -99,6 +99,17 @@ enum ToolKind: String, Sendable, CaseIterable {
case browser
case other
var displayName: LocalizedStringResource {
switch self {
case .read: return "Read"
case .edit: return "Edit"
case .execute: return "Execute"
case .fetch: return "Fetch"
case .browser: return "Browser"
case .other: return "Other"
}
}
var icon: String {
switch self {
case .read: return "doc.text.magnifyingglass"
@@ -0,0 +1,17 @@
import Foundation
/// A slash command available in chat. Sourced either from the ACP server
/// (`available_commands_update`) or from user-defined `quick_commands` in
/// `config.yaml`.
struct HermesSlashCommand: Identifiable, Sendable, Equatable {
enum Source: Sendable, Equatable {
case acp
case quickCommand
}
var id: String { name }
let name: String
let description: String
let argumentHint: String?
let source: Source
}
@@ -86,8 +86,8 @@ enum WidgetValue: Codable, Sendable, Hashable {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? String(Int(n))
: String(format: "%.1f", n)
? Int(n).formatted(.number)
: n.formatted(.number.precision(.fractionLength(1)))
}
}
@@ -0,0 +1,282 @@
import Foundation
// MARK: - Manifest (what lives inside the .scarftemplate zip)
/// On-disk manifest for a Scarf project template. Shipped as `template.json`
/// at the root of a `.scarftemplate` (zip) bundle.
///
/// The `contents` block is a claim the author makes about what the bundle
/// ships; the installer verifies the claim against the actual unpacked files
/// before showing the preview sheet so a malicious bundle can't hide extra
/// files from the user.
struct ProjectTemplateManifest: Codable, Sendable, Equatable {
let schemaVersion: Int
let id: String
let name: String
let version: String
let minScarfVersion: String?
let minHermesVersion: String?
let author: TemplateAuthor?
let description: String
let category: String?
let tags: [String]?
let icon: String?
let screenshots: [String]?
let contents: TemplateContents
/// Filesystem-safe slug derived from `id` (`"owner/name"` `"owner-name"`).
/// Used for the install directory name, skills namespace, and cron-job tag.
nonisolated var slug: String {
let ascii = id.unicodeScalars.map { scalar -> Character in
let c = Character(scalar)
if c.isLetter || c.isNumber || c == "-" || c == "_" { return c }
return "-"
}
let collapsed = String(ascii)
.split(separator: "-", omittingEmptySubsequences: true)
.joined(separator: "-")
return collapsed.isEmpty ? "template" : collapsed
}
}
struct TemplateAuthor: Codable, Sendable, Equatable {
let name: String
let url: String?
}
struct TemplateContents: Codable, Sendable, Equatable {
let dashboard: Bool
let agentsMd: Bool
let instructions: [String]?
let skills: [String]?
let cron: Int?
let memory: TemplateMemoryClaim?
}
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
let append: Bool
}
// MARK: - Inspection (what we learn by unpacking the zip)
/// Result of unpacking a `.scarftemplate` into a temp directory and validating
/// it. Callers hand this to `buildInstallPlan` to produce the concrete
/// filesystem plan.
struct TemplateInspection: Sendable {
let manifest: ProjectTemplateManifest
/// Absolute path to the temp directory holding the unpacked bundle. The
/// installer reads files from here; the caller is responsible for
/// cleaning it up after install (or cancel).
let unpackedDir: String
/// Every file found in the unpacked dir, as paths relative to
/// `unpackedDir`. Verified against the manifest's `contents` claim.
let files: [String]
/// Parsed cron jobs (may be empty even if the manifest claims some
/// verification catches that mismatch).
let cronJobs: [TemplateCronJobSpec]
}
/// The subset of a Hermes cron job that a template can ship. Only the fields
/// the `hermes cron create` CLI accepts are included; runtime state
/// (`enabled`, `state`, `next_run_at`, ) is deliberately omitted so a
/// template can't arrive already-running.
struct TemplateCronJobSpec: Codable, Sendable, Equatable {
let name: String
let schedule: String
let prompt: String?
let deliver: String?
let skills: [String]?
let repeatCount: Int?
enum CodingKeys: String, CodingKey {
case name, schedule, prompt, deliver, skills
case repeatCount = "repeat"
}
}
// MARK: - Install Plan (the preview sheet reads this)
/// Concrete, reviewed-before-apply filesystem operations the installer will
/// perform. Every side effect the installer can cause is represented here so
/// the preview sheet is an honest accounting of what's about to happen.
struct TemplateInstallPlan: Sendable {
let manifest: ProjectTemplateManifest
let unpackedDir: String
/// Absolute path of the new project directory. Installer refuses if this
/// already exists.
let projectDir: String
/// Files that will be created under `projectDir`, keyed by relative path.
let projectFiles: [TemplateFileCopy]
/// Absolute path of the skills namespace dir
/// (`~/.hermes/skills/templates/<slug>/`). Created if skills are present.
let skillsNamespaceDir: String?
/// Files that will be created under the skills namespace dir.
let skillsFiles: [TemplateFileCopy]
/// Cron job definitions to register via `hermes cron create`. Each job's
/// name is already prefixed with the template tag. All will be paused
/// immediately after creation.
let cronJobs: [TemplateCronJobSpec]
/// Memory appendix text (already wrapped in begin/end markers). `nil`
/// means no memory write happens.
let memoryAppendix: String?
/// Target memory path (`~/.hermes/memories/MEMORY.md`). Only used when
/// `memoryAppendix` is non-nil.
let memoryPath: String
/// `ProjectEntry.name` that will be appended to the projects registry.
let projectRegistryName: String
/// Convenience: total number of writes (files + cron jobs + optional
/// memory append + registry append). Displayed in the preview sheet.
nonisolated var totalWriteCount: Int {
projectFiles.count + skillsFiles.count + cronJobs.count + (memoryAppendix == nil ? 0 : 1) + 1
}
}
/// A single file to copy from the unpacked bundle into a target directory.
struct TemplateFileCopy: Sendable, Equatable {
/// Path inside `unpackedDir`, e.g. `"AGENTS.md"` or
/// `"skills/timer/SKILL.md"`.
let sourceRelativePath: String
/// Absolute path where the file should land.
let destinationPath: String
}
// MARK: - Lock file (uninstall manifest, dropped into <project>/.scarf/)
/// Dropped at `<project>/.scarf/template.lock.json` after a successful
/// install. Records exactly what was written so a future "Uninstall Template"
/// action can reverse it without guessing.
struct TemplateLock: Codable, Sendable {
let templateId: String
let templateVersion: String
let templateName: String
let installedAt: String
let projectFiles: [String]
let skillsNamespaceDir: String?
let skillsFiles: [String]
let cronJobNames: [String]
let memoryBlockId: String?
enum CodingKeys: String, CodingKey {
case templateId = "template_id"
case templateVersion = "template_version"
case templateName = "template_name"
case installedAt = "installed_at"
case projectFiles = "project_files"
case skillsNamespaceDir = "skills_namespace_dir"
case skillsFiles = "skills_files"
case cronJobNames = "cron_job_names"
case memoryBlockId = "memory_block_id"
}
}
// MARK: - Uninstall Plan (the uninstall-preview sheet reads this)
/// Symmetric with `TemplateInstallPlan` but for removal. Built from the
/// `<project>/.scarf/template.lock.json` the installer wrote. The preview
/// sheet lists every path the uninstall would touch; the uninstaller
/// executes the listed ops and nothing else.
struct TemplateUninstallPlan: Sendable {
/// The parsed lock file that seeded this plan. Kept so the sheet can
/// display the template id, version, and install timestamp.
let lock: TemplateLock
/// The registry entry that will be removed on success.
let project: ProjectEntry
/// Lock-tracked files still present on disk that will be removed.
let projectFilesToRemove: [String]
/// Lock-tracked files that were already missing (e.g. user deleted them
/// after install). Shown in the sheet so the user isn't surprised that
/// a file isn't removed; uninstaller skips these.
let projectFilesAlreadyGone: [String]
/// User-added files/dirs in the project dir that are NOT in the lock.
/// These are preserved the sheet lists them so the user knows the
/// project dir stays if any exist.
let extraProjectEntries: [String]
/// If `true`, the project dir ends up empty after removal and will be
/// removed along with its files. `false` means user content lives in
/// the dir and we leave it.
let projectDirBecomesEmpty: Bool
/// Lock-recorded skills namespace dir. `nil` means the template never
/// installed skills. Uninstaller removes the entire dir recursively.
let skillsNamespaceDir: String?
/// Cron jobs that will be removed, as (id, name) pairs. Ids were looked
/// up at plan time by matching lock names against the live cron list.
let cronJobsToRemove: [(id: String, name: String)]
/// Names recorded in the lock that we couldn't find in the current cron
/// list (user-deleted, renamed, etc.). Shown in the sheet; skipped on
/// uninstall.
let cronJobsAlreadyGone: [String]
/// `true` if MEMORY.md still contains the template's begin/end markers
/// and those bytes will be stripped on uninstall. `false` means no
/// memory block was ever installed OR the user removed it by hand.
let memoryBlockPresent: Bool
/// Hermes-side path to MEMORY.md. Only touched when
/// `memoryBlockPresent` is true.
let memoryPath: String
nonisolated var totalRemoveCount: Int {
projectFilesToRemove.count
+ (skillsNamespaceDir == nil ? 0 : 1)
+ cronJobsToRemove.count
+ (memoryBlockPresent ? 1 : 0)
+ 1 // registry entry
}
}
// MARK: - Errors
enum ProjectTemplateError: LocalizedError, Sendable {
case unzipFailed(String)
case manifestMissing
case manifestParseFailed(String)
case unsupportedSchemaVersion(Int)
case requiredFileMissing(String)
case contentClaimMismatch(String)
case projectDirExists(String)
case conflictingFile(String)
case memoryBlockAlreadyExists(String)
case cronCreateFailed(job: String, output: String)
case unsafeZipEntry(String)
case lockFileMissing(String)
case lockFileParseFailed(String)
var errorDescription: String? {
switch self {
case .unzipFailed(let s):
return "Couldn't unpack template archive: \(s)"
case .manifestMissing:
return "Template is missing template.json at the archive root."
case .manifestParseFailed(let s):
return "Template manifest couldn't be parsed: \(s)"
case .unsupportedSchemaVersion(let v):
return "Template uses schemaVersion \(v), which this version of Scarf doesn't understand."
case .requiredFileMissing(let f):
return "Template is missing a required file: \(f)"
case .contentClaimMismatch(let s):
return "Template manifest doesn't match its contents: \(s)"
case .projectDirExists(let p):
return "A directory already exists at \(p). Refusing to overwrite — choose a different parent folder."
case .conflictingFile(let p):
return "An existing file would be overwritten at \(p). Refusing to clobber."
case .memoryBlockAlreadyExists(let id):
return "A memory block for template '\(id)' already exists in MEMORY.md. Remove it first or install a fresh copy."
case .cronCreateFailed(let job, let output):
return "Failed to register cron job '\(job)': \(output)"
case .unsafeZipEntry(let p):
return "Template archive contains an unsafe entry: \(p)"
case .lockFileMissing(let path):
return "No template.lock.json found at \(path). This project wasn't installed by Scarf's template system — remove it by hand."
case .lockFileParseFailed(let s):
return "Couldn't read template.lock.json: \(s)"
}
}
}
@@ -9,8 +9,10 @@ struct ServerEntry: Identifiable, Codable, Hashable, Sendable {
var id: ServerID
var displayName: String
var kind: ServerKind
/// User preference: open this server in a window on launch. Phase 3
/// multi-window uses this; Phase 2 ignores it.
/// User preference: this server is the one Scarf opens into when a
/// fresh window has no prior binding (first launch or File New).
/// At most one entry should have this set `ServerRegistry` enforces
/// mutual exclusivity. If none do, Local is the implicit default.
var openOnLaunch: Bool = false
var context: ServerContext {
@@ -69,6 +71,36 @@ final class ServerRegistry {
return nil
}
/// The server a fresh window should open into. Returns the ID of the
/// remote entry flagged `openOnLaunch`, or Local's ID if none is
/// flagged (or if the flagged entry was removed out from under us).
/// Consumed by the `WindowGroup`'s `defaultValue` closure.
var defaultServerID: ServerID {
entries.first(where: { $0.openOnLaunch })?.id ?? ServerContext.local.id
}
/// Flip the default server to `id`. Passing `ServerContext.local.id`
/// clears the flag on every remote entry, making Local the implicit
/// default. Passing an unknown ID is a no-op. Persisted on return.
///
/// Intentionally doesn't fire `onEntriesChanged` that hook means "the
/// set of servers changed" and drives the menu-bar fanout rebuild. A
/// default-flag flip doesn't change the set; SwiftUI views reading
/// `defaultServerID` redraw via `@Observable`'s tracking of `entries`.
func setDefaultServer(_ id: ServerID) {
var changed = false
for idx in entries.indices {
let shouldBeDefault = (entries[idx].id == id)
if entries[idx].openOnLaunch != shouldBeDefault {
entries[idx].openOnLaunch = shouldBeDefault
changed = true
}
}
if changed {
save()
}
}
// MARK: - Mutations
/// Optional callback fired whenever `entries` changes. The app wires
@@ -20,7 +20,8 @@ struct HermesModelInfo: Sendable, Identifiable, Hashable {
/// Display-friendly cost string, or nil if cost is unknown.
var costDisplay: String? {
guard let input = costInput, let output = costOutput else { return nil }
return String(format: "$%.2f / $%.2f", input, output)
let currency = FloatingPointFormatStyle<Double>.Currency.currency(code: "USD").precision(.fractionLength(2))
return "\(input.formatted(currency)) / \(output.formatted(currency))"
}
/// Display-friendly context window ("200K", "1M", etc.).
@@ -0,0 +1,301 @@
import Foundation
import os
/// Builds a `.scarftemplate` bundle from an existing Scarf project plus the
/// caller's selection of skills and cron jobs. Symmetric with the
/// `ProjectTemplateService` + `ProjectTemplateInstaller` pair the output
/// of this exporter can be fed straight back to `inspect()` + `install()`.
struct ProjectTemplateExporter: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateExporter")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
/// Known filenames in the project root that map to specific agents. When
/// the author opts to include them, each is copied verbatim into
/// `instructions/` in the bundle.
nonisolated static let knownInstructionFiles: [String] = [
"CLAUDE.md",
"GEMINI.md",
".cursorrules",
".github/copilot-instructions.md"
]
/// Author-facing description of what `export` will do with the given
/// selections. Shown in the export sheet so the user knows exactly
/// what's about to go into the bundle before saving.
struct ExportPlan: Sendable {
let templateId: String
let templateName: String
let templateVersion: String
let projectDir: String
let dashboardPresent: Bool
let agentsMdPresent: Bool
let readmePresent: Bool
let instructionFiles: [String]
let skillIds: [String]
let cronJobs: [HermesCronJob]
let memoryAppendix: String?
}
/// Inputs collected by the export sheet.
struct ExportInputs: Sendable {
let project: ProjectEntry
let templateId: String
let templateName: String
let templateVersion: String
let description: String
let authorName: String?
let authorUrl: String?
let category: String?
let tags: [String]
let includeSkillIds: [String]
let includeCronJobIds: [String]
/// Raw markdown the author wants appended to installers' MEMORY.md.
/// `nil` to skip.
let memoryAppendix: String?
}
/// Scan the project dir and report what a fresh export would include
/// given the caller's inputs. Does not write anything.
///
/// Existence checks go through the context's transport the project
/// path comes from the registry on the active server and may be on a
/// remote filesystem (future remote-install support), where
/// `FileManager.default.fileExists` would silently return `false`.
nonisolated func previewPlan(for inputs: ExportInputs) -> ExportPlan {
let dir = inputs.project.path
let transport = context.makeTransport()
let dashboard = transport.fileExists(dir + "/.scarf/dashboard.json")
let readme = transport.fileExists(dir + "/README.md")
let agents = transport.fileExists(dir + "/AGENTS.md")
let instructions = Self.knownInstructionFiles.filter {
transport.fileExists(dir + "/" + $0)
}
let allJobs = HermesFileService(context: context).loadCronJobs()
let picked = allJobs.filter { inputs.includeCronJobIds.contains($0.id) }
return ExportPlan(
templateId: inputs.templateId,
templateName: inputs.templateName,
templateVersion: inputs.templateVersion,
projectDir: dir,
dashboardPresent: dashboard,
agentsMdPresent: agents,
readmePresent: readme,
instructionFiles: instructions,
skillIds: inputs.includeSkillIds,
cronJobs: picked,
memoryAppendix: inputs.memoryAppendix
)
}
/// Build the bundle and write it to `outputZipPath`. Throws if any
/// required file is missing or the zip step fails.
nonisolated func export(
inputs: ExportInputs,
outputZipPath: String
) throws {
let stagingDir = NSTemporaryDirectory() + "scarf-template-export-" + UUID().uuidString
try FileManager.default.createDirectory(atPath: stagingDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: stagingDir) }
let plan = previewPlan(for: inputs)
guard plan.dashboardPresent else {
throw ProjectTemplateError.requiredFileMissing("dashboard.json (expected at \(plan.projectDir)/.scarf/dashboard.json)")
}
guard plan.readmePresent else {
throw ProjectTemplateError.requiredFileMissing("README.md (expected at \(plan.projectDir)/README.md)")
}
guard plan.agentsMdPresent else {
throw ProjectTemplateError.requiredFileMissing("AGENTS.md (expected at \(plan.projectDir)/AGENTS.md)")
}
// Required files. All source reads go through the context's
// transport project paths come from the registry on the active
// server and may be on a remote filesystem. Destinations are in
// the local staging dir so Foundation writes are correct.
let transport = context.makeTransport()
try copyFromHermes(plan.projectDir + "/.scarf/dashboard.json", to: stagingDir + "/dashboard.json", transport: transport)
try copyFromHermes(plan.projectDir + "/README.md", to: stagingDir + "/README.md", transport: transport)
try copyFromHermes(plan.projectDir + "/AGENTS.md", to: stagingDir + "/AGENTS.md", transport: transport)
// Optional per-agent instruction shims
for relative in plan.instructionFiles {
let source = plan.projectDir + "/" + relative
let destination = stagingDir + "/instructions/" + relative
try createParent(of: destination)
try copyFromHermes(source, to: destination, transport: transport)
}
// Skills (copied from the global skills dir)
if !plan.skillIds.isEmpty {
let skillsRoot = stagingDir + "/skills"
try FileManager.default.createDirectory(atPath: skillsRoot, withIntermediateDirectories: true)
let allSkills = HermesFileService(context: context).loadSkills()
.flatMap(\.skills)
for skillId in plan.skillIds {
guard let skill = allSkills.first(where: { $0.id == skillId }) else {
throw ProjectTemplateError.requiredFileMissing("skills/" + skillId)
}
// The bundle uses a flat `skills/<name>/` layout (no
// category), matching what the installer expects. If two
// categories ship skills with the same `name`, the second
// collides warn by refusing rather than silently
// overwriting.
let targetDir = skillsRoot + "/" + skill.name
if FileManager.default.fileExists(atPath: targetDir) {
throw ProjectTemplateError.conflictingFile(targetDir)
}
try FileManager.default.createDirectory(atPath: targetDir, withIntermediateDirectories: true)
for file in skill.files {
try copyFromHermes(skill.path + "/" + file, to: targetDir + "/" + file, transport: transport)
}
}
}
// Cron jobs (stripped to the create-CLI-shaped spec)
if !plan.cronJobs.isEmpty {
let specs = plan.cronJobs.map { Self.strip($0) }
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(specs)
let cronDir = stagingDir + "/cron"
try FileManager.default.createDirectory(atPath: cronDir, withIntermediateDirectories: true)
try data.write(to: URL(fileURLWithPath: cronDir + "/jobs.json"))
}
// Memory appendix. A write failure here would silently produce a
// bundle whose manifest claims `memory.append = true` but ships an
// empty/missing file installers would then fail on
// contentClaimMismatch with no breadcrumb pointing back at the
// export step. Let the error propagate.
if let appendix = plan.memoryAppendix, !appendix.isEmpty {
let memDir = stagingDir + "/memory"
try FileManager.default.createDirectory(atPath: memDir, withIntermediateDirectories: true)
guard let data = appendix.data(using: .utf8) else {
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
}
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
}
// Manifest claims exactly what we just wrote
let manifest = ProjectTemplateManifest(
schemaVersion: 1,
id: inputs.templateId,
name: inputs.templateName,
version: inputs.templateVersion,
minScarfVersion: nil,
minHermesVersion: nil,
author: inputs.authorName.map {
TemplateAuthor(name: $0, url: inputs.authorUrl)
},
description: inputs.description,
category: inputs.category,
tags: inputs.tags.isEmpty ? nil : inputs.tags,
icon: nil,
screenshots: nil,
contents: TemplateContents(
dashboard: true,
agentsMd: true,
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil
)
)
let manifestEncoder = JSONEncoder()
manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let manifestData = try manifestEncoder.encode(manifest)
try manifestData.write(to: URL(fileURLWithPath: stagingDir + "/template.json"))
try zip(stagingDir: stagingDir, outputPath: outputZipPath)
}
// MARK: - Private
/// Copy a file whose source lives on the Hermes side (possibly remote)
/// into a local destination path under the staging dir. Using the
/// transport for the read keeps the exporter remote-ready; the write
/// goes through Foundation because the staging dir is always local to
/// the Mac running Scarf.
nonisolated private func copyFromHermes(
_ source: String,
to destination: String,
transport: any ServerTransport
) throws {
let data = try transport.readFile(source)
try createParent(of: destination)
try data.write(to: URL(fileURLWithPath: destination))
}
nonisolated private func createParent(of path: String) throws {
let parent = (path as NSString).deletingLastPathComponent
if !FileManager.default.fileExists(atPath: parent) {
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
}
}
/// Convert a live cron job (with runtime state) into the spec the
/// installer will feed back to `hermes cron create`. Only preserves
/// fields the CLI accepts.
nonisolated private static func strip(_ job: HermesCronJob) -> TemplateCronJobSpec {
let schedule: String = {
if let expr = job.schedule.expression, !expr.isEmpty { return expr }
if let runAt = job.schedule.runAt, !runAt.isEmpty { return runAt }
return job.schedule.display ?? ""
}()
return TemplateCronJobSpec(
name: job.name,
schedule: schedule,
prompt: job.prompt.isEmpty ? nil : job.prompt,
deliver: job.deliver?.isEmpty == false ? job.deliver : nil,
skills: (job.skills?.isEmpty == false) ? job.skills : nil,
repeatCount: nil
)
}
/// Shell out to `/usr/bin/zip -r` so the file ordering is deterministic
/// and the archive is standard Apple-provided tools (and the system
/// `unzip` the installer uses) will read it without trouble.
nonisolated private func zip(stagingDir: String, outputPath: String) throws {
// `zip` writes relative paths based on the cwd it's invoked in. Chdir
// via Process.currentDirectoryURL so entries are `template.json`,
// `AGENTS.md`, etc., not absolute paths.
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.currentDirectoryURL = URL(fileURLWithPath: stagingDir)
process.arguments = ["-qq", "-r", outputPath, "."]
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
// Close both ends of each Pipe so we don't leak 4 fds per zip call.
func closePipes() {
try? outPipe.fileHandleForReading.close()
try? outPipe.fileHandleForWriting.close()
try? errPipe.fileHandleForReading.close()
try? errPipe.fileHandleForWriting.close()
}
do {
try process.run()
} catch {
closePipes()
throw ProjectTemplateError.unzipFailed("zip failed to launch: \(error.localizedDescription)")
}
process.waitUntilExit()
let errData = try? errPipe.fileHandleForReading.readToEnd()
closePipes()
guard process.terminationStatus == 0 else {
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
}
}
}
@@ -0,0 +1,209 @@
import Foundation
import os
/// Executes a `TemplateInstallPlan`. All writes happen in one pass with
/// early-fail semantics: if any step throws, later steps don't run (but
/// earlier ones aren't reversed v1 doesn't ship an atomic rollback). The
/// plan has already verified `projectDir` doesn't exist and no conflicting
/// file exists at target paths, so by the time we start writing, the
/// expected-error surface is small (mostly I/O failures).
struct ProjectTemplateInstaller: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateInstaller")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
/// Apply the plan. On success, returns the `ProjectEntry` that was added
/// to the registry so the caller can set `AppCoordinator.selectedProjectName`.
@discardableResult
nonisolated func install(plan: TemplateInstallPlan) throws -> ProjectEntry {
try preflight(plan: plan)
try createProjectFiles(plan: plan)
try createSkillsFiles(plan: plan)
try appendMemoryIfNeeded(plan: plan)
let cronJobNames = try createCronJobs(plan: plan)
let entry = try registerProject(plan: plan)
try writeLockFile(plan: plan, cronJobNames: cronJobNames)
Self.logger.info("installed template \(plan.manifest.id, privacy: .public) v\(plan.manifest.version, privacy: .public) into \(plan.projectDir, privacy: .public)")
return entry
}
// MARK: - Preflight
nonisolated private func preflight(plan: TemplateInstallPlan) throws {
// Plan was built on a recent snapshot of the filesystem; re-check the
// invariants at install time so concurrent activity between
// preview-and-confirm can't slip past us.
//
// All existence and read checks for paths that come from
// `context.paths` go through the transport not `FileManager`
// so this code works identically against a future remote
// `ServerContext`. See the warning on `ServerContext.readText`:
// "Foundation file APIs are LOCAL ONLY using them with a remote
// path silently returns nil because the remote path doesn't exist
// on this Mac."
let transport = context.makeTransport()
if transport.fileExists(plan.projectDir) {
throw ProjectTemplateError.projectDirExists(plan.projectDir)
}
for copy in plan.projectFiles where transport.fileExists(copy.destinationPath) {
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
}
for copy in plan.skillsFiles where transport.fileExists(copy.destinationPath) {
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
}
// Memory appendix collision: re-scan MEMORY.md for an existing block
// with the same template id so two installs of v1.0.0 can't
// double-append. A missing MEMORY.md is fine (treated as empty),
// but any *other* read failure (permissions, bad file type) gets
// logged + surfaced so we don't silently pretend MEMORY.md is empty
// and append over a broken file.
if plan.memoryAppendix != nil {
let existing: String
if transport.fileExists(plan.memoryPath) {
do {
let data = try transport.readFile(plan.memoryPath)
existing = String(data: data, encoding: .utf8) ?? ""
} catch {
Self.logger.error("failed to read MEMORY.md at \(plan.memoryPath, privacy: .public): \(error.localizedDescription, privacy: .public)")
throw error
}
} else {
existing = ""
}
let marker = ProjectTemplateService.memoryBlockBeginMarker(templateId: plan.manifest.id)
if existing.contains(marker) {
throw ProjectTemplateError.memoryBlockAlreadyExists(plan.manifest.id)
}
}
}
// MARK: - Project files
nonisolated private func createProjectFiles(plan: TemplateInstallPlan) throws {
let transport = context.makeTransport()
try transport.createDirectory(plan.projectDir)
for copy in plan.projectFiles {
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
let data = try Data(contentsOf: URL(fileURLWithPath: source))
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
try transport.writeFile(copy.destinationPath, data: data)
}
}
// MARK: - Skills
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
guard let namespaceDir = plan.skillsNamespaceDir else { return }
let transport = context.makeTransport()
try transport.createDirectory(namespaceDir)
for copy in plan.skillsFiles {
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
let data = try Data(contentsOf: URL(fileURLWithPath: source))
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
try transport.writeFile(copy.destinationPath, data: data)
}
}
// MARK: - Memory
nonisolated private func appendMemoryIfNeeded(plan: TemplateInstallPlan) throws {
guard let appendix = plan.memoryAppendix else { return }
let transport = context.makeTransport()
let existing = (try? transport.readFile(plan.memoryPath)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
let combined = existing + appendix
guard let data = combined.data(using: .utf8) else {
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
}
let parent = (plan.memoryPath as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
try transport.writeFile(plan.memoryPath, data: data)
}
// MARK: - Cron
/// Create each cron job via `hermes cron create`, then immediately pause
/// it (Hermes creates jobs enabled). Returns the list of resolved job
/// names, which is what the lock file records we don't know the job
/// ids without parsing the create output, but the name is enough to
/// find + remove them later.
nonisolated private func createCronJobs(plan: TemplateInstallPlan) throws -> [String] {
guard !plan.cronJobs.isEmpty else { return [] }
let existingBefore = Set(HermesFileService(context: context).loadCronJobs().map(\.id))
var createdNames: [String] = []
for job in plan.cronJobs {
var args = ["cron", "create", "--name", job.name]
if let deliver = job.deliver, !deliver.isEmpty { args += ["--deliver", deliver] }
if let repeatCount = job.repeatCount { args += ["--repeat", String(repeatCount)] }
for skill in job.skills ?? [] where !skill.isEmpty {
args += ["--skill", skill]
}
args.append(job.schedule)
if let prompt = job.prompt, !prompt.isEmpty {
args.append(prompt)
}
let (output, exit) = context.runHermes(args)
guard exit == 0 else {
throw ProjectTemplateError.cronCreateFailed(job: job.name, output: output)
}
createdNames.append(job.name)
}
// Diff the current job set against the snapshot we took before
// creating anything new belongs to this install and gets paused.
// We pause by id (not name) because `cron pause` takes an id.
let currentJobs = HermesFileService(context: context).loadCronJobs()
let newJobs = currentJobs.filter { !existingBefore.contains($0.id) && createdNames.contains($0.name) }
for job in newJobs {
let (_, exit) = context.runHermes(["cron", "pause", job.id])
if exit != 0 {
Self.logger.warning("couldn't pause newly-created cron job \(job.id, privacy: .public) — leaving enabled")
}
}
return createdNames
}
// MARK: - Registry
nonisolated private func registerProject(plan: TemplateInstallPlan) throws -> ProjectEntry {
let service = ProjectDashboardService(context: context)
var registry = service.loadRegistry()
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
registry.projects.append(entry)
service.saveRegistry(registry)
return entry
}
// MARK: - Lock file
nonisolated private func writeLockFile(
plan: TemplateInstallPlan,
cronJobNames: [String]
) throws {
let lock = TemplateLock(
templateId: plan.manifest.id,
templateVersion: plan.manifest.version,
templateName: plan.manifest.name,
installedAt: ISO8601DateFormatter().string(from: Date()),
projectFiles: plan.projectFiles.map(\.destinationPath),
skillsNamespaceDir: plan.skillsNamespaceDir,
skillsFiles: plan.skillsFiles.map(\.destinationPath),
cronJobNames: cronJobNames,
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(lock)
let path = plan.projectDir + "/.scarf/template.lock.json"
try context.makeTransport().writeFile(path, data: data)
}
}
@@ -0,0 +1,437 @@
import Foundation
import os
/// Reads, validates, and plans the install of a `.scarftemplate` bundle. Pure
/// owns no state across calls. The installer (see
/// `ProjectTemplateInstaller`) consumes the `TemplateInstallPlan` this
/// produces.
///
/// Responsibilities:
/// 1. Unpack a `.scarftemplate` zip into a caller-owned temp directory.
/// 2. Parse `template.json` and validate it against the schema we know about.
/// 3. Walk the unpacked contents and verify they match the manifest's
/// `contents` claim (so a malicious bundle can't hide files from the
/// preview sheet).
/// 4. Produce a `TemplateInstallPlan` describing every concrete filesystem
/// op the installer will perform, given a parent directory the user
/// picked.
struct ProjectTemplateService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateService")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Inspection
/// Unpack the zip at `zipPath` into a fresh temp directory, parse and
/// validate the manifest, and walk the contents. Throws on any
/// inconsistency. On success, the caller owns `inspection.unpackedDir`
/// and must remove it once they're done.
nonisolated func inspect(zipPath: String) throws -> TemplateInspection {
let unpackedDir = try makeTempDir()
try unzip(zipPath: zipPath, intoDir: unpackedDir)
let manifestPath = unpackedDir + "/template.json"
guard FileManager.default.fileExists(atPath: manifestPath) else {
throw ProjectTemplateError.manifestMissing
}
let manifestData: Data
do {
manifestData = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
} catch {
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
}
let manifest: ProjectTemplateManifest
do {
manifest = try JSONDecoder().decode(ProjectTemplateManifest.self, from: manifestData)
} catch {
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
}
guard manifest.schemaVersion == 1 else {
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
}
let files = try Self.walk(unpackedDir)
let cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
try Self.verifyClaims(manifest: manifest, files: files, cronJobCount: cronJobs.count)
return TemplateInspection(
manifest: manifest,
unpackedDir: unpackedDir,
files: files,
cronJobs: cronJobs
)
}
// MARK: - Planning
/// Turn an inspection into a concrete install plan given the parent
/// directory the user picked. The plan is deterministic two calls with
/// the same inputs produce the same ops.
nonisolated func buildPlan(
inspection: TemplateInspection,
parentDir: String
) throws -> TemplateInstallPlan {
let manifest = inspection.manifest
let slug = manifest.slug
let projectDir = parentDir + "/" + slug
if FileManager.default.fileExists(atPath: projectDir) {
throw ProjectTemplateError.projectDirExists(projectDir)
}
var projectFiles: [TemplateFileCopy] = [
TemplateFileCopy(
sourceRelativePath: "README.md",
destinationPath: projectDir + "/README.md"
),
TemplateFileCopy(
sourceRelativePath: "AGENTS.md",
destinationPath: projectDir + "/AGENTS.md"
),
TemplateFileCopy(
sourceRelativePath: "dashboard.json",
destinationPath: projectDir + "/.scarf/dashboard.json"
)
]
// Optional per-agent instruction shims. Each is copied verbatim to
// its conventional project-root path; we don't try to be clever.
let instructionRoot = "instructions"
for relative in (manifest.contents.instructions ?? []) {
let source = instructionRoot + "/" + relative
guard inspection.files.contains(source) else {
throw ProjectTemplateError.requiredFileMissing(source)
}
projectFiles.append(
TemplateFileCopy(
sourceRelativePath: source,
destinationPath: projectDir + "/" + relative
)
)
}
// Namespaced skills: copied wholesale from skills/<name>/** into
// ~/.hermes/skills/templates/<slug>/<name>/**.
var skillsFiles: [TemplateFileCopy] = []
var skillsNamespaceDir: String? = nil
if let skillNames = manifest.contents.skills, !skillNames.isEmpty {
let namespaceDir = context.paths.skillsDir + "/templates/" + slug
skillsNamespaceDir = namespaceDir
for skillName in skillNames {
let prefix = "skills/" + skillName + "/"
let skillFiles = inspection.files.filter { $0.hasPrefix(prefix) }
guard !skillFiles.isEmpty else {
throw ProjectTemplateError.requiredFileMissing(prefix)
}
for relative in skillFiles {
let suffix = String(relative.dropFirst("skills/".count))
skillsFiles.append(
TemplateFileCopy(
sourceRelativePath: relative,
destinationPath: namespaceDir + "/" + suffix
)
)
}
}
}
// Cron jobs: always prefix name with the template tag so users can
// find and remove them later. Jobs ship disabled the installer
// pauses each one immediately after `cron create`.
let cronJobs: [TemplateCronJobSpec] = inspection.cronJobs.map { job in
TemplateCronJobSpec(
name: "[tmpl:\(manifest.id)] \(job.name)",
schedule: job.schedule,
prompt: job.prompt,
deliver: job.deliver,
skills: job.skills,
repeatCount: job.repeatCount
)
}
// Memory appendix: wrap whatever the template ships in
// begin/end markers so an uninstall can find and remove exactly the
// bytes this template added. `verifyClaims` already guaranteed the
// file is present so a read error here means something unusual
// (permissions, encoding, etc.); surface it with the real
// `error.localizedDescription` rather than hiding behind a
// generic "file missing."
var memoryAppendix: String? = nil
if manifest.contents.memory?.append == true {
let appendSource = inspection.unpackedDir + "/memory/append.md"
let raw: String
do {
raw = try String(contentsOf: URL(fileURLWithPath: appendSource), encoding: .utf8)
} catch {
Self.logger.error("failed to read memory/append.md in unpacked bundle: \(error.localizedDescription, privacy: .public)")
throw ProjectTemplateError.manifestParseFailed("memory/append.md: \(error.localizedDescription)")
}
memoryAppendix = Self.wrapMemoryBlock(
templateId: manifest.id,
templateVersion: manifest.version,
body: raw.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
return TemplateInstallPlan(
manifest: manifest,
unpackedDir: inspection.unpackedDir,
projectDir: projectDir,
projectFiles: projectFiles,
skillsNamespaceDir: skillsNamespaceDir,
skillsFiles: skillsFiles,
cronJobs: cronJobs,
memoryAppendix: memoryAppendix,
memoryPath: context.paths.memoryMD,
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context)
)
}
// MARK: - Cleanup
/// Remove a temp dir created by `inspect`. Safe to call if it already
/// doesn't exist (install or cancel flows both end here).
nonisolated func cleanupTempDir(_ path: String) {
try? FileManager.default.removeItem(atPath: path)
}
// MARK: - Memory block helpers (installer + future uninstaller share these)
nonisolated static func memoryBlockBeginMarker(templateId: String) -> String {
"<!-- scarf-template:\(templateId):begin -->"
}
nonisolated static func memoryBlockEndMarker(templateId: String) -> String {
"<!-- scarf-template:\(templateId):end -->"
}
nonisolated static func wrapMemoryBlock(
templateId: String,
templateVersion: String,
body: String
) -> String {
let begin = memoryBlockBeginMarker(templateId: templateId)
let end = memoryBlockEndMarker(templateId: templateId)
return "\n\n\(begin) v\(templateVersion)\n\(body)\n\(end)\n"
}
// MARK: - Private
private nonisolated func makeTempDir() throws -> String {
let base = NSTemporaryDirectory() + "scarf-template-" + UUID().uuidString
try FileManager.default.createDirectory(
atPath: base,
withIntermediateDirectories: true
)
return base
}
/// Shell out to `/usr/bin/unzip` matches the existing profile-export
/// pattern (`hermes profile import` shells to `unzip`) and avoids
/// pulling in a third-party zip library.
private nonisolated func unzip(zipPath: String, intoDir: String) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-qq", "-o", zipPath, "-d", intoDir]
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
// Foundation dup()s these handles into the child on `run()`, but the
// parent copies stay open until explicitly released. Both ends must
// be closed or each Process spawn leaks 4 fds.
func closePipes() {
try? outPipe.fileHandleForReading.close()
try? outPipe.fileHandleForWriting.close()
try? errPipe.fileHandleForReading.close()
try? errPipe.fileHandleForWriting.close()
}
do {
try process.run()
} catch {
closePipes()
throw ProjectTemplateError.unzipFailed(error.localizedDescription)
}
process.waitUntilExit()
let errData = try? errPipe.fileHandleForReading.readToEnd()
closePipes()
guard process.terminationStatus == 0 else {
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
}
}
/// Recursively walk `dir` and return every file (not directory) as a
/// path relative to `dir`. Skips symlinks entirely templates should
/// never contain them, and following them could escape the unpack dir.
///
/// Both the base dir and the enumerated URLs are resolved via
/// `resolvingSymlinksInPath` before comparison. On macOS, temp dirs
/// under `/var/folders/` resolve to `/private/var/folders/`, so a
/// naive string-prefix check would produce malformed relative paths
/// when the base is unresolved but enumerated URLs are resolved.
nonisolated private static func walk(_ dir: String) throws -> [String] {
var results: [String] = []
let baseURL = URL(fileURLWithPath: dir).resolvingSymlinksInPath()
let basePath = baseURL.path.hasSuffix("/") ? baseURL.path : baseURL.path + "/"
let enumerator = FileManager.default.enumerator(
at: baseURL,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
)
while let url = enumerator?.nextObject() as? URL {
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
if values.isSymbolicLink == true {
throw ProjectTemplateError.unsafeZipEntry(url.path)
}
guard values.isRegularFile == true else { continue }
var full = url.resolvingSymlinksInPath().path
if full.hasPrefix(basePath) {
full.removeFirst(basePath.count)
}
if full.contains("..") {
throw ProjectTemplateError.unsafeZipEntry(full)
}
results.append(full)
}
return results
}
nonisolated private static func readCronJobs(unpackedDir: String) throws -> [TemplateCronJobSpec] {
let path = unpackedDir + "/cron/jobs.json"
guard FileManager.default.fileExists(atPath: path) else { return [] }
let data: Data
do {
data = try Data(contentsOf: URL(fileURLWithPath: path))
} catch {
throw ProjectTemplateError.requiredFileMissing("cron/jobs.json")
}
do {
return try JSONDecoder().decode([TemplateCronJobSpec].self, from: data)
} catch {
throw ProjectTemplateError.manifestParseFailed("cron/jobs.json: \(error.localizedDescription)")
}
}
/// Verify the manifest's `contents` claim exactly matches the unpacked
/// files. Any mismatch claimed-but-missing or present-but-unclaimed
/// throws, so the preview sheet the user sees is always accurate.
nonisolated private static func verifyClaims(
manifest: ProjectTemplateManifest,
files: [String],
cronJobCount: Int
) throws {
let fileSet = Set(files)
if manifest.contents.dashboard {
if !fileSet.contains("dashboard.json") {
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
}
}
if manifest.contents.agentsMd {
if !fileSet.contains("AGENTS.md") {
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
}
}
// README and AGENTS are always required; dashboard is always required
// per spec. `contents.dashboard`/`contents.agentsMd` exist so a future
// schema can relax those rules; for v1 we hard-require them regardless.
if !fileSet.contains("README.md") {
throw ProjectTemplateError.requiredFileMissing("README.md")
}
if !fileSet.contains("AGENTS.md") {
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
}
if !fileSet.contains("dashboard.json") {
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
}
if let claimed = manifest.contents.instructions {
for rel in claimed {
let full = "instructions/" + rel
if !fileSet.contains(full) {
throw ProjectTemplateError.contentClaimMismatch(
"manifest lists \(full) but the file is missing from the bundle"
)
}
}
let present = fileSet.filter { $0.hasPrefix("instructions/") }
let claimedFull = Set(claimed.map { "instructions/" + $0 })
if let extra = present.first(where: { !claimedFull.contains($0) }) {
throw ProjectTemplateError.contentClaimMismatch(
"bundle contains \(extra) but it's not listed in manifest.contents.instructions"
)
}
} else if fileSet.contains(where: { $0.hasPrefix("instructions/") }) {
throw ProjectTemplateError.contentClaimMismatch(
"bundle has instructions/ files but manifest.contents.instructions is missing"
)
}
if let claimed = manifest.contents.skills {
for name in claimed {
let prefix = "skills/" + name + "/"
if !fileSet.contains(where: { $0.hasPrefix(prefix) }) {
throw ProjectTemplateError.contentClaimMismatch(
"manifest lists skill \(name) but skills/\(name)/ has no files"
)
}
}
let presentSkills = Set(fileSet.compactMap { path -> String? in
guard path.hasPrefix("skills/") else { return nil }
let rest = path.dropFirst("skills/".count)
return rest.split(separator: "/", maxSplits: 1).first.map(String.init)
})
let claimedSet = Set(claimed)
if let extra = presentSkills.subtracting(claimedSet).first {
throw ProjectTemplateError.contentClaimMismatch(
"bundle contains skills/\(extra)/ but it's not listed in manifest.contents.skills"
)
}
} else if fileSet.contains(where: { $0.hasPrefix("skills/") }) {
throw ProjectTemplateError.contentClaimMismatch(
"bundle contains skills/ but manifest.contents.skills is missing"
)
}
let claimedCron = manifest.contents.cron ?? 0
if claimedCron != cronJobCount {
throw ProjectTemplateError.contentClaimMismatch(
"manifest.contents.cron=\(claimedCron) but bundle contains \(cronJobCount) cron jobs"
)
}
let hasMemoryFile = fileSet.contains("memory/append.md")
let claimsMemory = manifest.contents.memory?.append == true
if claimsMemory != hasMemoryFile {
throw ProjectTemplateError.contentClaimMismatch(
"manifest.contents.memory.append=\(claimsMemory) disagrees with memory/append.md presence=\(hasMemoryFile)"
)
}
}
/// Resolve a project-registry name that doesn't collide. Deterministic
/// given the same existing registry, always returns the same answer.
nonisolated private static func uniqueProjectName(
preferred: String,
context: ServerContext
) -> String {
let existing = Set(ProjectDashboardService(context: context).loadRegistry().projects.map(\.name))
if !existing.contains(preferred) { return preferred }
var i = 2
while existing.contains("\(preferred) \(i)") {
i += 1
}
return "\(preferred) \(i)"
}
}
@@ -0,0 +1,301 @@
import Foundation
import os
/// Reverses the work of `ProjectTemplateInstaller`, driven by the
/// `<project>/.scarf/template.lock.json` the installer dropped. Symmetric
/// with the installer: `loadUninstallPlan(for:)` builds a plan the preview
/// sheet can display honestly; `uninstall(plan:)` executes it. No hidden
/// side effects every path the uninstaller touches is in the plan.
///
/// **User-added files are preserved.** The lock records exactly what the
/// installer wrote; any file the user created in the project dir after
/// install (e.g. a `sites.txt` or `status-log.md` authored by the agent
/// on first run) is listed as an "extra entry" in the plan and left on
/// disk. If the project dir ends up empty after removing lock-tracked
/// files, the dir itself is removed; otherwise the dir (with user content)
/// stays.
struct ProjectTemplateUninstaller: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateUninstaller")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Detection
/// Is the given project installed from a template that we can
/// uninstall cleanly? Cheap just a file-existence check on the lock
/// path.
nonisolated func isTemplateInstalled(project: ProjectEntry) -> Bool {
context.makeTransport().fileExists(lockPath(for: project))
}
// MARK: - Planning
/// Read the lock file, walk the filesystem + cron list, and produce a
/// plan listing every op the uninstaller will perform. Does not
/// modify anything.
nonisolated func loadUninstallPlan(for project: ProjectEntry) throws -> TemplateUninstallPlan {
let transport = context.makeTransport()
let path = lockPath(for: project)
guard transport.fileExists(path) else {
throw ProjectTemplateError.lockFileMissing(path)
}
let lockData: Data
do {
lockData = try transport.readFile(path)
} catch {
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
}
let lock: TemplateLock
do {
lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
} catch {
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
}
// Partition tracked project files into present vs. already-gone.
// The lock file itself is always in `projectFiles` the installer
// doesn't explicitly record it, but the preview sheet and the
// execute step must remove it.
var lockTrackedFiles = lock.projectFiles
lockTrackedFiles.append(path)
var toRemove: [String] = []
var alreadyGone: [String] = []
for file in lockTrackedFiles {
if transport.fileExists(file) {
toRemove.append(file)
} else {
alreadyGone.append(file)
}
}
// Scan the project dir for entries that AREN'T in the lock these
// are user-added and we preserve them. An empty project dir (after
// removing lock-tracked files) gets removed too.
let trackedSet = Set(lockTrackedFiles)
let extras = try enumerateProjectDirExtras(
projectDir: project.path,
trackedPaths: trackedSet,
transport: transport
)
let projectDirBecomesEmpty = extras.isEmpty
// Resolve cron job ids by matching lock names against the live
// list. Names that no longer exist go into the already-gone bucket
// the user likely removed them by hand.
let currentJobs = HermesFileService(context: context).loadCronJobs()
var cronToRemove: [(id: String, name: String)] = []
var cronGone: [String] = []
for name in lock.cronJobNames {
if let match = currentJobs.first(where: { $0.name == name }) {
cronToRemove.append((id: match.id, name: match.name))
} else {
cronGone.append(name)
}
}
// Memory block detection. The installer wraps its appendix between
// `<!-- scarf-template:<id>:begin -->` / `:end -->` markers; look
// for the begin marker in the current MEMORY.md. If it's missing
// (never installed, or removed by hand) we simply skip the memory
// strip step.
let memoryPath = context.paths.memoryMD
var memoryBlockPresent = false
if lock.memoryBlockId != nil {
if transport.fileExists(memoryPath),
let data = try? transport.readFile(memoryPath),
let text = String(data: data, encoding: .utf8) {
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(
templateId: lock.memoryBlockId!
)
memoryBlockPresent = text.contains(beginMarker)
}
}
return TemplateUninstallPlan(
lock: lock,
project: project,
projectFilesToRemove: toRemove,
projectFilesAlreadyGone: alreadyGone,
extraProjectEntries: extras,
projectDirBecomesEmpty: projectDirBecomesEmpty,
skillsNamespaceDir: lock.skillsNamespaceDir,
cronJobsToRemove: cronToRemove,
cronJobsAlreadyGone: cronGone,
memoryBlockPresent: memoryBlockPresent,
memoryPath: memoryPath
)
}
// MARK: - Execution
/// Execute the plan. Non-atomic: steps run in order, and if any step
/// throws, later steps don't run. v1 doesn't ship rollback the lock
/// file itself is only removed at the very end, so a mid-flight
/// failure leaves enough breadcrumbs for the user to retry or finish
/// by hand.
nonisolated func uninstall(plan: TemplateUninstallPlan) throws {
let transport = context.makeTransport()
// 1. Project files (tracked only user additions untouched).
for file in plan.projectFilesToRemove {
do {
try transport.removeFile(file)
} catch {
Self.logger.warning("couldn't remove project file \(file, privacy: .public): \(error.localizedDescription, privacy: .public)")
// keep going partial cleanup is better than bailing and
// leaving orphan skills/cron state
}
}
if plan.projectDirBecomesEmpty, transport.fileExists(plan.project.path) {
do {
try transport.removeFile(plan.project.path)
} catch {
Self.logger.warning("couldn't remove empty project dir \(plan.project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
// 2. Skills namespace dir (always removed wholesale it's
// isolated, never mixed with user skills).
if let skillsDir = plan.skillsNamespaceDir, transport.fileExists(skillsDir) {
try removeRecursively(skillsDir, transport: transport)
}
// 3. Cron jobs via CLI `hermes cron remove <id>`. A non-zero
// exit gets logged but doesn't abort the uninstall; leaving a
// stray cron job is better than leaving it AND the skills/memory
// state that was supposed to pair with it.
for job in plan.cronJobsToRemove {
let (output, exit) = context.runHermes(["cron", "remove", job.id])
if exit != 0 {
Self.logger.warning("failed to remove cron job \(job.id, privacy: .public) \(job.name, privacy: .public): \(output, privacy: .public)")
}
}
// 4. Memory block strip the bracketed block in place. Safe
// when the block is absent; we already decided presence in the
// plan and only come here when `memoryBlockPresent` was true
// AND the plan recorded a memoryBlockId.
if plan.memoryBlockPresent, let blockId = plan.lock.memoryBlockId {
try stripMemoryBlock(blockId: blockId, memoryPath: plan.memoryPath, transport: transport)
}
// 5. Projects registry remove the entry by path (more stable
// than name: user may have renamed the project in the UI).
let dashboardService = ProjectDashboardService(context: context)
var registry = dashboardService.loadRegistry()
registry.projects.removeAll { $0.path == plan.project.path }
dashboardService.saveRegistry(registry)
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
}
// MARK: - Helpers
nonisolated private func lockPath(for project: ProjectEntry) -> String {
project.path + "/.scarf/template.lock.json"
}
/// Walk the project dir and return the absolute paths of every entry
/// not in `trackedPaths`. `.scarf/` (and its remaining contents after
/// the lock is recorded) is filtered out because the installer owns
/// that directory entirely if the user dropped a file into it,
/// that's on them, but the common case is that `.scarf/` only holds
/// our dashboard.json + template.lock.json.
nonisolated private func enumerateProjectDirExtras(
projectDir: String,
trackedPaths: Set<String>,
transport: any ServerTransport
) throws -> [String] {
guard transport.fileExists(projectDir) else { return [] }
var extras: [String] = []
let entries: [String]
do {
entries = try transport.listDirectory(projectDir)
} catch {
return []
}
for entry in entries {
let full = projectDir + "/" + entry
// Skip the .scarf/ dir entirely when deciding "does the
// project dir have user content?" the only files we put
// there (dashboard.json + lock) are tracked already, and
// if they're still there the overall project is not yet
// "empty."
if entry == ".scarf" { continue }
if trackedPaths.contains(full) { continue }
extras.append(full)
}
return extras
}
/// Recursively delete a directory via the transport. The transport's
/// `removeFile` works on files and on empty directories; we walk
/// children first, then remove the now-empty parent.
nonisolated private func removeRecursively(
_ path: String,
transport: any ServerTransport
) throws {
guard transport.fileExists(path) else { return }
if transport.stat(path)?.isDirectory != true {
try transport.removeFile(path)
return
}
let entries = (try? transport.listDirectory(path)) ?? []
for entry in entries {
try removeRecursively(path + "/" + entry, transport: transport)
}
try transport.removeFile(path)
}
/// Remove the `<!-- scarf-template:<id>:begin --> :end -->` block
/// from MEMORY.md, preserving everything else. A missing end marker
/// is logged but doesn't fail we strip from the begin marker to
/// EOF in that case, on the theory that a broken template block is
/// worse than a slightly aggressive strip.
nonisolated private func stripMemoryBlock(
blockId: String,
memoryPath: String,
transport: any ServerTransport
) throws {
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(templateId: blockId)
let endMarker = ProjectTemplateService.memoryBlockEndMarker(templateId: blockId)
let data = try transport.readFile(memoryPath)
guard let text = String(data: data, encoding: .utf8) else { return }
guard let beginRange = text.range(of: beginMarker) else { return }
let stripRange: Range<String.Index>
if let endRange = text.range(of: endMarker, range: beginRange.upperBound..<text.endIndex) {
// Include the end marker and one trailing newline if present.
var upper = endRange.upperBound
if upper < text.endIndex, text[upper] == "\n" {
upper = text.index(after: upper)
}
stripRange = beginRange.lowerBound..<upper
} else {
Self.logger.warning("memory block for \(blockId, privacy: .public) has begin marker but no end marker; stripping to EOF")
stripRange = beginRange.lowerBound..<text.endIndex
}
// Also consume one leading blank line that the installer inserts
// before the begin marker, so repeated install/uninstall cycles
// don't accumulate blank lines at the insertion site.
var lower = stripRange.lowerBound
if lower > text.startIndex {
let prev = text.index(before: lower)
if text[prev] == "\n", prev > text.startIndex {
let prevPrev = text.index(before: prev)
if text[prevPrev] == "\n" {
lower = prev
}
}
}
let updated = text.replacingCharacters(in: lower..<stripRange.upperBound, with: "")
guard let outData = updated.data(using: .utf8) else { return }
try transport.writeFile(memoryPath, data: outData)
}
}
@@ -0,0 +1,87 @@
import Foundation
import Observation
import os
/// Process-wide router for `scarf://install?url=` URLs. The app delegate's
/// `onOpenURL` hands the URL in here; the Projects feature observes
/// `pendingInstallURL` and presents the install sheet when it flips non-nil.
///
/// Lives outside SwiftUI so a URL can arrive before any window exists (cold
/// launch from a browser link) and still be picked up by the first
/// `ProjectsView` that appears.
@Observable
@MainActor
final class TemplateURLRouter {
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateURLRouter")
static let shared = TemplateURLRouter()
/// Non-nil when an install request is waiting to be handled. Can be
/// either a remote `https://` URL (from a `scarf://install?url=` deep
/// link) or a local `file://` URL (from a Finder double-click on a
/// `.scarftemplate` file, or a drag onto the app icon). Observers read
/// this, dispatch by scheme, present the install sheet, then call
/// `consume` to clear it. Only one pending install at a time if a
/// second arrives before the first is consumed, it replaces the first
/// (matches browser-link intuition where the latest click wins).
var pendingInstallURL: URL?
private init() {}
/// Parse and validate an inbound URL. Returns `true` if the URL was
/// recognized and staged for handling. Unknown schemes or malformed
/// payloads return `false` so the caller can log/ignore. Supports:
///
/// - `scarf://install?url=https://` remote template URL from a web link.
/// - `file:////foo.scarftemplate` local file from a Finder
/// double-click or a drag onto the app icon.
@discardableResult
func handle(_ url: URL) -> Bool {
if url.isFileURL {
return handleFileURL(url)
}
if url.scheme?.lowercased() == "scarf" {
return handleScarfURL(url)
}
Self.logger.warning("Ignored URL with unknown scheme: \(url.absoluteString, privacy: .public)")
return false
}
private func handleFileURL(_ url: URL) -> Bool {
guard url.pathExtension.lowercased() == "scarftemplate" else {
Self.logger.warning("file:// URL handed to Scarf but not a .scarftemplate: \(url.absoluteString, privacy: .public)")
return false
}
pendingInstallURL = url
Self.logger.info("file:// install staged \(url.path, privacy: .public)")
return true
}
private func handleScarfURL(_ url: URL) -> Bool {
guard url.host?.lowercased() == "install" else {
Self.logger.warning("Ignored unknown scarf:// host: \(url.absoluteString, privacy: .public)")
return false
}
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let raw = components.queryItems?.first(where: { $0.name == "url" })?.value,
let remote = URL(string: raw) else {
Self.logger.warning("scarf://install missing or invalid ?url=: \(url.absoluteString, privacy: .public)")
return false
}
// Refuse anything but https defense-in-depth against a browser or
// mail client that would happily hand us a javascript: or http://
// URL pointing at something unexpected.
guard remote.scheme?.lowercased() == "https" else {
Self.logger.warning("scarf://install refused non-https url=\(remote.absoluteString, privacy: .public)")
return false
}
pendingInstallURL = remote
Self.logger.info("scarf://install staged \(remote.absoluteString, privacy: .public)")
return true
}
/// Called by the install sheet once it has picked up the URL.
func consume() {
pendingInstallURL = nil
}
}
@@ -114,7 +114,7 @@ struct ActivityView: View {
VStack(alignment: .leading, spacing: 2) {
Text(entry.toolName)
.font(.title3.bold().monospaced())
Text(entry.kind.rawValue.capitalized)
Text(entry.kind.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -50,6 +50,23 @@ final class ChatViewModel {
private var isHandlingDisconnect = false
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
var acpStatus: String = ""
/// True while a session is being established or restored from the user
/// kicking off "start chat" or "resume session" until the ACP session is
/// ready for messages. The chat pane uses this to show a loader in place
/// of the empty-state placeholder.
var isPreparingSession: Bool {
guard hasActiveProcess else { return false }
switch acpStatus {
case "Starting...",
"Creating session...",
"Creating new session...",
"Loading session...":
return true
default:
return acpStatus.hasPrefix("Reconnecting")
}
}
var acpError: String?
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
/// Shown above the raw error in the UI when present.
@@ -31,6 +31,7 @@ final class RichChatViewModel {
init(context: ServerContext = .local) {
self.context = context
self.dataService = HermesDataService(context: context)
loadQuickCommands()
}
@@ -49,9 +50,21 @@ final class RichChatViewModel {
private(set) var acpCachedReadTokens = 0
/// Slash commands advertised by the ACP server via `available_commands_update`.
private(set) var availableCommandNames: Set<String> = []
private(set) var acpCommands: [HermesSlashCommand] = []
/// User-defined commands parsed from `config.yaml` `quick_commands`.
private(set) var quickCommands: [HermesSlashCommand] = []
var supportsCompress: Bool { availableCommandNames.contains("compress") }
/// Merged list, ACP-first, de-duplicated by name.
var availableCommands: [HermesSlashCommand] {
let acpNames = Set(acpCommands.map(\.name))
return acpCommands + quickCommands.filter { !acpNames.contains($0.name) }
}
var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
/// True when the menu carries more than just `/compress` used to hide
/// the dedicated compress button in favor of the full slash menu.
var hasBroaderCommandMenu: Bool { availableCommands.count > 1 }
var hasMessages: Bool { !messages.isEmpty }
@@ -105,8 +118,9 @@ final class RichChatViewModel {
acpOutputTokens = 0
acpThoughtTokens = 0
acpCachedReadTokens = 0
availableCommandNames = []
acpCommands = []
pendingPermission = nil
loadQuickCommands()
}
func setSessionId(_ id: String?) {
@@ -156,6 +170,11 @@ final class RichChatViewModel {
streamingThinkingText = ""
streamingToolCalls = []
buildMessageGroups()
// User just submitted jump to the bottom so they see their message
// and the incoming response. `.defaultScrollAnchor(.bottom)` handles
// slow streaming fine, but rapid responses (slash commands especially)
// arrive faster than the anchor can track.
requestScrollToBottom()
}
/// Process a streaming ACP event and update the message list.
@@ -181,19 +200,59 @@ final class RichChatViewModel {
case .connectionLost(let reason):
handleConnectionLost(reason: reason)
case .availableCommands(_, let commands):
var names: Set<String> = []
for entry in commands {
if let name = entry["name"] as? String {
// Hermes sends names either as "compress" or "/compress"
names.insert(name.trimmingCharacters(in: CharacterSet(charactersIn: "/")))
}
}
availableCommandNames = names
acpCommands = parseACPCommands(commands)
case .unknown:
break
}
}
private func parseACPCommands(_ commands: [[String: Any]]) -> [HermesSlashCommand] {
var result: [HermesSlashCommand] = []
for entry in commands {
guard let rawName = entry["name"] as? String else { continue }
// Hermes sends names either as "compress" or "/compress"
let name = rawName.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !name.isEmpty else { continue }
let description = (entry["description"] as? String) ?? ""
var hint: String? = nil
if let input = entry["input"] as? [String: Any],
let h = input["hint"] as? String,
!h.isEmpty {
hint = h
}
result.append(HermesSlashCommand(
name: name,
description: description,
argumentHint: hint,
source: .acp
))
}
return result
}
/// Load `quick_commands` from `config.yaml` off the main actor and publish
/// them as slash commands. Safe to call repeatedly replaces the existing list.
func loadQuickCommands() {
let ctx = context
Task.detached { [weak self] in
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
let mapped = loaded.map { qc -> HermesSlashCommand in
let truncated = qc.command.count > 60
? String(qc.command.prefix(60)) + ""
: qc.command
return HermesSlashCommand(
name: qc.name,
description: "Run: \(truncated)",
argumentHint: nil,
source: .quickCommand
)
}
await MainActor.run { [weak self] in
self?.quickCommands = mapped
}
}
}
private func appendMessageChunk(text: String) {
streamingAssistantText += text
upsertStreamingMessage()
@@ -283,6 +342,10 @@ final class RichChatViewModel {
acpCachedReadTokens += response.cachedReadTokens
isAgentWorking = false
buildMessageGroups()
// Final position after the prompt settles. Catches fast responses
// (slash commands, short replies) where `.defaultScrollAnchor(.bottom)`
// didn't quite track the abrupt content growth.
requestScrollToBottom()
}
private func handleConnectionLost(reason: String) {
@@ -122,7 +122,7 @@ struct ChatView: View {
Circle()
.fill(.green)
.frame(width: 6, height: 6)
Text(viewModel.acpStatus.isEmpty ? "Active" : viewModel.acpStatus)
(viewModel.acpStatus.isEmpty ? Text("Active") : Text(viewModel.acpStatus))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -238,7 +238,7 @@ struct ChatView: View {
HStack(spacing: 4) {
Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash")
.foregroundStyle(viewModel.voiceEnabled ? .green : .secondary)
Text(viewModel.voiceEnabled ? "Voice On" : "Voice Off")
(viewModel.voiceEnabled ? Text("Voice On") : Text("Voice Off"))
.font(.caption)
.foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary)
}
@@ -253,7 +253,7 @@ struct ChatView: View {
HStack(spacing: 4) {
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
Text(viewModel.ttsEnabled ? "TTS On" : "TTS Off")
(viewModel.ttsEnabled ? Text("TTS On") : Text("TTS Off"))
.font(.caption)
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
}
@@ -268,7 +268,7 @@ struct ChatView: View {
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
.foregroundStyle(viewModel.isRecording ? .red : Color.accentColor)
.symbolEffect(.pulse, isActive: viewModel.isRecording)
Text(viewModel.isRecording ? "Recording..." : "Push to Talk")
(viewModel.isRecording ? Text("Recording…") : Text("Push to Talk"))
.font(.caption)
}
}
@@ -3,70 +3,127 @@ import SwiftUI
struct RichChatInputBar: View {
let onSend: (String) -> Void
let isEnabled: Bool
var supportsCompress: Bool = false
var commands: [HermesSlashCommand] = []
var showCompressButton: Bool = false
@State private var text = ""
@State private var showCompressSheet = false
@State private var compressFocus = ""
@State private var showMenu = false
@State private var selectedIndex = 0
@FocusState private var isFocused: Bool
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
if supportsCompress {
VStack(alignment: .leading, spacing: 0) {
if showMenu {
SlashCommandMenu(
commands: filteredCommands,
agentHasCommands: !commands.isEmpty,
selectedIndex: $selectedIndex,
onSelect: insertCommand
)
.id(menuQuery)
.background(.regularMaterial)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.separator, lineWidth: 0.5)
)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 2)
.padding(.horizontal, 12)
.padding(.top, 8)
}
HStack(alignment: .bottom, spacing: 8) {
if showCompressButton {
Button {
compressFocus = ""
showCompressSheet = true
} label: {
Image(systemName: "rectangle.compress.vertical")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.help("Compress conversation (/compress)")
}
TextEditor(text: $text)
.font(.body)
.scrollContentBackground(.hidden)
.focused($isFocused)
.frame(minHeight: 28, maxHeight: 120)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Message Hermes...")
.foregroundStyle(.tertiary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.allowsHitTesting(false)
}
}
.onKeyPress(.upArrow, phases: .down) { _ in
guard showMenu, !filteredCommands.isEmpty else { return .ignored }
let n = filteredCommands.count
selectedIndex = (selectedIndex - 1 + n) % n
return .handled
}
.onKeyPress(.downArrow, phases: .down) { _ in
guard showMenu, !filteredCommands.isEmpty else { return .ignored }
let n = filteredCommands.count
selectedIndex = (selectedIndex + 1) % n
return .handled
}
.onKeyPress(.tab, phases: .down) { _ in
guard showMenu,
let command = filteredCommands[safe: selectedIndex] else { return .ignored }
insertCommand(command)
return .handled
}
.onKeyPress(.escape, phases: .down) { _ in
guard showMenu else { return .ignored }
showMenu = false
return .handled
}
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.shift) {
return .ignored
}
if showMenu, let command = filteredCommands[safe: selectedIndex] {
insertCommand(command)
return .handled
}
send()
return .handled
}
Button {
compressFocus = ""
showCompressSheet = true
send()
} label: {
Image(systemName: "rectangle.compress.vertical")
.font(.title3)
.foregroundStyle(.secondary)
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(canSend ? Color.accentColor : .secondary)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.help("Compress conversation (/compress)")
.disabled(!canSend)
.help("Send message (Enter)")
}
TextEditor(text: $text)
.font(.body)
.scrollContentBackground(.hidden)
.focused($isFocused)
.frame(minHeight: 28, maxHeight: 120)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Message Hermes...")
.foregroundStyle(.tertiary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.allowsHitTesting(false)
}
}
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.shift) {
return .ignored
}
send()
return .handled
}
Button {
send()
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(canSend ? Color.accentColor : .secondary)
}
.buttonStyle(.plain)
.disabled(!canSend)
.help("Send message (Enter)")
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
.onChange(of: text) { _, _ in
updateMenuState()
}
.onChange(of: commands.map(\.id)) { _, _ in
updateMenuState()
}
.sheet(isPresented: $showCompressSheet) {
compressSheet
}
@@ -101,10 +158,61 @@ struct RichChatInputBar: View {
isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Show the slash menu only while the user is typing the command token:
/// text starts with `/` and contains no whitespace (space or newline).
private var shouldShowMenu: Bool {
guard text.hasPrefix("/") else { return false }
return !text.contains(" ") && !text.contains("\n")
}
private var menuQuery: String {
guard text.hasPrefix("/") else { return "" }
return String(text.dropFirst())
}
private var filteredCommands: [HermesSlashCommand] {
SlashCommandMenu.filter(commands: commands, query: menuQuery)
}
private func updateMenuState() {
let shouldShow = shouldShowMenu
if shouldShow != showMenu {
showMenu = shouldShow
}
// Re-clamp selection whenever the filtered list may have shrunk.
let count = filteredCommands.count
if count == 0 {
selectedIndex = 0
} else if selectedIndex >= count {
selectedIndex = count - 1
} else if selectedIndex < 0 {
selectedIndex = 0
}
}
private func insertCommand(_ command: HermesSlashCommand) {
if command.argumentHint != nil {
text = "/\(command.name) "
} else {
text = "/\(command.name)"
}
showMenu = false
selectedIndex = 0
isFocused = true
}
private func send() {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, isEnabled else { return }
onSend(trimmed)
text = ""
showMenu = false
selectedIndex = 0
}
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -3,32 +3,52 @@ import SwiftUI
struct RichChatMessageList: View {
let groups: [MessageGroup]
let isWorking: Bool
/// True while the ACP session is being established or restored used to
/// swap the empty-state placeholder for a progress indicator so the user
/// knows something is happening while history loads.
var isLoadingSession: Bool = false
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
var scrollTrigger: UUID = UUID()
/// Why `.defaultScrollAnchor(.bottom)` *alone* and no `proxy.scrollTo`.
/// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus
/// `.defaultScrollAnchor(.bottom)`.
///
/// `.defaultScrollAnchor(.bottom)` tells SwiftUI to pin the viewport to
/// the bottom of the content automatically as messages stream in or
/// new turns arrive, the scroll position tracks the bottom edge.
/// `LazyVStack` was causing the classic "loaded session shows whitespace
/// and the chat is above" bug: lazy rows return estimated heights before
/// they render, `.defaultScrollAnchor(.bottom)` positions the viewport
/// at the *estimated* bottom (which overshoots the real content), and
/// when rows materialize and real heights land, the viewport ends up
/// past the content. Attempts to correct via `proxy.scrollTo(lastID)`
/// failed because unrendered rows have no resolvable ID.
///
/// We used to also call `proxy.scrollTo(lastID, anchor: .bottom)` from
/// six different `onChange` handlers during streaming. The two
/// mechanisms fought each other: the ScrollViewReader can resolve an ID
/// to a position **before** LazyVStack has finished laying out that
/// row, so `scrollTo` would land past the actual content the
/// "viewport showing whitespace, chat is above" symptom. Removing the
/// manual scroll and trusting `defaultScrollAnchor` eliminates the race.
///
/// The only remaining explicit scroll is `scrollTrigger` for the "Return
/// to Active Session" button; that fires rarely, after layout has
/// settled, so the overshoot doesn't happen.
/// Switching to `VStack` materializes every row immediately, so
/// `.defaultScrollAnchor(.bottom)` has real heights to work with and
/// can't overshoot. For typical Hermes sessions (<500 messages) the
/// first-render cost is acceptable. If ever needed for huge sessions
/// we can reintroduce lazy with a preference-key-based height
/// measurement, but that's a much larger change.
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 16) {
if groups.isEmpty && !isWorking {
emptyState
// Fill the scroll view's visible height so Spacers
// can vertically center the placeholder. Previously
// `.padding(.vertical, 80)` left the placeholder
// floating at whatever y-offset `.defaultScrollAnchor(.bottom)`
// settled on usually near the bottom of the pane.
VStack {
Spacer(minLength: 0)
if isLoadingSession {
loadingState
} else {
emptyState
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
.containerRelativeFrame(.vertical)
.transition(.opacity)
}
ForEach(groups) { group in
@@ -42,6 +62,8 @@ struct RichChatMessageList: View {
}
}
.padding()
.animation(.easeInOut(duration: 0.15), value: isLoadingSession)
.animation(.easeInOut(duration: 0.15), value: groups.isEmpty)
}
.defaultScrollAnchor(.bottom)
.onChange(of: scrollTrigger) {
@@ -75,8 +97,16 @@ struct RichChatMessageList: View {
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 80)
}
private var loadingState: some View {
VStack(spacing: 14) {
ProgressView()
.controlSize(.large)
Text("Loading session…")
.font(.callout)
.foregroundStyle(.secondary)
}
}
private var typingIndicator: some View {
@@ -28,6 +28,7 @@ struct RichChatView: View {
RichChatMessageList(
groups: richChat.messageGroups,
isWorking: richChat.isAgentWorking,
isLoadingSession: chatViewModel.isPreparingSession,
scrollTrigger: richChat.scrollTrigger
)
@@ -37,7 +38,8 @@ struct RichChatView: View {
onSend(text)
},
isEnabled: isEnabled,
supportsCompress: richChat.supportsCompress
commands: richChat.availableCommands,
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
)
}
// DB polling fallback for terminal mode only never overwrite ACP messages
@@ -45,7 +45,8 @@ struct SessionInfoBar: View {
}
if let cost = session.displayCostUSD {
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
.contentTransition(.numericText())
}
@@ -75,11 +76,6 @@ struct SessionInfoBar: View {
}
private func formatTokens(_ count: Int) -> String {
if count >= 1_000_000 {
return String(format: "%.1fM", Double(count) / 1_000_000)
} else if count >= 1_000 {
return String(format: "%.1fK", Double(count) / 1_000)
}
return "\(count)"
count.formatted(.number.notation(.compactName).precision(.fractionLength(0...1)))
}
}
@@ -0,0 +1,114 @@
import SwiftUI
/// Floating menu of available slash commands shown above the chat input when
/// the user types `/` as the first character. Purely presentational the
/// parent filters the list and owns selection state.
struct SlashCommandMenu: View {
/// Pre-filtered commands to display.
let commands: [HermesSlashCommand]
/// Whether the agent advertised any commands at all. Lets us distinguish
/// "agent hasn't sent commands yet" from "filter matched nothing".
let agentHasCommands: Bool
@Binding var selectedIndex: Int
var onSelect: (HermesSlashCommand) -> Void
/// Case-insensitive prefix match on the command name. Exposed as a static
/// helper so the parent can share filter logic with its key handlers.
static func filter(commands: [HermesSlashCommand], query: String) -> [HermesSlashCommand] {
let q = query.lowercased()
if q.isEmpty { return commands }
return commands.filter { $0.name.lowercased().hasPrefix(q) }
}
var body: some View {
if !agentHasCommands {
VStack(alignment: .leading, spacing: 4) {
Text("No commands available")
.font(.callout)
.foregroundStyle(.secondary)
Text("The agent hasn't advertised any slash commands yet. Keep typing to send as a message, or press Esc.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(12)
.frame(minWidth: 360, alignment: .leading)
} else if commands.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("No matching commands")
.font(.callout)
.foregroundStyle(.secondary)
Text("Keep typing to send as a message, or press Esc.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(12)
.frame(minWidth: 360, alignment: .leading)
} else {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in
SlashCommandRow(
command: command,
isSelected: index == selectedIndex
)
.id(index)
.contentShape(Rectangle())
.onTapGesture {
selectedIndex = index
onSelect(command)
}
}
}
}
.frame(minWidth: 360, maxHeight: 260)
.onChange(of: selectedIndex) { _, newValue in
withAnimation(.easeOut(duration: 0.1)) {
proxy.scrollTo(newValue, anchor: .center)
}
}
}
}
}
}
private struct SlashCommandRow: View {
let command: HermesSlashCommand
let isSelected: Bool
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text("/\(command.name)")
.font(.system(.body, design: .monospaced))
.fontWeight(.semibold)
if let hint = command.argumentHint {
Text("<\(hint)>")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)
}
if command.source == .quickCommand {
Text("user")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary.opacity(0.8))
.clipShape(Capsule())
.foregroundStyle(.secondary)
}
}
if !command.description.isEmpty {
Text(command.description)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isSelected ? Color.accentColor.opacity(0.15) : Color.clear)
}
}
@@ -106,7 +106,7 @@ struct CredentialPoolsView: View {
@ViewBuilder
private func poolSection(_ pool: HermesCredentialPool) -> some View {
SettingsSection(title: pool.provider, icon: "key.horizontal") {
SettingsSection(title: LocalizedStringKey(pool.provider), icon: "key.horizontal") {
PickerRow(label: "Rotation", selection: pool.strategy, options: viewModel.strategyOptions) { strategy in
viewModel.setStrategy(strategy, for: pool.provider)
}
@@ -194,6 +194,13 @@ private struct AddCredentialSheet: View {
case apiKey = "API Key"
case oauth = "OAuth"
var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .apiKey: return "API Key"
case .oauth: return "OAuth"
}
}
}
@State private var providerID: String = ""
@@ -262,7 +269,7 @@ private struct AddCredentialSheet: View {
Text("Credential Type").font(.caption).foregroundStyle(.secondary)
Picker("", selection: $authType) {
ForEach(AuthType.allCases) { type in
Text(type.rawValue).tag(type)
Text(type.displayName).tag(type)
}
}
.pickerStyle(.segmented)
@@ -114,7 +114,7 @@ struct DashboardView: View {
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
if cost > 0 {
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
StatCard(label: "Cost", value: cost.formatted(.currency(code: "USD").precision(.fractionLength(2))))
}
}
}
@@ -217,7 +217,7 @@ struct SessionRow: View {
Label("\(session.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench")
if let cost = session.displayCostUSD, cost > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
Label(cost.formatted(.currency(code: "USD").precision(.fractionLength(4))), systemImage: "dollarsign.circle")
}
}
.font(.caption)
@@ -102,7 +102,7 @@ struct GatewayView: View {
Image(systemName: platform.icon)
.font(.title2)
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
Text(platform.name.capitalized)
Text(verbatim: platform.name.capitalized)
.font(.caption.bold())
StatusBadge(
label: platform.state,
@@ -132,7 +132,7 @@ struct HealthView: View {
Circle()
.fill(viewModel.hermesRunning ? .green : .red)
.frame(width: 8, height: 8)
Text(viewModel.hermesRunning ? "Hermes Running" : "Hermes Stopped")
(viewModel.hermesRunning ? Text("Hermes Running") : Text("Hermes Stopped"))
.font(.caption.bold())
if let pid = viewModel.hermesPID {
Text("PID \(pid)")
@@ -8,6 +8,15 @@ enum InsightsPeriod: String, CaseIterable, Identifiable {
var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .week: return "7 Days"
case .month: return "30 Days"
case .quarter: return "90 Days"
case .all: return "All Time"
}
}
var sinceDate: Date {
let calendar = Calendar.current
switch self {
@@ -37,7 +37,7 @@ struct InsightsView: View {
private var periodPicker: some View {
Picker("Period", selection: $viewModel.period) {
ForEach(InsightsPeriod.allCases) { period in
Text(period.rawValue).tag(period)
Text(period.displayName).tag(period)
}
}
.pickerStyle(.segmented)
@@ -61,10 +61,10 @@ struct InsightsView: View {
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
InsightCard(label: "Total Cost", value: viewModel.totalCost.formatted(.currency(code: "USD").precision(.fractionLength(2))))
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : (Double(viewModel.totalMessages) / Double(viewModel.sessions.count)).formatted(.number.precision(.fractionLength(1))))
}
}
}
@@ -90,7 +90,7 @@ struct InsightsView: View {
VStack(alignment: .trailing, spacing: 2) {
Text("\(model.sessions) sessions")
.font(.caption)
Text(formatTokens(model.totalTokens) + " tokens")
Text("\(formatTokens(model.totalTokens)) tokens")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -164,7 +164,7 @@ struct InsightsView: View {
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.frame(width: 40, alignment: .trailing)
Text(String(format: "%.1f%%", tool.percentage))
Text((tool.percentage / 100).formatted(.percent.precision(.fractionLength(1))))
.font(.caption)
.foregroundStyle(.tertiary)
.frame(width: 50, alignment: .trailing)
@@ -193,12 +193,12 @@ struct InsightsView: View {
Text("By Day")
.font(.caption.bold())
.foregroundStyle(.secondary)
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
let dayNames = Calendar.current.shortWeekdaySymbols
let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1)
ForEach(0..<7, id: \.self) { day in
let count = viewModel.dailyActivity[day] ?? 0
HStack(spacing: 6) {
Text(dayNames[day])
Text(verbatim: dayNames[(day + 1) % 7])
.font(.caption.monospaced())
.frame(width: 30, alignment: .trailing)
RoundedRectangle(cornerRadius: 2)
@@ -23,6 +23,14 @@ final class LogsViewModel {
case gateway = "gateway.log"
var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .agent: return "Agent"
case .errors: return "Errors"
case .gateway: return "Gateway"
}
}
}
private func path(for file: LogFile) -> String {
@@ -43,6 +51,17 @@ final class LogsViewModel {
var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .all: return "All"
case .gateway: return "Gateway"
case .agent: return "Agent"
case .tools: return "Tools"
case .cli: return "CLI"
case .cron: return "Cron"
}
}
var loggerPrefix: String? {
switch self {
case .all: return nil
@@ -27,7 +27,7 @@ struct LogsView: View {
set: { file in Task { await viewModel.switchLogFile(file) } }
)) {
ForEach(LogsViewModel.LogFile.allCases) { file in
Text(file.rawValue).tag(file)
Text(file.displayName).tag(file)
}
}
.pickerStyle(.segmented)
@@ -35,7 +35,7 @@ struct LogsView: View {
Picker("Component", selection: $viewModel.selectedComponent) {
ForEach(LogsViewModel.LogComponent.allCases) { component in
Text(component.rawValue).tag(component)
Text(component.displayName).tag(component)
}
}
.frame(maxWidth: 140)
@@ -45,7 +45,7 @@ struct LogsView: View {
Picker("Level", selection: $viewModel.filterLevel) {
Text("All Levels").tag(LogEntry.LogLevel?.none)
ForEach(LogEntry.LogLevel.allCases, id: \.rawValue) { level in
Text(level.rawValue).tag(LogEntry.LogLevel?.some(level))
Text(verbatim: level.rawValue).tag(LogEntry.LogLevel?.some(level))
}
}
.frame(maxWidth: 150)
@@ -66,7 +66,7 @@ struct LogsView: View {
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.frame(width: 140, alignment: .leading)
Text(entry.level.rawValue)
Text(verbatim: entry.level.rawValue)
.font(.caption.monospaced().bold())
.foregroundStyle(colorForLevel(entry.level))
.frame(width: 60, alignment: .leading)
@@ -154,7 +154,7 @@ struct MCPServerDetailView: View {
Text(key)
.font(.system(.caption, design: .monospaced))
Spacer()
Text(String(repeating: "", count: 10))
Text("••••••••••")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
@@ -182,7 +182,7 @@ struct MCPServerDetailView: View {
Text(key)
.font(.system(.caption, design: .monospaced))
Spacer()
Text(String(repeating: "", count: 10))
Text("••••••••••")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
@@ -33,9 +33,9 @@ struct MCPServerPresetPickerView: View {
}
}
VStack(alignment: .leading, spacing: 2) {
Text(selectedPreset?.displayName ?? "Add from Preset")
(selectedPreset.map { Text(verbatim: $0.displayName) } ?? Text("Add from Preset"))
.font(.headline)
Text(selectedPreset?.description ?? "Pick an MCP server to add.")
(selectedPreset.map { Text(verbatim: $0.description) } ?? Text("Pick an MCP server to add."))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -83,14 +83,14 @@ struct MCPServerPresetPickerView: View {
Image(systemName: preset.iconSystemName)
.font(.title3)
.foregroundStyle(Color.accentColor)
Text(preset.displayName)
Text(verbatim: preset.displayName)
.font(.body.bold())
Spacer()
Image(systemName: preset.transport == .http ? "network" : "terminal")
.font(.caption)
.foregroundStyle(.secondary)
}
Text(preset.description)
Text(verbatim: preset.description)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
@@ -10,9 +10,9 @@ struct MCPServerTestResultView: View {
Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill")
.foregroundStyle(result.succeeded ? .green : .red)
VStack(alignment: .leading, spacing: 2) {
Text(result.succeeded ? "Test passed" : "Test failed")
(result.succeeded ? Text("Test passed") : Text("Test failed"))
.font(.subheadline.bold())
Text(String(format: "%.1fs · %d tools", result.elapsed, result.tools.count))
Text("\(result.elapsed.formatted(.number.precision(.fractionLength(1))))s · \(result.tools.count) tools")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -20,8 +20,12 @@ struct MCPServerTestResultView: View {
Button {
showOutput.toggle()
} label: {
Label(showOutput ? "Hide Output" : "Show Output", systemImage: showOutput ? "chevron.up" : "chevron.down")
.font(.caption)
Label {
showOutput ? Text("Hide Output") : Text("Show Output")
} icon: {
Image(systemName: showOutput ? "chevron.up" : "chevron.down")
}
.font(.caption)
}
.buttonStyle(.borderless)
}
@@ -128,7 +128,7 @@ struct MCPServersView: View {
} else if let result = viewModel.testResults[server.name] {
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(result.succeeded ? .green : .red)
.help(result.succeeded ? "\(result.tools.count) tools" : "Test failed")
.help(result.succeeded ? Text("\(result.tools.count) tools") : Text("Test failed"))
}
}
}
@@ -51,7 +51,9 @@ struct SignalSetupView: View {
HStack(spacing: 8) {
Image(systemName: viewModel.signalCLIInstalled ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(viewModel.signalCLIInstalled ? .green : .orange)
Text(viewModel.signalCLIInstalled ? "signal-cli is available on PATH" : "signal-cli not found on PATH — install it first")
(viewModel.signalCLIInstalled
? Text("signal-cli is available on PATH")
: Text("signal-cli not found on PATH — install it first"))
.font(.caption)
.foregroundStyle(viewModel.signalCLIInstalled ? Color.primary : Color.orange)
Spacer()
@@ -40,7 +40,7 @@ struct PlatformsView: View {
HStack(spacing: 8) {
Image(systemName: KnownPlatforms.icon(for: platform.name))
.frame(width: 20)
Text(platform.displayName)
Text(verbatim: platform.displayName)
Spacer()
Circle()
.fill(statusColor(viewModel.connectivity(for: platform)))
@@ -88,7 +88,7 @@ struct PlatformsView: View {
Image(systemName: KnownPlatforms.icon(for: viewModel.selected.name))
.font(.title)
VStack(alignment: .leading) {
Text(viewModel.selected.displayName)
Text(verbatim: viewModel.selected.displayName)
.font(.title2.bold())
Text(statusDescription(viewModel.connectivity(for: viewModel.selected)))
.font(.caption)
@@ -139,7 +139,7 @@ struct PlatformsView: View {
case "homeassistant": HomeAssistantSetupView(context: ctx)
case "webhook": WebhookSetupView(context: ctx)
default:
SettingsSection(title: viewModel.selected.displayName, icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
SettingsSection(title: LocalizedStringKey(viewModel.selected.displayName), icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
}
}
@@ -142,7 +142,7 @@ struct ProfilesView: View {
.font(.title)
VStack(alignment: .leading) {
Text(profile.name).font(.title2.bold())
Text(profile.isActive ? "Active profile" : "Inactive")
(profile.isActive ? Text("Active profile") : Text("Inactive"))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -1,18 +1,39 @@
import SwiftUI
import UniformTypeIdentifiers
private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard"
case site = "Site"
var displayName: LocalizedStringResource {
switch self {
case .dashboard: return "Dashboard"
case .site: return "Site"
}
}
}
struct ProjectsView: View {
@State private var viewModel: ProjectsViewModel
@State private var installerViewModel: TemplateInstallerViewModel
@State private var uninstallerViewModel: TemplateUninstallerViewModel
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(\.serverContext) private var serverContext
@State private var showingAddSheet = false
@State private var showingInstallSheet = false
@State private var exportSheetProject: ProjectEntry?
@State private var showingInstallURLPrompt = false
@State private var installURLInput = ""
@State private var showingUninstallSheet = false
private let uninstaller: ProjectTemplateUninstaller
init(context: ServerContext) {
_viewModel = State(initialValue: ProjectsViewModel(context: context))
_installerViewModel = State(initialValue: TemplateInstallerViewModel(context: context))
_uninstallerViewModel = State(initialValue: TemplateUninstallerViewModel(context: context))
self.uninstaller = ProjectTemplateUninstaller(context: context)
}
@State private var selectedTab: DashboardTab = .dashboard
@@ -25,6 +46,7 @@ struct ProjectsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle("Projects")
.toolbar { templatesToolbar }
.task {
viewModel.load()
if let name = coordinator.selectedProjectName,
@@ -32,11 +54,151 @@ struct ProjectsView: View {
viewModel.selectProject(project)
}
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
// Cold-launch deep link or Finder double-click: the router may
// have a URL staged before this view installed the onChange
// observer below. Without this first-appearance check,
// SwiftUI's .onChange would never fire (it only reacts to
// *changes* after installation) and the URL would sit on the
// singleton forever.
if let pending = TemplateURLRouter.shared.pendingInstallURL {
dispatchPendingInstall(pending)
}
}
.onChange(of: fileWatcher.lastChangeDate) {
viewModel.load()
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
.onChange(of: TemplateURLRouter.shared.pendingInstallURL) { _, new in
// A URL landed *while the app was already running*.
if let new {
dispatchPendingInstall(new)
}
}
.sheet(isPresented: $showingInstallSheet) {
TemplateInstallSheet(viewModel: installerViewModel) { entry in
viewModel.load()
coordinator.selectedProjectName = entry.name
if let project = viewModel.projects.first(where: { $0.name == entry.name }) {
viewModel.selectProject(project)
}
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
.sheet(item: $exportSheetProject) { project in
TemplateExportSheet(
viewModel: TemplateExporterViewModel(context: serverContext, project: project)
)
}
.sheet(isPresented: $showingInstallURLPrompt) {
installURLSheet
}
.sheet(isPresented: $showingUninstallSheet) {
TemplateUninstallSheet(viewModel: uninstallerViewModel) { removed in
// Refresh the registry and clear selection if we just
// removed the project the user was viewing.
if viewModel.selectedProject?.path == removed.path {
viewModel.selectedProject = nil
}
if coordinator.selectedProjectName == removed.name {
coordinator.selectedProjectName = nil
}
viewModel.load()
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var templatesToolbar: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("Install from File…", systemImage: "tray.and.arrow.down") {
openInstallFilePicker()
}
Button("Install from URL…", systemImage: "link") {
installURLInput = ""
showingInstallURLPrompt = true
}
Divider()
if let selected = viewModel.selectedProject {
Button("Export \"\(selected.name)\" as Template…", systemImage: "tray.and.arrow.up") {
exportSheetProject = selected
}
} else {
Button("Export as Template…", systemImage: "tray.and.arrow.up") {}
.disabled(true)
}
} label: {
Label("Templates", systemImage: "shippingbox")
}
}
}
private var installURLSheet: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Install Template from URL")
.font(.headline)
Text("Paste an https URL pointing at a .scarftemplate file.")
.font(.caption)
.foregroundStyle(.secondary)
TextField("https://example.com/my.scarftemplate", text: $installURLInput)
.textFieldStyle(.roundedBorder)
HStack {
Button("Cancel") { showingInstallURLPrompt = false }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Install") {
if let url = URL(string: installURLInput), url.scheme?.lowercased() == "https" {
installerViewModel.openRemoteURL(url)
showingInstallURLPrompt = false
showingInstallSheet = true
}
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(URL(string: installURLInput)?.scheme?.lowercased() != "https")
}
}
.padding()
.frame(minWidth: 480)
}
/// Route a pending install URL to the right VM entry point. `file://`
/// URLs come from Finder double-clicks + the "Install from File" flow
/// when routed via the router; `https://` URLs come from `scarf://`
/// deep links and the "Install from URL" prompt.
private func dispatchPendingInstall(_ url: URL) {
if url.isFileURL {
installerViewModel.openLocalFile(url.path)
} else {
installerViewModel.openRemoteURL(url)
}
TemplateURLRouter.shared.consume()
showingInstallSheet = true
}
private func openInstallFilePicker() {
let panel = NSOpenPanel()
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
// Accept both the declared Scarf template UTI and plain zip the
// custom UTI wins for files with the .scarftemplate extension, and
// the zip fallback means an author distributing under .zip (e.g.
// before the UTI is registered on the receiving Mac) still works.
var types: [UTType] = [.zip]
if let templateType = UTType("com.scarf.template") {
types.insert(templateType, at: 0)
}
panel.allowedContentTypes = types
panel.allowsOtherFileTypes = true
panel.prompt = String(localized: "Install Template")
if panel.runModal() == .OK, let url = panel.url {
installerViewModel.openLocalFile(url.path)
showingInstallSheet = true
}
}
// MARK: - Project List
@@ -58,6 +220,18 @@ struct ProjectsView: View {
Text(project.name)
}
.tag(project)
.contextMenu {
if uninstaller.isTemplateInstalled(project: project) {
Button("Uninstall Template…", systemImage: "trash") {
uninstallerViewModel.begin(project: project)
showingUninstallSheet = true
}
Divider()
}
Button("Remove from Scarf", systemImage: "minus.circle") {
viewModel.removeProject(project)
}
}
}
.listStyle(.sidebar)
@@ -150,7 +324,7 @@ struct ProjectsView: View {
HStack(spacing: 4) {
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
.font(.caption)
Text(tab.rawValue)
Text(tab.displayName)
.font(.subheadline)
}
.padding(.horizontal, 12)
@@ -209,6 +383,16 @@ struct ProjectsView: View {
Image(systemName: "folder")
}
.buttonStyle(.borderless)
if uninstaller.isTemplateInstalled(project: project) {
Button {
uninstallerViewModel.begin(project: project)
showingUninstallSheet = true
} label: {
Image(systemName: "shippingbox.and.arrow.backward")
}
.buttonStyle(.borderless)
.help("Uninstall template")
}
}
}
}
@@ -25,29 +25,33 @@ final class QuickCommandsViewModel {
func load() {
let ctx = context
Task.detached { [weak self] in
let yaml = ctx.readText(ctx.paths.configYAML)
let result: [HermesQuickCommand] = {
guard let yaml else { return [] }
let parsed = HermesFileService.parseNestedYAML(yaml)
var byName: [String: (type: String, command: String)] = [:]
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3 else { continue }
let name = String(parts[1])
let field = String(parts[2])
var existing = byName[name] ?? (type: "exec", command: "")
let stripped = HermesFileService.stripYAMLQuotes(value)
if field == "type" { existing.type = stripped }
if field == "command" { existing.command = stripped }
byName[name] = existing
}
return byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) }
.sorted { $0.name < $1.name }
}()
let result = Self.loadQuickCommands(context: ctx)
await MainActor.run { [weak self] in self?.commands = result }
}
}
/// Parse `quick_commands` from `config.yaml` on the given context. Safe to
/// call from any actor performs synchronous file I/O, so dispatch from a
/// detached task when called from `@MainActor`.
nonisolated static func loadQuickCommands(context: ServerContext) -> [HermesQuickCommand] {
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
let parsed = HermesFileService.parseNestedYAML(yaml)
var byName: [String: (type: String, command: String)] = [:]
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3 else { continue }
let name = String(parts[1])
let field = String(parts[2])
var existing = byName[name] ?? (type: "exec", command: "")
let stripped = HermesFileService.stripYAMLQuotes(value)
if field == "type" { existing.type = stripped }
if field == "command" { existing.command = stripped }
byName[name] = existing
}
return byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) }
.sorted { $0.name < $1.name }
}
/// Check for obviously destructive shell strings. Display-only; we do not block.
static func isDangerous(_ command: String) -> Bool {
let lowered = command.lowercased()
@@ -145,7 +145,7 @@ private struct QuickCommandEditor: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(initial == nil ? "Add Quick Command" : "Edit /\(initial!.name)")
(initial == nil ? Text("Add Quick Command") : Text("Edit /\(initial!.name)"))
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
Text("Name (no leading slash)")
@@ -31,7 +31,7 @@ struct ConnectionStatusPill: View {
Image(systemName: iconName)
.foregroundStyle(color)
.symbolRenderingMode(.hierarchical)
Text(label)
labelText
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -39,7 +39,7 @@ struct ConnectionStatusPill: View {
.padding(.horizontal, 4)
}
.buttonStyle(.plain)
.help(tooltip)
.help(tooltipText)
.popover(isPresented: $showDetails, arrowEdge: .bottom) {
errorDetails.frame(width: 400)
}
@@ -70,27 +70,27 @@ struct ConnectionStatusPill: View {
}
}
private var label: String {
private var labelText: Text {
switch status.status {
case .connected: return "Connected"
case .degraded: return "Connected — can't read Hermes state"
case .idle: return "Checking…"
case .error(let message, _): return message
case .connected: return Text("Connected")
case .degraded: return Text("Connected — can't read Hermes state")
case .idle: return Text("Checking…")
case .error(let message, _): return Text(verbatim: message)
}
}
private var tooltip: String {
private var tooltipText: Text {
switch status.status {
case .connected:
if let ts = status.lastSuccess {
let fmt = RelativeDateTimeFormatter()
return "Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))"
return Text("Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))")
}
return "Connected"
return Text("Connected")
case .degraded(let reason):
return "SSH works but \(reason). Click for diagnostics."
case .idle: return "Waiting for first probe"
case .error(_, _): return "Click for details"
return Text("SSH works but \(reason). Click for diagnostics.")
case .idle: return Text("Waiting for first probe")
case .error: return Text("Click for details")
}
}
@@ -87,13 +87,32 @@ struct ManageServersView: View {
}
private var list: some View {
List {
let defaultID = registry.defaultServerID
return List {
// Local sits at the top so users can mark it as the open-on-launch
// default alongside remote servers. It's synthesized (not in
// `registry.entries`), so render it explicitly.
HStack(spacing: 10) {
defaultStar(for: ServerContext.local.id, currentDefault: defaultID)
Image(systemName: "laptopcomputer")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("Local").font(.body)
Text("This Mac")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
ForEach(registry.entries) { entry in
HStack(spacing: 10) {
defaultStar(for: entry.id, currentDefault: defaultID)
Image(systemName: "server.rack")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(entry.displayName).font(.body)
Text(verbatim: entry.displayName).font(.body)
if case .ssh(let config) = entry.kind {
Text(summary(for: config))
.font(.caption)
@@ -123,6 +142,24 @@ struct ManageServersView: View {
.listStyle(.inset)
}
/// A star button that marks the open-on-launch default. Filled + yellow
/// on the current default row (disabled, since clicking would be a
/// no-op); outline + secondary elsewhere, clicking promotes that row
/// to default.
@ViewBuilder
private func defaultStar(for id: ServerID, currentDefault: ServerID) -> some View {
let isDefault = id == currentDefault
Button {
registry.setDefaultServer(id)
} label: {
Image(systemName: isDefault ? "star.fill" : "star")
.foregroundStyle(isDefault ? .yellow : .secondary)
}
.buttonStyle(.borderless)
.disabled(isDefault)
.help(isDefault ? "Opens on launch" : "Set as default — open this server when Scarf launches.")
}
private func summary(for config: SSHConfig) -> String {
var s = ""
if let user = config.user, !user.isEmpty { s += "\(user)@" }
@@ -37,7 +37,7 @@ struct ServerSwitcherToolbar: View {
Circle()
.fill(current.isRemote ? Color.blue : Color.green)
.frame(width: 8, height: 8)
Text(current.displayName)
Text(verbatim: current.displayName)
.font(.callout)
.lineLimit(1)
Image(systemName: "chevron.down")
@@ -159,12 +159,7 @@ final class SessionsViewModel {
let dbPath = context.paths.stateDB
let fileSize: String
if let stat = context.makeTransport().stat(dbPath) {
let size = Double(stat.size)
if size >= FileSizeUnit.megabyte {
fileSize = String(format: "%.1f MB", size / FileSizeUnit.megabyte)
} else {
fileSize = String(format: "%.0f KB", size / FileSizeUnit.kilobyte)
}
fileSize = Int64(stat.size).formatted(.byteCount(style: .file))
} else {
fileSize = "unknown"
}
@@ -60,7 +60,8 @@ struct SessionDetailView: View {
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
}
if let date = session.startedAt {
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
@@ -20,7 +20,6 @@ final class SettingsViewModel {
var hermesRunning = false
var rawConfigYAML = ""
var personalities: [String] = []
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
var browserBackends = ["browseruse", "firecrawl", "local"]
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts"]
@@ -102,7 +102,7 @@ struct ModelPickerSheet: View {
.font(.system(.body, design: .default, weight: .medium))
Spacer()
if let ctx = model.contextDisplay {
Text(ctx + " ctx")
Text("\(ctx) ctx")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
@@ -6,7 +6,7 @@ import AppKit
/// on large view bodies (per project guidance in CLAUDE.md).
struct SettingsSection<Content: View>: View {
let title: String
let title: LocalizedStringKey
let icon: String
@ViewBuilder let content: Content
@@ -224,7 +224,7 @@ struct DoubleStepperRow: View {
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Text(String(format: "%.2f", value))
Text(value.formatted(.number.precision(.fractionLength(2))))
.font(.system(.caption, design: .monospaced))
.frame(width: 70, alignment: .leading)
Stepper("", value: Binding(
@@ -26,6 +26,22 @@ struct SettingsView: View {
case advanced = "Advanced"
var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .general: return "General"
case .display: return "Display"
case .agent: return "Agent"
case .terminal: return "Terminal"
case .browser: return "Browser"
case .voice: return "Voice"
case .memory: return "Memory"
case .auxiliary: return "Aux Models"
case .security: return "Security"
case .advanced: return "Advanced"
}
}
var icon: String {
switch self {
case .general: return "gear"
@@ -56,7 +72,11 @@ struct SettingsView: View {
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.tabItem {
Label(tab.rawValue, systemImage: tab.icon)
Label {
Text(tab.displayName)
} icon: {
Image(systemName: tab.icon)
}
}
.tag(tab)
}
@@ -6,7 +6,7 @@ struct AuxiliaryTab: View {
@Bindable var viewModel: SettingsViewModel
// Keyed by the config path name matches `auxiliary.<task>.*` in config.yaml.
private let tasks: [(key: String, title: String, icon: String)] = [
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
("vision", "Vision", "eye"),
("web_extract", "Web Extract", "doc.richtext"),
("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"),
@@ -14,6 +14,14 @@ struct SkillsView: View {
case hub = "Browse Hub"
case updates = "Updates"
var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .installed: return "Installed"
case .hub: return "Browse Hub"
case .updates: return "Updates"
}
}
}
var body: some View {
@@ -34,7 +42,7 @@ struct SkillsView: View {
HStack {
Picker("", selection: $currentTab) {
ForEach(Tab.allCases) { tab in
Text(tab.rawValue).tag(tab)
Text(tab.displayName).tag(tab)
}
}
.pickerStyle(.segmented)
@@ -0,0 +1,131 @@
import Foundation
import os
/// Drives the template export sheet. Holds form state for the author-facing
/// fields (id, name, version, description, ) and the selection of skills
/// and cron jobs to include, then builds and writes the `.scarftemplate` on
/// confirm.
@Observable
@MainActor
final class TemplateExporterViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateExporterViewModel")
enum Stage: Sendable {
case idle
case exporting
case succeeded(path: String)
case failed(String)
}
let context: ServerContext
let project: ProjectEntry
private let exporter: ProjectTemplateExporter
init(context: ServerContext, project: ProjectEntry) {
self.context = context
self.project = project
self.exporter = ProjectTemplateExporter(context: context)
self.templateName = project.name
self.templateId = "you/\(ProjectTemplateExporter.slugify(project.name))"
}
// Form fields
var templateId: String
var templateName: String
var templateVersion: String = "1.0.0"
var templateDescription: String = ""
var authorName: String = ""
var authorURL: String = ""
var category: String = ""
var tags: String = ""
var includeSkillIds: Set<String> = []
var includeCronJobIds: Set<String> = []
var memoryAppendix: String = ""
// Derived: what the author can pick from
var availableSkills: [HermesSkill] = []
var availableCronJobs: [HermesCronJob] = []
var stage: Stage = .idle
func load() {
let ctx = context
Task.detached { [weak self] in
let service = HermesFileService(context: ctx)
let skills = service.loadSkills().flatMap(\.skills)
let jobs = service.loadCronJobs()
await MainActor.run { [weak self] in
self?.availableSkills = skills
self?.availableCronJobs = jobs
}
}
}
func previewPlan() -> ProjectTemplateExporter.ExportPlan {
exporter.previewPlan(for: currentInputs)
}
/// Kick off the export, writing to `outputPath`. The caller is
/// responsible for bouncing the user through an `NSSavePanel` to get
/// that path.
func export(to outputPath: String) {
stage = .exporting
let exporter = exporter
let inputs = currentInputs
Task.detached { [weak self] in
do {
try exporter.export(inputs: inputs, outputZipPath: outputPath)
await MainActor.run { [weak self] in
self?.stage = .succeeded(path: outputPath)
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
// MARK: - Private
private var currentInputs: ProjectTemplateExporter.ExportInputs {
let parsedTags = tags
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
let trimmedAppendix = memoryAppendix.trimmingCharacters(in: .whitespacesAndNewlines)
return ProjectTemplateExporter.ExportInputs(
project: project,
templateId: templateId.trimmingCharacters(in: .whitespaces),
templateName: templateName.trimmingCharacters(in: .whitespaces),
templateVersion: templateVersion.trimmingCharacters(in: .whitespaces),
description: templateDescription.trimmingCharacters(in: .whitespaces),
authorName: authorName.isEmpty ? nil : authorName,
authorUrl: authorURL.isEmpty ? nil : authorURL,
category: category.isEmpty ? nil : category,
tags: parsedTags,
includeSkillIds: Array(includeSkillIds),
includeCronJobIds: Array(includeCronJobIds),
memoryAppendix: trimmedAppendix.isEmpty ? nil : trimmedAppendix
)
}
}
extension ProjectTemplateExporter {
/// Lowercase-and-hyphenate a human name into something safe for a
/// template id suffix. Only used to seed the default id in the export
/// form the author can overwrite it.
nonisolated static func slugify(_ raw: String) -> String {
let lower = raw.lowercased()
let mapped = lower.unicodeScalars.map { scalar -> Character in
let c = Character(scalar)
if c.isLetter || c.isNumber { return c }
return "-"
}
let collapsed = String(mapped)
.split(separator: "-", omittingEmptySubsequences: true)
.joined(separator: "-")
return collapsed.isEmpty ? "template" : collapsed
}
}
@@ -0,0 +1,200 @@
import Foundation
import os
/// Drives the template install sheet. Handles three entry points:
/// 1. `openLocalFile(_:)` user picked a `.scarftemplate` from disk.
/// 2. `openRemoteURL(_:)` user pasted/deeplinked a https URL.
/// 3. `confirmInstall()` user clicked "Install" in the preview sheet.
///
/// The view model owns one ephemeral temp dir at a time (the unpacked
/// bundle). `cancel()` or `confirmInstall()` removes it.
@Observable
@MainActor
final class TemplateInstallerViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateInstallerViewModel")
enum Stage: Sendable {
case idle
case fetching(sourceDescription: String)
case inspecting
case awaitingParentDirectory
case planned
case installing
case succeeded(installed: ProjectEntry)
case failed(String)
}
let context: ServerContext
private let templateService: ProjectTemplateService
private let installer: ProjectTemplateInstaller
init(context: ServerContext) {
self.context = context
self.templateService = ProjectTemplateService(context: context)
self.installer = ProjectTemplateInstaller(context: context)
}
var stage: Stage = .idle
var inspection: TemplateInspection?
var plan: TemplateInstallPlan?
var chosenParentDirectory: String?
/// README body preloaded off MainActor when inspection completes, so the
/// preview sheet can render it without hitting `String(contentsOf:)` from
/// inside a View body.
var readmeBody: String?
// MARK: - Entry points
/// Inspect a local `.scarftemplate` file. Moves stage to `.inspecting`
/// then either `.awaitingParentDirectory` or `.failed`. The unpacked
/// README body is read off MainActor here and stored on the VM so the
/// preview sheet doesn't do sync I/O during View body evaluation.
func openLocalFile(_ zipPath: String) {
resetTempState()
stage = .inspecting
let service = templateService
Task.detached { [weak self] in
do {
let inspection = try service.inspect(zipPath: zipPath)
let readme = Self.readReadme(unpackedDir: inspection.unpackedDir)
await MainActor.run { [weak self] in
guard let self else { return }
self.inspection = inspection
self.readmeBody = readme
self.stage = .awaitingParentDirectory
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
/// Read README.md from an unpacked template dir. Nonisolated so the
/// inspect task can call it off MainActor. Returns `nil` on any I/O
/// failure the preview sheet treats a nil README as "no section."
nonisolated private static func readReadme(unpackedDir: String) -> String? {
let path = unpackedDir + "/README.md"
do {
return try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
} catch {
Logger(subsystem: "com.scarf", category: "TemplateInstallerViewModel")
.warning("couldn't read README at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
return nil
}
}
/// Download a https `.scarftemplate` to a temp file, then hand off to
/// `openLocalFile`. The 50 MB cap matches the plan templates shouldn't
/// be anywhere near that, and rejecting huge downloads is cheap defense.
///
/// Content-Length is checked first as an early-out, but chunked
/// transfer responses omit that header. The authoritative check is the
/// actual on-disk file size after the download completes it runs
/// unconditionally and covers the chunked-transfer case.
func openRemoteURL(_ url: URL) {
resetTempState()
stage = .fetching(sourceDescription: url.host ?? url.absoluteString)
Task.detached { [weak self] in
let maxBytes: Int64 = 50 * 1024 * 1024
do {
let tempZip = NSTemporaryDirectory() + "scarf-template-download-" + UUID().uuidString + ".scarftemplate"
let (tempURL, response) = try await URLSession.shared.download(from: url)
defer { try? FileManager.default.removeItem(at: tempURL) }
if let httpResponse = response as? HTTPURLResponse {
guard (200...299).contains(httpResponse.statusCode) else {
throw ProjectTemplateError.unzipFailed("HTTP \(httpResponse.statusCode)")
}
if let length = httpResponse.value(forHTTPHeaderField: "Content-Length"),
let bytes = Int64(length), bytes > maxBytes {
throw ProjectTemplateError.unzipFailed("template exceeds 50 MB size cap (\(bytes) bytes)")
}
}
// Unconditional post-download size check catches chunked
// responses that ship no Content-Length. The download already
// hit disk, but refusing to *process* it bounds the blast
// radius to one temp file that gets removed in the defer.
let attrs = try FileManager.default.attributesOfItem(atPath: tempURL.path)
let actualSize = (attrs[.size] as? NSNumber)?.int64Value ?? 0
guard actualSize <= maxBytes else {
throw ProjectTemplateError.unzipFailed("template exceeds 50 MB size cap (\(actualSize) bytes)")
}
try FileManager.default.moveItem(atPath: tempURL.path, toPath: tempZip)
await MainActor.run { [weak self] in
self?.openLocalFile(tempZip)
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed("Couldn't fetch template: \(error.localizedDescription)")
}
}
}
}
// MARK: - Planning + confirmation
/// Finalize the plan now that the user has picked a parent directory.
func pickParentDirectory(_ parentDir: String) {
guard let inspection else { return }
chosenParentDirectory = parentDir
let service = templateService
let context = context
Task.detached { [weak self] in
do {
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
_ = context
await MainActor.run { [weak self] in
self?.plan = plan
self?.stage = .planned
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
func confirmInstall() {
guard let plan else { return }
stage = .installing
let installer = installer
let service = templateService
Task.detached { [weak self] in
do {
let entry = try installer.install(plan: plan)
service.cleanupTempDir(plan.unpackedDir)
await MainActor.run { [weak self] in
guard let self else { return }
self.stage = .succeeded(installed: entry)
self.inspection = nil
self.plan = nil
self.chosenParentDirectory = nil
self.readmeBody = nil
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
// MARK: - Cleanup
func cancel() {
resetTempState()
stage = .idle
}
private func resetTempState() {
if let inspection {
templateService.cleanupTempDir(inspection.unpackedDir)
}
inspection = nil
plan = nil
chosenParentDirectory = nil
readmeBody = nil
}
}
@@ -0,0 +1,76 @@
import Foundation
import os
/// Drives the template-uninstall sheet. Mirrors the installer VM in
/// stage shape: open a plan (`begin`), preview it, confirm or cancel.
@Observable
@MainActor
final class TemplateUninstallerViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateUninstallerViewModel")
enum Stage: Sendable {
case idle
case loading
case planned
case uninstalling
case succeeded(removed: ProjectEntry)
case failed(String)
}
let context: ServerContext
private let uninstaller: ProjectTemplateUninstaller
init(context: ServerContext) {
self.context = context
self.uninstaller = ProjectTemplateUninstaller(context: context)
}
var stage: Stage = .idle
var plan: TemplateUninstallPlan?
/// Load the `template.lock.json` for the given project and build a
/// removal plan. Moves stage to `.planned` on success.
func begin(project: ProjectEntry) {
stage = .loading
let uninstaller = uninstaller
Task.detached { [weak self] in
do {
let plan = try uninstaller.loadUninstallPlan(for: project)
await MainActor.run { [weak self] in
guard let self else { return }
self.plan = plan
self.stage = .planned
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
func confirmUninstall() {
guard let plan else { return }
stage = .uninstalling
let uninstaller = uninstaller
Task.detached { [weak self] in
do {
try uninstaller.uninstall(plan: plan)
await MainActor.run { [weak self] in
guard let self else { return }
self.stage = .succeeded(removed: plan.project)
self.plan = nil
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
func cancel() {
plan = nil
stage = .idle
}
}
@@ -0,0 +1,259 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
/// Author-facing sheet for exporting an existing project as a
/// `.scarftemplate`. Mirrors the profile-export flow: fill in a few fields,
/// pick which skills/cron jobs to include, save via NSSavePanel.
struct TemplateExportSheet: View {
@Environment(\.dismiss) private var dismiss
@State var viewModel: TemplateExporterViewModel
var body: some View {
VStack(spacing: 0) {
switch viewModel.stage {
case .idle:
form
case .exporting:
VStack(spacing: 12) {
ProgressView()
Text("Building template…")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .succeeded(let path):
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.green)
Text("Exported").font(.title2.bold())
Text(path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
HStack {
Button("Show in Finder") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
Button("Done") { dismiss() }
.keyboardShortcut(.defaultAction)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("Export Failed").font(.title2.bold())
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Close") { dismiss() }
.keyboardShortcut(.defaultAction)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
.frame(minWidth: 620, minHeight: 560)
.padding()
.task { viewModel.load() }
}
@ViewBuilder
private var form: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Export \"\(viewModel.project.name)\" as Template")
.font(.title2.bold())
metadataGroup
Divider()
requiredFilesGroup
Divider()
instructionsGroup
Divider()
skillsGroup
Divider()
cronGroup
Divider()
memoryGroup
}
.padding(.bottom)
}
HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Export…") { runExport() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(!canExport)
}
.padding(.top, 8)
}
private var metadataGroup: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Metadata").font(.headline)
LabeledContent("Template ID") {
TextField("owner/name", text: $viewModel.templateId)
.textFieldStyle(.roundedBorder)
}
LabeledContent("Display Name") {
TextField("", text: $viewModel.templateName)
.textFieldStyle(.roundedBorder)
}
LabeledContent("Version") {
TextField("1.0.0", text: $viewModel.templateVersion)
.textFieldStyle(.roundedBorder)
}
LabeledContent("Description") {
TextField("One-line pitch", text: $viewModel.templateDescription, axis: .vertical)
.lineLimit(2...4)
.textFieldStyle(.roundedBorder)
}
LabeledContent("Author") {
TextField("Your name", text: $viewModel.authorName)
.textFieldStyle(.roundedBorder)
}
LabeledContent("Author URL") {
TextField("https://…", text: $viewModel.authorURL)
.textFieldStyle(.roundedBorder)
}
LabeledContent("Category") {
TextField("e.g. productivity", text: $viewModel.category)
.textFieldStyle(.roundedBorder)
}
LabeledContent("Tags (comma-separated)") {
TextField("focus, timer", text: $viewModel.tags)
.textFieldStyle(.roundedBorder)
}
}
}
private var requiredFilesGroup: some View {
let plan = viewModel.previewPlan()
return VStack(alignment: .leading, spacing: 6) {
Text("Required Files").font(.headline)
check(label: "dashboard.json (\(plan.projectDir)/.scarf/dashboard.json)", ok: plan.dashboardPresent)
check(label: "README.md (\(plan.projectDir)/README.md)", ok: plan.readmePresent)
check(label: "AGENTS.md (\(plan.projectDir)/AGENTS.md)", ok: plan.agentsMdPresent)
}
}
private var instructionsGroup: some View {
let plan = viewModel.previewPlan()
return VStack(alignment: .leading, spacing: 4) {
Text("Agent-specific instructions (optional)").font(.headline)
if plan.instructionFiles.isEmpty {
Text("No per-agent instruction files found in the project root.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(plan.instructionFiles, id: \.self) { file in
Label(file, systemImage: "doc.plaintext")
.font(.callout)
}
}
}
}
private var skillsGroup: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Include Skills").font(.headline)
if viewModel.availableSkills.isEmpty {
Text("No skills found.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.availableSkills) { skill in
Toggle(isOn: Binding(
get: { viewModel.includeSkillIds.contains(skill.id) },
set: { on in
if on { viewModel.includeSkillIds.insert(skill.id) }
else { viewModel.includeSkillIds.remove(skill.id) }
}
)) {
Text(skill.id).font(.callout.monospaced())
}
}
}
}
}
private var cronGroup: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Include Cron Jobs").font(.headline)
if viewModel.availableCronJobs.isEmpty {
Text("No cron jobs found.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.availableCronJobs) { job in
Toggle(isOn: Binding(
get: { viewModel.includeCronJobIds.contains(job.id) },
set: { on in
if on { viewModel.includeCronJobIds.insert(job.id) }
else { viewModel.includeCronJobIds.remove(job.id) }
}
)) {
VStack(alignment: .leading, spacing: 0) {
Text(job.name).font(.callout)
Text(job.schedule.display ?? job.schedule.expression ?? job.schedule.kind)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
}
private var memoryGroup: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Memory Appendix (optional)").font(.headline)
Text("Markdown that will be appended to the installer's MEMORY.md, wrapped in template-specific markers so it can be removed cleanly later.")
.font(.caption)
.foregroundStyle(.secondary)
TextEditor(text: $viewModel.memoryAppendix)
.font(.callout.monospaced())
.frame(minHeight: 80, maxHeight: 160)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.secondary.opacity(0.4))
)
}
}
private func check(label: String, ok: Bool) -> some View {
HStack(spacing: 6) {
Image(systemName: ok ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(ok ? .green : .red)
Text(label)
.font(.caption)
.foregroundStyle(ok ? .primary : .secondary)
}
}
private var canExport: Bool {
let plan = viewModel.previewPlan()
return plan.dashboardPresent
&& plan.readmePresent
&& plan.agentsMdPresent
&& !viewModel.templateId.trimmingCharacters(in: .whitespaces).isEmpty
&& !viewModel.templateName.trimmingCharacters(in: .whitespaces).isEmpty
&& !viewModel.templateVersion.trimmingCharacters(in: .whitespaces).isEmpty
&& !viewModel.templateDescription.trimmingCharacters(in: .whitespaces).isEmpty
}
private func runExport() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.zip]
panel.nameFieldStringValue = ProjectTemplateExporter.slugify(viewModel.templateName) + ".scarftemplate"
if panel.runModal() == .OK, let url = panel.url {
viewModel.export(to: url.path)
}
}
}
@@ -0,0 +1,312 @@
import SwiftUI
import AppKit
/// Preview-and-confirm sheet for installing a `.scarftemplate`. Honest
/// accounting: shows every file that will be written, every cron job that
/// will be registered, and the memory diff nothing gets written until the
/// user clicks Install.
struct TemplateInstallSheet: View {
@Environment(\.dismiss) private var dismiss
@State var viewModel: TemplateInstallerViewModel
let onCompleted: (ProjectEntry) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
switch viewModel.stage {
case .idle:
idleView
case .fetching(let src):
progress("Downloading from \(src)")
case .inspecting:
progress("Inspecting template…")
case .awaitingParentDirectory:
pickParentView
case .planned:
if let plan = viewModel.plan {
plannedView(plan: plan)
} else {
progress("Preparing…")
}
case .installing:
progress("Installing…")
case .succeeded(let entry):
successView(entry: entry)
case .failed(let message):
failureView(message: message)
}
}
.frame(minWidth: 640, minHeight: 520)
.padding()
}
// MARK: - Stages
private var idleView: some View {
VStack(spacing: 16) {
Text("No template loaded.")
.font(.headline)
Button("Close") { dismiss() }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func progress(_ label: LocalizedStringKey) -> some View {
VStack(spacing: 16) {
ProgressView()
Text(label)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var pickParentView: some View {
VStack(alignment: .leading, spacing: 12) {
if let manifest = viewModel.inspection?.manifest {
manifestHeader(manifest)
Divider()
}
Text("Where should this project live?")
.font(.headline)
Text("Scarf will create a new folder inside the directory you pick, named after the template id.")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
HStack {
Button("Cancel") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Button("Choose Folder…") { chooseParentDirectory() }
.keyboardShortcut(.defaultAction)
}
}
}
private func plannedView(plan: TemplateInstallPlan) -> some View {
VStack(alignment: .leading, spacing: 0) {
manifestHeader(plan.manifest)
.padding(.bottom, 8)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
projectFilesSection(plan: plan)
if plan.skillsNamespaceDir != nil {
skillsSection(plan: plan)
}
if !plan.cronJobs.isEmpty {
cronSection(plan: plan)
}
if plan.memoryAppendix != nil {
memorySection(plan: plan)
}
readmeSection
}
.padding(.vertical)
}
Divider()
HStack {
Button("Cancel") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Text("\(plan.totalWriteCount) changes")
.font(.caption)
.foregroundStyle(.secondary)
Button("Install") { viewModel.confirmInstall() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
.padding(.top, 8)
}
}
private func manifestHeader(_ manifest: ProjectTemplateManifest) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(manifest.name).font(.title2.bold())
Text("v\(manifest.version)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(manifest.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Text(manifest.description)
.font(.subheadline)
.foregroundStyle(.secondary)
if let author = manifest.author {
HStack(spacing: 4) {
Image(systemName: "person.crop.circle")
.font(.caption)
.foregroundStyle(.secondary)
Text(author.name)
.font(.caption)
.foregroundStyle(.secondary)
if let url = author.url, let parsed = URL(string: url) {
Link(parsed.host ?? url, destination: parsed)
.font(.caption)
}
}
}
}
}
private func projectFilesSection(plan: TemplateInstallPlan) -> some View {
section(title: "New project directory", subtitle: plan.projectDir) {
VStack(alignment: .leading, spacing: 2) {
ForEach(plan.projectFiles, id: \.destinationPath) { copy in
fileRow(label: copy.destinationPath, systemImage: "doc.text")
}
}
}
}
private func skillsSection(plan: TemplateInstallPlan) -> some View {
section(
title: "Skills (namespaced, safe to remove later)",
subtitle: plan.skillsNamespaceDir
) {
VStack(alignment: .leading, spacing: 2) {
ForEach(plan.skillsFiles, id: \.destinationPath) { copy in
fileRow(label: copy.destinationPath, systemImage: "puzzlepiece")
}
}
}
}
private func cronSection(plan: TemplateInstallPlan) -> some View {
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
VStack(alignment: .leading, spacing: 4) {
ForEach(plan.cronJobs, id: \.name) { job in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 1) {
Text(job.name).font(.callout.monospaced())
Text("schedule: \(job.schedule)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
}
private func memorySection(plan: TemplateInstallPlan) -> some View {
section(title: "Memory appendix", subtitle: plan.memoryPath) {
ScrollView {
Text(plan.memoryAppendix ?? "")
.font(.caption.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.frame(maxHeight: 160)
}
}
private var readmeSection: some View {
Group {
// The body is preloaded in the VM off MainActor when inspection
// completes no sync file I/O during View body evaluation.
if let readme = viewModel.readmeBody {
section(title: "README", subtitle: nil) {
ScrollView {
Text(readme)
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 200)
}
}
}
}
@ViewBuilder
private func section<Content: View>(title: String, subtitle: String?, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
if let subtitle {
Text(subtitle)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
content()
.padding(.top, 2)
}
}
private func fileRow(label: String, systemImage: String) -> some View {
HStack(spacing: 6) {
Image(systemName: systemImage)
.foregroundStyle(.secondary)
.font(.caption)
Text(label)
.font(.caption.monospaced())
.lineLimit(1)
.truncationMode(.head)
}
}
private func successView(entry: ProjectEntry) -> some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.green)
Text("Installed \(entry.name)")
.font(.title2.bold())
Text(entry.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Button("Open Project") {
onCompleted(entry)
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func failureView(message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("Install Failed").font(.title2.bold())
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Close") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.defaultAction)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
// MARK: - Actions
private func chooseParentDirectory() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.prompt = String(localized: "Choose Parent Folder")
if panel.runModal() == .OK, let url = panel.url {
viewModel.pickParentDirectory(url.path)
}
}
}
@@ -0,0 +1,309 @@
import SwiftUI
/// Preview-and-confirm sheet for uninstalling a template-installed
/// project. Symmetric with the install sheet: lists every file, cron
/// job, and memory block that will be removed BEFORE anything happens.
struct TemplateUninstallSheet: View {
@Environment(\.dismiss) private var dismiss
@State var viewModel: TemplateUninstallerViewModel
/// Called on success with the project that was removed. Parent uses
/// this to refresh its projects list and clear any selection.
let onCompleted: (ProjectEntry) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
switch viewModel.stage {
case .idle:
idleView
case .loading:
progress("Reading template.lock.json…")
case .planned:
if let plan = viewModel.plan {
plannedView(plan: plan)
} else {
progress("Preparing…")
}
case .uninstalling:
progress("Removing…")
case .succeeded(let removed):
successView(removed: removed)
case .failed(let message):
failureView(message: message)
}
}
.frame(minWidth: 620, minHeight: 480)
.padding()
}
// MARK: - Stages
private var idleView: some View {
VStack(spacing: 16) {
Text("No template loaded.")
.font(.headline)
Button("Close") { dismiss() }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func progress(_ label: LocalizedStringKey) -> some View {
VStack(spacing: 16) {
ProgressView()
Text(label)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func plannedView(plan: TemplateUninstallPlan) -> some View {
VStack(alignment: .leading, spacing: 0) {
header(plan: plan)
.padding(.bottom, 8)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
projectFilesSection(plan: plan)
if plan.skillsNamespaceDir != nil {
skillsSection(plan: plan)
}
cronSection(plan: plan)
memorySection(plan: plan)
registrySection(plan: plan)
}
.padding(.vertical)
}
Divider()
HStack {
Button("Cancel") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Text("\(plan.totalRemoveCount) changes")
.font(.caption)
.foregroundStyle(.secondary)
Button("Remove") { viewModel.confirmUninstall() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.tint(.red)
}
.padding(.top, 8)
}
}
private func header(plan: TemplateUninstallPlan) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text("Remove “\(plan.lock.templateName)").font(.title2.bold())
Text("v\(plan.lock.templateVersion)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(plan.lock.templateId)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Text("Installed \(plan.lock.installedAt)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func projectFilesSection(plan: TemplateUninstallPlan) -> some View {
section(title: "Project directory", subtitle: plan.project.path) {
VStack(alignment: .leading, spacing: 4) {
ForEach(plan.projectFilesToRemove, id: \.self) { path in
fileRow(
label: path,
systemImage: "minus.circle",
color: .red,
tag: "remove"
)
}
ForEach(plan.projectFilesAlreadyGone, id: \.self) { path in
fileRow(
label: path,
systemImage: "questionmark.circle",
color: .secondary,
tag: "already gone"
)
}
ForEach(plan.extraProjectEntries, id: \.self) { path in
fileRow(
label: path,
systemImage: "lock.shield",
color: .green,
tag: "keep (not installed by template)"
)
}
if plan.projectDirBecomesEmpty {
Text("Project directory will also be removed (nothing user-owned left inside).")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 4)
} else if !plan.extraProjectEntries.isEmpty {
Text("Project directory stays — it still holds files you created after install.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 4)
}
}
}
}
private func skillsSection(plan: TemplateUninstallPlan) -> some View {
section(
title: "Skills",
subtitle: plan.skillsNamespaceDir
) {
HStack(spacing: 6) {
Image(systemName: "minus.circle")
.foregroundStyle(.red)
.font(.caption)
Text("Remove the entire namespace dir recursively")
.font(.caption)
}
}
}
private func cronSection(plan: TemplateUninstallPlan) -> some View {
section(
title: "Cron jobs",
subtitle: plan.cronJobsToRemove.isEmpty && plan.cronJobsAlreadyGone.isEmpty
? "none"
: nil
) {
VStack(alignment: .leading, spacing: 4) {
ForEach(plan.cronJobsToRemove, id: \.id) { job in
HStack(spacing: 6) {
Image(systemName: "minus.circle")
.foregroundStyle(.red)
.font(.caption)
VStack(alignment: .leading, spacing: 1) {
Text(job.name).font(.callout.monospaced())
Text(job.id)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
ForEach(plan.cronJobsAlreadyGone, id: \.self) { name in
HStack(spacing: 6) {
Image(systemName: "questionmark.circle")
.foregroundStyle(.secondary)
.font(.caption)
Text("\(name) — already gone")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
@ViewBuilder
private func memorySection(plan: TemplateUninstallPlan) -> some View {
if plan.memoryBlockPresent {
section(title: "Memory block", subtitle: plan.memoryPath) {
HStack(spacing: 6) {
Image(systemName: "minus.circle")
.foregroundStyle(.red)
.font(.caption)
Text("Strip the template's begin/end block, preserve everything else in MEMORY.md")
.font(.caption)
}
}
} else if plan.lock.memoryBlockId != nil {
section(title: "Memory block", subtitle: nil) {
Text("A memory block was recorded in the lock but is no longer present in MEMORY.md — skipping.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func registrySection(plan: TemplateUninstallPlan) -> some View {
section(title: "Projects registry", subtitle: nil) {
HStack(spacing: 6) {
Image(systemName: "minus.circle")
.foregroundStyle(.red)
.font(.caption)
Text("Remove \"\(plan.project.name)\" from Scarf's project list")
.font(.caption)
}
}
}
@ViewBuilder
private func section<Content: View>(
title: LocalizedStringKey,
subtitle: String?,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
if let subtitle {
Text(subtitle)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
content()
.padding(.top, 2)
}
}
private func fileRow(label: String, systemImage: String, color: Color, tag: LocalizedStringKey) -> some View {
HStack(spacing: 6) {
Image(systemName: systemImage)
.foregroundStyle(color)
.font(.caption)
Text(label)
.font(.caption.monospaced())
.lineLimit(1)
.truncationMode(.head)
Spacer()
Text(tag)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private func successView(removed: ProjectEntry) -> some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.green)
Text("Removed \(removed.name)")
.font(.title2.bold())
Button("Done") {
onCompleted(removed)
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func failureView(message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("Uninstall Failed").font(.title2.bold())
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Close") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.defaultAction)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
@@ -43,7 +43,7 @@ struct ToolsView: View {
} label: {
HStack(spacing: 8) {
Image(systemName: KnownPlatforms.icon(for: viewModel.selectedPlatform.name))
Text(viewModel.selectedPlatform.displayName)
Text(verbatim: viewModel.selectedPlatform.displayName)
.fontWeight(.medium)
statusDot(for: viewModel.connectivity[viewModel.selectedPlatform.name] ?? .notConfigured)
Image(systemName: "chevron.down")
+51
View File
@@ -38,5 +38,56 @@
<integer>86400</integer>
<key>SUEnableInstallerLauncherService</key>
<false/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.scarf.url</string>
<key>CFBundleURLSchemes</key>
<array>
<string>scarf</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.scarf.template</string>
<key>UTTypeDescription</key>
<string>Scarf Project Template</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>scarftemplate</string>
</array>
<key>public.mime-type</key>
<array>
<string>application/vnd.scarf.template+zip</string>
</array>
</dict>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Scarf Project Template</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.scarf.template</string>
</array>
</dict>
</array>
</dict>
</plist>
+93
View File
@@ -0,0 +1,93 @@
{
"sourceLanguage" : "en",
"strings" : {
"CFBundleDisplayName" : {
"comment" : "Bundle display name",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Scarf"
}
}
}
},
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "scarf"
}
}
}
},
"NSHumanReadableCopyright" : {
"comment" : "Copyright (human-readable)",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
}
}
},
"NSMicrophoneUsageDescription" : {
"comment" : "Shown by macOS when Scarf first requests microphone access for Hermes voice chat.",
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Scarf verwendet das Mikrofon für den Hermes-Sprach-Chat."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Scarf uses the microphone for Hermes voice chat."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Scarf usa el micrófono para el chat de voz de Hermes."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Scarf utilise le microphone pour le chat vocal de Hermes."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Scarf は Hermes の音声チャットのためにマイクを使用します。"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "O Scarf usa o microfone para o chat de voz do Hermes."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Scarf 使用麦克风进行 Hermes 语音聊天。"
}
}
}
},
"Scarf Project Template" : {
}
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,33 @@ enum SidebarSection: String, CaseIterable, Identifiable {
var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .dashboard: return "Dashboard"
case .insights: return "Insights"
case .sessions: return "Sessions"
case .activity: return "Activity"
case .projects: return "Projects"
case .chat: return "Chat"
case .memory: return "Memory"
case .skills: return "Skills"
case .platforms: return "Platforms"
case .personalities: return "Personalities"
case .quickCommands: return "Quick Commands"
case .credentialPools: return "Credential Pools"
case .plugins: return "Plugins"
case .webhooks: return "Webhooks"
case .profiles: return "Profiles"
case .tools: return "Tools"
case .mcpServers: return "MCP Servers"
case .gateway: return "Gateway"
case .cron: return "Cron"
case .health: return "Health"
case .logs: return "Logs"
case .settings: return "Settings"
}
}
var icon: String {
switch self {
case .dashboard: return "gauge.with.dots.needle.33percent"
+32 -10
View File
@@ -2,42 +2,64 @@ import SwiftUI
struct SidebarView: View {
@Environment(AppCoordinator.self) private var coordinator
@Environment(\.serverContext) private var serverContext
var body: some View {
@Bindable var coordinator = coordinator
List(selection: $coordinator.selectedSection) {
Section("Monitor") {
ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
Label {
Text(section.displayName)
} icon: {
Image(systemName: section.icon)
}
.tag(section)
}
}
Section("Projects") {
ForEach([SidebarSection.projects]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
Label {
Text(section.displayName)
} icon: {
Image(systemName: section.icon)
}
.tag(section)
}
}
Section("Interact") {
ForEach([SidebarSection.chat, .memory, .skills]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
Label {
Text(section.displayName)
} icon: {
Image(systemName: section.icon)
}
.tag(section)
}
}
Section("Configure") {
ForEach([SidebarSection.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
Label {
Text(section.displayName)
} icon: {
Image(systemName: section.icon)
}
.tag(section)
}
}
Section("Manage") {
ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
Label {
Text(section.displayName)
} icon: {
Image(systemName: section.icon)
}
.tag(section)
}
}
}
.listStyle(.sidebar)
.navigationTitle("Scarf")
.splitViewAutosaveName("ScarfMainSidebar.\(serverContext.id)")
}
}
@@ -0,0 +1,57 @@
import AppKit
import SwiftUI
/// Makes the enclosing `NSSplitView` remember its divider positions across
/// app launches. `NavigationSplitView` is backed by `NSSplitViewController`,
/// whose split view honours `autosaveName` AppKit writes the divider
/// offsets to `UserDefaults` on drag and restores them on the next launch.
///
/// Usage: attach `.splitViewAutosaveName("")` to a child of the split view
/// (the sidebar is a good choice). The modifier installs an invisible helper
/// that walks up the view hierarchy on first layout, finds the `NSSplitView`,
/// and assigns its autosave name. Subsequent launches restore the divider
/// positions before the window appears.
///
/// The name is also used to key the entry in `UserDefaults` (AppKit stores
/// it as `NSSplitView Subview Frames <name>`), so changing the name resets
/// the remembered width. Pick a stable string and leave it alone.
struct SplitViewAutosaveFinder: NSViewRepresentable {
let autosaveName: String
func makeNSView(context: Context) -> NSView {
let view = NSView()
// Defer the hierarchy walk until after SwiftUI has attached this
// view to its host window at makeNSView time the view has no
// superview yet, so we can't find the split view above us.
DispatchQueue.main.async { [weak view] in
guard let view else { return }
SplitViewAutosaveFinder.apply(autosaveName, startingFrom: view)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
private static func apply(_ name: String, startingFrom view: NSView) {
var current: NSView? = view
while let node = current {
if let split = node as? NSSplitView {
// Only set once reassigning clobbers AppKit's restore path.
if split.autosaveName != NSSplitView.AutosaveName(name) {
split.autosaveName = NSSplitView.AutosaveName(name)
}
return
}
current = node.superview
}
}
}
extension View {
/// Persist the enclosing `NavigationSplitView` / `NSSplitView` divider
/// positions to `UserDefaults` under `autosaveName`. Attach to any child
/// of the split view (the sidebar works well).
func splitViewAutosaveName(_ autosaveName: String) -> some View {
background(SplitViewAutosaveFinder(autosaveName: autosaveName))
}
}
+21 -1
View File
@@ -57,13 +57,33 @@ struct ScarfApp: App {
// covers the case where the user added a server in
// another window since this one last opened.
.onAppear { liveRegistry.rebuild() }
// scarf://install?url= deep-link handler. Stages the
// URL on the process-wide router; ProjectsView picks it
// up and presents the install sheet. Activating the
// app here ensures a cold launch from a browser click
// surfaces the sheet without the user having to click
// into Scarf first.
.onOpenURL { url in
TemplateURLRouter.shared.handle(url)
NSApplication.shared.activate()
}
} else {
// MissingServerView is a dead-end "server was removed" pane
// with no ProjectsView so no observer of the router's
// pendingInstallURL exists in this window. Routing a
// scarf://install URL here would silently drop it. Leave
// onOpenURL off this branch; ContextBoundRoot windows in
// the same app instance will still handle it.
MissingServerView(removedServerID: serverID)
.environment(registry)
.environment(updater)
}
} defaultValue: {
ServerContext.local.id
// Honour the user's "open on launch" choice from the Manage
// Servers popover. Falls back to Local when no entry is flagged
// (the default behaviour for fresh installs) or when the
// flagged entry was removed while the app was closed.
registry.defaultServerID
}
.defaultSize(width: 1100, height: 700)
.commands {
+616
View File
@@ -0,0 +1,616 @@
import Testing
import Foundation
@testable import scarf
/// Exercises the service's ability to unpack, parse, and validate bundles.
/// Doesn't touch the installer see `ProjectTemplateInstallerTests` so
/// these don't need write access to ~/.hermes.
@Suite struct ProjectTemplateServiceTests {
@Test func manifestSlugSanitizesPunctuation() {
let manifest = Self.sampleManifest(id: "alan@w/focus dashboard!")
#expect(manifest.slug == "alan-w-focus-dashboard")
}
@Test func manifestSlugFallsBackToPlaceholder() {
let manifest = Self.sampleManifest(id: "////")
#expect(manifest.slug == "template")
}
@Test func inspectRejectsMissingManifest() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
// A zip with no template.json
let bundle = try Self.makeBundle(dir: dir, files: [
"README.md": "hi",
"AGENTS.md": "hi",
"dashboard.json": "{}"
], includeManifest: false)
let service = ProjectTemplateService(context: .local)
#expect(throws: ProjectTemplateError.self) {
try service.inspect(zipPath: bundle)
}
}
@Test func inspectRejectsMissingAgentsMd() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let bundle = try Self.makeBundle(dir: dir, files: [
"README.md": "# Readme",
"dashboard.json": Self.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
#expect(throws: ProjectTemplateError.self) {
try service.inspect(zipPath: bundle)
}
}
@Test func inspectAcceptsMinimalValidBundle() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let bundle = try Self.makeBundle(dir: dir, files: [
"README.md": "# Readme",
"AGENTS.md": "# Agents",
"dashboard.json": Self.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
#expect(inspection.manifest.id == "test/example")
#expect(inspection.manifest.slug == "test-example")
#expect(inspection.cronJobs.isEmpty)
#expect(inspection.files.contains("AGENTS.md"))
}
@Test func inspectRejectsContentClaimMismatch() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
// Claim cron: 2 but ship no cron dir service must reject.
let manifest = Self.sampleManifest(cron: 2)
let manifestJSON = try JSONEncoder().encode(manifest)
let manifestString = String(data: manifestJSON, encoding: .utf8)!
let bundle = try Self.makeBundle(dir: dir, files: [
"README.md": "# Readme",
"AGENTS.md": "# Agents",
"dashboard.json": Self.sampleDashboardJSON,
"template.json": manifestString
], includeManifest: false)
let service = ProjectTemplateService(context: .local)
#expect(throws: ProjectTemplateError.self) {
try service.inspect(zipPath: bundle)
}
}
// MARK: - Helpers
static let sampleDashboardJSON = """
{
"version": 1,
"title": "Example",
"description": "test",
"sections": []
}
"""
static func sampleManifest(
id: String = "test/example",
cron: Int? = nil,
skills: [String]? = nil,
instructions: [String]? = nil
) -> ProjectTemplateManifest {
ProjectTemplateManifest(
schemaVersion: 1,
id: id,
name: "Example",
version: "1.0.0",
minScarfVersion: nil,
minHermesVersion: nil,
author: TemplateAuthor(name: "Tester", url: nil),
description: "Test template",
category: nil,
tags: nil,
icon: nil,
screenshots: nil,
contents: TemplateContents(
dashboard: true,
agentsMd: true,
instructions: instructions,
skills: skills,
cron: cron,
memory: nil
)
)
}
static func makeTempDir() throws -> String {
let dir = NSTemporaryDirectory() + "scarf-template-test-" + UUID().uuidString
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}
/// Write files to a staging dir, then zip them into `<dir>/bundle.scarftemplate`
/// and return its path. When `includeManifest` is true the caller doesn't
/// need to provide `template.json` we synthesize a valid one.
static func makeBundle(
dir: String,
files: [String: String],
includeManifest: Bool = true
) throws -> String {
let staging = dir + "/staging"
try FileManager.default.createDirectory(atPath: staging, withIntermediateDirectories: true)
for (relativePath, content) in files {
let full = staging + "/" + relativePath
let parent = (full as NSString).deletingLastPathComponent
if !FileManager.default.fileExists(atPath: parent) {
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
}
try content.data(using: .utf8)!.write(to: URL(fileURLWithPath: full))
}
if includeManifest {
let manifest = sampleManifest()
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(manifest)
try data.write(to: URL(fileURLWithPath: staging + "/template.json"))
}
let bundlePath = dir + "/bundle.scarftemplate"
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.currentDirectoryURL = URL(fileURLWithPath: staging)
process.arguments = ["-qq", "-r", bundlePath, "."]
try process.run()
process.waitUntilExit()
#expect(process.terminationStatus == 0)
return bundlePath
}
}
/// URL-router has no filesystem side effects safe to unit-test directly.
@Suite struct TemplateURLRouterTests {
@Test @MainActor func refusesNonScarfScheme() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let ok = router.handle(URL(string: "https://example.com/foo")!)
#expect(ok == false)
#expect(router.pendingInstallURL == nil)
}
@Test @MainActor func refusesUnknownHost() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let ok = router.handle(URL(string: "scarf://bogus?url=https://example.com/x.scarftemplate")!)
#expect(ok == false)
#expect(router.pendingInstallURL == nil)
}
@Test @MainActor func refusesNonHttpsPayload() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let ok = router.handle(URL(string: "scarf://install?url=file:///etc/passwd")!)
#expect(ok == false)
#expect(router.pendingInstallURL == nil)
}
@Test @MainActor func acceptsFileURLWithScarftemplateExtension() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let path = "/tmp/example.scarftemplate"
let ok = router.handle(URL(fileURLWithPath: path))
#expect(ok)
#expect(router.pendingInstallURL?.isFileURL == true)
#expect(router.pendingInstallURL?.path == path)
router.consume()
}
@Test @MainActor func refusesFileURLWithOtherExtension() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let ok = router.handle(URL(fileURLWithPath: "/tmp/somefile.zip"))
#expect(ok == false)
#expect(router.pendingInstallURL == nil)
}
@Test @MainActor func acceptsHttpsInstallUrl() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let target = "https://example.com/foo.scarftemplate"
let ok = router.handle(URL(string: "scarf://install?url=\(target)")!)
#expect(ok)
#expect(router.pendingInstallURL?.absoluteString == target)
router.consume()
}
}
/// End-to-end install test against a minimal bundle (dashboard + README +
/// AGENTS.md, no skills/cron/memory). Exercises the full install path
/// through `preflight createProjectFiles registerProject
/// writeLockFile`. We avoid touching user state by:
/// 1. Picking a temp `projectDir` under `NSTemporaryDirectory()`.
/// 2. Snapshotting and restoring `~/.hermes/scarf/projects.json` around
/// each test so the registry write is reversible.
/// Skills/cron/memory paths aren't touched because the test bundles claim
/// none. That's the intentional v1 coverage: the project-dir side effects
/// are exhaustively tested; global-state side effects (skills namespace,
/// cron CLI, memory append) are covered by manual verification per the
/// plan's step 7.
@Suite struct ProjectTemplateInstallerTests {
@Test func installsMinimalBundleAndWritesLockFile() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
let registryBefore = Self.snapshotRegistry()
defer { Self.restoreRegistry(registryBefore) }
let installer = ProjectTemplateInstaller(context: .local)
let entry = try installer.install(plan: plan)
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/AGENTS.md"))
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/README.md"))
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/dashboard.json"))
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/template.lock.json"))
#expect(entry.path == plan.projectDir)
let lockData = try Data(contentsOf: URL(fileURLWithPath: plan.projectDir + "/.scarf/template.lock.json"))
let lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
#expect(lock.templateId == inspection.manifest.id)
#expect(lock.templateVersion == inspection.manifest.version)
#expect(lock.projectFiles.contains(plan.projectDir + "/AGENTS.md"))
#expect(lock.cronJobNames.isEmpty)
#expect(lock.memoryBlockId == nil)
}
@Test func preflightRejectsExistingProjectDir() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
// Simulate a concurrent creation between buildPlan and install.
try FileManager.default.createDirectory(atPath: plan.projectDir, withIntermediateDirectories: true)
let installer = ProjectTemplateInstaller(context: .local)
#expect(throws: ProjectTemplateError.self) {
try installer.install(plan: plan)
}
}
@Test func buildPlanRefusesDuplicateProjectDir() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
// Pre-create the slugged project dir so buildPlan's collision check
// fires before we get to install.
let slugDir = parentDir + "/" + inspection.manifest.slug
try FileManager.default.createDirectory(atPath: slugDir, withIntermediateDirectories: true)
#expect(throws: ProjectTemplateError.self) {
try service.buildPlan(inspection: inspection, parentDir: parentDir)
}
}
// MARK: - Registry snapshot helpers
/// Read the raw bytes of the current projects.json so we can restore
/// it byte-for-byte after the test. `nil` means the file didn't exist
/// restore by deleting whatever got created.
nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry
return try? Data(contentsOf: URL(fileURLWithPath: path))
}
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
}
}
/// End-to-end install + uninstall test: install a minimal bundle, uninstall
/// it, verify every tracked file is gone, the registry is restored to its
/// pre-install state, and user-added files (if any) are preserved. Scoped
/// to bundles with no skills/cron/memory so no global state is touched.
@Suite struct ProjectTemplateUninstallerTests {
@Test func roundTripsInstallThenUninstall() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
let registryBefore = Self.snapshotRegistry()
defer { Self.restoreRegistry(registryBefore) }
let installer = ProjectTemplateInstaller(context: .local)
let entry = try installer.install(plan: plan)
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
let uninstaller = ProjectTemplateUninstaller(context: .local)
#expect(uninstaller.isTemplateInstalled(project: entry))
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
#expect(uninstallPlan.projectFilesToRemove.count == 4) // README, AGENTS, dashboard.json, lock
#expect(uninstallPlan.extraProjectEntries.isEmpty)
#expect(uninstallPlan.projectDirBecomesEmpty)
#expect(uninstallPlan.skillsNamespaceDir == nil)
#expect(uninstallPlan.cronJobsToRemove.isEmpty)
#expect(uninstallPlan.memoryBlockPresent == false)
try uninstaller.uninstall(plan: uninstallPlan)
#expect(FileManager.default.fileExists(atPath: plan.projectDir) == false)
// Registry entry gone length matches pre-install snapshot.
let service2 = ProjectDashboardService(context: .local)
let registryAfter = service2.loadRegistry()
#expect(registryAfter.projects.contains(where: { $0.path == entry.path }) == false)
}
@Test func preservesUserAddedFiles() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
let registryBefore = Self.snapshotRegistry()
defer { Self.restoreRegistry(registryBefore) }
let installer = ProjectTemplateInstaller(context: .local)
let entry = try installer.install(plan: plan)
// Simulate the user / agent creating files post-install.
let userFile = plan.projectDir + "/sites.txt"
try "https://example.com\n".data(using: .utf8)!
.write(to: URL(fileURLWithPath: userFile))
let uninstaller = ProjectTemplateUninstaller(context: .local)
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
#expect(uninstallPlan.extraProjectEntries.contains(userFile))
#expect(uninstallPlan.projectDirBecomesEmpty == false)
try uninstaller.uninstall(plan: uninstallPlan)
// Project dir should still exist because sites.txt is there.
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
#expect(FileManager.default.fileExists(atPath: userFile))
// Lock-tracked files are gone.
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/AGENTS.md") == false)
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/README.md") == false)
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/template.lock.json") == false)
}
@Test func loadUninstallPlanRejectsProjectWithoutLock() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
try FileManager.default.createDirectory(atPath: scratch + "/bare", withIntermediateDirectories: true)
let entry = ProjectEntry(name: "Bare", path: scratch + "/bare")
let uninstaller = ProjectTemplateUninstaller(context: .local)
#expect(uninstaller.isTemplateInstalled(project: entry) == false)
#expect(throws: ProjectTemplateError.self) {
try uninstaller.loadUninstallPlan(for: entry)
}
}
// MARK: - Registry snapshot helpers (dup'd intentionally from
// ProjectTemplateInstallerTests small helper, not worth a shared
// fixture file for one more suite).
nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry
return try? Data(contentsOf: URL(fileURLWithPath: path))
}
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
}
}
/// Validates every `.scarftemplate` shipped under `templates/<author>/<name>/`
/// in the repo. A template whose manifest, `contents` claim, or file set is
/// out of sync will fail here so shipped templates can't silently rot.
@Suite struct ProjectTemplateExampleTemplateTests {
@Test func siteStatusCheckerParsesAndPlans() throws {
let bundle = try Self.locateExample(author: "awizemann", name: "site-status-checker")
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
#expect(inspection.manifest.id == "awizemann/site-status-checker")
#expect(inspection.manifest.contents.dashboard)
#expect(inspection.manifest.contents.agentsMd)
#expect(inspection.manifest.contents.cron == 1)
#expect(inspection.cronJobs.count == 1)
#expect(inspection.cronJobs.first?.name == "Check site status")
#expect(inspection.cronJobs.first?.schedule == "0 9 * * *")
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
#expect(plan.projectDir.hasSuffix("awizemann-site-status-checker"))
#expect(plan.skillsFiles.isEmpty)
#expect(plan.memoryAppendix == nil)
#expect(plan.cronJobs.count == 1)
// Cron job name gets prefixed with the template tag so users can
// find + remove it later.
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/site-status-checker] Check site status")
// Verify the bundled dashboard.json decodes against the same
// `ProjectDashboard` struct the app uses at runtime catches drift
// between template-author conventions and the actual renderer
// (e.g. a widget type that ProjectsView doesn't know, a
// non-number value for a stat, etc.).
let dashboardPath = inspection.unpackedDir + "/dashboard.json"
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
#expect(dashboard.title == "Site Status")
#expect(dashboard.sections.count == 3)
// First section should have three stat widgets that the cron job
// updates by value. Assert titles + types so the AGENTS.md contract
// can't drift from the actual dashboard.
let statsSection = dashboard.sections[0]
#expect(statsSection.title == "Current Status")
let statTitles = statsSection.widgets.filter { $0.type == "stat" }.map(\.title)
#expect(statTitles.contains("Sites Up"))
#expect(statTitles.contains("Sites Down"))
#expect(statTitles.contains("Last Checked"))
// The cron prompt mentions sites.txt and dashboard.json if it
// ever stops doing that, the agent won't know what files to touch.
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
#expect(cronPrompt.contains("sites.txt"))
#expect(cronPrompt.contains("dashboard.json"))
#expect(cronPrompt.contains("status-log.md"))
}
/// Resolve the example bundle path robustly. Unit-test working dirs
/// differ between `xcodebuild test` (project root) and an Xcode IDE
/// run (build-output dir), so we walk up from this source file until
/// we find the repo root. Templates live at
/// `templates/<author>/<name>/<name>.scarftemplate` per the catalog
/// layout (see `templates/README.md`).
nonisolated private static func locateExample(author: String, name: String) throws -> String {
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
for _ in 0..<6 {
let candidate = dir.appendingPathComponent("templates/\(author)/\(name)/\(name).scarftemplate")
if FileManager.default.fileExists(atPath: candidate.path) {
return candidate.path
}
dir = dir.deletingLastPathComponent()
}
throw ProjectTemplateError.requiredFileMissing("templates/\(author)/\(name)/\(name).scarftemplate")
}
}
/// Round-trips a real project structure through the exporter and back into
/// the service. Does NOT run the installer (which would write to
/// ~/.hermes) it verifies the produced bundle is valid, and stops there.
@Suite struct ProjectTemplateExportTests {
@Test func roundTripsMinimalProject() throws {
let fakeProject = NSTemporaryDirectory() + "scarf-project-" + UUID().uuidString
try FileManager.default.createDirectory(atPath: fakeProject + "/.scarf", withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: fakeProject) }
try ProjectTemplateServiceTests.sampleDashboardJSON
.data(using: .utf8)!
.write(to: URL(fileURLWithPath: fakeProject + "/.scarf/dashboard.json"))
try "# Test project".data(using: .utf8)!
.write(to: URL(fileURLWithPath: fakeProject + "/README.md"))
try "# Agent notes".data(using: .utf8)!
.write(to: URL(fileURLWithPath: fakeProject + "/AGENTS.md"))
let entry = ProjectEntry(name: "Round Trip", path: fakeProject)
let exporter = ProjectTemplateExporter(context: .local)
let outputDir = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: outputDir) }
let outputPath = outputDir + "/rt.scarftemplate"
let inputs = ProjectTemplateExporter.ExportInputs(
project: entry,
templateId: "tester/round-trip",
templateName: "Round Trip",
templateVersion: "0.1.0",
description: "round-trip test",
authorName: "Tester",
authorUrl: nil,
category: nil,
tags: [],
includeSkillIds: [],
includeCronJobIds: [],
memoryAppendix: nil
)
try exporter.export(inputs: inputs, outputZipPath: outputPath)
#expect(FileManager.default.fileExists(atPath: outputPath))
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: outputPath)
defer { service.cleanupTempDir(inspection.unpackedDir) }
#expect(inspection.manifest.id == "tester/round-trip")
#expect(inspection.files.contains("dashboard.json"))
#expect(inspection.files.contains("README.md"))
#expect(inspection.files.contains("AGENTS.md"))
}
}
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# Scarf templates catalog helper — runs the Python validator, renders the
# static site into .gh-pages-worktree/templates/, and (on `publish`)
# commits + pushes that subdir on the gh-pages branch.
#
# Usage:
# ./scripts/catalog.sh check # validate every template; no output
# ./scripts/catalog.sh build # validate + write templates/catalog.json + .gh-pages-worktree/templates/
# ./scripts/catalog.sh preview [DIR] # render self-contained preview; DIR defaults to /tmp/scarf-catalog-preview
# ./scripts/catalog.sh publish # secret-scan + commit + push gh-pages (templates subdir only)
# ./scripts/catalog.sh serve [PORT] # serve .gh-pages-worktree/ on localhost:PORT (default 8000)
# ./scripts/catalog.sh --help # this help
#
# The secret-scan runs BEFORE publish and inspects the generated
# .gh-pages-worktree/templates/ tree — same hard-pattern regex as
# scripts/wiki.sh so template README/AGENTS content that accidentally
# leaks credentials gets blocked before it reaches the public site.
#
# Bootstrap (one-time): requires a .gh-pages-worktree/ clone of the
# gh-pages branch. The release script (scripts/release.sh) creates it on
# first use. If it's missing:
# git worktree add .gh-pages-worktree gh-pages
#
# Recovery: if .gh-pages-worktree/ is deleted, re-run the command above.
set -euo pipefail
# ---------- config ----------
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GHPAGES_DIR="$REPO_ROOT/.gh-pages-worktree"
CATALOG_SUBDIR="templates"
PY="${PYTHON:-python3}"
BUILDER="$REPO_ROOT/tools/build-catalog.py"
# ---------- helpers (same shape as scripts/wiki.sh so a reader doesn't
# have to learn two conventions) ----------
log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31m[ERR] %s\033[0m\n' "$*" >&2; exit 1; }
need_builder() {
[[ -f "$BUILDER" ]] || die "missing $BUILDER"
command -v "$PY" >/dev/null 2>&1 || die "python3 not found (set \$PYTHON if needed)"
}
need_ghpages() {
[[ -d "$GHPAGES_DIR/.git" ]] || die "no gh-pages worktree at $GHPAGES_DIR
Run: git worktree add .gh-pages-worktree gh-pages"
}
# ---------- secret-scan (mirrors scripts/wiki.sh hard-pattern set) ----------
hard_regex='(sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{30,}|ghs_[A-Za-z0-9]{30,}|ghu_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|ghr_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|-----BEGIN [A-Z ]*PRIVATE KEY-----|BEGIN OPENSSH PRIVATE KEY)'
scan_hard_ghpages() {
# Scan the generated output, NOT the repo source — the validator
# already scans bundle contents. This pass catches anything that leaked
# through template.json fields or README prose.
local hits
hits="$(grep -rInE --exclude-dir=.git "$hard_regex" "$GHPAGES_DIR/$CATALOG_SUBDIR" 2>/dev/null || true)"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" >&2
die "hard-pattern secret match in rendered site — refusing to publish."
fi
}
# ---------- commands ----------
cmd_check() {
need_builder
"$PY" "$BUILDER" --check --repo "$REPO_ROOT"
}
cmd_build() {
need_builder
"$PY" "$BUILDER" --build --repo "$REPO_ROOT"
}
cmd_preview() {
need_builder
local dir="${1:-/tmp/scarf-catalog-preview}"
rm -rf "$dir"
mkdir -p "$dir"
"$PY" "$BUILDER" --preview "$dir" --repo "$REPO_ROOT"
log "Preview rendered to $dir"
log "Serve with: (cd $dir && python3 -m http.server 8000) then open http://localhost:8000/"
}
cmd_serve() {
need_ghpages
local port="${1:-8000}"
log "Serving $GHPAGES_DIR on http://localhost:$port/"
(cd "$GHPAGES_DIR" && "$PY" -m http.server "$port")
}
cmd_publish() {
need_builder
need_ghpages
log "Validating"
"$PY" "$BUILDER" --check --repo "$REPO_ROOT"
log "Building"
"$PY" "$BUILDER" --build --repo "$REPO_ROOT"
log "Secret-scanning rendered site"
scan_hard_ghpages
log "Staging + committing gh-pages"
(cd "$GHPAGES_DIR" && git add "$CATALOG_SUBDIR")
if (cd "$GHPAGES_DIR" && git diff --cached --quiet); then
log "No changes to publish."
return 0
fi
local msg
msg="catalog: rebuild at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
(cd "$GHPAGES_DIR" && git commit -m "$msg")
log "Pushing gh-pages"
(cd "$GHPAGES_DIR" && git push origin gh-pages)
log "Published."
}
cmd_help() {
sed -n '1,30p' "$0" | sed -n '/^# Usage/,/^#$/p'
}
# ---------- dispatch ----------
sub="${1:-help}"
shift || true
case "$sub" in
check) cmd_check "$@" ;;
build) cmd_build "$@" ;;
preview) cmd_preview "$@" ;;
serve) cmd_serve "$@" ;;
publish) cmd_publish "$@" ;;
help|--help|-h) cmd_help ;;
*) die "unknown command: $sub (try --help)" ;;
esac
Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scarf Templates</title>
<meta name="description" content="Community catalog of Scarf project templates — pre-configured AI-agent projects with dashboards, cron jobs, and agent instructions.">
<link rel="stylesheet" href="styles.css">
<link rel="icon" type="image/png" href="assets/icon.png">
</head>
<body>
<header class="site-header">
<a class="brand" href=".">
<img src="assets/icon.png" alt="" width="40" height="40">
<span class="brand-name">Scarf Templates</span>
</a>
<nav class="site-nav">
<a href="https://github.com/awizemann/scarf">GitHub</a>
<a href="https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md">Contribute</a>
</nav>
</header>
<section class="hero">
<h1>Pre-packaged projects for Scarf</h1>
<p>
Browse {{COUNT}} community template{{COUNT_PLURAL}} — each ships with a
ready-to-install Scarf dashboard, a cross-agent <code>AGENTS.md</code>, optional
cron jobs and skills. Click a template to see what it does; one click installs
it into Scarf.
</p>
</section>
<main class="catalog">
<div class="grid">
{{CARDS}}
</div>
</main>
<footer class="site-footer">
<p>
Scarf is open source:
<a href="https://github.com/awizemann/scarf">github.com/awizemann/scarf</a>.
Want to add your own template? See the
<a href="https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md">contribution guide</a>.
</p>
</footer>
</body>
</html>
+341
View File
@@ -0,0 +1,341 @@
/* Scarf Templates catalog site.
* Vanilla CSS, no framework. Matches Scarf's green accent and keeps
* decoration minimal the live dashboard preview is the point. */
:root {
--fg: #1a1a1a;
--fg-muted: #666;
--bg: #fafafa;
--bg-card: #ffffff;
--border: #e5e5e5;
--accent: #2aa876; /* scarf green */
--accent-dark: #1f7f5a;
--red: #d9534f;
--blue: #3498db;
--orange: #f0ad4e;
--radius: 8px;
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e5e5e5;
--fg-muted: #9a9a9a;
--bg: #141414;
--bg-card: #1e1e1e;
--border: #2a2a2a;
--accent: #3abf8a;
--accent-dark: #2aa876;
--shadow: 0 1px 2px rgba(0,0,0,0.3), 0 4px 12px rgba(0,0,0,0.3);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--sans);
color: var(--fg);
background: var(--bg);
line-height: 1.5;
}
code, pre {
font-family: var(--mono);
font-size: 0.92em;
}
pre {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
overflow-x: auto;
}
code {
background: rgba(0,0,0,0.05);
padding: 2px 5px;
border-radius: 4px;
}
pre code { background: transparent; padding: 0; }
a { color: var(--accent-dark); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3 { line-height: 1.25; }
/* ---------- header / footer ---------- */
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
color: var(--fg);
}
.brand:hover { text-decoration: none; }
.brand-name { font-weight: 600; font-size: 18px; }
.site-nav a {
margin-left: 20px;
color: var(--fg-muted);
font-size: 14px;
}
.site-footer {
padding: 32px;
text-align: center;
color: var(--fg-muted);
font-size: 14px;
border-top: 1px solid var(--border);
margin-top: 40px;
}
/* ---------- landing ---------- */
.hero {
padding: 48px 32px 24px;
max-width: 720px;
margin: 0 auto;
text-align: center;
}
.hero h1 { font-size: 32px; margin: 0 0 12px; }
.hero p { color: var(--fg-muted); }
.catalog {
max-width: 1100px;
margin: 0 auto;
padding: 16px 32px 48px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.card {
display: block;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
color: inherit;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 2px 4px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.06);
text-decoration: none;
}
.card h3 { margin: 0 0 6px; font-size: 18px; }
.card .desc { color: var(--fg-muted); margin: 0 0 14px; font-size: 14px; }
.card .meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--fg-muted);
margin-bottom: 8px;
}
.card .author { font-weight: 500; }
.card .version { font-family: var(--mono); }
.tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
display: inline-block;
padding: 2px 8px;
background: rgba(42,168,118,0.15);
color: var(--accent-dark);
border-radius: 10px;
font-size: 11px;
}
/* ---------- template detail page ---------- */
.detail {
max-width: 900px;
margin: 0 auto;
padding: 32px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 32px;
margin-bottom: 40px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.detail-header h1 { margin: 0 0 4px; }
.detail-header h1 .version {
font-family: var(--mono);
font-size: 16px;
color: var(--fg-muted);
font-weight: 400;
}
.detail-header .desc { color: var(--fg-muted); margin: 0 0 12px; }
.detail-header .meta {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--fg-muted);
margin-bottom: 8px;
}
.detail-header .id { font-family: var(--mono); }
.install-actions { display: flex; flex-direction: column; gap: 8px; min-width: 200px; }
.btn {
display: inline-block;
padding: 10px 20px;
border-radius: var(--radius);
font-weight: 500;
text-align: center;
font-size: 14px;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover { background: var(--accent-dark); text-decoration: none; color: white; }
.btn-secondary {
background: transparent;
color: var(--fg);
border: 1px solid var(--border);
}
.btn-secondary:hover { border-color: var(--accent); text-decoration: none; color: var(--accent-dark); }
.detail-dashboard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 32px;
}
.detail-dashboard h2 { margin-top: 0; }
.detail-dashboard-note {
color: var(--fg-muted);
font-size: 13px;
margin-top: 4px;
margin-bottom: 20px;
}
.detail-readme h2 { margin-top: 0; }
.detail-readme > div {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
}
/* ---------- dashboard preview ---------- */
.dashboard-header h1.dashboard-title { margin: 0 0 4px; font-size: 22px; }
.dashboard-desc { color: var(--fg-muted); margin: 0 0 24px; font-size: 14px; }
.dashboard-section { margin-bottom: 24px; }
.section-title { margin: 0 0 10px; font-size: 14px; font-weight: 600; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.widget-grid {
display: grid;
grid-template-columns: repeat(var(--cols, 3), 1fr);
gap: 12px;
}
.widget {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
}
.widget-title {
font-size: 12px;
font-weight: 500;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* stat */
.widget-stat { display: flex; flex-direction: column; gap: 6px; }
.widget-stat-top { display: flex; align-items: center; gap: 8px; }
.widget-stat-icon { font-size: 14px; color: var(--fg-muted); }
.widget-stat-value { font-size: 32px; font-weight: 600; line-height: 1.1; }
.widget-stat-subtitle { font-size: 11px; color: var(--fg-muted); }
.widget-stat[data-color="green"] .widget-stat-icon { color: var(--accent); }
.widget-stat[data-color="red"] .widget-stat-icon { color: var(--red); }
.widget-stat[data-color="blue"] .widget-stat-icon { color: var(--blue); }
.widget-stat[data-color="orange"] .widget-stat-icon { color: var(--orange); }
/* progress */
.widget-progress-label { font-size: 13px; margin: 6px 0 8px; }
.progress-bar { height: 8px; background: rgba(0,0,0,0.05); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: 4px; }
/* text */
.widget-text-body { font-size: 14px; margin-top: 6px; }
.widget-text-body h1 { font-size: 20px; margin: 12px 0 8px; }
.widget-text-body h2 { font-size: 17px; margin: 10px 0 6px; }
.widget-text-body h3 { font-size: 14px; margin: 8px 0 4px; }
.widget-text-body p { margin: 8px 0; }
.widget-text-body ul, .widget-text-body ol { padding-left: 22px; }
/* table */
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
.data-table th, .data-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
.data-table th { font-weight: 500; color: var(--fg-muted); }
/* list */
.widget-list-items { margin: 6px 0 0; padding-left: 18px; font-size: 13px; }
.widget-list-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 3px 0;
}
.widget-list-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.widget-list-status {
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
background: rgba(0,0,0,0.08);
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.widget-list-status[data-status="up"] { background: rgba(42,168,118,0.18); color: var(--accent-dark); }
.widget-list-status[data-status="down"] { background: rgba(217,83,79,0.18); color: var(--red); }
/* chart */
.widget-chart-svg { width: 100%; height: auto; margin-top: 8px; }
.chart-axis { stroke: var(--border); stroke-width: 1; }
.chart-line { fill: none; stroke-width: 2; }
.chart-line[data-color="accent"], .chart-bar[data-color="accent"] { stroke: var(--accent); fill: var(--accent); }
.chart-line[data-color="red"], .chart-bar[data-color="red"] { stroke: var(--red); fill: var(--red); }
.chart-line[data-color="blue"], .chart-bar[data-color="blue"] { stroke: var(--blue); fill: var(--blue); }
.chart-line[data-color="orange"], .chart-bar[data-color="orange"] { stroke: var(--orange); fill: var(--orange); }
.widget-chart-empty { color: var(--fg-muted); font-size: 13px; padding: 20px 0; text-align: center; }
/* webview */
.widget-webview iframe { border: 1px solid var(--border); border-radius: 6px; margin-top: 8px; }
/* unknown */
.widget-unknown-body { color: var(--fg-muted); font-size: 13px; margin-top: 6px; }
/* ---------- responsive ---------- */
@media (max-width: 680px) {
.site-header { padding: 12px 16px; }
.site-nav a { margin-left: 12px; font-size: 13px; }
.hero { padding: 32px 16px 16px; }
.catalog, .detail { padding: 16px; }
.detail-header { flex-direction: column; gap: 16px; }
.install-actions { flex-direction: row; min-width: 0; }
.btn { flex: 1; }
}
+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{NAME}} — Scarf Templates</title>
<meta name="description" content="{{DESC}}">
<link rel="stylesheet" href="../styles.css">
<link rel="icon" type="image/png" href="../assets/icon.png">
</head>
<body>
<header class="site-header">
<a class="brand" href="..">
<img src="../assets/icon.png" alt="" width="40" height="40">
<span class="brand-name">Scarf Templates</span>
</a>
<nav class="site-nav">
<a href="..">Catalog</a>
<a href="https://github.com/awizemann/scarf">GitHub</a>
<a href="https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md">Contribute</a>
</nav>
</header>
<main class="detail">
<section class="detail-header">
<div>
<h1>{{NAME}} <span class="version">v{{VERSION}}</span></h1>
<p class="desc">{{DESC}}</p>
<p class="meta">
<span class="author">by {{AUTHOR_HTML}}</span>
<span class="id">{{ID}}</span>
<span class="category">{{CATEGORY}}</span>
</p>
<p class="tags">{{TAGS_HTML}}</p>
</div>
<div class="install-actions">
<a class="btn btn-primary" href="{{SCARF_INSTALL_URL}}">Install with Scarf</a>
<a class="btn btn-secondary" href="{{INSTALL_URL_ENCODED}}">Download .scarftemplate</a>
</div>
</section>
<section class="detail-dashboard">
<h2>Live dashboard preview</h2>
<p class="detail-dashboard-note">
Exactly what you'll see inside Scarf after install. Values shown here are
placeholders; the agent updates them each time the cron job runs.
</p>
<div id="dashboard-preview"></div>
</section>
<section class="detail-readme">
<h2>README</h2>
<div id="readme-body"></div>
</section>
</main>
<footer class="site-footer">
<p>
Scarf is open source:
<a href="https://github.com/awizemann/scarf">github.com/awizemann/scarf</a>.
</p>
</footer>
<script src="../widgets.js"></script>
<script>
// Fetch + render dashboard + README at page load. Both files live
// alongside index.html in this template's detail dir.
(async function () {
const dashboardEl = document.getElementById("dashboard-preview");
const readmeEl = document.getElementById("readme-body");
try {
const d = await fetch("dashboard.json").then(r => r.json());
ScarfWidgets.renderDashboard(dashboardEl, d);
} catch (e) {
dashboardEl.textContent = "Could not load dashboard preview.";
}
try {
const md = await fetch("README.md").then(r => r.text());
readmeEl.innerHTML = ScarfWidgets.renderMarkdown(md);
} catch (e) {
readmeEl.textContent = "Could not load README.";
}
})();
</script>
</body>
</html>
+419
View File
@@ -0,0 +1,419 @@
// Scarf dashboard widget renderer — the dogfood piece.
//
// Takes the SAME `dashboard.json` shape the Scarf macOS app renders
// (see scarf/scarf/Core/Models/ProjectDashboard.swift) and produces an
// HTML approximation for the catalog site. A template's detail page
// shows a live preview of exactly what the user's project dashboard
// will look like after install.
//
// Widget types mirrored from the Swift dispatcher:
// stat — big number + label + icon + color
// progress — label + 0..1 bar
// text — markdown (tiny subset renderer)
// table — plain HTML table
// list — bulleted list with optional status badge
// chart — SVG line/bar by series
// webview — sandboxed <iframe>
//
// Vanilla JS, no build step, no external deps. ~300 lines.
(function (global) {
"use strict";
const SF_SYMBOL_FALLBACK = "●"; // SF Symbols aren't available on the web — use a dot.
// ---------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------
/**
* Render a ProjectDashboard JSON into `container`.
* @param {HTMLElement} container
* @param {object} dashboard
*/
function renderDashboard(container, dashboard) {
container.innerHTML = "";
if (!dashboard || !Array.isArray(dashboard.sections)) {
container.appendChild(elt("div", "dashboard-error", "Could not render dashboard."));
return;
}
const root = elt("div", "dashboard");
if (dashboard.title) {
const header = elt("div", "dashboard-header");
header.appendChild(elt("h1", "dashboard-title", dashboard.title));
if (dashboard.description) {
header.appendChild(elt("p", "dashboard-desc", dashboard.description));
}
root.appendChild(header);
}
for (const section of dashboard.sections) {
root.appendChild(renderSection(section));
}
container.appendChild(root);
}
function renderSection(section) {
const wrap = elt("section", "dashboard-section");
if (section.title) {
wrap.appendChild(elt("h2", "section-title", section.title));
}
const cols = Math.max(1, Math.min(6, section.columns || 3));
const grid = elt("div", "widget-grid");
grid.style.setProperty("--cols", String(cols));
// Webview widgets render in a dedicated tab in the Scarf app but
// we inline them here so the catalog preview is single-scroll.
for (const widget of section.widgets || []) {
grid.appendChild(renderWidget(widget));
}
wrap.appendChild(grid);
return wrap;
}
function renderWidget(widget) {
try {
switch (widget.type) {
case "stat": return renderStat(widget);
case "progress": return renderProgress(widget);
case "text": return renderText(widget);
case "table": return renderTable(widget);
case "list": return renderList(widget);
case "chart": return renderChart(widget);
case "webview": return renderWebview(widget);
default: return renderUnknown(widget);
}
} catch (e) {
console.error("widget render error", widget, e);
return renderUnknown({ ...widget, title: (widget.title || "") + " (render error)" });
}
}
// ---------------------------------------------------------------------
// Stat
// ---------------------------------------------------------------------
function renderStat(widget) {
const card = elt("div", "widget widget-stat");
card.dataset.color = widget.color || "blue";
const top = elt("div", "widget-stat-top");
top.appendChild(elt("span", "widget-stat-icon", SF_SYMBOL_FALLBACK));
top.appendChild(elt("span", "widget-title", widget.title || ""));
card.appendChild(top);
const value = elt("div", "widget-stat-value", displayValue(widget.value));
card.appendChild(value);
if (widget.subtitle) {
card.appendChild(elt("div", "widget-stat-subtitle", widget.subtitle));
}
return card;
}
function displayValue(v) {
if (v === null || v === undefined) return "—";
if (typeof v === "number") {
return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1);
}
return String(v);
}
// ---------------------------------------------------------------------
// Progress
// ---------------------------------------------------------------------
function renderProgress(widget) {
const card = elt("div", "widget widget-progress");
card.appendChild(elt("div", "widget-title", widget.title || ""));
if (widget.label) {
card.appendChild(elt("div", "widget-progress-label", widget.label));
}
const bar = elt("div", "progress-bar");
const fill = elt("div", "progress-fill");
const pct = Math.max(0, Math.min(1, Number(widget.value) || 0));
fill.style.width = (pct * 100).toFixed(1) + "%";
bar.appendChild(fill);
card.appendChild(bar);
return card;
}
// ---------------------------------------------------------------------
// Text (markdown)
// ---------------------------------------------------------------------
function renderText(widget) {
const card = elt("div", "widget widget-text");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const body = elt("div", "widget-text-body");
if ((widget.format || "").toLowerCase() === "markdown") {
body.innerHTML = renderMarkdown(widget.content || "");
} else {
body.textContent = widget.content || "";
}
card.appendChild(body);
return card;
}
/** Minimal markdown subset: headings, bold, italic, inline code, code
* blocks, bullet/numbered lists, links, paragraphs. Deliberately tiny
* the catalog showcases dashboards, not blog posts. */
function renderMarkdown(src) {
const lines = src.split(/\r?\n/);
let html = "";
let inCode = false;
let inList = null; // "ul" | "ol" | null
const flushList = () => {
if (inList) {
html += `</${inList}>`;
inList = null;
}
};
for (const rawLine of lines) {
const line = rawLine;
if (line.trim().startsWith("```")) {
flushList();
if (inCode) {
html += "</code></pre>";
inCode = false;
} else {
html += "<pre><code>";
inCode = true;
}
continue;
}
if (inCode) {
html += escapeHTML(line) + "\n";
continue;
}
if (/^#{1,6}\s/.test(line)) {
flushList();
const level = Math.min(6, (line.match(/^#+/) || ["#"])[0].length);
const text = line.replace(/^#+\s*/, "");
html += `<h${level}>${renderInline(text)}</h${level}>`;
continue;
}
const bulletMatch = line.match(/^\s*[-*]\s+(.*)$/);
const orderedMatch = line.match(/^\s*\d+\.\s+(.*)$/);
if (bulletMatch) {
if (inList !== "ul") { flushList(); html += "<ul>"; inList = "ul"; }
html += `<li>${renderInline(bulletMatch[1])}</li>`;
continue;
}
if (orderedMatch) {
if (inList !== "ol") { flushList(); html += "<ol>"; inList = "ol"; }
html += `<li>${renderInline(orderedMatch[1])}</li>`;
continue;
}
if (line.trim() === "") {
flushList();
continue;
}
flushList();
html += `<p>${renderInline(line)}</p>`;
}
flushList();
if (inCode) html += "</code></pre>";
return html;
}
function renderInline(text) {
// Escape first, then re-apply formatting on the escaped text.
let s = escapeHTML(text);
// Inline code before bold/italic so the markers inside `…` stay literal.
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^\w])\*([^*]+)\*/g, "$1<em>$2</em>");
s = s.replace(/(^|[^\w])_([^_]+)_/g, "$1<em>$2</em>");
// Links: [text](url)
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
return `<a href="${url}">${text}</a>`;
});
return s;
}
// ---------------------------------------------------------------------
// Table
// ---------------------------------------------------------------------
function renderTable(widget) {
const card = elt("div", "widget widget-table");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const table = elt("table", "data-table");
if (Array.isArray(widget.columns)) {
const thead = elt("thead");
const tr = elt("tr");
for (const col of widget.columns) {
tr.appendChild(elt("th", null, col));
}
thead.appendChild(tr);
table.appendChild(thead);
}
if (Array.isArray(widget.rows)) {
const tbody = elt("tbody");
for (const row of widget.rows) {
const tr = elt("tr");
for (const cell of row) {
tr.appendChild(elt("td", null, cell));
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
}
card.appendChild(table);
return card;
}
// ---------------------------------------------------------------------
// List
// ---------------------------------------------------------------------
function renderList(widget) {
const card = elt("div", "widget widget-list");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const ul = elt("ul", "widget-list-items");
for (const item of widget.items || []) {
const li = elt("li", "widget-list-item");
li.appendChild(elt("span", "widget-list-text", item.text || ""));
if (item.status) {
const badge = elt("span", "widget-list-status", item.status);
badge.dataset.status = item.status;
li.appendChild(badge);
}
ul.appendChild(li);
}
card.appendChild(ul);
return card;
}
// ---------------------------------------------------------------------
// Chart (SVG — no Chart.js dep)
// ---------------------------------------------------------------------
function renderChart(widget) {
const card = elt("div", "widget widget-chart");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const series = widget.series || [];
if (series.length === 0) {
card.appendChild(elt("div", "widget-chart-empty", "No chart data."));
return card;
}
// Collect x-labels (assume aligned across series).
const xs = series[0].data.map((p) => p.x);
const ys = series.flatMap((s) => s.data.map((p) => p.y));
const maxY = Math.max(0, ...ys);
const minY = Math.min(0, ...ys);
const W = 320;
const H = 120;
const padL = 24, padR = 8, padT = 8, padB = 22;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.classList.add("widget-chart-svg");
const yToPixel = (y) => {
if (maxY === minY) return padT + plotH / 2;
return padT + plotH - ((y - minY) / (maxY - minY)) * plotH;
};
const xToPixel = (i) => padL + (plotW * (i / Math.max(1, xs.length - 1)));
// Axis baseline
const axis = document.createElementNS(svgNS, "line");
axis.setAttribute("x1", String(padL));
axis.setAttribute("y1", String(padT + plotH));
axis.setAttribute("x2", String(W - padR));
axis.setAttribute("y2", String(padT + plotH));
axis.setAttribute("class", "chart-axis");
svg.appendChild(axis);
const kind = (widget.chartType || "line").toLowerCase();
series.forEach((s, idx) => {
const color = s.color || ["accent", "red", "blue", "orange"][idx % 4];
if (kind === "bar") {
const barW = Math.max(2, plotW / (xs.length * series.length) - 2);
s.data.forEach((p, i) => {
const rect = document.createElementNS(svgNS, "rect");
const x = xToPixel(i) - barW / 2 + idx * barW;
const y = yToPixel(p.y);
rect.setAttribute("x", String(x));
rect.setAttribute("y", String(y));
rect.setAttribute("width", String(barW));
rect.setAttribute("height", String(padT + plotH - y));
rect.setAttribute("class", "chart-bar");
rect.dataset.color = color;
svg.appendChild(rect);
});
} else {
const d = s.data.map((p, i) => {
const x = xToPixel(i);
const y = yToPixel(p.y);
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
}).join(" ");
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", d);
path.setAttribute("class", "chart-line");
path.dataset.color = color;
svg.appendChild(path);
}
});
card.appendChild(svg);
return card;
}
// ---------------------------------------------------------------------
// Webview
// ---------------------------------------------------------------------
function renderWebview(widget) {
const card = elt("div", "widget widget-webview");
card.appendChild(elt("div", "widget-title", widget.title || ""));
const frame = document.createElement("iframe");
frame.src = widget.url || "about:blank";
frame.setAttribute("sandbox", "allow-scripts allow-popups allow-forms");
frame.style.width = "100%";
frame.style.height = (widget.height ? Number(widget.height) : 300) + "px";
frame.loading = "lazy";
card.appendChild(frame);
return card;
}
// ---------------------------------------------------------------------
// Unknown / placeholder
// ---------------------------------------------------------------------
function renderUnknown(widget) {
const card = elt("div", "widget widget-unknown");
card.appendChild(elt("div", "widget-title", widget.title || ""));
card.appendChild(elt("div", "widget-unknown-body",
`Unknown widget type: ${widget.type}`));
return card;
}
// ---------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------
function elt(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined && text !== null) e.textContent = String(text);
return e;
}
function escapeHTML(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ---------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------
global.ScarfWidgets = {
renderDashboard,
renderMarkdown, // exposed for the template detail page's README block
};
})(typeof window !== "undefined" ? window : this);
+144
View File
@@ -0,0 +1,144 @@
# Contributing a template to Scarf
Thanks for packaging something up for other Scarf users. This guide walks you through the full submission flow end-to-end.
## Before you start
- You need Scarf 2.2 or later installed to build + test your template.
- Your template must ship a cross-agent **`AGENTS.md`** — that's the Linux Foundation open standard ([agents.md](https://agents.md/)) every major coding agent reads. Templates without one are rejected; Scarf specifically supports agent-portable projects.
- Templates are free and MIT-licensed implicitly by submission. Don't submit anything you don't have rights to.
## What makes a good template
- **Scoped.** One purpose per template. A "does-everything" template is harder to maintain than three focused ones.
- **Agent-first.** The `AGENTS.md` tells any agent how to interact with your project. Spell out the project layout, what each file is for, and what the agent should do when the user asks common questions ("run the X job", "add a Y").
- **Self-contained prompts.** Cron jobs + skills should not assume state the template doesn't ship. If you need a `sites.txt`, have `AGENTS.md` tell the agent to bootstrap it on first run (see `awizemann/site-status-checker` for the pattern).
- **Paused by default.** Every cron job ships disabled — Scarf pauses new jobs on install. Write prompts that work whether fired by cron or invoked directly in chat.
- **No secrets.** No API keys, no hostnames, no paths specific to your machine. The catalog's CI secret-scan will block obvious cases but this is on you.
- **No config writes.** Templates must not modify `~/.hermes/config.yaml`, `auth.json`, or any credential path. The installer refuses v1 bundles that claim to. If you need integration with, say, a specific MCP server, document the prerequisite in your README instead of trying to install it.
## Step-by-step submission
### 1. Fork + clone
```bash
gh repo fork awizemann/scarf --clone && cd scarf
```
### 2. Create your template directory
```bash
mkdir -p templates/<your-github-handle>/<your-template-name>/staging
cd templates/<your-github-handle>/<your-template-name>/staging
```
Directory names are lowercase, hyphenated, stable: people will type them.
### 3. Author the bundle
Minimum required files under `staging/`:
- **`template.json`** — manifest. Schema:
```json
{
"schemaVersion": 1,
"id": "<your-handle>/<your-template-name>",
"name": "Your Template Name",
"version": "1.0.0",
"minScarfVersion": "2.2.0",
"minHermesVersion": "0.9.0",
"author": { "name": "Your Name", "url": "https://…" },
"description": "One-line pitch shown in the catalog.",
"category": "monitoring",
"tags": ["short", "list"],
"contents": {
"dashboard": true,
"agentsMd": true,
"cron": 0,
"instructions": null,
"skills": null,
"memory": null
}
}
```
The `contents` claim must exactly match what's in `staging/` — the validator cross-checks and rejects mismatches.
- **`README.md`** — shown on the catalog detail page. Include: what the project does, what the user has to do after install, how to customize, how to uninstall.
- **`AGENTS.md`** — the cross-agent spec. Include: project layout, first-run bootstrap (if any), what each cron job expects to happen, and answers to common user prompts (`"what's the status"`, `"add a X"`, etc.).
- **`dashboard.json`** — the Scarf dashboard that renders on the catalog detail page and after install. See [awizemann/site-status-checker/staging/dashboard.json](awizemann/site-status-checker/staging/dashboard.json) for the schema in action.
Optional:
- `instructions/CLAUDE.md`, `instructions/GEMINI.md`, `instructions/.cursorrules`, `instructions/.github/copilot-instructions.md` — agent-specific shims beyond `AGENTS.md`.
- `skills/<skill-name>/SKILL.md` — shipped skills, installed into `~/.hermes/skills/templates/<slug>/` on the user's side.
- `cron/jobs.json` — an array of cron job specs. Each has `name`, `schedule` (e.g. `0 9 * * *` or `every 2h`), `prompt`, optional `deliver`, `skills[]`, `repeat`.
- `memory/append.md` — markdown appended to the user's `MEMORY.md` between template-specific markers. Use sparingly — most templates don't need this.
### 4. Build the bundle
From the `staging/` directory:
```bash
cd ..
zip -qq -r <your-template-name>.scarftemplate staging/
mv <your-template-name>.scarftemplate . # end up alongside staging/
```
Or equivalently:
```bash
cd staging && zip -qq -r ../<your-template-name>.scarftemplate . && cd ..
```
### 5. Test locally in Scarf
1. Open Scarf → Projects → Templates → **Install from File…** → select your `.scarftemplate`.
2. Walk through the preview sheet. Make sure every file, cron job, and memory block shown is something you meant to ship.
3. Install into a scratch parent dir. Verify the dashboard renders. Enable the cron job(s) if any and trigger them manually to confirm your `AGENTS.md` drives the right behavior.
4. Right-click the project → **Uninstall Template…** → verify nothing unexpected remains.
### 6. Validate
Before opening the PR, run the catalog validator locally:
```bash
python3 tools/build-catalog.py --check
```
This checks every template in the repo (including yours), verifies the manifest matches the bundle contents, refuses bundles >5 MB, and flags common secret patterns. If it fails, fix the reported issues before pushing.
### 7. Open the PR
```bash
git checkout -b add-<your-template-name>
git add templates/<your-handle>/<your-template-name>
git commit -m "feat(templates): add <your-template-name>"
git push origin add-<your-template-name>
gh pr create
```
**Do not modify `templates/catalog.json`** — the maintainer regenerates it after merge to keep PR diffs small.
The scarf repo ships a tailored submission checklist at [.github/PULL_REQUEST_TEMPLATE/template-submission.md](../.github/PULL_REQUEST_TEMPLATE/template-submission.md). To apply it to your PR, append `?template=template-submission.md` to the compare URL when opening the PR in the browser, or copy the checkbox list into the body manually.
GitHub Actions runs the validator on your PR (see [.github/workflows/validate-template-pr.yml](../.github/workflows/validate-template-pr.yml)). A green check means the bundle structure is sound; it doesn't mean the content is approved. Expect a maintainer pass for content quality (is the `AGENTS.md` clear, does the prompt do what you describe, is the scope reasonable).
### 8. Iterate + ship
Respond to review feedback. Common requests:
- Sharpen the `README.md` so install/uninstall steps are copy-pasteable.
- Split ambitious cron prompts into smaller, clearly-scoped ones.
- Remove things the template doesn't need (an empty `skills/` dir, an unused `deliver` target, etc.).
Once merged, your template shows up at `https://awizemann.github.io/scarf/templates/<your-handle>-<your-name>/` within a few minutes (the maintainer pushes the site regeneration by hand).
## Updating an existing template
Bump `version` in `template.json`, rebuild the `.scarftemplate`, commit, PR. The Install button on the catalog always points at the latest `main` version — there's no per-version pinning in v1. Users who already installed get no automatic update; they'd have to uninstall + reinstall for v2.
## Questions?
Open a [GitHub Discussion](https://github.com/awizemann/scarf/discussions) — the tag `templates` is watched.
+52
View File
@@ -0,0 +1,52 @@
# Scarf Templates
The community template catalog for [Scarf](https://github.com/awizemann/scarf) — a macOS GUI for the Hermes AI agent. Each subdirectory here is one installable project template. Browse the live catalog with live dashboard previews at **<https://awizemann.github.io/scarf/templates/>**.
## What's a template?
A `.scarftemplate` bundle ships:
- A pre-configured project **dashboard** (widgets for stats, lists, text, charts).
- A cross-agent **`AGENTS.md`** ([agents.md standard](https://agents.md/)) that tells Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, etc. how to work with the project.
- Optional **skills**, **cron jobs**, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), and an optional **memory appendix**.
Users install with one click from the catalog site or by opening a `.scarftemplate` file in Scarf.
## Layout
Each template lives at `templates/<github-handle>/<template-name>/` with:
```
templates/<github-handle>/<template-name>/
├── staging/ source tree
│ ├── template.json manifest (id, name, version, contents claim)
│ ├── README.md shown on catalog detail page
│ ├── AGENTS.md required cross-agent instructions
│ ├── dashboard.json rendered as a live preview on the catalog site
│ ├── instructions/… optional per-agent shims
│ ├── skills/… optional namespaced skills
│ ├── cron/jobs.json optional cron job definitions
│ └── memory/append.md optional memory appendix
├── <template-name>.scarftemplate built bundle (zipped staging/), committed as-is
└── screenshots/ optional PNGs for the detail page
```
The built `.scarftemplate` is served directly from `raw.githubusercontent.com` — the catalog's Install button links at:
```
scarf://install?url=https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate
```
## Contributing a template
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full submission walkthrough. tl;dr: fork, drop a template under `templates/<your-handle>/<your-name>/`, open a PR. A CI check validates the bundle; a maintainer reviews the content.
## Catalog metadata
`catalog.json` at this directory is the aggregate index that the website reads. It's regenerated by the maintainer on merge — **do not modify it in your PR**, the build script will take care of it.
## Current templates
| Template | Author | Description |
|---|---|---|
| [site-status-checker](awizemann/site-status-checker/) | awizemann | Daily HTTP uptime check for a user-editable list of URLs. Dashboard + cron + AGENTS.md. |
@@ -0,0 +1,66 @@
# Site Status Checker — Agent Instructions
This project maintains a daily uptime check for a short list of URLs. The same instructions apply whether you're Hermes, Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
## Project layout
- `sites.txt` — one URL per line. Lines starting with `#` are comments. This is the source of truth for what to check. **Not shipped with the template** — created on first run (see below).
- `status-log.md` — append-only markdown log. Newest run at the top. Each run is a section with the ISO-8601 timestamp as the heading. Also created on first run.
- `.scarf/dashboard.json` — Scarf dashboard. **Only the `value` fields of the three stat widgets and the `items` array of the "Watched Sites" list widget should be updated.** The section titles, widget types, and structure must stay intact.
## First-run bootstrap
If `sites.txt` doesn't exist in the project root, create it with this starter content and tell the user you did:
```
# One URL per line. Lines starting with # are comments.
# Replace these placeholders with the sites you want to watch.
https://example.com
https://example.org
```
If `status-log.md` doesn't exist, create it with a one-line header:
```
# Site Status Log
Newest run at the top. Each section is a single check.
```
## What to do when the cron job fires
The cron job runs this project's "Check site status" prompt. When invoked:
1. Read `sites.txt` in the project root. Ignore empty lines and `#`-prefixed comments. Expect plain URLs; be tolerant of whitespace around them.
2. For each URL, make an HTTP GET request with a 10-second timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**.
3. Build a results table: URL, status (up/down), HTTP code (or error reason), response time in milliseconds.
4. Prepend a new section to `status-log.md`:
```
## <ISO-8601 timestamp>
| URL | Status | Code | Latency |
|-----|--------|------|---------|
| … | up | 200 | 142 ms |
| … | down | timeout | — |
```
5. Update `.scarf/dashboard.json`:
- `Sites Up` stat widget: `value` = count of up results.
- `Sites Down` stat widget: `value` = count of down results.
- `Last Checked` stat widget: `value` = the ISO-8601 timestamp you just wrote.
- `Watched Sites` list widget `items`: one entry per URL with `text` = URL and `status` = `"up"` or `"down"` (lowercase).
6. If the cron job has a `deliver` target set, emit a one-line summary (`3 up, 1 down — example.com timed out`) as the agent's final response so the delivery mechanism picks it up.
## What not to do
- Don't modify the structure of `dashboard.json` (section titles, widget types, widget titles, `columns`). Only the values listed above are writable.
- Don't truncate `status-log.md` — it's the historical record. If it grows past 1 MB, add a one-line note at the top of the file asking the user to archive it.
- Don't invent URLs. If `sites.txt` is empty or missing, leave the dashboard untouched and write a single `status-log.md` entry noting "no sites configured."
- Don't run browsers or headless Chrome. Plain HTTP GET is sufficient.
## When the user asks you things
- "What's the status of my sites?" — read the top section of `status-log.md` and summarize.
- "Add a site" — append the URL to `sites.txt` on its own line. Don't sort or reorder existing entries. Confirm back to the user which URL you added.
- "Remove a site" — delete the matching line from `sites.txt`. If multiple match, ask before choosing.
- "Run the check now" — do everything in the cron flow above, then summarize the results in chat.
- "Why is [site] down?" — read the last 3-5 entries for that URL in `status-log.md` and report any pattern you see (consistent timeouts, intermittent 5xx, DNS failures, etc.). Don't speculate beyond what the log shows.
@@ -0,0 +1,33 @@
# Site Status Checker
A minimal uptime watchdog that pings a list of URLs once a day, records pass/fail results, and keeps a simple Scarf dashboard up to date.
## What you get
- **`sites.txt`** — one URL per line. This is the source of truth for what the cron job checks. Edit it to add or remove sites.
- **`status-log.md`** — the agent's append-only log of check results. New runs append a section at the top.
- **`.scarf/dashboard.json`** — Scarf dashboard with live stat widgets (sites up, sites down, last checked), the full list of watched sites with their last-known status, and a usage guide.
- **Cron job `Check site status`** — registered (paused) by the installer; tag `[tmpl:awizemann/site-status-checker]`. Runs daily at 9:00 AM when enabled. The prompt tells the agent to read `sites.txt`, check each URL, write results to `status-log.md`, and update the stat widgets in `dashboard.json`.
## First steps
1. Open the **Cron** sidebar and enable the `[tmpl:awizemann/site-status-checker] Check site status` job. It's paused on install so nothing runs without your explicit say-so.
2. Edit `sites.txt` in your project root — replace the two placeholder URLs with the sites you actually want to watch.
3. From the project's dashboard, ask your agent to run the job now: "Run the site status check and update the dashboard."
4. Future runs happen automatically at 9 AM daily.
## Customizing
- **Change the schedule.** Edit the cron job in the Cron sidebar — the schedule field accepts `30m`, `every 2h`, or standard cron expressions like `0 9 * * *`.
- **Change what "down" means.** By default the agent treats any non-2xx HTTP response as down. If you want to check for specific strings in the body (e.g. "Maintenance"), tell the agent in `AGENTS.md` and it will adapt.
- **Add alerting.** Set a `deliver` target on the cron job (Discord, Slack, Telegram) — the agent will post the run summary there instead of just writing to `status-log.md`.
## Uninstalling
Templates don't auto-uninstall in Scarf 2.2. To remove this one by hand:
1. Delete this project directory (removes the dashboard, AGENTS.md, sites.txt, status-log.md).
2. Remove the project entry from the Scarf sidebar (click the `` next to the project name).
3. Delete the `[tmpl:awizemann/site-status-checker] Check site status` cron job from the Cron sidebar.
No memory appendix or skills were installed, so nothing else needs cleanup.
@@ -0,0 +1,7 @@
[
{
"name": "Check site status",
"schedule": "0 9 * * *",
"prompt": "Run the site status check for this project. Follow the instructions in AGENTS.md: read sites.txt, HTTP GET each URL, prepend a results section to status-log.md, and update the three stat widgets plus the Watched Sites list items in .scarf/dashboard.json. When done, reply with a one-line summary like '3 up, 1 down — example.com timed out'."
}
]
@@ -0,0 +1,64 @@
{
"version": 1,
"title": "Site Status",
"description": "Daily uptime check for your watched URLs. The stat widgets and list update automatically when the cron job runs.",
"theme": { "accent": "green" },
"sections": [
{
"title": "Current Status",
"columns": 3,
"widgets": [
{
"type": "stat",
"title": "Sites Up",
"value": 0,
"icon": "checkmark.circle.fill",
"color": "green",
"subtitle": "responded 2xx/3xx"
},
{
"type": "stat",
"title": "Sites Down",
"value": 0,
"icon": "xmark.circle.fill",
"color": "red",
"subtitle": "non-2xx, timeout, DNS"
},
{
"type": "stat",
"title": "Last Checked",
"value": "never",
"icon": "clock",
"color": "blue",
"subtitle": "ISO-8601 timestamp"
}
]
},
{
"title": "Watched Sites",
"columns": 1,
"widgets": [
{
"type": "list",
"title": "Configured Sites (from sites.txt)",
"items": [
{ "text": "https://example.com", "status": "unknown" },
{ "text": "https://example.org", "status": "unknown" }
]
}
]
},
{
"title": "How to Use",
"columns": 1,
"widgets": [
{
"type": "text",
"title": "Quick Start",
"format": "markdown",
"content": "**1.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**2.** Edit `sites.txt` in this project's folder to replace the placeholder URLs with the sites you actually want to watch.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The dashboard refreshes and a new entry appears at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec."
}
]
}
]
}
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "awizemann/site-status-checker",
"name": "Site Status Checker",
"version": "1.0.0",
"minScarfVersion": "2.2.0",
"minHermesVersion": "0.9.0",
"author": {
"name": "Alan Wizemann",
"url": "https://github.com/awizemann/scarf"
},
"description": "A daily uptime check for a short list of URLs. Writes status to status-log.md and updates the dashboard with current counts.",
"category": "monitoring",
"tags": ["monitoring", "uptime", "cron", "starter"],
"contents": {
"dashboard": true,
"agentsMd": true,
"cron": 1
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"generated": true,
"schemaVersion": 1,
"templates": [
{
"author": {
"name": "Alan Wizemann",
"url": "https://github.com/awizemann/scarf"
},
"bundleSha256": "32b8c12706de8596be63dcdda32d46fc5bf478d5b9f7c1fc4c6d96ced251186a",
"bundleSize": 5410,
"category": "monitoring",
"contents": {
"agentsMd": true,
"cron": 1,
"dashboard": true
},
"description": "A daily uptime check for a short list of URLs. Writes status to status-log.md and updates the dashboard with current counts.",
"detailSlug": "awizemann-site-status-checker",
"id": "awizemann/site-status-checker",
"installUrl": "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/site-status-checker/site-status-checker.scarftemplate",
"minHermesVersion": "0.9.0",
"minScarfVersion": "2.2.0",
"name": "Site Status Checker",
"tags": [
"monitoring",
"uptime",
"cron",
"starter"
],
"version": "1.0.0"
}
]
}
+651
View File
@@ -0,0 +1,651 @@
#!/usr/bin/env python3
"""Scarf template catalog builder + validator.
Walks every `templates/<author>/<name>/` in this repo, validates the
`.scarftemplate` bundle against its manifest claim (same invariants the
Swift `ProjectTemplateService.verifyClaims` enforces at install time), and
produces:
templates/catalog.json aggregate index for the site
.gh-pages-worktree/templates/... per-template HTML + dashboard.json
(only produced by --build / --publish)
This is stdlib-only Python so it runs in a GitHub Action with zero
dependencies and in under a second even when the catalog has thousands of
templates. Schema drift between this validator and the Swift installer
breaks one of two contracts add a failing test in both places when you
change anything here.
Usage:
tools/build-catalog.py --check validate; no output written
tools/build-catalog.py --build validate + write catalog.json + site
tools/build-catalog.py --preview DIR render a self-contained preview
site into DIR (for local viewing)
Exit codes:
0 success
1 validation failure (one or more templates rejected)
2 IO / usage error
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
# ---------------------------------------------------------------------------
# Schema + invariants
# ---------------------------------------------------------------------------
SCHEMA_VERSION = 1
MAX_BUNDLE_BYTES = 5 * 1024 * 1024 # 5 MB cap on submissions; installer is 50 MB
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
# Common secret patterns — keep in sync with `scripts/wiki.sh` and reuse a
# conservative subset. The validator rejects hard matches; the site's
# CONTRIBUTING guide covers the rest.
SECRET_PATTERNS = [
(re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), "private key block"),
(re.compile(r"(?i)\bgh[pousr]_[A-Za-z0-9]{36,}"), "github personal access token"),
(re.compile(r"(?i)\bxox[abpso]-[A-Za-z0-9-]{10,}"), "slack token"),
(re.compile(r"(?i)\bAKIA[0-9A-Z]{16}"), "aws access key id"),
(re.compile(r"(?i)\bsk-[A-Za-z0-9]{32,}"), "openai/anthropic api key"),
]
REPO_ROOT = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class ValidationError:
template_path: Path
message: str
def __str__(self) -> str:
# Render a repo-relative path when possible for concise CLI output;
# fall back to the absolute path when the template lives outside
# the repo tree (unit tests use temp dirs).
try:
rel: Path | str = self.template_path.relative_to(REPO_ROOT)
except ValueError:
rel = self.template_path
return f"{rel}: {self.message}"
@dataclass
class TemplateRecord:
"""One entry in the generated catalog.json. Mirrors the Swift
ProjectTemplateManifest but with a few derived fields added."""
path: Path
manifest: dict
bundle_path: Path
bundle_sha256: str
bundle_size: int
install_url: str
detail_slug: str
def to_catalog_entry(self) -> dict:
"""Subset suitable for catalog.json. Keep fields stable — the
site's widgets.js reads this shape."""
m = self.manifest
return {
"id": m["id"],
"name": m["name"],
"version": m["version"],
"description": m["description"],
"author": m.get("author"),
"category": m.get("category"),
"tags": m.get("tags") or [],
"contents": m["contents"],
"installUrl": self.install_url,
"detailSlug": self.detail_slug,
"bundleSha256": self.bundle_sha256,
"bundleSize": self.bundle_size,
"minScarfVersion": m.get("minScarfVersion"),
"minHermesVersion": m.get("minHermesVersion"),
}
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def manifest_slug(manifest_id: str) -> str:
"""Mirror of Swift `ProjectTemplateManifest.slug`. Non-alphanumeric
runs collapse to single hyphens; empty collapses to 'template'."""
cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", manifest_id).strip("-")
return cleaned or "template"
def _iter_templates(repo_root: Path) -> Iterable[Path]:
"""Yield every `templates/<author>/<name>/` directory (those that hold
a `template.json` or a built `.scarftemplate`). Authors whose dirs
only hold a README are silently skipped."""
root = repo_root / "templates"
if not root.is_dir():
return
for author_dir in sorted(root.iterdir()):
if not author_dir.is_dir() or author_dir.name.startswith("."):
continue
for template_dir in sorted(author_dir.iterdir()):
if not template_dir.is_dir():
continue
if (template_dir / "staging").is_dir():
yield template_dir
def _validate_manifest(manifest: dict, template_dir: Path, errors: list[ValidationError]) -> None:
required = ["schemaVersion", "id", "name", "version", "description", "contents"]
for field in required:
if field not in manifest:
errors.append(ValidationError(template_dir, f"manifest missing required field: {field}"))
if manifest.get("schemaVersion") != SCHEMA_VERSION:
errors.append(ValidationError(template_dir, f"unsupported schemaVersion: {manifest.get('schemaVersion')}"))
# Manifest id must match the directory layout.
mid = manifest.get("id", "")
if "/" not in mid:
errors.append(ValidationError(template_dir, f"manifest id must be owner/name, got {mid!r}"))
else:
expected_author = template_dir.parent.name
author_part, _, _ = mid.partition("/")
if author_part != expected_author:
errors.append(ValidationError(
template_dir,
f"manifest id {mid!r} author component does not match directory "
f"({expected_author!r})"
))
def _validate_contents_claim(
manifest: dict,
bundle_files: set[str],
cron_job_count: int,
template_dir: Path,
errors: list[ValidationError],
) -> None:
"""Mirrors Swift `ProjectTemplateService.verifyClaims`. Rejects any
mismatch between what the manifest says and what's actually in the
bundle so the catalog site can't misrepresent a template."""
contents = manifest.get("contents", {})
for required in REQUIRED_BUNDLE_FILES:
if required not in bundle_files:
errors.append(ValidationError(template_dir, f"bundle missing required file: {required}"))
# Optional instructions/ dir — claim must match presence exactly.
claimed_instructions = contents.get("instructions") or []
claimed_full = {f"instructions/{p}" for p in claimed_instructions}
present_instructions = {f for f in bundle_files if f.startswith("instructions/")}
for claim in claimed_full:
if claim not in bundle_files:
errors.append(ValidationError(template_dir, f"contents.instructions claims {claim} but file is missing"))
for present in present_instructions - claimed_full:
errors.append(ValidationError(
template_dir,
f"bundle has {present} but it's not listed in contents.instructions"
))
# Skills — each claimed skill name must exist as a subdir with at least
# one file; extra skill dirs not listed are rejected.
claimed_skills = set(contents.get("skills") or [])
present_skills = set()
for f in bundle_files:
if f.startswith("skills/"):
rest = f[len("skills/"):]
if "/" in rest:
present_skills.add(rest.split("/", 1)[0])
for skill in claimed_skills:
if not any(f.startswith(f"skills/{skill}/") for f in bundle_files):
errors.append(ValidationError(template_dir, f"contents.skills claims {skill!r} but skills/{skill}/ is empty"))
for extra in present_skills - claimed_skills:
errors.append(ValidationError(template_dir, f"bundle has skills/{extra}/ not listed in contents.skills"))
# Cron — numeric count must match bundle.
claimed_cron = int(contents.get("cron") or 0)
if claimed_cron != cron_job_count:
errors.append(ValidationError(
template_dir,
f"contents.cron={claimed_cron} but bundle contains {cron_job_count} cron jobs"
))
# Memory appendix — claim must match file presence.
claimed_memory = bool((contents.get("memory") or {}).get("append"))
has_memory_file = "memory/append.md" in bundle_files
if claimed_memory != has_memory_file:
errors.append(ValidationError(
template_dir,
f"contents.memory.append={claimed_memory} disagrees with memory/append.md presence={has_memory_file}"
))
def _validate_dashboard(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
"""Decode dashboard.json against the widget-type vocabulary the Swift
renderer knows. An unknown widget type means the app will render an
'unknown widget' placeholder that's a bad catalog experience."""
try:
dashboard = json.loads(zf.read("dashboard.json"))
except Exception as e:
errors.append(ValidationError(template_dir, f"dashboard.json failed to parse: {e}"))
return
if dashboard.get("version") != 1:
errors.append(ValidationError(template_dir, f"dashboard.version must be 1, got {dashboard.get('version')}"))
sections = dashboard.get("sections") or []
if not isinstance(sections, list):
errors.append(ValidationError(template_dir, "dashboard.sections must be a list"))
return
for section in sections:
for widget in section.get("widgets") or []:
widget_type = widget.get("type")
if widget_type not in SUPPORTED_WIDGET_TYPES:
errors.append(ValidationError(
template_dir,
f"dashboard widget {widget.get('title')!r} has unknown type {widget_type!r}"
))
def _scan_for_secrets(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
"""Refuse bundles containing obvious secret patterns. Conservative —
matches only high-confidence substrings (no keyword-only warnings)."""
for info in zf.infolist():
if info.is_dir() or info.file_size > 256 * 1024:
continue # skip big binaries
try:
data = zf.read(info.filename).decode("utf-8", errors="replace")
except Exception:
continue
for pattern, label in SECRET_PATTERNS:
if pattern.search(data):
errors.append(ValidationError(
template_dir,
f"bundle file {info.filename} matches {label} pattern — refusing"
))
break
def _parse_cron_jobs(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> int:
"""Parse cron/jobs.json if present; return the job count. Logs a
validation error on a malformed file."""
if "cron/jobs.json" not in set(zf.namelist()):
return 0
try:
data = json.loads(zf.read("cron/jobs.json"))
except Exception as e:
errors.append(ValidationError(template_dir, f"cron/jobs.json failed to parse: {e}"))
return 0
if not isinstance(data, list):
errors.append(ValidationError(template_dir, "cron/jobs.json must be a JSON array"))
return 0
for i, job in enumerate(data):
if not isinstance(job, dict):
errors.append(ValidationError(template_dir, f"cron/jobs.json[{i}] must be an object"))
continue
if "name" not in job or "schedule" not in job:
errors.append(ValidationError(
template_dir,
f"cron/jobs.json[{i}] missing required field (name, schedule)"
))
return len(data)
def _bundle_files(zf: zipfile.ZipFile) -> set[str]:
"""Unique regular-file paths in the bundle, excluding dir entries and
macOS __MACOSX/ metadata."""
return {
info.filename
for info in zf.infolist()
if not info.is_dir() and not info.filename.startswith("__MACOSX/")
}
def validate_template(template_dir: Path) -> tuple[TemplateRecord | None, list[ValidationError]]:
"""Validate one template dir and return a (record, errors) pair.
record is None when errors are fatal enough that we can't build a
catalog entry at all."""
errors: list[ValidationError] = []
# Find the bundle. By convention it's `<dir>/<dir-basename>.scarftemplate`
# or any single .scarftemplate in the dir.
bundles = sorted(template_dir.glob("*.scarftemplate"))
if not bundles:
errors.append(ValidationError(template_dir, "no .scarftemplate found in template directory"))
return None, errors
if len(bundles) > 1:
errors.append(ValidationError(
template_dir,
f"more than one .scarftemplate present: {[b.name for b in bundles]}"
))
bundle_path = bundles[0]
bundle_size = bundle_path.stat().st_size
if bundle_size > MAX_BUNDLE_BYTES:
errors.append(ValidationError(
template_dir,
f"bundle size {bundle_size} exceeds catalog cap of {MAX_BUNDLE_BYTES} bytes"
))
try:
with zipfile.ZipFile(bundle_path, "r") as zf:
bundle_files = _bundle_files(zf)
if "template.json" not in bundle_files:
errors.append(ValidationError(template_dir, "bundle is missing template.json"))
return None, errors
try:
manifest = json.loads(zf.read("template.json"))
except Exception as e:
errors.append(ValidationError(template_dir, f"template.json failed to parse: {e}"))
return None, errors
_validate_manifest(manifest, template_dir, errors)
cron_count = _parse_cron_jobs(zf, template_dir, errors)
_validate_contents_claim(manifest, bundle_files, cron_count, template_dir, errors)
_validate_dashboard(zf, template_dir, errors)
_scan_for_secrets(zf, template_dir, errors)
except zipfile.BadZipFile:
errors.append(ValidationError(template_dir, "bundle is not a valid zip archive"))
return None, errors
# Compute the catalog-ready record.
sha = hashlib.sha256(bundle_path.read_bytes()).hexdigest()
author = template_dir.parent.name
short_name = template_dir.name
install_url = (
"https://raw.githubusercontent.com/awizemann/scarf/main/"
f"templates/{author}/{short_name}/{bundle_path.name}"
)
detail_slug = manifest_slug(manifest.get("id", f"{author}/{short_name}"))
record = TemplateRecord(
path=template_dir,
manifest=manifest,
bundle_path=bundle_path,
bundle_sha256=sha,
bundle_size=bundle_size,
install_url=install_url,
detail_slug=detail_slug,
)
return record, errors
# ---------------------------------------------------------------------------
# Staging/bundle drift check — keeps authors honest
# ---------------------------------------------------------------------------
def _check_staging_matches_bundle(record: TemplateRecord) -> list[ValidationError]:
"""If the template dir has a staging/ source tree, rebuild the bundle
in memory and diff against the committed one. Catches the common
failure mode of an author editing staging/ but forgetting to
regenerate the .scarftemplate."""
errors: list[ValidationError] = []
staging = record.path / "staging"
if not staging.is_dir():
return errors
committed = {}
with zipfile.ZipFile(record.bundle_path, "r") as zf:
for info in zf.infolist():
if info.is_dir() or info.filename.startswith("__MACOSX/"):
continue
committed[info.filename] = zf.read(info.filename)
source = {}
for path in staging.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(staging).as_posix()
if rel.startswith(".") or "/.DS_Store" in rel or rel.endswith("/.DS_Store") or rel == ".DS_Store":
continue
source[rel] = path.read_bytes()
missing_in_bundle = sorted(set(source) - set(committed))
if missing_in_bundle:
errors.append(ValidationError(
record.path,
f"staging has files not in the built bundle: {missing_in_bundle} "
"(rebuild with `zip -qq -r <name>.scarftemplate .` from staging/)"
))
missing_in_source = sorted(set(committed) - set(source))
if missing_in_source:
errors.append(ValidationError(
record.path,
f"bundle has files not in staging/: {missing_in_source} "
"(commit them to staging/ or rebuild the bundle from staging/)"
))
diff = [name for name, data in source.items() if name in committed and committed[name] != data]
if diff:
errors.append(ValidationError(
record.path,
f"staging content differs from built bundle: {diff} "
"(rebuild the bundle from staging/)"
))
return errors
# ---------------------------------------------------------------------------
# Build: write catalog.json (site rendering comes in a later commit)
# ---------------------------------------------------------------------------
def write_catalog_json(records: list[TemplateRecord], out_path: Path) -> None:
catalog = {
"schemaVersion": SCHEMA_VERSION,
"generated": True, # human reminder; a timestamp would churn the diff every run
"templates": [r.to_catalog_entry() for r in records],
}
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--check", action="store_true", help="validate every template; don't write output")
group.add_argument("--build", action="store_true", help="validate + write catalog.json")
group.add_argument("--preview", metavar="DIR", help="render a self-contained site preview into DIR")
parser.add_argument("--only", metavar="PATH", action="append", default=[],
help="validate only the given template dir (may repeat); useful for PR-diff runs")
parser.add_argument("--repo", metavar="PATH", default=str(REPO_ROOT),
help="repo root to operate on (default: auto-detect)")
args = parser.parse_args(argv)
repo_root = Path(args.repo).resolve()
template_dirs = list(_iter_templates(repo_root))
if args.only:
only = {Path(p).resolve() for p in args.only}
template_dirs = [t for t in template_dirs if t.resolve() in only]
if not template_dirs:
if args.only:
print(f"no templates matched --only filter", file=sys.stderr)
return 2
print("no templates found under templates/ — nothing to do", file=sys.stderr)
return 0
records: list[TemplateRecord] = []
all_errors: list[ValidationError] = []
for tdir in template_dirs:
record, errors = validate_template(tdir)
all_errors.extend(errors)
if record is not None:
all_errors.extend(_check_staging_matches_bundle(record))
records.append(record)
if all_errors:
print(f"{len(all_errors)} validation error(s):", file=sys.stderr)
for err in all_errors:
print(f" {err}", file=sys.stderr)
return 1
print(f"{len(records)} template(s) validated", file=sys.stderr)
for r in records:
rel = r.path.relative_to(repo_root)
print(f" {rel}{r.manifest['id']} v{r.manifest['version']}")
if args.check:
return 0
catalog_path = repo_root / "templates" / "catalog.json"
write_catalog_json(records, catalog_path)
print(f"wrote {catalog_path.relative_to(repo_root)}", file=sys.stderr)
if args.preview:
preview_dir = Path(args.preview).resolve()
render_site(records, preview_dir, repo_root)
print(f"preview site rendered to {preview_dir}", file=sys.stderr)
if args.build:
# --build renders into .gh-pages-worktree/templates/ so the
# maintainer's publish step just has to commit + push gh-pages.
gh_pages = repo_root / ".gh-pages-worktree" / "templates"
render_site(records, gh_pages, repo_root)
print(f"site rendered to {gh_pages.relative_to(repo_root)}", file=sys.stderr)
return 0
def render_site(records: list[TemplateRecord], out_dir: Path, repo_root: Path) -> None:
"""Render the catalog site. Defined here as a stub so --build and
--preview both have a landing spot; the real HTML templates ship in
the next commit (Phase 3)."""
site_src = repo_root / "site"
if not site_src.is_dir():
# Phase 2: no site/ yet. Write just catalog.json into out_dir so
# the preview mode is still demonstrable (and --build stays
# idempotent).
out_dir.mkdir(parents=True, exist_ok=True)
write_catalog_json(records, out_dir / "catalog.json")
return
out_dir.mkdir(parents=True, exist_ok=True)
index_tmpl = (site_src / "index.html.tmpl").read_text(encoding="utf-8")
template_tmpl = (site_src / "template.html.tmpl").read_text(encoding="utf-8")
# Copy static site assets (widgets.js, styles.css, assets/).
for name in ("widgets.js", "styles.css"):
src = site_src / name
if src.exists():
shutil.copy2(src, out_dir / name)
assets_src = site_src / "assets"
if assets_src.is_dir():
assets_dst = out_dir / "assets"
if assets_dst.exists():
shutil.rmtree(assets_dst)
shutil.copytree(assets_src, assets_dst)
# Catalog index
(out_dir / "index.html").write_text(
render_index(index_tmpl, records),
encoding="utf-8",
)
# Per-template detail pages + dashboard.json copies
for r in records:
detail_dir = out_dir / r.detail_slug
detail_dir.mkdir(parents=True, exist_ok=True)
(detail_dir / "index.html").write_text(
render_detail(template_tmpl, r),
encoding="utf-8",
)
# Copy the unpacked dashboard.json so widgets.js can fetch it
# without cross-directory relative paths.
with zipfile.ZipFile(r.bundle_path, "r") as zf:
(detail_dir / "dashboard.json").write_bytes(zf.read("dashboard.json"))
if "README.md" in zf.namelist():
(detail_dir / "README.md").write_bytes(zf.read("README.md"))
# The aggregate catalog.json is copied in so the frontend can fetch
# /templates/catalog.json without reaching back into the repo.
write_catalog_json(records, out_dir / "catalog.json")
def render_index(tmpl: str, records: list[TemplateRecord]) -> str:
"""Very light string substitution — the site's JS does most of the
rendering from catalog.json at page load."""
cards = []
for r in records:
m = r.manifest
author = (m.get("author") or {}).get("name", "")
tags_html = "".join(f'<span class="tag">{t}</span>' for t in (m.get("tags") or []))
cards.append(
'<a class="card" href="{slug}/">'
'<h3>{name}</h3>'
'<p class="desc">{desc}</p>'
'<div class="meta"><span class="author">{author}</span>'
'<span class="version">v{version}</span></div>'
'<div class="tags">{tags}</div>'
'</a>'.format(
slug=_html_escape(r.detail_slug),
name=_html_escape(m["name"]),
desc=_html_escape(m["description"]),
author=_html_escape(author),
version=_html_escape(m["version"]),
tags=tags_html,
)
)
count = len(records)
return (
tmpl.replace("{{CARDS}}", "\n".join(cards))
.replace("{{COUNT}}", str(count))
.replace("{{COUNT_PLURAL}}", "" if count == 1 else "s")
)
def render_detail(tmpl: str, record: TemplateRecord) -> str:
m = record.manifest
author = m.get("author") or {}
author_html = _html_escape(author.get("name", ""))
author_url = author.get("url") or ""
if author_url:
author_html = f'<a href="{_html_escape(author_url)}">{author_html}</a>'
tags_html = "".join(f'<span class="tag">{_html_escape(t)}</span>' for t in (m.get("tags") or []))
install_url = record.install_url
tokens = {
"ID": m["id"],
"NAME": m["name"],
"VERSION": m["version"],
"DESC": m["description"],
"AUTHOR_HTML": author_html,
"CATEGORY": m.get("category") or "",
"TAGS_HTML": tags_html,
"INSTALL_URL_ENCODED": install_url,
"SCARF_INSTALL_URL": f"scarf://install?url={install_url}",
}
out = tmpl
for k, v in tokens.items():
out = out.replace("{{" + k + "}}", _html_escape(v) if k != "TAGS_HTML" and k != "AUTHOR_HTML" else v)
return out
def _html_escape(s: str) -> str:
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;")
)
if __name__ == "__main__":
sys.exit(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
Merge per-locale translation JSON files into Localizable.xcstrings.
Each JSON under tools/translations/<locale>.json is a flat
{ "English source key": "Translation" } map. Keys absent from the JSON
fall through to English at runtime that's the desired behavior for
proper nouns, format-only strings, and technical terminology.
Usage:
python3 tools/merge-translations.py
Re-runnable: rewrites the per-locale stringUnit entries each time, so
translators can iterate on a JSON and re-merge.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CATALOG = REPO_ROOT / "scarf" / "scarf" / "Localizable.xcstrings"
TRANSLATIONS_DIR = REPO_ROOT / "tools" / "translations"
LOCALES = ["zh-Hans", "de", "fr", "es", "ja", "pt-BR"]
def load_json(path: Path) -> dict:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def save_json(path: Path, data: dict) -> None:
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
def main() -> int:
catalog = load_json(CATALOG)
source_keys = set(catalog.get("strings", {}).keys())
applied: dict[str, int] = {}
skipped_unknown: dict[str, list[str]] = {}
for locale in LOCALES:
path = TRANSLATIONS_DIR / f"{locale}.json"
if not path.exists():
print(f"[skip] {locale}: no file at {path}")
continue
translations = load_json(path)
applied[locale] = 0
skipped_unknown[locale] = []
for source, target in translations.items():
if source not in source_keys:
skipped_unknown[locale].append(source)
continue
entry = catalog["strings"].setdefault(source, {})
entry.setdefault("localizations", {})
entry["localizations"][locale] = {
"stringUnit": {
"state": "translated",
"value": target,
}
}
applied[locale] += 1
save_json(CATALOG, catalog)
# Summary
print("Merge summary:")
for locale in LOCALES:
if locale in applied:
extras = len(skipped_unknown.get(locale, []))
print(f" {locale:8} applied={applied[locale]:4} unknown-keys-skipped={extras}")
any_unknown = any(skipped_unknown.values())
if any_unknown:
print("\nKeys present in translation files but missing from the catalog:")
for locale, unknowns in skipped_unknown.items():
for k in unknowns:
print(f" [{locale}] {k!r}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+454
View File
@@ -0,0 +1,454 @@
"""Unit tests for tools/build-catalog.py.
Run with: python3 -m unittest tools.test_build_catalog
Or just: python3 tools/test_build_catalog.py
Covers the validator's invariants against synthetic template directories
created under a temp dir no network, no global state, no dependency on
the repo's actual templates/. A separate test at the bottom exercises the
real shipped `templates/awizemann/site-status-checker` bundle to catch
drift between validator + installer.
"""
from __future__ import annotations
import importlib.util
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
# Import tools/build-catalog.py via spec-loader (the dash in the filename
# would otherwise make a plain `import` ugly). Register the module in
# sys.modules BEFORE exec — Python 3.9's dataclass inspection reads
# `sys.modules[cls.__module__].__dict__` and blows up if the module isn't
# there yet (fixed in 3.10+, still matters on system-Python Macs).
_SPEC_PATH = Path(__file__).resolve().parent / "build-catalog.py"
_spec = importlib.util.spec_from_file_location("build_catalog", _SPEC_PATH)
build_catalog = importlib.util.module_from_spec(_spec)
sys.modules["build_catalog"] = build_catalog
_spec.loader.exec_module(build_catalog)
# ---------------------------------------------------------------------------
# Fixture builders
# ---------------------------------------------------------------------------
MINIMAL_DASHBOARD = {
"version": 1,
"title": "Test",
"description": "test",
"sections": [
{
"title": "Current Status",
"columns": 3,
"widgets": [
{"type": "stat", "title": "Sites Up", "value": 0},
],
},
],
}
def make_fake_repo(tmp_root: Path) -> Path:
"""Create a repo layout: <tmp>/templates/ and (optionally) fake
site/ dirs on demand. Returns the repo root."""
(tmp_root / "templates").mkdir(parents=True)
return tmp_root
def make_template_dir(
repo: Path,
author: str,
name: str,
manifest: dict | None = None,
bundle_files: dict[str, bytes] | None = None,
include_staging: bool = True,
bundle_name: str | None = None,
) -> Path:
"""Create a template dir under <repo>/templates/<author>/<name>/
with a built bundle and (optionally) a staging dir whose contents
match the bundle byte-for-byte. Returns the template dir."""
template_dir = repo / "templates" / author / name
(template_dir / "staging").mkdir(parents=True, exist_ok=True)
manifest = manifest or {
"schemaVersion": 1,
"id": f"{author}/{name}",
"name": name.replace("-", " ").title(),
"version": "1.0.0",
"description": "test description",
"contents": {
"dashboard": True,
"agentsMd": True,
},
}
files = bundle_files or {
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme\n",
"AGENTS.md": b"# agents\n",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
}
# Write staging/ source tree so the drift check passes by default.
if include_staging:
for path, data in files.items():
full = template_dir / "staging" / path
full.parent.mkdir(parents=True, exist_ok=True)
full.write_bytes(data)
# Write the zipped bundle.
bundle_name = bundle_name or f"{name}.scarftemplate"
with zipfile.ZipFile(template_dir / bundle_name, "w", zipfile.ZIP_DEFLATED) as zf:
for path, data in files.items():
zf.writestr(path, data)
return template_dir
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class ManifestSlugTests(unittest.TestCase):
"""Mirrors the Swift test of the same name so the two
implementations stay in sync."""
def test_sanitizes_punctuation(self):
self.assertEqual(build_catalog.manifest_slug("alan@w/focus dashboard!"), "alan-w-focus-dashboard")
def test_falls_back_to_placeholder(self):
self.assertEqual(build_catalog.manifest_slug("////"), "template")
def test_preserves_letters_numbers_dash_underscore(self):
self.assertEqual(build_catalog.manifest_slug("user_1/name-2"), "user_1-name-2")
class ValidationTests(unittest.TestCase):
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.repo = make_fake_repo(Path(self._dir.name))
self.addCleanup(self._dir.cleanup)
def test_accepts_minimal_valid_template(self):
make_template_dir(self.repo, "tester", "minimal")
records, errors = self._validate_all()
self.assertEqual(errors, [])
self.assertEqual(len(records), 1)
self.assertEqual(records[0].manifest["id"], "tester/minimal")
def test_rejects_missing_agents_md(self):
# Build a bundle that lacks AGENTS.md.
manifest = {
"schemaVersion": 1,
"id": "tester/bad",
"name": "Bad",
"version": "1.0.0",
"description": "missing AGENTS.md",
"contents": {"dashboard": True, "agentsMd": True},
}
make_template_dir(
self.repo, "tester", "bad",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
_, errors = self._validate_all()
self.assertTrue(any("AGENTS.md" in str(e) for e in errors), errors)
def test_rejects_content_claim_mismatch(self):
# Manifest claims cron: 2, bundle ships zero cron jobs.
manifest = {
"schemaVersion": 1,
"id": "tester/claims",
"name": "Claims",
"version": "1.0.0",
"description": "claim mismatch",
"contents": {"dashboard": True, "agentsMd": True, "cron": 2},
}
make_template_dir(
self.repo, "tester", "claims",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
_, errors = self._validate_all()
self.assertTrue(any("contents.cron=2" in str(e) for e in errors), errors)
def test_rejects_manifest_author_mismatch(self):
# Template lives under /tester/ but manifest id says /other/.
manifest = {
"schemaVersion": 1,
"id": "other/name",
"name": "Mismatch",
"version": "1.0.0",
"description": "author mismatch",
"contents": {"dashboard": True, "agentsMd": True},
}
make_template_dir(
self.repo, "tester", "name",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
_, errors = self._validate_all()
self.assertTrue(any("author component" in str(e) for e in errors), errors)
def test_rejects_oversized_bundle(self):
# Synthetic bundle > 5MB cap.
template_dir = self.repo / "templates" / "tester" / "huge"
(template_dir / "staging").mkdir(parents=True)
manifest = {
"schemaVersion": 1,
"id": "tester/huge",
"name": "Huge",
"version": "1.0.0",
"description": "oversized",
"contents": {"dashboard": True, "agentsMd": True},
}
payload = b"x" * (6 * 1024 * 1024)
files = {
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
"ballast.bin": payload,
}
with zipfile.ZipFile(template_dir / "huge.scarftemplate", "w", zipfile.ZIP_STORED) as zf:
for p, data in files.items():
zf.writestr(p, data)
_, errors = self._validate_all()
self.assertTrue(any("exceeds catalog cap" in str(e) for e in errors), errors)
def test_rejects_unknown_widget_type(self):
bad_dashboard = {
"version": 1,
"title": "Bad",
"sections": [{"title": "x", "columns": 1, "widgets": [{"type": "hologram", "title": "huh"}]}],
}
manifest = {
"schemaVersion": 1,
"id": "tester/weird",
"name": "Weird",
"version": "1.0.0",
"description": "unknown widget",
"contents": {"dashboard": True, "agentsMd": True},
}
make_template_dir(
self.repo, "tester", "weird",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
},
)
_, errors = self._validate_all()
self.assertTrue(any("unknown type" in str(e) for e in errors), errors)
def test_rejects_secret_in_bundle(self):
leaky = b"config:\n github_token: ghp_" + b"A" * 40 + b"\n"
manifest = {
"schemaVersion": 1,
"id": "tester/leaky",
"name": "Leaky",
"version": "1.0.0",
"description": "has a secret",
"contents": {"dashboard": True, "agentsMd": True},
}
make_template_dir(
self.repo, "tester", "leaky",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": leaky,
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
_, errors = self._validate_all()
self.assertTrue(any("github" in str(e).lower() for e in errors), errors)
def test_detects_staging_vs_bundle_drift(self):
# Bundle ships an old README; staging/ has an edited one — should fail.
manifest = {
"schemaVersion": 1,
"id": "tester/drift",
"name": "Drift",
"version": "1.0.0",
"description": "staging ahead of bundle",
"contents": {"dashboard": True, "agentsMd": True},
}
template_dir = make_template_dir(
self.repo, "tester", "drift",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# old",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
# Edit staging/ AFTER building the bundle.
(template_dir / "staging" / "README.md").write_bytes(b"# new")
_, errors = self._validate_all()
self.assertTrue(any("differs from built bundle" in str(e) for e in errors), errors)
def test_rejects_missing_bundle(self):
template_dir = self.repo / "templates" / "tester" / "bare"
(template_dir / "staging").mkdir(parents=True)
# No .scarftemplate in the dir.
_, errors = self._validate_all()
self.assertTrue(any("no .scarftemplate found" in str(e) for e in errors), errors)
# --- helpers --------------------------------------------------------
def _validate_all(self) -> tuple[list, list]:
records = []
errors = []
for tdir in build_catalog._iter_templates(self.repo):
record, errs = build_catalog.validate_template(tdir)
errors.extend(errs)
if record is not None:
errors.extend(build_catalog._check_staging_matches_bundle(record))
records.append(record)
return records, errors
class CatalogJsonTests(unittest.TestCase):
"""Shape of the emitted catalog.json must stay stable — the site's
widgets.js reads these fields by name."""
def test_catalog_json_shape(self):
with tempfile.TemporaryDirectory() as tmp:
repo = make_fake_repo(Path(tmp))
make_template_dir(repo, "tester", "shape")
records = []
for tdir in build_catalog._iter_templates(repo):
record, errors = build_catalog.validate_template(tdir)
self.assertEqual(errors, [])
records.append(record)
out = Path(tmp) / "catalog.json"
build_catalog.write_catalog_json(records, out)
data = json.loads(out.read_text())
self.assertEqual(data["schemaVersion"], 1)
self.assertEqual(len(data["templates"]), 1)
entry = data["templates"][0]
for required in ["id", "name", "version", "description", "contents",
"installUrl", "detailSlug", "bundleSha256", "bundleSize"]:
self.assertIn(required, entry)
self.assertTrue(entry["installUrl"].startswith("https://raw.githubusercontent.com/"))
self.assertEqual(entry["detailSlug"], "tester-shape")
class SiteRenderingTests(unittest.TestCase):
"""Verify the regenerator produces usable HTML + copies dashboard.json
+ README.md into each detail dir for widgets.js to fetch. No browser
automation just shape checks so we catch silly breakages
(missing tokens, stale templates, broken copy)."""
def test_render_site_end_to_end(self):
with tempfile.TemporaryDirectory() as tmp:
repo = make_fake_repo(Path(tmp))
# Build a couple templates so the grid has more than one card.
make_template_dir(repo, "alice", "alpha")
make_template_dir(repo, "bob", "beta")
# Give the fake repo a site/ dir so render_site produces HTML.
site_src = repo / "site"
site_src.mkdir()
(site_src / "index.html.tmpl").write_text(
"<h1>Catalog ({{COUNT}} template{{COUNT_PLURAL}})</h1>{{CARDS}}"
)
(site_src / "template.html.tmpl").write_text(
"<h1>{{NAME}}</h1><p>{{DESC}}</p>"
"<a href=\"{{SCARF_INSTALL_URL}}\">install</a>"
"<a href=\"{{INSTALL_URL_ENCODED}}\">download</a>"
)
(site_src / "widgets.js").write_text("/* test widgets */")
(site_src / "styles.css").write_text("/* test styles */")
records = []
for tdir in build_catalog._iter_templates(repo):
r, errors = build_catalog.validate_template(tdir)
self.assertEqual(errors, [])
records.append(r)
out = Path(tmp) / "out"
build_catalog.render_site(records, out, repo)
# Index: both cards present, plural form flipped for count=2.
idx = (out / "index.html").read_text()
self.assertIn("Catalog (2 templates)", idx)
self.assertIn("alice-alpha/", idx)
self.assertIn("bob-beta/", idx)
# Static assets copied.
self.assertTrue((out / "widgets.js").exists())
self.assertTrue((out / "styles.css").exists())
self.assertTrue((out / "catalog.json").exists())
# Each detail dir has index.html + dashboard.json + README.md.
alpha = out / "alice-alpha"
self.assertTrue((alpha / "index.html").exists())
self.assertTrue((alpha / "dashboard.json").exists())
self.assertTrue((alpha / "README.md").exists())
alpha_html = (alpha / "index.html").read_text()
# Install URL wires through the scarf:// scheme + raw GH URL.
self.assertIn("scarf://install?url=https://raw.githubusercontent.com/", alpha_html)
def test_render_index_singular_form_for_one_template(self):
with tempfile.TemporaryDirectory() as tmp:
repo = make_fake_repo(Path(tmp))
make_template_dir(repo, "alice", "alpha")
records = []
for tdir in build_catalog._iter_templates(repo):
r, _ = build_catalog.validate_template(tdir)
records.append(r)
html = build_catalog.render_index("{{COUNT}} template{{COUNT_PLURAL}}", records)
self.assertEqual(html, "1 template")
class RealBundleTest(unittest.TestCase):
"""Run the validator against the actual shipped Site Status Checker
bundle. Catches drift between validator + real-world author
conventions. Skipped if run outside the repo tree."""
def test_site_status_checker_passes(self):
repo_root = Path(__file__).resolve().parent.parent
template = repo_root / "templates" / "awizemann" / "site-status-checker"
if not template.exists():
self.skipTest("site-status-checker not present (running outside repo?)")
record, errors = build_catalog.validate_template(template)
self.assertIsNotNone(record)
drift = build_catalog._check_staging_matches_bundle(record)
self.assertEqual(errors + drift, [], f"errors: {errors}, drift: {drift}")
self.assertEqual(record.manifest["id"], "awizemann/site-status-checker")
if __name__ == "__main__":
unittest.main()
+585
View File
@@ -0,0 +1,585 @@
{
"%@ ctx": "%@ Kontext",
"%@ in / %@ out": "%1$@ ein / %2$@ aus",
"%@ reasoning": "%@ Reasoning",
"%@ tokens": "%@ Tokens",
"%@s · %lld tools": "%1$@ s · %2$lld Tools",
"%lld %@": "%1$lld %2$@",
"%lld chars": "%lld Zeichen",
"%lld delivery failure%@": "%1$lld Zustellfehler%2$@",
"%lld entries": "%lld Einträge",
"%lld files": "%lld Dateien",
"%lld messages": "%lld Nachrichten",
"%lld msgs": "%lld Nachr.",
"%lld of %lld enabled": "%1$lld von %2$lld aktiviert",
"%lld reasoning": "%lld Reasoning",
"%lld req": "%lld erforderlich",
"%lld required config": "%lld Pflichteinstellungen",
"%lld sessions": "%lld Sitzungen",
"%lld tokens": "%lld Tokens",
"%lld tools": "%lld Tools",
"30 Days": "30 Tage",
"7 Days": "7 Tage",
"90 Days": "90 Tage",
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts.": "Unten erscheint ein QR-Code. Scanne ihn mit WhatsApp auf deinem Telefon. Die Sitzung wird unter ~/.hermes/platforms/whatsapp/ gespeichert, damit nach Neustarts kein erneuter Scan nötig ist.",
"API Key": "API-Schlüssel",
"API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.": "API-Schlüssel werden nie vollständig angezeigt. Scarf zeigt nur die letzten 4 Zeichen zur Identifikation. Die vollständigen Werte speichert hermes in ~/.hermes/auth.json.",
"Access Control": "Zugriffskontrolle",
"Actions": "Aktionen",
"Active": "Aktiv",
"Active Personality": "Aktive Persönlichkeit",
"Active profile": "Aktives Profil",
"Activity": "Aktivität",
"Activity Patterns": "Aktivitätsmuster",
"Add": "Hinzufügen",
"Add Command": "Befehl hinzufügen",
"Add Credential": "Anmeldedaten hinzufügen",
"Add Custom": "Eigene hinzufügen",
"Add Custom MCP Server": "Eigenen MCP-Server hinzufügen",
"Add Project": "Projekt hinzufügen",
"Add Quick Command": "Schnellbefehl hinzufügen",
"Add Remote Server": "Remote-Server hinzufügen",
"Add Server": "Server hinzufügen",
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.": "Füge einen Projektordner hinzu, um zu beginnen. Lege in deinem Projekt eine .scarf/dashboard.json an, um Widgets zu definieren.",
"Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.": "Anmeldedaten in **Konfigurieren → Credential-Pools** hinzufügen, `ANTHROPIC_API_KEY` (oder ähnlich) in `~/.hermes/.env` setzen oder in deinem Shell-Profil exportieren, dann Scarf neu starten.",
"Add from Preset": "Aus Voreinstellung hinzufügen",
"Add rotation credentials so hermes can failover between keys when one hits rate limits.": "Rotations-Anmeldedaten hinzufügen, damit hermes zwischen Schlüsseln wechseln kann, wenn einer ein Rate-Limit erreicht.",
"Add your first command": "Füge deinen ersten Befehl hinzu",
"Advanced": "Erweitert",
"After approving in your browser, the provider shows a code. Paste it below and submit.": "Nach der Genehmigung im Browser zeigt der Anbieter einen Code. Füge ihn unten ein und sende ab.",
"Agent": "Agent",
"All": "Alle",
"All Levels": "Alle Ebenen",
"All Sessions": "Alle Sitzungen",
"All Time": "Gesamter Zeitraum",
"All installed hub skills are up to date.": "Alle installierten Hub-Skills sind aktuell.",
"App Credentials": "App-Anmeldedaten",
"Approval": "Genehmigung",
"Approvals": "Genehmigungen",
"Approve": "Genehmigen",
"Archive": "Archivieren",
"Args (one per line)": "Argumente (eines pro Zeile)",
"Arguments": "Argumente",
"Assistant Message": "Assistentennachricht",
"Auth": "Auth",
"Authentication": "Authentifizierung",
"Authentication uses ssh-agent": "Authentifizierung nutzt ssh-agent",
"Authorization Code": "Autorisierungscode",
"Authorization URL": "Autorisierungs-URL",
"Aux Models": "Hilfsmodelle",
"Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.": "Hilfsaufgaben nutzen separate, typischerweise günstigere Modelle. Provider auf `auto` lassen, um den Hauptanbieter zu übernehmen.",
"Back": "Zurück",
"Back to Catalog": "Zurück zum Katalog",
"Backend": "Backend",
"Backup & Restore": "Sicherung & Wiederherstellung",
"Backup Now": "Jetzt sichern",
"Becomes the key under mcp_servers: in config.yaml.": "Wird zum Schlüssel unter mcp_servers: in config.yaml.",
"Behavior": "Verhalten",
"Browse": "Durchsuchen",
"Browse Hub": "Hub durchsuchen",
"Browse the Hub": "Hub durchsuchen",
"Browse...": "Durchsuchen...",
"Browser": "Browser",
"Built-in Memory": "Integrierter Speicher",
"By Day": "Nach Tag",
"By Hour": "Nach Stunde",
"Call timeout": "Aufruf-Timeout",
"Can't read Hermes state on %@": "Hermes-Status auf %@ nicht lesbar",
"Cancel": "Abbrechen",
"Changes won't take effect until Hermes reloads the config.": "Änderungen werden erst wirksam, wenn Hermes die Konfiguration neu lädt.",
"Chat": "Chat",
"Chat Messages": "Chat-Nachrichten",
"Check": "Prüfen",
"Check Now": "Jetzt prüfen",
"Check for Updates": "Nach Updates suchen",
"Check for Updates…": "Nach Updates suchen…",
"Checking…": "Prüfe…",
"Checkpoints": "Checkpoints",
"Choose a cron job from the list": "Wähle einen Cron-Job aus der Liste",
"Choose a profile to inspect.": "Wähle ein Profil zur Ansicht.",
"Choose a project from the sidebar to view its dashboard.": "Wähle ein Projekt aus der Seitenleiste, um sein Dashboard zu sehen.",
"Choose a session from the list": "Wähle eine Sitzung aus der Liste",
"Choose a skill from the list": "Wähle einen Skill aus der Liste",
"Choose an entry from the list": "Wähle einen Eintrag aus der Liste",
"Choose…": "Auswählen…",
"Clear Token": "Token löschen",
"Clear all skills on save": "Alle Skills beim Speichern löschen",
"Click Add to connect to a remote Hermes installation over SSH.": "Klicke auf Hinzufügen, um dich per SSH mit einer entfernten Hermes-Installation zu verbinden.",
"Click for details": "Für Details klicken",
"Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.": "Ein Klick auf OAuth starten öffnet die Autorisierungsseite des Anbieters im Browser. Nach der Genehmigung kopierst du den angezeigten Code und fügst ihn in das erscheinende Terminal ein.",
"Clone config, .env, SOUL.md from active profile": "config, .env, SOUL.md aus aktivem Profil klonen",
"Close": "Schließen",
"Close Window": "Fenster schließen",
"Code: %@": "Code: %@",
"Command": "Befehl",
"Command Allowlist": "Befehls-Allowlist",
"Command looks destructive. Double-check before saving.": "Der Befehl wirkt destruktiv. Vor dem Speichern noch einmal prüfen.",
"Component": "Komponente",
"Compress": "Komprimieren",
"Compress Conversation": "Unterhaltung komprimieren",
"Compress conversation (/compress)": "Unterhaltung komprimieren (/compress)",
"Compression": "Komprimierung",
"Config Diagnostics": "Konfigurations-Diagnose",
"Configure": "Konfigurieren",
"Connect timeout": "Verbindungs-Timeout",
"Connected": "Verbunden",
"Connected — can't read Hermes state": "Verbunden — Hermes-Status nicht lesbar",
"Connection": "Verbindung",
"Container Limits": "Container-Limits",
"Context & Compression": "Kontext & Komprimierung",
"Continue Last Session": "Letzte Sitzung fortsetzen",
"Copied": "Kopiert",
"Copy": "Kopieren",
"Copy Full Report": "Vollständigen Bericht kopieren",
"Copy a plain-text summary of every check (passes and fails) — paste into GitHub issues so we can see everything at once.": "Kopiert eine Klartextzusammenfassung jeder Prüfung (bestanden und fehlgeschlagen) — in GitHub-Issues einfügen, damit wir alles auf einmal sehen.",
"Copy code": "Code kopieren",
"Copy error details": "Fehlerdetails kopieren",
"Create": "Erstellen",
"Create Profile": "Profil erstellen",
"Create Subscription": "Abonnement erstellen",
"Create a Slack app at api.slack.com/apps, enable Socket Mode, grant bot scopes (chat:write, app_mentions:read, channels:history, etc.), then copy the Bot User OAuth Token (xoxb-) and the App-Level Token (xapp-).": "Erstelle eine Slack-App auf api.slack.com/apps, aktiviere Socket Mode, vergib Bot-Scopes (chat:write, app_mentions:read, channels:history usw.) und kopiere dann das Bot User OAuth Token (xoxb-) sowie das App-Level Token (xapp-).",
"Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users.": "Erstelle einen Bot via @BotFather und hole dir deine numerische User-ID von @userinfobot. Füge Token und User-ID unten ein — der Bot antwortet nur erlaubten Nutzern.",
"Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.": "Erstelle ein Long-Lived Access Token in Home Assistant (Profil → Sicherheit → Long-Lived Access Tokens). Standardmäßig werden keine Ereignisse weitergeleitet — aktiviere Watch All Changes oder füge unten Entity-Filter hinzu.",
"Create a personal access token under Profile → Security → Personal Access Tokens, or create a bot account. Use the token as the MATTERMOST_TOKEN value.": "Erstelle ein persönliches Access Token unter Profil → Sicherheit → Personal Access Tokens oder ein Bot-Konto. Verwende das Token als MATTERMOST_TOKEN-Wert.",
"Create a profile to isolate config and skills.": "Erstelle ein Profil, um Konfiguration und Skills zu isolieren.",
"Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.": "Erstelle eine App im Discord Developer Portal, aktiviere die Intents Message Content und Server Members und kopiere das Bot-Token. Lade den Bot über den OAuth2-URL-Generator auf deinen Server ein.",
"Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.": "Erstelle eine App in der Feishu/Lark Developer Console, aktiviere Interactive Card bei Bedarf für Button-Antworten und kopiere App ID und App Secret. Der WebSocket-Modus (empfohlen) braucht keinen öffentlichen Endpunkt.",
"Credential Pools": "Credential-Pools",
"Credential Type": "Anmeldedaten-Typ",
"Credentials": "Anmeldedaten",
"Cron": "Cron",
"Cron Jobs": "Cron-Jobs",
"Current: %@": "Aktuell: %@",
"Custom…": "Benutzerdefiniert…",
"Daemon Endpoint": "Daemon-Endpunkt",
"Daemon running": "Daemon läuft",
"Dashboard": "Dashboard",
"Default": "Standard",
"Default: ~/.hermes": "Standard: ~/.hermes",
"Defaults to ~/.ssh/config or current user": "Standard ist ~/.ssh/config oder der aktuelle Benutzer",
"Defined Personalities": "Definierte Persönlichkeiten",
"Delegation": "Delegation",
"Delete": "Löschen",
"Delete %@?": "%@ löschen?",
"Delete Session?": "Sitzung löschen?",
"Delete profile '%@'?": "Profil '%@' löschen?",
"Delete...": "Löschen...",
"Deliver: %@": "Zustellen: %@",
"Details": "Details",
"Diagnostic Output": "Diagnose-Ausgabe",
"Diagnostics": "Diagnose",
"Disable": "Deaktivieren",
"Disabled": "Deaktiviert",
"Display": "Anzeige",
"Docs": "Dokumentation",
"Done": "Fertig",
"Edit": "Bearbeiten",
"Edit %@": "%@ bearbeiten",
"Edit /%@": "/%@ bearbeiten",
"Edit Agent Memory": "Agent-Speicher bearbeiten",
"Edit User Profile": "Nutzerprofil bearbeiten",
"Edit config.yaml": "config.yaml bearbeiten",
"Empty": "Leer",
"Enable": "Aktivieren",
"Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.": "Aktiviere 2FA für dein E-Mail-Konto und erzeuge ein App-Passwort. Normale Kontopasswörter funktionieren nicht. Setze immer erlaubte Absender — sonst kann jeder, der die Adresse kennt, dem Agent Nachrichten schicken.",
"Enable the webhook platform to accept event-driven agent triggers. The HMAC secret is used as a fallback when individual routes don't provide their own.": "Aktiviere die Webhook-Plattform, um ereignisgesteuerte Agent-Trigger zu akzeptieren. Das HMAC-Secret dient als Fallback, wenn einzelne Routen keines liefern.",
"Enabled": "Aktiviert",
"End-to-End Encryption (experimental)": "Ende-zu-Ende-Verschlüsselung (experimentell)",
"Entity Filters (config.yaml only)": "Entity-Filter (nur config.yaml)",
"Env vars, headers, and tool filters can be edited after the server is added.": "Umgebungsvariablen, Header und Tool-Filter können nach dem Hinzufügen des Servers bearbeitet werden.",
"Environment Variables": "Umgebungsvariablen",
"Error": "Fehler",
"Errors": "Fehler",
"Event Filters": "Ereignisfilter",
"Exclude": "Ausschließen",
"Execute": "Ausführen",
"Expected at %@": "Erwartet unter %@",
"Export All": "Alle exportieren",
"Export...": "Exportieren...",
"Export…": "Exportieren…",
"Expose prompts": "Prompts verfügbar machen",
"Expose resources": "Ressourcen verfügbar machen",
"External Provider": "Externer Anbieter",
"Feedback": "Feedback",
"Fetch": "Abrufen",
"Files": "Dateien",
"Filter logs...": "Logs filtern...",
"Filter servers...": "Server filtern...",
"Filter skills...": "Skills filtern...",
"Filter to session %@": "Auf Sitzung %@ filtern",
"Flush Memories": "Speicher leeren",
"Focus topic (optional)": "Fokusthema (optional)",
"Full copy of active profile (all state)": "Vollständige Kopie des aktiven Profils (gesamter Zustand)",
"Gateway": "Gateway",
"Gateway Running": "Gateway läuft",
"Gateway Stopped": "Gateway gestoppt",
"Gateway restart required": "Gateway-Neustart erforderlich",
"General": "Allgemein",
"Global Settings": "Globale Einstellungen",
"Header": "Header",
"Headers": "Header",
"Health": "Zustand",
"Hermes Not Found": "Hermes nicht gefunden",
"Hermes Running": "Hermes läuft",
"Hermes Stopped": "Hermes gestoppt",
"Hermes binary not found": "Hermes-Binary nicht gefunden",
"Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually.": "Hermes braucht ein globales Webhook-Secret und einen Port, bevor Abonnements Traffic empfangen können. Starte den Gateway-Einrichtungsassistenten oder bearbeite ~/.hermes/config.yaml manuell.",
"Hide": "Ausblenden",
"Hide Output": "Ausgabe ausblenden",
"Hide details": "Details ausblenden",
"Home Channel": "Home-Kanal",
"Homeserver": "Homeserver",
"Host key changed": "Host-Schlüssel geändert",
"Human Delay": "Menschliche Verzögerung",
"ID: %@": "ID: %@",
"If this is the first connection, ensure your key is loaded with `ssh-add` and that the remote accepts it.": "Wenn dies die erste Verbindung ist, stelle sicher, dass dein Schlüssel mit `ssh-add` geladen wurde und der Remote ihn akzeptiert.",
"If you trust the change, remove the stale entry and reconnect:": "Wenn du der Änderung vertraust, entferne den veralteten Eintrag und verbinde dich erneut:",
"Import": "Importieren",
"Inactive": "Inaktiv",
"Include (comma-separated — if set, only these are exposed)": "Einschließen (kommagetrennt — wenn gesetzt, werden nur diese freigegeben)",
"Insights": "Einsichten",
"Install": "Installieren",
"Install BlueBubbles Server": "BlueBubbles Server installieren",
"Install Plugin": "Plugin installieren",
"Install a Plugin": "Plugin installieren",
"Install signal-cli": "signal-cli installieren",
"Installed": "Installiert",
"Interact": "Interagieren",
"Invalid URL": "Ungültige URL",
"Keep typing to send as a message, or press Esc.": "Weitertippen, um als Nachricht zu senden, oder Esc drücken.",
"Label (optional)": "Label (optional)",
"Last Output": "Letzte Ausgabe",
"Last probe: %@": "Letzte Prüfung: %@",
"Last run: %@": "Letzter Lauf: %@",
"Last updated: %@": "Zuletzt aktualisiert: %@",
"Layout": "Layout",
"Leave blank to infer from the model ID's prefix (\"openai/...\" → openai).": "Leer lassen, um aus dem Präfix der Modell-ID abzuleiten (\"openai/...\" → openai).",
"Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates.": "Leer lassen, außer Hermes liegt nicht am Standardpfad (systemd-Dienste oft unter /var/lib/hermes/.hermes; Docker-Sidecars variieren). Test Connection schlägt automatisch einen Wert vor, wenn es eine bekannte Alternative erkennt.",
"Level": "Ebene",
"Link Device": "Gerät koppeln",
"Link the device first to generate and scan a QR code. Once linked, start the daemon — it must keep running for hermes to send/receive messages.": "Kopple zuerst das Gerät, um einen QR-Code zu erzeugen und zu scannen. Nach dem Koppeln starte den Daemon — er muss laufen, damit hermes Nachrichten senden/empfangen kann.",
"Linking…": "Kopple…",
"Loaded": "Geladen",
"Loading session…": "Lade Sitzung…",
"Local": "Lokal",
"Local (stdio)": "Lokal (stdio)",
"Locale": "Sprache & Region",
"Log File": "Log-Datei",
"Logging": "Logging",
"Logs": "Logs",
"MCP Servers": "MCP-Server",
"MCP Servers (%lld)": "MCP-Server (%lld)",
"Manage": "Verwalten",
"Manage Servers…": "Server verwalten…",
"Manage in Credential Pools": "In Credential-Pools verwalten",
"Matrix uses either an access token (preferred) or username/password. Get an access token from Element: Settings → Help & About → Access Token.": "Matrix nutzt entweder ein Access Token (bevorzugt) oder Benutzername/Passwort. Hol dir ein Access Token in Element: Einstellungen → Hilfe & Info → Access Token.",
"Memory": "Speicher",
"Memory is managed by %@. File contents shown here may be stale.": "Der Speicher wird von %@ verwaltet. Die hier angezeigten Dateiinhalte können veraltet sein.",
"Message Hermes...": "Hermes Nachricht senden...",
"Messages will appear here as the conversation progresses.": "Nachrichten erscheinen hier im Laufe der Unterhaltung.",
"Migrate": "Migrieren",
"Missing required config:": "Fehlende erforderliche Konfiguration:",
"Modal": "Modal",
"Model": "Modell",
"Model ID": "Modell-ID",
"Models": "Modelle",
"Monitor": "Überwachen",
"Name": "Name",
"Name (no leading slash)": "Name (ohne führenden Schrägstrich)",
"Network": "Netzwerk",
"New Session": "Neue Sitzung",
"New Webhook Subscription": "Neues Webhook-Abonnement",
"New name for '%@'": "Neuer Name für '%@'",
"Next run: %@": "Nächster Lauf: %@",
"No AI provider credentials detected": "Keine Anmeldedaten für KI-Anbieter erkannt",
"No Active Session": "Keine aktive Sitzung",
"No Activity": "Keine Aktivität",
"No Cron Jobs": "Keine Cron-Jobs",
"No Dashboard": "Kein Dashboard",
"No MCP servers configured": "Keine MCP-Server konfiguriert",
"No Models": "Keine Modelle",
"No Profiles": "Keine Profile",
"No Projects": "Keine Projekte",
"No Updates": "Keine Updates",
"No active session": "Keine aktive Sitzung",
"No additional output. Check ~/.ssh/config and ssh-agent.": "Keine weitere Ausgabe. Prüfe ~/.ssh/config und ssh-agent.",
"No commands available": "Keine Befehle verfügbar",
"No credential pools configured": "Keine Credential-Pools konfiguriert",
"No data": "Keine Daten",
"No env vars configured.": "Keine Umgebungsvariablen konfiguriert.",
"No env vars. Add one with the button below.": "Keine Umgebungsvariablen. Füge eine mit der Schaltfläche unten hinzu.",
"No headers configured.": "Keine Header konfiguriert.",
"No headers. Add one with the button below.": "Keine Header. Füge einen mit der Schaltfläche unten hinzu.",
"No matching commands": "Keine passenden Befehle",
"No paired users": "Keine gekoppelten Nutzer",
"No platforms connected": "Keine Plattformen verbunden",
"No plugins installed": "Keine Plugins installiert",
"No quick commands configured": "Keine Schnellbefehle konfiguriert",
"No remote servers": "Keine Remote-Server",
"No scheduled jobs configured": "Keine geplanten Jobs konfiguriert",
"No servers configured yet": "Noch keine Server konfiguriert",
"No sessions found": "Keine Sitzungen gefunden",
"No tool calls found": "Keine Tool-Aufrufe gefunden",
"No webhook subscriptions": "Keine Webhook-Abonnements",
"None": "Keine",
"Notable Sessions": "Bemerkenswerte Sitzungen",
"OAuth login for %@": "OAuth-Anmeldung für %@",
"OK": "OK",
"Open BotFather": "BotFather öffnen",
"Open Developer Portal": "Developer Portal öffnen",
"Open Local": "Lokal öffnen",
"Open Other Server…": "Anderen Server öffnen…",
"Open Scarf": "Scarf öffnen",
"Open Server": "Server öffnen",
"Open Slack API": "Slack-API öffnen",
"Open in Browser": "Im Browser öffnen",
"Open in Editor": "Im Editor öffnen",
"Open in new window": "In neuem Fenster öffnen",
"Open session": "Sitzung öffnen",
"Optional": "Optional",
"Optional — defaults to hostname": "Optional — Standard ist der Hostname",
"Optionally focus the summary on a specific topic. Leave blank to compress evenly.": "Fokussiere die Zusammenfassung optional auf ein bestimmtes Thema. Leer lassen, um gleichmäßig zu komprimieren.",
"Other": "Andere",
"Output": "Ausgabe",
"Overview": "Übersicht",
"PID %d": "PID %d",
"PID %lld": "PID %lld",
"Pair Device": "Gerät koppeln",
"Paired Users": "Gekoppelte Nutzer",
"Paste code here…": "Code hier einfügen…",
"Paths": "Pfade",
"Pause": "Pausieren",
"Pending Approvals": "Ausstehende Genehmigungen",
"Per-route subscriptions (events, prompt template, delivery target) are managed in the Webhooks sidebar — not here. This panel only controls whether the webhook platform is listening at all.": "Abonnements pro Route (Ereignisse, Prompt-Vorlage, Zustellziel) werden in der Webhooks-Seitenleiste verwaltet — nicht hier. Dieses Panel steuert nur, ob die Webhook-Plattform überhaupt zuhört.",
"Period": "Zeitraum",
"Personalities": "Persönlichkeiten",
"Personality": "Persönlichkeit",
"Pick an MCP server to add.": "Wähle einen MCP-Server zum Hinzufügen.",
"Pick one from the list, or add a new server from the toolbar.": "Wähle einen aus der Liste oder füge über die Symbolleiste einen neuen Server hinzu.",
"Platforms": "Plattformen",
"Plugins": "Plugins",
"Plugins extend hermes with custom tools, providers, or memory backends.": "Plugins erweitern hermes um eigene Tools, Anbieter oder Speicher-Backends.",
"Pre-Run Script": "Vorab-Skript",
"Preset:": "Voreinstellung:",
"Probe": "Prüfen",
"Profile": "Profil",
"Profiles": "Profile",
"Project Name": "Projektname",
"Project Path": "Projektpfad",
"Projects": "Projekte",
"Prompt": "Prompt",
"Provide a Git URL (https://github.com/...) or a shorthand like `owner/repo`.": "Gib eine Git-URL (https://github.com/...) oder ein Kürzel wie `owner/repo` an.",
"Provider": "Anbieter",
"Push to Talk": "Push-to-Talk",
"Push to talk (Ctrl+B)": "Push-to-Talk (Strg+B)",
"Push-to-Talk": "Push-to-Talk",
"Quick Commands": "Schnellbefehle",
"Quick commands are shell shortcuts hermes exposes in chat as `/command_name`. They live under `quick_commands:` in config.yaml.": "Schnellbefehle sind Shell-Shortcuts, die hermes im Chat als `/command_name` verfügbar macht. Sie stehen unter `quick_commands:` in config.yaml.",
"Quit Scarf": "Scarf beenden",
"Raw Config": "Rohkonfiguration",
"Raw remote output (for debugging)": "Rohdaten der Remote-Ausgabe (zum Debuggen)",
"Re-run": "Erneut ausführen",
"Read": "Lesen",
"Reasoning": "Reasoning",
"Recent Sessions": "Letzte Sitzungen",
"Reconnect": "Erneut verbinden",
"Recording…": "Nehme auf…",
"Redaction": "Redaktion",
"Refresh": "Aktualisieren",
"Reload": "Neu laden",
"Remote (HTTP)": "Remote (HTTP)",
"Remote Diagnostics — %@": "Remote-Diagnose — %@",
"Remove": "Entfernen",
"Remove %@?": "%@ entfernen?",
"Remove credential for %@?": "Anmeldedaten für %@ entfernen?",
"Remove this server from Scarf.": "Diesen Server aus Scarf entfernen.",
"Remove this server?": "Diesen Server entfernen?",
"Remove via config.yaml…": "Über config.yaml entfernen…",
"Remove webhook %@?": "Webhook %@ entfernen?",
"Rename": "Umbenennen",
"Rename Profile": "Profil umbenennen",
"Rename Session": "Sitzung umbenennen",
"Rename...": "Umbenennen...",
"Required": "Erforderlich",
"Required Tokens": "Erforderliche Tokens",
"Requires: %@": "Erfordert: %@",
"Reset Cooldowns": "Cooldowns zurücksetzen",
"Restart": "Neu starten",
"Restart Gateway": "Gateway neu starten",
"Restart Hermes": "Hermes neu starten",
"Restart Now": "Jetzt neu starten",
"Restore": "Wiederherstellen",
"Restore from backup?": "Aus Backup wiederherstellen?",
"Restore…": "Wiederherstellen…",
"Result": "Ergebnis",
"Resume": "Fortsetzen",
"Resume Session": "Sitzung fortsetzen",
"Retry": "Erneut versuchen",
"Return to Active Session (%@...)": "Zurück zur aktiven Sitzung (%@...)",
"Reveal": "Anzeigen",
"Revoke": "Widerrufen",
"Rich Chat": "Rich Chat",
"Run Diagnostics…": "Diagnose ausführen…",
"Run Dump": "Dump ausführen",
"Run Now": "Jetzt ausführen",
"Run Setup in Terminal": "Einrichtung im Terminal ausführen",
"Run `hermes memory setup` in Terminal for full provider configuration.": "Führe `hermes memory setup` im Terminal aus für die vollständige Anbieter-Konfiguration.",
"Run remote diagnostics — check exactly which files are readable on this server.": "Remote-Diagnose ausführen — prüfe genau, welche Dateien auf diesem Server lesbar sind.",
"Running a single shell session on %@ that exercises every path Scarf reads…": "Führe eine einzelne Shell-Sitzung auf %@ aus, die jeden Pfad durchläuft, den Scarf liest…",
"Running checks…": "Führe Prüfungen aus…",
"SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.": "SOUL.md beschreibt Stimme, Werte und Persönlichkeit des Agents unter ~/.hermes/SOUL.md. Sie wird in den Kontext jeder Sitzung injiziert.",
"SSH works but %@. Click for diagnostics.": "SSH funktioniert, aber %@. Für Diagnose klicken.",
"Save": "Speichern",
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:": "Scarf fragt nie nach Passphrasen. Füge deinen Schlüssel im Terminal zum ssh-agent hinzu und klicke dann auf Erneut versuchen. Falls dein Schlüssel nicht `id_ed25519` ist, tausche den Pfad:",
"Scarf runs these over a single SSH session that mirrors the shell your dashboard reads from, so a green row here means Scarf can actually read that file at runtime.": "Scarf führt diese über eine einzige SSH-Sitzung aus, die der Shell entspricht, aus der dein Dashboard liest. Eine grüne Zeile hier bedeutet also, dass Scarf die Datei zur Laufzeit tatsächlich lesen kann.",
"Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.": "Scarf nutzt ssh-agent zur Authentifizierung. Hat dein Schlüssel eine Passphrase, führe vor dem Verbinden `ssh-add` aus — Scarf fragt oder speichert keine Passphrasen.",
"Scarf — %@": "Scarf — %@",
"Search": "Suchen",
"Search Results (%lld)": "Suchergebnisse (%lld)",
"Search messages...": "Nachrichten suchen...",
"Search or browse skills published to registries like skills.sh, GitHub, and the official hub.": "Durchsuche oder browse Skills, die in Registries wie skills.sh, GitHub und dem offiziellen Hub veröffentlicht sind.",
"Search registries": "Registries durchsuchen",
"Search…": "Suchen…",
"Security": "Sicherheit",
"Select": "Auswählen",
"Select Model": "Modell auswählen",
"Select a Job": "Job auswählen",
"Select a Profile": "Profil auswählen",
"Select a Project": "Projekt auswählen",
"Select a Session": "Sitzung auswählen",
"Select a Skill": "Skill auswählen",
"Select a Tool Call": "Tool-Aufruf auswählen",
"Select an MCP Server": "MCP-Server auswählen",
"Send message (Enter)": "Nachricht senden (Enter)",
"Series": "Serie",
"Server": "Server",
"Server No Longer Exists": "Server existiert nicht mehr",
"Server name": "Servername",
"Servers": "Server",
"Service": "Dienst",
"Service definition stale": "Dienstdefinition veraltet",
"Session": "Sitzung",
"Session Search": "Sitzungssuche",
"Session title": "Sitzungstitel",
"Sessions": "Sitzungen",
"Settings": "Einstellungen",
"Setup": "Einrichtung",
"Share Debug Report…": "Debug-Bericht teilen…",
"Shell Command": "Shell-Befehl",
"Show": "Anzeigen",
"Show Output": "Ausgabe anzeigen",
"Show all %lld lines": "Alle %lld Zeilen anzeigen",
"Show details": "Details anzeigen",
"Show less": "Weniger anzeigen",
"Show values": "Werte anzeigen",
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages.": "Die Signal-Integration benötigt lokal installiertes signal-cli (Java-basiert). Kopple diesen Mac als Signal-Gerät und lasse den Daemon laufen, damit hermes Nachrichten senden/empfangen kann.",
"Site": "Seite",
"Skills": "Skills",
"Skills (%lld)": "Skills (%lld)",
"Skills Hub": "Skills-Hub",
"Source": "Quelle",
"Speech-to-Text": "Spracherkennung",
"Start": "Starten",
"Start Daemon": "Daemon starten",
"Start Hermes": "Hermes starten",
"Start OAuth": "OAuth starten",
"Start Pairing": "Kopplung starten",
"Start a new session or resume an existing one from the Session menu above.": "Starte eine neue Sitzung oder setze eine bestehende aus dem Sitzungsmenü oben fort.",
"Status": "Status",
"Stop": "Stoppen",
"Stop Hermes": "Hermes stoppen",
"Subagent": "Sub-Agent",
"Subagent Sessions (%lld)": "Sub-Agent-Sitzungen (%lld)",
"Submit": "Absenden",
"Subscribe": "Abonnieren",
"Succeeded": "Erfolgreich",
"Switch to This Profile": "Zu diesem Profil wechseln",
"Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.": "Das Wechseln des aktiven Profils ändert das von hermes verwendete `~/.hermes`-Verzeichnis. Starte Scarf nach dem Wechsel neu, damit es aus den Dateien des neuen Profils liest.",
"TTS Off": "TTS aus",
"TTS On": "TTS an",
"Terminal": "Terminal",
"Test": "Testen",
"Test All": "Alle testen",
"Test Connection": "Verbindung testen",
"Test failed": "Test fehlgeschlagen",
"Test passed": "Test bestanden",
"Text-to-Speech": "Text-to-Speech",
"The agent hasn't advertised any slash commands yet. Keep typing to send as a message, or press Esc.": "Der Agent hat bisher keine Slash-Befehle angeboten. Weitertippen, um als Nachricht zu senden, oder Esc drücken.",
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection.": "Der SSH-Fingerabdruck des Remote passt nicht mehr zu dem, was deine `~/.ssh/known_hosts`-Datei erwartet. Meist bedeutet das, dass der Remote neu installiert wurde — seltener, dass jemand die Verbindung abfängt.",
"The server this window was opened with has been removed from your registry.": "Der Server, mit dem dieses Fenster geöffnet wurde, wurde aus deiner Registrierung entfernt.",
"The server's SSH configuration is removed from Scarf. Your remote files are untouched.": "Die SSH-Konfiguration des Servers wird aus Scarf entfernt. Deine Remote-Dateien bleiben unangetastet.",
"The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".": "Das Terminal ist ein echtes TTY — mit ⌘V einfügen, Return drücken und warten, bis der Prozess mit \"login succeeded\" endet.",
"These list fields must be edited directly in config.yaml.": "Diese Listenfelder müssen direkt in config.yaml bearbeitet werden.",
"This provider has no catalogued models.": "Für diesen Anbieter sind keine katalogisierten Modelle vorhanden.",
"This removes the credential from hermes. The upstream provider key is not revoked.": "Damit werden die Anmeldedaten aus hermes entfernt. Der Schlüssel beim Upstream-Anbieter wird nicht widerrufen.",
"This removes the profile directory and all data within it. This cannot be undone.": "Damit werden das Profilverzeichnis und alle darin enthaltenen Daten entfernt. Dies kann nicht rückgängig gemacht werden.",
"This removes the scheduled job permanently.": "Damit wird der geplante Job dauerhaft entfernt.",
"This removes the server from config.yaml and deletes any OAuth token.": "Damit wird der Server aus config.yaml entfernt und jedes OAuth-Token gelöscht.",
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.": "Damit werden Logs, Konfiguration (mit geschwärzten Secrets) und Systeminfos zur Support-Infrastruktur von Nous Research hochgeladen. Prüfe die Ausgabe unten, bevor du die zurückgegebene URL teilst.",
"This will overwrite files under ~/.hermes/ with the archive contents.": "Damit werden Dateien unter ~/.hermes/ mit dem Archivinhalt überschrieben.",
"This will permanently delete the session and all its messages.": "Damit werden die Sitzung und alle ihre Nachrichten dauerhaft gelöscht.",
"Timeout: %llds (%@)": "Timeout: %1$lld s (%2$@)",
"Timeouts": "Timeouts",
"Tirith Sandbox": "Tirith-Sandbox",
"To skip the passphrase prompt at every reboot, add `--apple-use-keychain` to cache it in macOS Keychain.": "Um die Passphrase-Abfrage bei jedem Neustart zu überspringen, füge `--apple-use-keychain` hinzu, um sie im macOS-Schlüsselbund zu cachen.",
"Toggle text-to-speech (/voice tts)": "Text-to-Speech umschalten (/voice tts)",
"Toggle voice mode (/voice)": "Sprachmodus umschalten (/voice)",
"Token on disk. Clear to re-authenticate next time the gateway connects.": "Token auf der Festplatte. Löschen, damit sich das Gateway beim nächsten Verbinden neu authentifiziert.",
"Tool Approval Required": "Tool-Genehmigung erforderlich",
"Tool Filters": "Tool-Filter",
"Tool Progress": "Tool-Fortschritt",
"Tools": "Tools",
"Top Tools": "Top-Tools",
"Turns & Reasoning": "Turns & Reasoning",
"Uninstall": "Deinstallieren",
"Unknown: %@": "Unbekannt: %@",
"Update": "Aktualisieren",
"Update All": "Alle aktualisieren",
"Updated: %@": "Aktualisiert: %@",
"Updates": "Updates",
"Upload": "Hochladen",
"Upload debug report?": "Debug-Bericht hochladen?",
"Usage Stats": "Nutzungsstatistik",
"Use": "Verwenden",
"Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".": "Verwende ein Modell, das nicht im Katalog ist. Hermes akzeptiert jede Zeichenkette, die der Anbieter erkennt, inklusive anbieter-präfixierter Formen wie \"openrouter/anthropic/claude-opus-4.6\".",
"Use this": "Dieses verwenden",
"Use {dot.notation} to reference fields in the webhook payload.": "Verwende {dot.notation}, um Felder im Webhook-Payload zu referenzieren.",
"Used as the YAML key. Lowercase, no spaces.": "Wird als YAML-Schlüssel verwendet. Kleinbuchstaben, keine Leerzeichen.",
"View": "Anzeigen",
"View All": "Alle anzeigen",
"Vision": "Vision",
"Voice": "Stimme",
"Voice Off": "Stimme aus",
"Voice On": "Stimme an",
"Waiting for authorization URL…": "Warte auf Autorisierungs-URL…",
"Waiting for first probe": "Warte auf erste Prüfung",
"Waiting for hermes to prompt for the code…": "Warte, bis hermes nach dem Code fragt…",
"Web Extract": "Web-Extraktion",
"Webhook (advanced)": "Webhook (erweitert)",
"Webhook (hermes side)": "Webhook (hermes-Seite)",
"Webhook Security": "Webhook-Sicherheit",
"Webhook platform not enabled": "Webhook-Plattform nicht aktiviert",
"Webhooks": "Webhooks",
"Webhooks let external services trigger agent responses. Each subscription gets its own URL endpoint.": "Webhooks ermöglichen externen Diensten, Agent-Antworten auszulösen. Jedes Abonnement hat seinen eigenen URL-Endpunkt.",
"Website Blocklist": "Website-Blockliste",
"WhatsApp uses the Baileys library to emulate a WhatsApp Web session. Pair this Mac as a linked device by running the pairing wizard and scanning the QR code with your phone (Settings → Linked Devices → Link a Device).": "WhatsApp nutzt die Baileys-Bibliothek, um eine WhatsApp-Web-Sitzung zu emulieren. Kopple diesen Mac als verknüpftes Gerät, indem du den Kopplungsassistenten ausführst und den QR-Code mit deinem Telefon scannst (Einstellungen → Verknüpfte Geräte → Gerät verknüpfen).",
"Working": "In Arbeit",
"e.g. anthropic": "z. B. anthropic",
"e.g. deploy": "z. B. deploy",
"e.g. experimental": "z. B. experimental",
"e.g. github": "z. B. github",
"e.g. openai": "z. B. openai",
"e.g. openai/gpt-4o": "z. B. openai/gpt-4o",
"e.g. team-prod": "z. B. team-prod",
"exit code: %d": "Exit-Code: %d",
"hermes at %@": "hermes auf %@",
"iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.": "Die iMessage-Integration läuft über BlueBubbles Server. Du brauchst einen Mac, der eingeschaltet bleibt und in Messages.app angemeldet ist — installiere BlueBubbles Server darauf und richte hermes auf die URL dieses Servers aus.",
"signal-cli is available on PATH": "signal-cli ist im PATH verfügbar",
"signal-cli not found on PATH — install it first": "signal-cli im PATH nicht gefunden — bitte zuerst installieren",
"ssh trace": "ssh-Trace",
"ssh-agent (leave blank)": "ssh-agent (leer lassen)",
"state.db not found at the configured path. Either Hermes hasn't run yet on this server, or it's installed at a non-default location — set the Hermes data directory field above.": "state.db wurde am konfigurierten Pfad nicht gefunden. Entweder lief Hermes auf diesem Server noch nicht, oder es ist an einem nicht standardmäßigen Ort installiert — setze oben das Feld für das Hermes-Datenverzeichnis.",
"state.db not found at the default location, but Scarf found one at:": "state.db wurde am Standardort nicht gefunden, Scarf hat aber eine unter folgendem Pfad entdeckt:",
"state.db readable": "state.db lesbar",
"— or use user/password login —": "— oder Benutzername/Passwort-Anmeldung verwenden —"
}
+585
View File
@@ -0,0 +1,585 @@
{
"%@ ctx": "%@ contexto",
"%@ in / %@ out": "%1$@ entrada / %2$@ salida",
"%@ reasoning": "%@ razonamiento",
"%@ tokens": "%@ tokens",
"%@s · %lld tools": "%1$@ s · %2$lld herramientas",
"%lld %@": "%1$lld %2$@",
"%lld chars": "%lld caracteres",
"%lld delivery failure%@": "%1$lld error de entrega%2$@",
"%lld entries": "%lld entradas",
"%lld files": "%lld archivos",
"%lld messages": "%lld mensajes",
"%lld msgs": "%lld msjs",
"%lld of %lld enabled": "%1$lld de %2$lld habilitados",
"%lld reasoning": "%lld razonamiento",
"%lld req": "%lld requeridos",
"%lld required config": "%lld configuraciones requeridas",
"%lld sessions": "%lld sesiones",
"%lld tokens": "%lld tokens",
"%lld tools": "%lld herramientas",
"30 Days": "30 días",
"7 Days": "7 días",
"90 Days": "90 días",
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts.": "Aparecerá un código QR abajo. Escanéalo con WhatsApp en tu teléfono. La sesión se guarda en ~/.hermes/platforms/whatsapp/ para no tener que volver a escanearla tras reiniciar.",
"API Key": "Clave de API",
"API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.": "Las claves de API nunca se muestran completas. Scarf solo muestra los últimos 4 caracteres para identificación. Los valores completos los guarda hermes en ~/.hermes/auth.json.",
"Access Control": "Control de acceso",
"Actions": "Acciones",
"Active": "Activo",
"Active Personality": "Personalidad activa",
"Active profile": "Perfil activo",
"Activity": "Actividad",
"Activity Patterns": "Patrones de actividad",
"Add": "Añadir",
"Add Command": "Añadir comando",
"Add Credential": "Añadir credencial",
"Add Custom": "Añadir personalizado",
"Add Custom MCP Server": "Añadir servidor MCP personalizado",
"Add Project": "Añadir proyecto",
"Add Quick Command": "Añadir comando rápido",
"Add Remote Server": "Añadir servidor remoto",
"Add Server": "Añadir servidor",
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.": "Añade una carpeta de proyecto para empezar. Crea un archivo .scarf/dashboard.json en tu proyecto para definir widgets.",
"Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.": "Añade credenciales en **Configurar → Grupos de credenciales**, establece `ANTHROPIC_API_KEY` (o similar) en `~/.hermes/.env`, o expórtala en tu perfil de shell, y reinicia Scarf.",
"Add from Preset": "Añadir desde preajuste",
"Add rotation credentials so hermes can failover between keys when one hits rate limits.": "Añade credenciales de rotación para que hermes pueda cambiar entre claves cuando una alcance el límite de tasa.",
"Add your first command": "Añade tu primer comando",
"Advanced": "Avanzado",
"After approving in your browser, the provider shows a code. Paste it below and submit.": "Tras aprobar en tu navegador, el proveedor muestra un código. Pégalo abajo y envíalo.",
"Agent": "Agent",
"All": "Todos",
"All Levels": "Todos los niveles",
"All Sessions": "Todas las sesiones",
"All Time": "Todo el tiempo",
"All installed hub skills are up to date.": "Todas las habilidades instaladas desde el hub están al día.",
"App Credentials": "Credenciales de la aplicación",
"Approval": "Aprobación",
"Approvals": "Aprobaciones",
"Approve": "Aprobar",
"Archive": "Archivar",
"Args (one per line)": "Argumentos (uno por línea)",
"Arguments": "Argumentos",
"Assistant Message": "Mensaje del asistente",
"Auth": "Auth",
"Authentication": "Autenticación",
"Authentication uses ssh-agent": "La autenticación usa ssh-agent",
"Authorization Code": "Código de autorización",
"Authorization URL": "URL de autorización",
"Aux Models": "Modelos auxiliares",
"Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.": "Las tareas auxiliares usan modelos separados, normalmente más baratos. Deja Proveedor en `auto` para heredar el proveedor principal.",
"Back": "Atrás",
"Back to Catalog": "Volver al catálogo",
"Backend": "Backend",
"Backup & Restore": "Copia de seguridad y restauración",
"Backup Now": "Copia de seguridad ahora",
"Becomes the key under mcp_servers: in config.yaml.": "Se convierte en la clave bajo mcp_servers: en config.yaml.",
"Behavior": "Comportamiento",
"Browse": "Examinar",
"Browse Hub": "Examinar el hub",
"Browse the Hub": "Examinar el hub",
"Browse...": "Examinar...",
"Browser": "Navegador",
"Built-in Memory": "Memoria integrada",
"By Day": "Por día",
"By Hour": "Por hora",
"Call timeout": "Tiempo de espera de llamada",
"Can't read Hermes state on %@": "No se puede leer el estado de Hermes en %@",
"Cancel": "Cancelar",
"Changes won't take effect until Hermes reloads the config.": "Los cambios no surtirán efecto hasta que Hermes recargue la configuración.",
"Chat": "Chat",
"Chat Messages": "Mensajes de chat",
"Check": "Comprobar",
"Check Now": "Comprobar ahora",
"Check for Updates": "Buscar actualizaciones",
"Check for Updates…": "Buscar actualizaciones…",
"Checking…": "Comprobando…",
"Checkpoints": "Puntos de control",
"Choose a cron job from the list": "Elige una tarea cron de la lista",
"Choose a profile to inspect.": "Elige un perfil para inspeccionar.",
"Choose a project from the sidebar to view its dashboard.": "Elige un proyecto en la barra lateral para ver su panel.",
"Choose a session from the list": "Elige una sesión de la lista",
"Choose a skill from the list": "Elige una habilidad de la lista",
"Choose an entry from the list": "Elige una entrada de la lista",
"Choose…": "Elegir…",
"Clear Token": "Borrar token",
"Clear all skills on save": "Borrar todas las habilidades al guardar",
"Click Add to connect to a remote Hermes installation over SSH.": "Haz clic en Añadir para conectarte a una instalación remota de Hermes mediante SSH.",
"Click for details": "Haz clic para ver detalles",
"Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.": "Al hacer clic en Iniciar OAuth se abre la página de autorización del proveedor en tu navegador. Tras aprobar, copia el código mostrado y pégalo en el terminal que aparecerá a continuación.",
"Clone config, .env, SOUL.md from active profile": "Clonar config, .env, SOUL.md del perfil activo",
"Close": "Cerrar",
"Close Window": "Cerrar ventana",
"Code: %@": "Código: %@",
"Command": "Comando",
"Command Allowlist": "Lista de comandos permitidos",
"Command looks destructive. Double-check before saving.": "El comando parece destructivo. Revísalo antes de guardar.",
"Component": "Componente",
"Compress": "Comprimir",
"Compress Conversation": "Comprimir conversación",
"Compress conversation (/compress)": "Comprimir conversación (/compress)",
"Compression": "Compresión",
"Config Diagnostics": "Diagnóstico de configuración",
"Configure": "Configurar",
"Connect timeout": "Tiempo de espera de conexión",
"Connected": "Conectado",
"Connected — can't read Hermes state": "Conectado — no se puede leer el estado de Hermes",
"Connection": "Conexión",
"Container Limits": "Límites del contenedor",
"Context & Compression": "Contexto y compresión",
"Continue Last Session": "Continuar última sesión",
"Copied": "Copiado",
"Copy": "Copiar",
"Copy Full Report": "Copiar informe completo",
"Copy a plain-text summary of every check (passes and fails) — paste into GitHub issues so we can see everything at once.": "Copia un resumen en texto plano de cada comprobación (éxitos y fallos) — pégalo en issues de GitHub para que veamos todo a la vez.",
"Copy code": "Copiar código",
"Copy error details": "Copiar detalles de error",
"Create": "Crear",
"Create Profile": "Crear perfil",
"Create Subscription": "Crear suscripción",
"Create a Slack app at api.slack.com/apps, enable Socket Mode, grant bot scopes (chat:write, app_mentions:read, channels:history, etc.), then copy the Bot User OAuth Token (xoxb-) and the App-Level Token (xapp-).": "Crea una app de Slack en api.slack.com/apps, activa Socket Mode, concede los scopes de bot (chat:write, app_mentions:read, channels:history, etc.) y copia el Bot User OAuth Token (xoxb-) y el App-Level Token (xapp-).",
"Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users.": "Crea un bot con @BotFather y obtén tu ID numérico en @userinfobot. Pega el token y tu ID de usuario abajo — el bot solo responderá a usuarios permitidos.",
"Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.": "Crea un token de acceso de larga duración en Home Assistant (Perfil → Seguridad → Long-Lived Access Tokens). Por defecto no se reenvían eventos — activa Watch All Changes o añade filtros de entidades abajo.",
"Create a personal access token under Profile → Security → Personal Access Tokens, or create a bot account. Use the token as the MATTERMOST_TOKEN value.": "Crea un token de acceso personal en Perfil → Seguridad → Personal Access Tokens, o crea una cuenta de bot. Usa el token como valor de MATTERMOST_TOKEN.",
"Create a profile to isolate config and skills.": "Crea un perfil para aislar configuración y habilidades.",
"Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.": "Crea una app en el Developer Portal de Discord, activa los intents Message Content y Server Members y copia el token del bot. Invita al bot a tu servidor con el generador de URLs OAuth2.",
"Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.": "Crea una app en la Feishu/Lark Developer Console, activa Interactive Card si necesitas respuestas por botón y copia el App ID y el App Secret. El modo WebSocket (recomendado) no requiere un endpoint público.",
"Credential Pools": "Grupos de credenciales",
"Credential Type": "Tipo de credencial",
"Credentials": "Credenciales",
"Cron": "Cron",
"Cron Jobs": "Tareas cron",
"Current: %@": "Actual: %@",
"Custom…": "Personalizado…",
"Daemon Endpoint": "Endpoint del demonio",
"Daemon running": "Demonio en ejecución",
"Dashboard": "Panel",
"Default": "Predeterminado",
"Default: ~/.hermes": "Predeterminado: ~/.hermes",
"Defaults to ~/.ssh/config or current user": "Por defecto ~/.ssh/config o el usuario actual",
"Defined Personalities": "Personalidades definidas",
"Delegation": "Delegación",
"Delete": "Eliminar",
"Delete %@?": "¿Eliminar %@?",
"Delete Session?": "¿Eliminar sesión?",
"Delete profile '%@'?": "¿Eliminar perfil '%@'?",
"Delete...": "Eliminar...",
"Deliver: %@": "Entregar: %@",
"Details": "Detalles",
"Diagnostic Output": "Salida de diagnóstico",
"Diagnostics": "Diagnósticos",
"Disable": "Desactivar",
"Disabled": "Desactivado",
"Display": "Pantalla",
"Docs": "Docs",
"Done": "Listo",
"Edit": "Editar",
"Edit %@": "Editar %@",
"Edit /%@": "Editar /%@",
"Edit Agent Memory": "Editar memoria del agente",
"Edit User Profile": "Editar perfil de usuario",
"Edit config.yaml": "Editar config.yaml",
"Empty": "Vacío",
"Enable": "Activar",
"Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.": "Activa 2FA en tu cuenta de correo y genera una contraseña de aplicación. Las contraseñas normales no funcionarán. Establece siempre remitentes permitidos — de lo contrario, cualquiera que conozca la dirección podrá enviar mensajes al agente.",
"Enable the webhook platform to accept event-driven agent triggers. The HMAC secret is used as a fallback when individual routes don't provide their own.": "Activa la plataforma de webhooks para aceptar disparadores de agente dirigidos por eventos. El secreto HMAC se usa como respaldo cuando las rutas individuales no aportan el suyo.",
"Enabled": "Activado",
"End-to-End Encryption (experimental)": "Cifrado de extremo a extremo (experimental)",
"Entity Filters (config.yaml only)": "Filtros de entidades (solo config.yaml)",
"Env vars, headers, and tool filters can be edited after the server is added.": "Las variables de entorno, cabeceras y filtros de herramientas se pueden editar después de añadir el servidor.",
"Environment Variables": "Variables de entorno",
"Error": "Error",
"Errors": "Errores",
"Event Filters": "Filtros de eventos",
"Exclude": "Excluir",
"Execute": "Ejecutar",
"Expected at %@": "Esperado en %@",
"Export All": "Exportar todo",
"Export...": "Exportar...",
"Export…": "Exportar…",
"Expose prompts": "Exponer prompts",
"Expose resources": "Exponer recursos",
"External Provider": "Proveedor externo",
"Feedback": "Comentarios",
"Fetch": "Obtener",
"Files": "Archivos",
"Filter logs...": "Filtrar registros...",
"Filter servers...": "Filtrar servidores...",
"Filter skills...": "Filtrar habilidades...",
"Filter to session %@": "Filtrar a la sesión %@",
"Flush Memories": "Vaciar memorias",
"Focus topic (optional)": "Tema de enfoque (opcional)",
"Full copy of active profile (all state)": "Copia completa del perfil activo (todo el estado)",
"Gateway": "Gateway",
"Gateway Running": "Gateway en ejecución",
"Gateway Stopped": "Gateway detenido",
"Gateway restart required": "Se requiere reiniciar el gateway",
"General": "General",
"Global Settings": "Ajustes globales",
"Header": "Cabecera",
"Headers": "Cabeceras",
"Health": "Salud",
"Hermes Not Found": "Hermes no encontrado",
"Hermes Running": "Hermes en ejecución",
"Hermes Stopped": "Hermes detenido",
"Hermes binary not found": "Binario de Hermes no encontrado",
"Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually.": "Hermes necesita un secreto y puerto globales de webhook antes de que las suscripciones reciban tráfico. Ejecuta el asistente de configuración del gateway o edita ~/.hermes/config.yaml manualmente.",
"Hide": "Ocultar",
"Hide Output": "Ocultar salida",
"Hide details": "Ocultar detalles",
"Home Channel": "Canal principal",
"Homeserver": "Homeserver",
"Host key changed": "Clave de host cambiada",
"Human Delay": "Retraso humano",
"ID: %@": "ID: %@",
"If this is the first connection, ensure your key is loaded with `ssh-add` and that the remote accepts it.": "Si es la primera conexión, asegúrate de que tu clave esté cargada con `ssh-add` y de que el remoto la acepte.",
"If you trust the change, remove the stale entry and reconnect:": "Si confías en el cambio, elimina la entrada obsoleta y reconéctate:",
"Import": "Importar",
"Inactive": "Inactivo",
"Include (comma-separated — if set, only these are exposed)": "Incluir (separados por comas — si se define, solo estos se exponen)",
"Insights": "Analíticas",
"Install": "Instalar",
"Install BlueBubbles Server": "Instalar BlueBubbles Server",
"Install Plugin": "Instalar plugin",
"Install a Plugin": "Instalar un plugin",
"Install signal-cli": "Instalar signal-cli",
"Installed": "Instalado",
"Interact": "Interactuar",
"Invalid URL": "URL no válida",
"Keep typing to send as a message, or press Esc.": "Sigue escribiendo para enviar como mensaje, o pulsa Esc.",
"Label (optional)": "Etiqueta (opcional)",
"Last Output": "Última salida",
"Last probe: %@": "Última comprobación: %@",
"Last run: %@": "Última ejecución: %@",
"Last updated: %@": "Última actualización: %@",
"Layout": "Diseño",
"Leave blank to infer from the model ID's prefix (\"openai/...\" → openai).": "Déjalo vacío para deducirlo del prefijo del ID del modelo (\"openai/...\" → openai).",
"Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates.": "Déjalo vacío salvo que Hermes esté instalado en una ruta no predeterminada (los servicios systemd suelen estar en /var/lib/hermes/.hermes; los sidecars Docker varían). Probar conexión sugiere un valor automáticamente si detecta una alternativa conocida.",
"Level": "Nivel",
"Link Device": "Vincular dispositivo",
"Link the device first to generate and scan a QR code. Once linked, start the daemon — it must keep running for hermes to send/receive messages.": "Vincula primero el dispositivo para generar y escanear un código QR. Una vez vinculado, inicia el demonio — debe seguir ejecutándose para que hermes envíe/reciba mensajes.",
"Linking…": "Vinculando…",
"Loaded": "Cargado",
"Loading session…": "Cargando sesión…",
"Local": "Local",
"Local (stdio)": "Local (stdio)",
"Locale": "Configuración regional",
"Log File": "Archivo de registro",
"Logging": "Registro",
"Logs": "Registros",
"MCP Servers": "Servidores MCP",
"MCP Servers (%lld)": "Servidores MCP (%lld)",
"Manage": "Administrar",
"Manage Servers…": "Administrar servidores…",
"Manage in Credential Pools": "Administrar en grupos de credenciales",
"Matrix uses either an access token (preferred) or username/password. Get an access token from Element: Settings → Help & About → Access Token.": "Matrix usa un token de acceso (preferido) o usuario/contraseña. Obtén un token de acceso en Element: Ajustes → Ayuda y acerca de → Access Token.",
"Memory": "Memoria",
"Memory is managed by %@. File contents shown here may be stale.": "La memoria la gestiona %@. El contenido de archivos mostrado aquí puede estar desactualizado.",
"Message Hermes...": "Mensaje a Hermes...",
"Messages will appear here as the conversation progresses.": "Los mensajes aparecerán aquí a medida que avance la conversación.",
"Migrate": "Migrar",
"Missing required config:": "Falta configuración requerida:",
"Modal": "Modal",
"Model": "Modelo",
"Model ID": "ID del modelo",
"Models": "Modelos",
"Monitor": "Monitor",
"Name": "Nombre",
"Name (no leading slash)": "Nombre (sin barra inicial)",
"Network": "Red",
"New Session": "Nueva sesión",
"New Webhook Subscription": "Nueva suscripción de webhook",
"New name for '%@'": "Nuevo nombre para '%@'",
"Next run: %@": "Próxima ejecución: %@",
"No AI provider credentials detected": "No se detectaron credenciales de proveedor de IA",
"No Active Session": "Sin sesión activa",
"No Activity": "Sin actividad",
"No Cron Jobs": "Sin tareas cron",
"No Dashboard": "Sin panel",
"No MCP servers configured": "No hay servidores MCP configurados",
"No Models": "Sin modelos",
"No Profiles": "Sin perfiles",
"No Projects": "Sin proyectos",
"No Updates": "Sin actualizaciones",
"No active session": "Sin sesión activa",
"No additional output. Check ~/.ssh/config and ssh-agent.": "Sin salida adicional. Revisa ~/.ssh/config y ssh-agent.",
"No commands available": "No hay comandos disponibles",
"No credential pools configured": "No hay grupos de credenciales configurados",
"No data": "Sin datos",
"No env vars configured.": "Sin variables de entorno configuradas.",
"No env vars. Add one with the button below.": "Sin variables de entorno. Añade una con el botón de abajo.",
"No headers configured.": "Sin cabeceras configuradas.",
"No headers. Add one with the button below.": "Sin cabeceras. Añade una con el botón de abajo.",
"No matching commands": "Sin comandos coincidentes",
"No paired users": "Sin usuarios vinculados",
"No platforms connected": "Sin plataformas conectadas",
"No plugins installed": "Sin plugins instalados",
"No quick commands configured": "Sin comandos rápidos configurados",
"No remote servers": "Sin servidores remotos",
"No scheduled jobs configured": "Sin tareas programadas configuradas",
"No servers configured yet": "Aún no hay servidores configurados",
"No sessions found": "No se encontraron sesiones",
"No tool calls found": "No se encontraron llamadas a herramientas",
"No webhook subscriptions": "Sin suscripciones de webhook",
"None": "Ninguno",
"Notable Sessions": "Sesiones destacadas",
"OAuth login for %@": "Inicio de sesión OAuth para %@",
"OK": "Aceptar",
"Open BotFather": "Abrir BotFather",
"Open Developer Portal": "Abrir Developer Portal",
"Open Local": "Abrir local",
"Open Other Server…": "Abrir otro servidor…",
"Open Scarf": "Abrir Scarf",
"Open Server": "Abrir servidor",
"Open Slack API": "Abrir Slack API",
"Open in Browser": "Abrir en el navegador",
"Open in Editor": "Abrir en el editor",
"Open in new window": "Abrir en nueva ventana",
"Open session": "Abrir sesión",
"Optional": "Opcional",
"Optional — defaults to hostname": "Opcional — por defecto el nombre de host",
"Optionally focus the summary on a specific topic. Leave blank to compress evenly.": "Opcionalmente, enfoca el resumen en un tema específico. Déjalo vacío para comprimir uniformemente.",
"Other": "Otro",
"Output": "Salida",
"Overview": "Resumen",
"PID %d": "PID %d",
"PID %lld": "PID %lld",
"Pair Device": "Emparejar dispositivo",
"Paired Users": "Usuarios emparejados",
"Paste code here…": "Pega el código aquí…",
"Paths": "Rutas",
"Pause": "Pausar",
"Pending Approvals": "Aprobaciones pendientes",
"Per-route subscriptions (events, prompt template, delivery target) are managed in the Webhooks sidebar — not here. This panel only controls whether the webhook platform is listening at all.": "Las suscripciones por ruta (eventos, plantilla de prompt, destino de entrega) se gestionan en la barra lateral de Webhooks — no aquí. Este panel solo controla si la plataforma de webhooks está escuchando.",
"Period": "Período",
"Personalities": "Personalidades",
"Personality": "Personalidad",
"Pick an MCP server to add.": "Elige un servidor MCP para añadir.",
"Pick one from the list, or add a new server from the toolbar.": "Elige uno de la lista o añade un nuevo servidor desde la barra de herramientas.",
"Platforms": "Plataformas",
"Plugins": "Plugins",
"Plugins extend hermes with custom tools, providers, or memory backends.": "Los plugins extienden hermes con herramientas, proveedores o backends de memoria personalizados.",
"Pre-Run Script": "Script previo a la ejecución",
"Preset:": "Preajuste:",
"Probe": "Probar",
"Profile": "Perfil",
"Profiles": "Perfiles",
"Project Name": "Nombre del proyecto",
"Project Path": "Ruta del proyecto",
"Projects": "Proyectos",
"Prompt": "Prompt",
"Provide a Git URL (https://github.com/...) or a shorthand like `owner/repo`.": "Proporciona una URL de Git (https://github.com/...) o una forma corta como `owner/repo`.",
"Provider": "Proveedor",
"Push to Talk": "Pulsar para hablar",
"Push to talk (Ctrl+B)": "Pulsar para hablar (Ctrl+B)",
"Push-to-Talk": "Pulsar para hablar",
"Quick Commands": "Comandos rápidos",
"Quick commands are shell shortcuts hermes exposes in chat as `/command_name`. They live under `quick_commands:` in config.yaml.": "Los comandos rápidos son atajos de shell que hermes expone en el chat como `/command_name`. Viven bajo `quick_commands:` en config.yaml.",
"Quit Scarf": "Salir de Scarf",
"Raw Config": "Configuración en bruto",
"Raw remote output (for debugging)": "Salida remota en bruto (para depurar)",
"Re-run": "Volver a ejecutar",
"Read": "Leer",
"Reasoning": "Razonamiento",
"Recent Sessions": "Sesiones recientes",
"Reconnect": "Reconectar",
"Recording…": "Grabando…",
"Redaction": "Censura",
"Refresh": "Actualizar",
"Reload": "Recargar",
"Remote (HTTP)": "Remoto (HTTP)",
"Remote Diagnostics — %@": "Diagnósticos remotos — %@",
"Remove": "Quitar",
"Remove %@?": "¿Quitar %@?",
"Remove credential for %@?": "¿Quitar credencial de %@?",
"Remove this server from Scarf.": "Quitar este servidor de Scarf.",
"Remove this server?": "¿Quitar este servidor?",
"Remove via config.yaml…": "Quitar vía config.yaml…",
"Remove webhook %@?": "¿Quitar webhook %@?",
"Rename": "Renombrar",
"Rename Profile": "Renombrar perfil",
"Rename Session": "Renombrar sesión",
"Rename...": "Renombrar...",
"Required": "Obligatorio",
"Required Tokens": "Tokens requeridos",
"Requires: %@": "Requiere: %@",
"Reset Cooldowns": "Restablecer enfriamientos",
"Restart": "Reiniciar",
"Restart Gateway": "Reiniciar gateway",
"Restart Hermes": "Reiniciar Hermes",
"Restart Now": "Reiniciar ahora",
"Restore": "Restaurar",
"Restore from backup?": "¿Restaurar desde copia de seguridad?",
"Restore…": "Restaurar…",
"Result": "Resultado",
"Resume": "Reanudar",
"Resume Session": "Reanudar sesión",
"Retry": "Reintentar",
"Return to Active Session (%@...)": "Volver a sesión activa (%@...)",
"Reveal": "Mostrar",
"Revoke": "Revocar",
"Rich Chat": "Chat enriquecido",
"Run Diagnostics…": "Ejecutar diagnósticos…",
"Run Dump": "Ejecutar dump",
"Run Now": "Ejecutar ahora",
"Run Setup in Terminal": "Ejecutar instalación en el terminal",
"Run `hermes memory setup` in Terminal for full provider configuration.": "Ejecuta `hermes memory setup` en el terminal para la configuración completa del proveedor.",
"Run remote diagnostics — check exactly which files are readable on this server.": "Ejecutar diagnósticos remotos — comprueba exactamente qué archivos se pueden leer en este servidor.",
"Running a single shell session on %@ that exercises every path Scarf reads…": "Ejecutando una sola sesión de shell en %@ que recorre cada ruta que lee Scarf…",
"Running checks…": "Ejecutando comprobaciones…",
"SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.": "SOUL.md describe la voz, valores y personalidad del agente en ~/.hermes/SOUL.md. Se inyecta en el contexto de cada sesión.",
"SSH works but %@. Click for diagnostics.": "SSH funciona pero %@. Haz clic para ver diagnósticos.",
"Save": "Guardar",
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:": "Scarf nunca pide frases de contraseña. Añade tu clave a ssh-agent en el terminal y haz clic en Reintentar. Si tu clave no es `id_ed25519`, cambia la ruta:",
"Scarf runs these over a single SSH session that mirrors the shell your dashboard reads from, so a green row here means Scarf can actually read that file at runtime.": "Scarf ejecuta estos comandos en una única sesión SSH idéntica al shell desde el que lee tu panel, por lo que una fila en verde significa que Scarf puede leer ese archivo en tiempo de ejecución.",
"Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.": "Scarf usa ssh-agent para autenticarse. Si tu clave tiene frase de contraseña, ejecuta `ssh-add` antes de conectarte — Scarf nunca pide ni almacena frases de contraseña.",
"Scarf — %@": "Scarf — %@",
"Search": "Buscar",
"Search Results (%lld)": "Resultados de búsqueda (%lld)",
"Search messages...": "Buscar mensajes...",
"Search or browse skills published to registries like skills.sh, GitHub, and the official hub.": "Busca o examina habilidades publicadas en registros como skills.sh, GitHub y el hub oficial.",
"Search registries": "Buscar en registros",
"Search…": "Buscar…",
"Security": "Seguridad",
"Select": "Seleccionar",
"Select Model": "Seleccionar modelo",
"Select a Job": "Seleccionar una tarea",
"Select a Profile": "Seleccionar un perfil",
"Select a Project": "Seleccionar un proyecto",
"Select a Session": "Seleccionar una sesión",
"Select a Skill": "Seleccionar una habilidad",
"Select a Tool Call": "Seleccionar una llamada a herramienta",
"Select an MCP Server": "Seleccionar un servidor MCP",
"Send message (Enter)": "Enviar mensaje (Intro)",
"Series": "Serie",
"Server": "Servidor",
"Server No Longer Exists": "El servidor ya no existe",
"Server name": "Nombre del servidor",
"Servers": "Servidores",
"Service": "Servicio",
"Service definition stale": "Definición de servicio obsoleta",
"Session": "Sesión",
"Session Search": "Búsqueda de sesiones",
"Session title": "Título de sesión",
"Sessions": "Sesiones",
"Settings": "Ajustes",
"Setup": "Configuración",
"Share Debug Report…": "Compartir informe de depuración…",
"Shell Command": "Comando de shell",
"Show": "Mostrar",
"Show Output": "Mostrar salida",
"Show all %lld lines": "Mostrar las %lld líneas",
"Show details": "Mostrar detalles",
"Show less": "Mostrar menos",
"Show values": "Mostrar valores",
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages.": "La integración con Signal requiere signal-cli (basado en Java) instalado localmente. Vincula este Mac como dispositivo Signal y mantén el demonio en ejecución para que hermes envíe/reciba mensajes.",
"Site": "Sitio",
"Skills": "Habilidades",
"Skills (%lld)": "Habilidades (%lld)",
"Skills Hub": "Hub de habilidades",
"Source": "Origen",
"Speech-to-Text": "Voz a texto",
"Start": "Iniciar",
"Start Daemon": "Iniciar demonio",
"Start Hermes": "Iniciar Hermes",
"Start OAuth": "Iniciar OAuth",
"Start Pairing": "Iniciar emparejamiento",
"Start a new session or resume an existing one from the Session menu above.": "Inicia una nueva sesión o reanuda una existente desde el menú Sesión de arriba.",
"Status": "Estado",
"Stop": "Detener",
"Stop Hermes": "Detener Hermes",
"Subagent": "Subagente",
"Subagent Sessions (%lld)": "Sesiones de subagente (%lld)",
"Submit": "Enviar",
"Subscribe": "Suscribir",
"Succeeded": "Exitoso",
"Switch to This Profile": "Cambiar a este perfil",
"Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.": "Cambiar el perfil activo cambia el directorio `~/.hermes` que usa hermes. Reinicia Scarf tras cambiar para que relea los archivos del nuevo perfil.",
"TTS Off": "TTS apagado",
"TTS On": "TTS encendido",
"Terminal": "Terminal",
"Test": "Probar",
"Test All": "Probar todo",
"Test Connection": "Probar conexión",
"Test failed": "Prueba fallida",
"Test passed": "Prueba superada",
"Text-to-Speech": "Texto a voz",
"The agent hasn't advertised any slash commands yet. Keep typing to send as a message, or press Esc.": "El agente aún no ha anunciado comandos slash. Sigue escribiendo para enviar como mensaje, o pulsa Esc.",
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection.": "La huella SSH del remoto ya no coincide con lo que tu archivo `~/.ssh/known_hosts` esperaba. Normalmente significa que el remoto se reinstaló — o, con menos frecuencia, que alguien intercepta la conexión.",
"The server this window was opened with has been removed from your registry.": "El servidor con el que se abrió esta ventana se ha eliminado de tu registro.",
"The server's SSH configuration is removed from Scarf. Your remote files are untouched.": "La configuración SSH del servidor se elimina de Scarf. Tus archivos remotos no se tocan.",
"The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".": "El terminal es un TTY real — pega con ⌘V, pulsa Intro y espera a que el proceso termine con «login succeeded».",
"These list fields must be edited directly in config.yaml.": "Estos campos de lista deben editarse directamente en config.yaml.",
"This provider has no catalogued models.": "Este proveedor no tiene modelos catalogados.",
"This removes the credential from hermes. The upstream provider key is not revoked.": "Esto quita la credencial de hermes. La clave del proveedor original no se revoca.",
"This removes the profile directory and all data within it. This cannot be undone.": "Esto elimina el directorio del perfil y todos sus datos. No se puede deshacer.",
"This removes the scheduled job permanently.": "Esto elimina la tarea programada de forma permanente.",
"This removes the server from config.yaml and deletes any OAuth token.": "Esto elimina el servidor de config.yaml y borra cualquier token OAuth.",
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.": "Esto sube registros, configuración (con secretos redactados) e información del sistema a la infraestructura de soporte de Nous Research. Revisa la salida antes de compartir la URL devuelta.",
"This will overwrite files under ~/.hermes/ with the archive contents.": "Esto sobrescribirá archivos bajo ~/.hermes/ con el contenido del archivo.",
"This will permanently delete the session and all its messages.": "Esto eliminará permanentemente la sesión y todos sus mensajes.",
"Timeout: %llds (%@)": "Tiempo de espera: %1$lld s (%2$@)",
"Timeouts": "Tiempos de espera",
"Tirith Sandbox": "Sandbox Tirith",
"To skip the passphrase prompt at every reboot, add `--apple-use-keychain` to cache it in macOS Keychain.": "Para evitar que pida la frase de contraseña en cada reinicio, añade `--apple-use-keychain` para almacenarla en el llavero de macOS.",
"Toggle text-to-speech (/voice tts)": "Alternar texto a voz (/voice tts)",
"Toggle voice mode (/voice)": "Alternar modo de voz (/voice)",
"Token on disk. Clear to re-authenticate next time the gateway connects.": "Token en disco. Bórralo para que se vuelva a autenticar en la próxima conexión del gateway.",
"Tool Approval Required": "Se requiere aprobación de herramienta",
"Tool Filters": "Filtros de herramientas",
"Tool Progress": "Progreso de herramientas",
"Tools": "Herramientas",
"Top Tools": "Herramientas principales",
"Turns & Reasoning": "Turnos y razonamiento",
"Uninstall": "Desinstalar",
"Unknown: %@": "Desconocido: %@",
"Update": "Actualizar",
"Update All": "Actualizar todo",
"Updated: %@": "Actualizado: %@",
"Updates": "Actualizaciones",
"Upload": "Subir",
"Upload debug report?": "¿Subir informe de depuración?",
"Usage Stats": "Estadísticas de uso",
"Use": "Usar",
"Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".": "Usa un modelo que no esté en el catálogo. Hermes acepta cualquier cadena que reconozca el proveedor, incluidas formas con prefijo como «openrouter/anthropic/claude-opus-4.6».",
"Use this": "Usar este",
"Use {dot.notation} to reference fields in the webhook payload.": "Usa {dot.notation} para referenciar campos del payload del webhook.",
"Used as the YAML key. Lowercase, no spaces.": "Se usa como clave YAML. Minúsculas, sin espacios.",
"View": "Ver",
"View All": "Ver todo",
"Vision": "Visión",
"Voice": "Voz",
"Voice Off": "Voz desactivada",
"Voice On": "Voz activada",
"Waiting for authorization URL…": "Esperando URL de autorización…",
"Waiting for first probe": "Esperando primera comprobación",
"Waiting for hermes to prompt for the code…": "Esperando a que hermes pida el código…",
"Web Extract": "Extracción web",
"Webhook (advanced)": "Webhook (avanzado)",
"Webhook (hermes side)": "Webhook (lado hermes)",
"Webhook Security": "Seguridad de webhook",
"Webhook platform not enabled": "Plataforma de webhooks no activada",
"Webhooks": "Webhooks",
"Webhooks let external services trigger agent responses. Each subscription gets its own URL endpoint.": "Los webhooks permiten que servicios externos disparen respuestas del agente. Cada suscripción obtiene su propio endpoint de URL.",
"Website Blocklist": "Lista de bloqueo de sitios",
"WhatsApp uses the Baileys library to emulate a WhatsApp Web session. Pair this Mac as a linked device by running the pairing wizard and scanning the QR code with your phone (Settings → Linked Devices → Link a Device).": "WhatsApp usa la biblioteca Baileys para emular una sesión de WhatsApp Web. Empareja este Mac como dispositivo vinculado ejecutando el asistente y escaneando el código QR con tu teléfono (Ajustes → Dispositivos vinculados → Vincular dispositivo).",
"Working": "Trabajando",
"e.g. anthropic": "p. ej. anthropic",
"e.g. deploy": "p. ej. deploy",
"e.g. experimental": "p. ej. experimental",
"e.g. github": "p. ej. github",
"e.g. openai": "p. ej. openai",
"e.g. openai/gpt-4o": "p. ej. openai/gpt-4o",
"e.g. team-prod": "p. ej. team-prod",
"exit code: %d": "código de salida: %d",
"hermes at %@": "hermes en %@",
"iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.": "La integración de iMessage usa BlueBubbles Server. Necesitas un Mac encendido con Messages.app iniciado — instala BlueBubbles Server ahí y apunta hermes a la URL de ese servidor.",
"signal-cli is available on PATH": "signal-cli está disponible en el PATH",
"signal-cli not found on PATH — install it first": "signal-cli no está en el PATH — instálalo primero",
"ssh trace": "traza ssh",
"ssh-agent (leave blank)": "ssh-agent (dejar vacío)",
"state.db not found at the configured path. Either Hermes hasn't run yet on this server, or it's installed at a non-default location — set the Hermes data directory field above.": "No se encontró state.db en la ruta configurada. O bien Hermes no se ha ejecutado aún en este servidor, o está instalado en una ubicación no predeterminada — establece arriba el campo del directorio de datos de Hermes.",
"state.db not found at the default location, but Scarf found one at:": "No se encontró state.db en la ubicación predeterminada, pero Scarf encontró uno en:",
"state.db readable": "state.db legible",
"— or use user/password login —": "— o usa inicio de sesión con usuario/contraseña —"
}
+585
View File
@@ -0,0 +1,585 @@
{
"%@ ctx": "%@ contexte",
"%@ in / %@ out": "%1$@ entrée / %2$@ sortie",
"%@ reasoning": "%@ raisonnement",
"%@ tokens": "%@ jetons",
"%@s · %lld tools": "%1$@ s · %2$lld outils",
"%lld %@": "%1$lld %2$@",
"%lld chars": "%lld caractères",
"%lld delivery failure%@": "%1$lld échec de livraison%2$@",
"%lld entries": "%lld entrées",
"%lld files": "%lld fichiers",
"%lld messages": "%lld messages",
"%lld msgs": "%lld msgs",
"%lld of %lld enabled": "%1$lld sur %2$lld activés",
"%lld reasoning": "%lld raisonnement",
"%lld req": "%lld requis",
"%lld required config": "%lld configuration(s) requise(s)",
"%lld sessions": "%lld sessions",
"%lld tokens": "%lld jetons",
"%lld tools": "%lld outils",
"30 Days": "30 jours",
"7 Days": "7 jours",
"90 Days": "90 jours",
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts.": "Un QR code apparaîtra ci-dessous. Scannez-le avec WhatsApp sur votre téléphone. La session est enregistrée dans ~/.hermes/platforms/whatsapp/, vous n'aurez donc pas besoin de la rescanner après un redémarrage.",
"API Key": "Clé API",
"API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.": "Les clés API ne sont jamais affichées en entier. Scarf n'affiche que les 4 derniers caractères pour identification. Les valeurs complètes sont stockées par hermes dans ~/.hermes/auth.json.",
"Access Control": "Contrôle d'accès",
"Actions": "Actions",
"Active": "Actif",
"Active Personality": "Personnalité active",
"Active profile": "Profil actif",
"Activity": "Activité",
"Activity Patterns": "Schémas d'activité",
"Add": "Ajouter",
"Add Command": "Ajouter une commande",
"Add Credential": "Ajouter des identifiants",
"Add Custom": "Ajouter personnalisé",
"Add Custom MCP Server": "Ajouter un serveur MCP personnalisé",
"Add Project": "Ajouter un projet",
"Add Quick Command": "Ajouter une commande rapide",
"Add Remote Server": "Ajouter un serveur distant",
"Add Server": "Ajouter un serveur",
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.": "Ajoutez un dossier de projet pour commencer. Créez un fichier .scarf/dashboard.json dans votre projet pour définir des widgets.",
"Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.": "Ajoutez des identifiants dans **Configurer → Pools d'identifiants**, définissez `ANTHROPIC_API_KEY` (ou équivalent) dans `~/.hermes/.env`, ou exportez-la dans votre profil shell, puis redémarrez Scarf.",
"Add from Preset": "Ajouter depuis un préréglage",
"Add rotation credentials so hermes can failover between keys when one hits rate limits.": "Ajoutez des identifiants de rotation pour que hermes puisse basculer entre les clés lorsqu'une atteint la limite de débit.",
"Add your first command": "Ajoutez votre première commande",
"Advanced": "Avancé",
"After approving in your browser, the provider shows a code. Paste it below and submit.": "Après avoir approuvé dans votre navigateur, le fournisseur affiche un code. Collez-le ci-dessous et soumettez.",
"Agent": "Agent",
"All": "Tous",
"All Levels": "Tous les niveaux",
"All Sessions": "Toutes les sessions",
"All Time": "Tout le temps",
"All installed hub skills are up to date.": "Toutes les compétences installées depuis le hub sont à jour.",
"App Credentials": "Identifiants de l'application",
"Approval": "Approbation",
"Approvals": "Approbations",
"Approve": "Approuver",
"Archive": "Archiver",
"Args (one per line)": "Arguments (un par ligne)",
"Arguments": "Arguments",
"Assistant Message": "Message de l'assistant",
"Auth": "Auth",
"Authentication": "Authentification",
"Authentication uses ssh-agent": "L'authentification utilise ssh-agent",
"Authorization Code": "Code d'autorisation",
"Authorization URL": "URL d'autorisation",
"Aux Models": "Modèles auxiliaires",
"Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.": "Les tâches auxiliaires utilisent des modèles distincts, généralement moins coûteux. Laissez Fournisseur sur `auto` pour hériter du fournisseur principal.",
"Back": "Retour",
"Back to Catalog": "Retour au catalogue",
"Backend": "Backend",
"Backup & Restore": "Sauvegarde et restauration",
"Backup Now": "Sauvegarder maintenant",
"Becomes the key under mcp_servers: in config.yaml.": "Devient la clé sous mcp_servers : dans config.yaml.",
"Behavior": "Comportement",
"Browse": "Parcourir",
"Browse Hub": "Parcourir le hub",
"Browse the Hub": "Parcourir le hub",
"Browse...": "Parcourir...",
"Browser": "Navigateur",
"Built-in Memory": "Mémoire intégrée",
"By Day": "Par jour",
"By Hour": "Par heure",
"Call timeout": "Délai d'appel",
"Can't read Hermes state on %@": "Impossible de lire l'état de Hermes sur %@",
"Cancel": "Annuler",
"Changes won't take effect until Hermes reloads the config.": "Les modifications ne prendront effet qu'au rechargement de la configuration par Hermes.",
"Chat": "Chat",
"Chat Messages": "Messages de chat",
"Check": "Vérifier",
"Check Now": "Vérifier maintenant",
"Check for Updates": "Vérifier les mises à jour",
"Check for Updates…": "Vérifier les mises à jour…",
"Checking…": "Vérification…",
"Checkpoints": "Points de contrôle",
"Choose a cron job from the list": "Choisissez une tâche cron dans la liste",
"Choose a profile to inspect.": "Choisissez un profil à inspecter.",
"Choose a project from the sidebar to view its dashboard.": "Choisissez un projet dans la barre latérale pour voir son tableau de bord.",
"Choose a session from the list": "Choisissez une session dans la liste",
"Choose a skill from the list": "Choisissez une compétence dans la liste",
"Choose an entry from the list": "Choisissez une entrée dans la liste",
"Choose…": "Choisir…",
"Clear Token": "Effacer le jeton",
"Clear all skills on save": "Effacer toutes les compétences à l'enregistrement",
"Click Add to connect to a remote Hermes installation over SSH.": "Cliquez sur Ajouter pour vous connecter à une installation Hermes distante via SSH.",
"Click for details": "Cliquez pour les détails",
"Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.": "Cliquer sur Démarrer OAuth ouvre la page d'autorisation du fournisseur dans votre navigateur. Après approbation, copiez le code affiché par le fournisseur et collez-le dans le terminal qui apparaît ensuite.",
"Clone config, .env, SOUL.md from active profile": "Cloner config, .env, SOUL.md depuis le profil actif",
"Close": "Fermer",
"Close Window": "Fermer la fenêtre",
"Code: %@": "Code : %@",
"Command": "Commande",
"Command Allowlist": "Liste d'autorisations de commandes",
"Command looks destructive. Double-check before saving.": "La commande semble destructive. Vérifiez avant d'enregistrer.",
"Component": "Composant",
"Compress": "Compresser",
"Compress Conversation": "Compresser la conversation",
"Compress conversation (/compress)": "Compresser la conversation (/compress)",
"Compression": "Compression",
"Config Diagnostics": "Diagnostics de configuration",
"Configure": "Configurer",
"Connect timeout": "Délai de connexion",
"Connected": "Connecté",
"Connected — can't read Hermes state": "Connecté — impossible de lire l'état de Hermes",
"Connection": "Connexion",
"Container Limits": "Limites du conteneur",
"Context & Compression": "Contexte et compression",
"Continue Last Session": "Continuer la dernière session",
"Copied": "Copié",
"Copy": "Copier",
"Copy Full Report": "Copier le rapport complet",
"Copy a plain-text summary of every check (passes and fails) — paste into GitHub issues so we can see everything at once.": "Copie un résumé en texte brut de chaque vérification (succès et échecs) — à coller dans les issues GitHub pour tout voir d'un coup.",
"Copy code": "Copier le code",
"Copy error details": "Copier les détails de l'erreur",
"Create": "Créer",
"Create Profile": "Créer un profil",
"Create Subscription": "Créer un abonnement",
"Create a Slack app at api.slack.com/apps, enable Socket Mode, grant bot scopes (chat:write, app_mentions:read, channels:history, etc.), then copy the Bot User OAuth Token (xoxb-) and the App-Level Token (xapp-).": "Créez une application Slack sur api.slack.com/apps, activez Socket Mode, accordez les scopes bot (chat:write, app_mentions:read, channels:history, etc.), puis copiez le Bot User OAuth Token (xoxb-) et l'App-Level Token (xapp-).",
"Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users.": "Créez un bot via @BotFather et obtenez votre ID numérique via @userinfobot. Collez le jeton et votre ID ci-dessous — le bot ne répondra qu'aux utilisateurs autorisés.",
"Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.": "Créez un jeton d'accès à longue durée dans Home Assistant (Profil → Sécurité → Long-Lived Access Tokens). Par défaut, aucun événement n'est transféré — activez Watch All Changes ou ajoutez des filtres d'entités ci-dessous.",
"Create a personal access token under Profile → Security → Personal Access Tokens, or create a bot account. Use the token as the MATTERMOST_TOKEN value.": "Créez un jeton d'accès personnel sous Profil → Sécurité → Personal Access Tokens, ou créez un compte bot. Utilisez le jeton comme valeur de MATTERMOST_TOKEN.",
"Create a profile to isolate config and skills.": "Créez un profil pour isoler configuration et compétences.",
"Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.": "Créez une application dans le Developer Portal de Discord, activez les intents Message Content et Server Members, puis copiez le jeton du bot. Invitez le bot sur votre serveur via le générateur d'URL OAuth2.",
"Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.": "Créez une application dans la Feishu/Lark Developer Console, activez Interactive Card si vous avez besoin de réponses par bouton, et copiez l'App ID et l'App Secret. Le mode WebSocket (recommandé) ne nécessite pas de point d'accès public.",
"Credential Pools": "Pools d'identifiants",
"Credential Type": "Type d'identifiants",
"Credentials": "Identifiants",
"Cron": "Cron",
"Cron Jobs": "Tâches cron",
"Current: %@": "Actuel : %@",
"Custom…": "Personnalisé…",
"Daemon Endpoint": "Endpoint du démon",
"Daemon running": "Démon en cours d'exécution",
"Dashboard": "Tableau de bord",
"Default": "Par défaut",
"Default: ~/.hermes": "Par défaut : ~/.hermes",
"Defaults to ~/.ssh/config or current user": "Par défaut : ~/.ssh/config ou utilisateur courant",
"Defined Personalities": "Personnalités définies",
"Delegation": "Délégation",
"Delete": "Supprimer",
"Delete %@?": "Supprimer %@ ?",
"Delete Session?": "Supprimer la session ?",
"Delete profile '%@'?": "Supprimer le profil « %@ » ?",
"Delete...": "Supprimer...",
"Deliver: %@": "Livrer : %@",
"Details": "Détails",
"Diagnostic Output": "Sortie de diagnostic",
"Diagnostics": "Diagnostics",
"Disable": "Désactiver",
"Disabled": "Désactivé",
"Display": "Affichage",
"Docs": "Docs",
"Done": "Terminé",
"Edit": "Modifier",
"Edit %@": "Modifier %@",
"Edit /%@": "Modifier /%@",
"Edit Agent Memory": "Modifier la mémoire de l'agent",
"Edit User Profile": "Modifier le profil utilisateur",
"Edit config.yaml": "Modifier config.yaml",
"Empty": "Vide",
"Enable": "Activer",
"Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.": "Activez la 2FA sur votre compte email et générez un mot de passe d'application. Les mots de passe de compte classiques ne fonctionneront pas. Définissez toujours les expéditeurs autorisés — sinon toute personne connaissant l'adresse pourra envoyer des messages à l'agent.",
"Enable the webhook platform to accept event-driven agent triggers. The HMAC secret is used as a fallback when individual routes don't provide their own.": "Activez la plateforme webhook pour accepter les déclencheurs d'agent pilotés par événements. Le secret HMAC est utilisé en repli quand les routes individuelles n'en fournissent pas.",
"Enabled": "Activé",
"End-to-End Encryption (experimental)": "Chiffrement de bout en bout (expérimental)",
"Entity Filters (config.yaml only)": "Filtres d'entités (config.yaml uniquement)",
"Env vars, headers, and tool filters can be edited after the server is added.": "Les variables d'environnement, en-têtes et filtres d'outils peuvent être modifiés après l'ajout du serveur.",
"Environment Variables": "Variables d'environnement",
"Error": "Erreur",
"Errors": "Erreurs",
"Event Filters": "Filtres d'événements",
"Exclude": "Exclure",
"Execute": "Exécuter",
"Expected at %@": "Attendu à %@",
"Export All": "Tout exporter",
"Export...": "Exporter...",
"Export…": "Exporter…",
"Expose prompts": "Exposer les prompts",
"Expose resources": "Exposer les ressources",
"External Provider": "Fournisseur externe",
"Feedback": "Retour",
"Fetch": "Récupérer",
"Files": "Fichiers",
"Filter logs...": "Filtrer les journaux...",
"Filter servers...": "Filtrer les serveurs...",
"Filter skills...": "Filtrer les compétences...",
"Filter to session %@": "Filtrer sur la session %@",
"Flush Memories": "Vider les mémoires",
"Focus topic (optional)": "Sujet ciblé (optionnel)",
"Full copy of active profile (all state)": "Copie complète du profil actif (tout l'état)",
"Gateway": "Gateway",
"Gateway Running": "Gateway en cours",
"Gateway Stopped": "Gateway arrêté",
"Gateway restart required": "Redémarrage du gateway requis",
"General": "Général",
"Global Settings": "Paramètres globaux",
"Header": "En-tête",
"Headers": "En-têtes",
"Health": "Santé",
"Hermes Not Found": "Hermes introuvable",
"Hermes Running": "Hermes en cours",
"Hermes Stopped": "Hermes arrêté",
"Hermes binary not found": "Binaire Hermes introuvable",
"Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually.": "Hermes a besoin d'un secret webhook global et d'un port avant que les abonnements puissent recevoir du trafic. Lancez l'assistant de configuration du gateway ou éditez ~/.hermes/config.yaml manuellement.",
"Hide": "Masquer",
"Hide Output": "Masquer la sortie",
"Hide details": "Masquer les détails",
"Home Channel": "Canal principal",
"Homeserver": "Homeserver",
"Host key changed": "Clé d'hôte modifiée",
"Human Delay": "Délai humain",
"ID: %@": "ID : %@",
"If this is the first connection, ensure your key is loaded with `ssh-add` and that the remote accepts it.": "S'il s'agit de la première connexion, assurez-vous que votre clé est chargée avec `ssh-add` et que l'hôte distant l'accepte.",
"If you trust the change, remove the stale entry and reconnect:": "Si vous faites confiance au changement, supprimez l'entrée obsolète et reconnectez-vous :",
"Import": "Importer",
"Inactive": "Inactif",
"Include (comma-separated — if set, only these are exposed)": "Inclure (séparés par des virgules — si défini, seuls ceux-ci sont exposés)",
"Insights": "Analyses",
"Install": "Installer",
"Install BlueBubbles Server": "Installer BlueBubbles Server",
"Install Plugin": "Installer le plugin",
"Install a Plugin": "Installer un plugin",
"Install signal-cli": "Installer signal-cli",
"Installed": "Installé",
"Interact": "Interagir",
"Invalid URL": "URL invalide",
"Keep typing to send as a message, or press Esc.": "Continuez à taper pour envoyer en tant que message, ou appuyez sur Échap.",
"Label (optional)": "Étiquette (optionnel)",
"Last Output": "Dernière sortie",
"Last probe: %@": "Dernière sonde : %@",
"Last run: %@": "Dernière exécution : %@",
"Last updated: %@": "Mis à jour : %@",
"Layout": "Mise en page",
"Leave blank to infer from the model ID's prefix (\"openai/...\" → openai).": "Laissez vide pour déduire du préfixe de l'ID du modèle (« openai/... » → openai).",
"Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates.": "Laissez vide sauf si Hermes est installé à un chemin non standard (les services systemd résident souvent dans /var/lib/hermes/.hermes ; les sidecars Docker varient). Tester la connexion suggère automatiquement une valeur lorsqu'un des chemins alternatifs connus est détecté.",
"Level": "Niveau",
"Link Device": "Associer un appareil",
"Link the device first to generate and scan a QR code. Once linked, start the daemon — it must keep running for hermes to send/receive messages.": "Associez d'abord l'appareil pour générer et scanner un QR code. Une fois associé, démarrez le démon — il doit continuer à fonctionner pour que hermes puisse envoyer/recevoir des messages.",
"Linking…": "Association…",
"Loaded": "Chargé",
"Loading session…": "Chargement de la session…",
"Local": "Local",
"Local (stdio)": "Local (stdio)",
"Locale": "Locale",
"Log File": "Fichier journal",
"Logging": "Journalisation",
"Logs": "Journaux",
"MCP Servers": "Serveurs MCP",
"MCP Servers (%lld)": "Serveurs MCP (%lld)",
"Manage": "Gérer",
"Manage Servers…": "Gérer les serveurs…",
"Manage in Credential Pools": "Gérer dans les pools d'identifiants",
"Matrix uses either an access token (preferred) or username/password. Get an access token from Element: Settings → Help & About → Access Token.": "Matrix utilise soit un jeton d'accès (préféré), soit un nom d'utilisateur/mot de passe. Obtenez un jeton d'accès dans Element : Paramètres → Aide & À propos → Access Token.",
"Memory": "Mémoire",
"Memory is managed by %@. File contents shown here may be stale.": "La mémoire est gérée par %@. Le contenu des fichiers affichés ici peut être obsolète.",
"Message Hermes...": "Envoyer un message à Hermes...",
"Messages will appear here as the conversation progresses.": "Les messages apparaîtront ici au fur et à mesure de la conversation.",
"Migrate": "Migrer",
"Missing required config:": "Configuration requise manquante :",
"Modal": "Modale",
"Model": "Modèle",
"Model ID": "ID du modèle",
"Models": "Modèles",
"Monitor": "Surveiller",
"Name": "Nom",
"Name (no leading slash)": "Nom (sans barre oblique initiale)",
"Network": "Réseau",
"New Session": "Nouvelle session",
"New Webhook Subscription": "Nouvel abonnement webhook",
"New name for '%@'": "Nouveau nom pour « %@ »",
"Next run: %@": "Prochaine exécution : %@",
"No AI provider credentials detected": "Aucun identifiant de fournisseur d'IA détecté",
"No Active Session": "Aucune session active",
"No Activity": "Aucune activité",
"No Cron Jobs": "Aucune tâche cron",
"No Dashboard": "Aucun tableau de bord",
"No MCP servers configured": "Aucun serveur MCP configuré",
"No Models": "Aucun modèle",
"No Profiles": "Aucun profil",
"No Projects": "Aucun projet",
"No Updates": "Aucune mise à jour",
"No active session": "Aucune session active",
"No additional output. Check ~/.ssh/config and ssh-agent.": "Aucune sortie supplémentaire. Vérifiez ~/.ssh/config et ssh-agent.",
"No commands available": "Aucune commande disponible",
"No credential pools configured": "Aucun pool d'identifiants configuré",
"No data": "Aucune donnée",
"No env vars configured.": "Aucune variable d'environnement configurée.",
"No env vars. Add one with the button below.": "Aucune variable d'environnement. Ajoutez-en une avec le bouton ci-dessous.",
"No headers configured.": "Aucun en-tête configuré.",
"No headers. Add one with the button below.": "Aucun en-tête. Ajoutez-en un avec le bouton ci-dessous.",
"No matching commands": "Aucune commande correspondante",
"No paired users": "Aucun utilisateur appairé",
"No platforms connected": "Aucune plateforme connectée",
"No plugins installed": "Aucun plugin installé",
"No quick commands configured": "Aucune commande rapide configurée",
"No remote servers": "Aucun serveur distant",
"No scheduled jobs configured": "Aucune tâche planifiée configurée",
"No servers configured yet": "Aucun serveur configuré pour l'instant",
"No sessions found": "Aucune session trouvée",
"No tool calls found": "Aucun appel d'outil trouvé",
"No webhook subscriptions": "Aucun abonnement webhook",
"None": "Aucun",
"Notable Sessions": "Sessions notables",
"OAuth login for %@": "Connexion OAuth pour %@",
"OK": "OK",
"Open BotFather": "Ouvrir BotFather",
"Open Developer Portal": "Ouvrir le Developer Portal",
"Open Local": "Ouvrir local",
"Open Other Server…": "Ouvrir un autre serveur…",
"Open Scarf": "Ouvrir Scarf",
"Open Server": "Ouvrir le serveur",
"Open Slack API": "Ouvrir l'API Slack",
"Open in Browser": "Ouvrir dans le navigateur",
"Open in Editor": "Ouvrir dans l'éditeur",
"Open in new window": "Ouvrir dans une nouvelle fenêtre",
"Open session": "Ouvrir la session",
"Optional": "Optionnel",
"Optional — defaults to hostname": "Optionnel — par défaut : nom d'hôte",
"Optionally focus the summary on a specific topic. Leave blank to compress evenly.": "Centrez éventuellement le résumé sur un sujet précis. Laissez vide pour compresser uniformément.",
"Other": "Autre",
"Output": "Sortie",
"Overview": "Vue d'ensemble",
"PID %d": "PID %d",
"PID %lld": "PID %lld",
"Pair Device": "Appairer l'appareil",
"Paired Users": "Utilisateurs appairés",
"Paste code here…": "Collez le code ici…",
"Paths": "Chemins",
"Pause": "Pause",
"Pending Approvals": "Approbations en attente",
"Per-route subscriptions (events, prompt template, delivery target) are managed in the Webhooks sidebar — not here. This panel only controls whether the webhook platform is listening at all.": "Les abonnements par route (événements, modèle de prompt, cible de livraison) sont gérés dans la barre latérale Webhooks — pas ici. Ce panneau contrôle uniquement si la plateforme webhook écoute.",
"Period": "Période",
"Personalities": "Personnalités",
"Personality": "Personnalité",
"Pick an MCP server to add.": "Choisissez un serveur MCP à ajouter.",
"Pick one from the list, or add a new server from the toolbar.": "Choisissez-en un dans la liste ou ajoutez un nouveau serveur depuis la barre d'outils.",
"Platforms": "Plateformes",
"Plugins": "Plugins",
"Plugins extend hermes with custom tools, providers, or memory backends.": "Les plugins étendent hermes avec des outils, fournisseurs ou backends mémoire personnalisés.",
"Pre-Run Script": "Script de pré-exécution",
"Preset:": "Préréglage :",
"Probe": "Sonder",
"Profile": "Profil",
"Profiles": "Profils",
"Project Name": "Nom du projet",
"Project Path": "Chemin du projet",
"Projects": "Projets",
"Prompt": "Prompt",
"Provide a Git URL (https://github.com/...) or a shorthand like `owner/repo`.": "Fournissez une URL Git (https://github.com/...) ou un raccourci comme `owner/repo`.",
"Provider": "Fournisseur",
"Push to Talk": "Push-to-Talk",
"Push to talk (Ctrl+B)": "Push-to-Talk (Ctrl+B)",
"Push-to-Talk": "Push-to-Talk",
"Quick Commands": "Commandes rapides",
"Quick commands are shell shortcuts hermes exposes in chat as `/command_name`. They live under `quick_commands:` in config.yaml.": "Les commandes rapides sont des raccourcis shell que hermes expose dans le chat sous la forme `/command_name`. Elles se trouvent sous `quick_commands:` dans config.yaml.",
"Quit Scarf": "Quitter Scarf",
"Raw Config": "Configuration brute",
"Raw remote output (for debugging)": "Sortie distante brute (pour le débogage)",
"Re-run": "Relancer",
"Read": "Lire",
"Reasoning": "Raisonnement",
"Recent Sessions": "Sessions récentes",
"Reconnect": "Reconnecter",
"Recording…": "Enregistrement…",
"Redaction": "Rédaction",
"Refresh": "Actualiser",
"Reload": "Recharger",
"Remote (HTTP)": "Distant (HTTP)",
"Remote Diagnostics — %@": "Diagnostics distants — %@",
"Remove": "Retirer",
"Remove %@?": "Retirer %@ ?",
"Remove credential for %@?": "Retirer les identifiants pour %@ ?",
"Remove this server from Scarf.": "Retirer ce serveur de Scarf.",
"Remove this server?": "Retirer ce serveur ?",
"Remove via config.yaml…": "Retirer via config.yaml…",
"Remove webhook %@?": "Retirer le webhook %@ ?",
"Rename": "Renommer",
"Rename Profile": "Renommer le profil",
"Rename Session": "Renommer la session",
"Rename...": "Renommer...",
"Required": "Requis",
"Required Tokens": "Jetons requis",
"Requires: %@": "Nécessite : %@",
"Reset Cooldowns": "Réinitialiser les temps de repos",
"Restart": "Redémarrer",
"Restart Gateway": "Redémarrer le gateway",
"Restart Hermes": "Redémarrer Hermes",
"Restart Now": "Redémarrer maintenant",
"Restore": "Restaurer",
"Restore from backup?": "Restaurer depuis la sauvegarde ?",
"Restore…": "Restaurer…",
"Result": "Résultat",
"Resume": "Reprendre",
"Resume Session": "Reprendre la session",
"Retry": "Réessayer",
"Return to Active Session (%@...)": "Retour à la session active (%@...)",
"Reveal": "Afficher",
"Revoke": "Révoquer",
"Rich Chat": "Chat enrichi",
"Run Diagnostics…": "Lancer les diagnostics…",
"Run Dump": "Exécuter Dump",
"Run Now": "Exécuter maintenant",
"Run Setup in Terminal": "Lancer l'installation dans le terminal",
"Run `hermes memory setup` in Terminal for full provider configuration.": "Exécutez `hermes memory setup` dans le terminal pour la configuration complète du fournisseur.",
"Run remote diagnostics — check exactly which files are readable on this server.": "Lancer les diagnostics distants — vérifier exactement quels fichiers sont lisibles sur ce serveur.",
"Running a single shell session on %@ that exercises every path Scarf reads…": "Exécution d'une session shell unique sur %@ qui teste chaque chemin que Scarf lit…",
"Running checks…": "Exécution des vérifications…",
"SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.": "SOUL.md décrit la voix, les valeurs et la personnalité de l'agent dans ~/.hermes/SOUL.md. Il est injecté dans le contexte de chaque session.",
"SSH works but %@. Click for diagnostics.": "SSH fonctionne mais %@. Cliquez pour les diagnostics.",
"Save": "Enregistrer",
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:": "Scarf ne demande jamais de phrases secrètes. Ajoutez votre clé à ssh-agent dans le terminal, puis cliquez sur Réessayer. Si votre clé n'est pas `id_ed25519`, changez le chemin :",
"Scarf runs these over a single SSH session that mirrors the shell your dashboard reads from, so a green row here means Scarf can actually read that file at runtime.": "Scarf exécute ces commandes sur une session SSH unique identique au shell utilisé par votre tableau de bord. Une ligne verte signifie donc que Scarf peut réellement lire ce fichier à l'exécution.",
"Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.": "Scarf utilise ssh-agent pour l'authentification. Si votre clé a une phrase secrète, exécutez `ssh-add` avant de vous connecter — Scarf ne demande ni ne stocke jamais de phrases secrètes.",
"Scarf — %@": "Scarf — %@",
"Search": "Rechercher",
"Search Results (%lld)": "Résultats de la recherche (%lld)",
"Search messages...": "Rechercher des messages...",
"Search or browse skills published to registries like skills.sh, GitHub, and the official hub.": "Recherchez ou parcourez les compétences publiées sur des registres comme skills.sh, GitHub et le hub officiel.",
"Search registries": "Rechercher dans les registres",
"Search…": "Rechercher…",
"Security": "Sécurité",
"Select": "Sélectionner",
"Select Model": "Sélectionner le modèle",
"Select a Job": "Sélectionner une tâche",
"Select a Profile": "Sélectionner un profil",
"Select a Project": "Sélectionner un projet",
"Select a Session": "Sélectionner une session",
"Select a Skill": "Sélectionner une compétence",
"Select a Tool Call": "Sélectionner un appel d'outil",
"Select an MCP Server": "Sélectionner un serveur MCP",
"Send message (Enter)": "Envoyer le message (Entrée)",
"Series": "Série",
"Server": "Serveur",
"Server No Longer Exists": "Le serveur n'existe plus",
"Server name": "Nom du serveur",
"Servers": "Serveurs",
"Service": "Service",
"Service definition stale": "Définition de service obsolète",
"Session": "Session",
"Session Search": "Recherche de sessions",
"Session title": "Titre de session",
"Sessions": "Sessions",
"Settings": "Réglages",
"Setup": "Configuration",
"Share Debug Report…": "Partager le rapport de débogage…",
"Shell Command": "Commande shell",
"Show": "Afficher",
"Show Output": "Afficher la sortie",
"Show all %lld lines": "Afficher toutes les %lld lignes",
"Show details": "Afficher les détails",
"Show less": "Afficher moins",
"Show values": "Afficher les valeurs",
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages.": "L'intégration Signal nécessite signal-cli (basé sur Java) installé localement. Associez ce Mac comme appareil Signal, puis laissez le démon fonctionner pour que hermes puisse envoyer/recevoir des messages.",
"Site": "Site",
"Skills": "Compétences",
"Skills (%lld)": "Compétences (%lld)",
"Skills Hub": "Skills Hub",
"Source": "Source",
"Speech-to-Text": "Reconnaissance vocale",
"Start": "Démarrer",
"Start Daemon": "Démarrer le démon",
"Start Hermes": "Démarrer Hermes",
"Start OAuth": "Démarrer OAuth",
"Start Pairing": "Démarrer l'appairage",
"Start a new session or resume an existing one from the Session menu above.": "Démarrez une nouvelle session ou reprenez-en une existante depuis le menu Session ci-dessus.",
"Status": "Statut",
"Stop": "Arrêter",
"Stop Hermes": "Arrêter Hermes",
"Subagent": "Sous-agent",
"Subagent Sessions (%lld)": "Sessions de sous-agent (%lld)",
"Submit": "Soumettre",
"Subscribe": "S'abonner",
"Succeeded": "Réussi",
"Switch to This Profile": "Basculer sur ce profil",
"Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.": "Changer de profil actif modifie le répertoire `~/.hermes` utilisé par hermes. Redémarrez Scarf après le changement pour qu'il relise les fichiers du nouveau profil.",
"TTS Off": "TTS désactivé",
"TTS On": "TTS activé",
"Terminal": "Terminal",
"Test": "Tester",
"Test All": "Tout tester",
"Test Connection": "Tester la connexion",
"Test failed": "Test échoué",
"Test passed": "Test réussi",
"Text-to-Speech": "Synthèse vocale",
"The agent hasn't advertised any slash commands yet. Keep typing to send as a message, or press Esc.": "L'agent n'a pas encore annoncé de commandes slash. Continuez à taper pour envoyer en tant que message, ou appuyez sur Échap.",
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection.": "L'empreinte SSH de l'hôte distant ne correspond plus à ce qu'attendait votre fichier `~/.ssh/known_hosts`. Cela signifie généralement que l'hôte distant a été réinstallé — ou, plus rarement, que quelqu'un intercepte la connexion.",
"The server this window was opened with has been removed from your registry.": "Le serveur avec lequel cette fenêtre a été ouverte a été retiré de votre registre.",
"The server's SSH configuration is removed from Scarf. Your remote files are untouched.": "La configuration SSH du serveur est supprimée de Scarf. Vos fichiers distants ne sont pas modifiés.",
"The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".": "Le terminal est un véritable TTY — collez avec ⌘V, appuyez sur Retour et attendez que le processus se termine avec « login succeeded ».",
"These list fields must be edited directly in config.yaml.": "Ces champs liste doivent être modifiés directement dans config.yaml.",
"This provider has no catalogued models.": "Ce fournisseur n'a pas de modèles catalogués.",
"This removes the credential from hermes. The upstream provider key is not revoked.": "Cela retire les identifiants de hermes. La clé chez le fournisseur amont n'est pas révoquée.",
"This removes the profile directory and all data within it. This cannot be undone.": "Cela supprime le répertoire du profil et toutes les données qu'il contient. Action irréversible.",
"This removes the scheduled job permanently.": "Cela supprime définitivement la tâche planifiée.",
"This removes the server from config.yaml and deletes any OAuth token.": "Cela retire le serveur de config.yaml et supprime tout jeton OAuth.",
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.": "Cela téléverse les journaux, la configuration (secrets masqués) et les infos système vers l'infrastructure de support Nous Research. Vérifiez la sortie ci-dessous avant de partager l'URL retournée.",
"This will overwrite files under ~/.hermes/ with the archive contents.": "Cela écrasera les fichiers sous ~/.hermes/ avec le contenu de l'archive.",
"This will permanently delete the session and all its messages.": "Cela supprimera définitivement la session et tous ses messages.",
"Timeout: %llds (%@)": "Délai : %1$lld s (%2$@)",
"Timeouts": "Délais",
"Tirith Sandbox": "Bac à sable Tirith",
"To skip the passphrase prompt at every reboot, add `--apple-use-keychain` to cache it in macOS Keychain.": "Pour éviter la demande de phrase secrète à chaque redémarrage, ajoutez `--apple-use-keychain` pour la mettre en cache dans le trousseau macOS.",
"Toggle text-to-speech (/voice tts)": "Basculer la synthèse vocale (/voice tts)",
"Toggle voice mode (/voice)": "Basculer le mode vocal (/voice)",
"Token on disk. Clear to re-authenticate next time the gateway connects.": "Jeton sur disque. Effacez-le pour forcer une nouvelle authentification à la prochaine connexion du gateway.",
"Tool Approval Required": "Approbation d'outil requise",
"Tool Filters": "Filtres d'outils",
"Tool Progress": "Progression des outils",
"Tools": "Outils",
"Top Tools": "Outils principaux",
"Turns & Reasoning": "Tours et raisonnement",
"Uninstall": "Désinstaller",
"Unknown: %@": "Inconnu : %@",
"Update": "Mettre à jour",
"Update All": "Tout mettre à jour",
"Updated: %@": "Mis à jour : %@",
"Updates": "Mises à jour",
"Upload": "Téléverser",
"Upload debug report?": "Téléverser le rapport de débogage ?",
"Usage Stats": "Statistiques d'utilisation",
"Use": "Utiliser",
"Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".": "Utilisez un modèle absent du catalogue. Hermes accepte toute chaîne reconnue par le fournisseur, y compris des formes préfixées comme « openrouter/anthropic/claude-opus-4.6 ».",
"Use this": "Utiliser celui-ci",
"Use {dot.notation} to reference fields in the webhook payload.": "Utilisez {dot.notation} pour référencer des champs dans la charge utile du webhook.",
"Used as the YAML key. Lowercase, no spaces.": "Utilisé comme clé YAML. Minuscules, sans espaces.",
"View": "Voir",
"View All": "Tout voir",
"Vision": "Vision",
"Voice": "Voix",
"Voice Off": "Voix désactivée",
"Voice On": "Voix activée",
"Waiting for authorization URL…": "En attente de l'URL d'autorisation…",
"Waiting for first probe": "En attente de la première sonde",
"Waiting for hermes to prompt for the code…": "En attente que hermes demande le code…",
"Web Extract": "Extraction Web",
"Webhook (advanced)": "Webhook (avancé)",
"Webhook (hermes side)": "Webhook (côté hermes)",
"Webhook Security": "Sécurité webhook",
"Webhook platform not enabled": "Plateforme webhook non activée",
"Webhooks": "Webhooks",
"Webhooks let external services trigger agent responses. Each subscription gets its own URL endpoint.": "Les webhooks permettent à des services externes de déclencher des réponses d'agent. Chaque abonnement a son propre point d'accès URL.",
"Website Blocklist": "Liste de blocage de sites",
"WhatsApp uses the Baileys library to emulate a WhatsApp Web session. Pair this Mac as a linked device by running the pairing wizard and scanning the QR code with your phone (Settings → Linked Devices → Link a Device).": "WhatsApp utilise la bibliothèque Baileys pour émuler une session WhatsApp Web. Appairez ce Mac comme appareil lié en lançant l'assistant d'appairage et en scannant le QR code avec votre téléphone (Paramètres → Appareils liés → Associer un appareil).",
"Working": "Travail en cours",
"e.g. anthropic": "par ex. anthropic",
"e.g. deploy": "par ex. deploy",
"e.g. experimental": "par ex. experimental",
"e.g. github": "par ex. github",
"e.g. openai": "par ex. openai",
"e.g. openai/gpt-4o": "par ex. openai/gpt-4o",
"e.g. team-prod": "par ex. team-prod",
"exit code: %d": "code de sortie : %d",
"hermes at %@": "hermes sur %@",
"iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.": "L'intégration iMessage passe par BlueBubbles Server. Il vous faut un Mac qui reste allumé avec Messages.app connecté — installez BlueBubbles Server dessus, puis pointez hermes vers l'URL de ce serveur.",
"signal-cli is available on PATH": "signal-cli est disponible dans le PATH",
"signal-cli not found on PATH — install it first": "signal-cli introuvable dans le PATH — installez-le d'abord",
"ssh trace": "trace ssh",
"ssh-agent (leave blank)": "ssh-agent (laisser vide)",
"state.db not found at the configured path. Either Hermes hasn't run yet on this server, or it's installed at a non-default location — set the Hermes data directory field above.": "state.db introuvable au chemin configuré. Soit Hermes n'a pas encore été lancé sur ce serveur, soit il est installé à un emplacement non standard — définissez le champ répertoire de données Hermes ci-dessus.",
"state.db not found at the default location, but Scarf found one at:": "state.db introuvable à l'emplacement par défaut, mais Scarf en a trouvé une à :",
"state.db readable": "state.db lisible",
"— or use user/password login —": "— ou utilisez la connexion utilisateur/mot de passe —"
}
+585
View File
@@ -0,0 +1,585 @@
{
"%@ ctx": "%@ コンテキスト",
"%@ in / %@ out": "入力 %1$@ / 出力 %2$@",
"%@ reasoning": "%@ 推論",
"%@ tokens": "%@ トークン",
"%@s · %lld tools": "%1$@ 秒 · %2$lld ツール",
"%lld %@": "%1$lld %2$@",
"%lld chars": "%lld 文字",
"%lld delivery failure%@": "%1$lld 件の配信失敗%2$@",
"%lld entries": "%lld 件",
"%lld files": "%lld ファイル",
"%lld messages": "%lld メッセージ",
"%lld msgs": "%lld メッセージ",
"%lld of %lld enabled": "%2$lld 中 %1$lld 個が有効",
"%lld reasoning": "%lld 推論",
"%lld req": "%lld 必須",
"%lld required config": "必須設定 %lld 件",
"%lld sessions": "%lld セッション",
"%lld tokens": "%lld トークン",
"%lld tools": "%lld ツール",
"30 Days": "30 日間",
"7 Days": "7 日間",
"90 Days": "90 日間",
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts.": "下に QR コードが表示されます。スマートフォンの WhatsApp でスキャンしてください。セッションは ~/.hermes/platforms/whatsapp/ に保存されるため、再起動後に再度スキャンする必要はありません。",
"API Key": "API キー",
"API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.": "API キーは完全な形では表示されません。Scarf は識別用に末尾 4 文字のみを表示します。完全なキーの値は hermes が ~/.hermes/auth.json に保存します。",
"Access Control": "アクセス制御",
"Actions": "アクション",
"Active": "アクティブ",
"Active Personality": "アクティブなパーソナリティ",
"Active profile": "アクティブなプロファイル",
"Activity": "アクティビティ",
"Activity Patterns": "アクティビティパターン",
"Add": "追加",
"Add Command": "コマンドを追加",
"Add Credential": "資格情報を追加",
"Add Custom": "カスタム追加",
"Add Custom MCP Server": "カスタム MCP サーバーを追加",
"Add Project": "プロジェクトを追加",
"Add Quick Command": "クイックコマンドを追加",
"Add Remote Server": "リモートサーバーを追加",
"Add Server": "サーバーを追加",
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.": "プロジェクトフォルダを追加して始めましょう。ウィジェットを定義するには、プロジェクト内に .scarf/dashboard.json ファイルを作成してください。",
"Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.": "**設定 → 資格情報プール** で資格情報を追加するか、`~/.hermes/.env` で `ANTHROPIC_API_KEY`(または類似の変数)を設定するか、シェルプロファイルでエクスポートしてから Scarf を再起動してください。",
"Add from Preset": "プリセットから追加",
"Add rotation credentials so hermes can failover between keys when one hits rate limits.": "ローテーション用の資格情報を追加すると、あるキーがレート制限に達した際に hermes が別のキーにフェイルオーバーできます。",
"Add your first command": "最初のコマンドを追加",
"Advanced": "詳細",
"After approving in your browser, the provider shows a code. Paste it below and submit.": "ブラウザで承認するとプロバイダーがコードを表示します。下に貼り付けて送信してください。",
"Agent": "Agent",
"All": "すべて",
"All Levels": "すべてのレベル",
"All Sessions": "すべてのセッション",
"All Time": "全期間",
"All installed hub skills are up to date.": "インストールされているハブスキルはすべて最新です。",
"App Credentials": "アプリ資格情報",
"Approval": "承認",
"Approvals": "承認",
"Approve": "承認",
"Archive": "アーカイブ",
"Args (one per line)": "引数(1 行に 1 つ)",
"Arguments": "引数",
"Assistant Message": "アシスタントメッセージ",
"Auth": "認証",
"Authentication": "認証",
"Authentication uses ssh-agent": "認証には ssh-agent を使用します",
"Authorization Code": "認可コード",
"Authorization URL": "認可 URL",
"Aux Models": "補助モデル",
"Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.": "補助タスクには別の、通常はより安価なモデルを使用します。メインプロバイダーを継承するには Provider を `auto` のままにしてください。",
"Back": "戻る",
"Back to Catalog": "カタログに戻る",
"Backend": "バックエンド",
"Backup & Restore": "バックアップと復元",
"Backup Now": "今すぐバックアップ",
"Becomes the key under mcp_servers: in config.yaml.": "config.yaml の mcp_servers: 下のキーになります。",
"Behavior": "動作",
"Browse": "参照",
"Browse Hub": "ハブを参照",
"Browse the Hub": "ハブを参照",
"Browse...": "参照...",
"Browser": "ブラウザ",
"Built-in Memory": "ビルトインメモリ",
"By Day": "日別",
"By Hour": "時間別",
"Call timeout": "呼び出しタイムアウト",
"Can't read Hermes state on %@": "%@ 上の Hermes 状態を読み取れません",
"Cancel": "キャンセル",
"Changes won't take effect until Hermes reloads the config.": "Hermes が設定を再読み込みするまで変更は反映されません。",
"Chat": "チャット",
"Chat Messages": "チャットメッセージ",
"Check": "確認",
"Check Now": "今すぐ確認",
"Check for Updates": "アップデートを確認",
"Check for Updates…": "アップデートを確認…",
"Checking…": "確認中…",
"Checkpoints": "チェックポイント",
"Choose a cron job from the list": "リストから cron ジョブを選択",
"Choose a profile to inspect.": "検査するプロファイルを選択してください。",
"Choose a project from the sidebar to view its dashboard.": "サイドバーからプロジェクトを選択してダッシュボードを表示します。",
"Choose a session from the list": "リストからセッションを選択",
"Choose a skill from the list": "リストからスキルを選択",
"Choose an entry from the list": "リストからエントリを選択",
"Choose…": "選択…",
"Clear Token": "トークンをクリア",
"Clear all skills on save": "保存時にすべてのスキルをクリア",
"Click Add to connect to a remote Hermes installation over SSH.": "追加をクリックして SSH 経由でリモートの Hermes インストールに接続します。",
"Click for details": "クリックして詳細",
"Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.": "OAuth を開始をクリックすると、ブラウザでプロバイダーの認可ページが開きます。承認後、プロバイダーが表示したコードをコピーし、次に表示されるターミナルに貼り付けてください。",
"Clone config, .env, SOUL.md from active profile": "アクティブなプロファイルから config、.env、SOUL.md を複製",
"Close": "閉じる",
"Close Window": "ウィンドウを閉じる",
"Code: %@": "コード: %@",
"Command": "コマンド",
"Command Allowlist": "コマンド許可リスト",
"Command looks destructive. Double-check before saving.": "コマンドが破壊的に見えます。保存前に再確認してください。",
"Component": "コンポーネント",
"Compress": "圧縮",
"Compress Conversation": "会話を圧縮",
"Compress conversation (/compress)": "会話を圧縮 (/compress)",
"Compression": "圧縮",
"Config Diagnostics": "設定診断",
"Configure": "設定",
"Connect timeout": "接続タイムアウト",
"Connected": "接続済み",
"Connected — can't read Hermes state": "接続済み — Hermes 状態を読み取れません",
"Connection": "接続",
"Container Limits": "コンテナ制限",
"Context & Compression": "コンテキストと圧縮",
"Continue Last Session": "前回のセッションを続ける",
"Copied": "コピー済み",
"Copy": "コピー",
"Copy Full Report": "完全なレポートをコピー",
"Copy a plain-text summary of every check (passes and fails) — paste into GitHub issues so we can see everything at once.": "各チェック(成功・失敗)の概要をプレーンテキストでコピーします — GitHub issue に貼り付けて一度に確認できるようにします。",
"Copy code": "コードをコピー",
"Copy error details": "エラー詳細をコピー",
"Create": "作成",
"Create Profile": "プロファイルを作成",
"Create Subscription": "サブスクリプションを作成",
"Create a Slack app at api.slack.com/apps, enable Socket Mode, grant bot scopes (chat:write, app_mentions:read, channels:history, etc.), then copy the Bot User OAuth Token (xoxb-) and the App-Level Token (xapp-).": "api.slack.com/apps で Slack アプリを作成し、Socket Mode を有効にし、bot スコープ(chat:write、app_mentions:read、channels:history など)を付与してから Bot User OAuth Token(xoxb-)と App-Level Token(xapp-)をコピーしてください。",
"Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users.": "@BotFather でボットを作成し、@userinfobot から数値ユーザー ID を取得してください。トークンとユーザー ID を下に貼り付けてください — ボットは許可されたユーザーにのみ応答します。",
"Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.": "Home Assistant で長期アクセストークンを作成してください(プロファイル → セキュリティ → Long-Lived Access Tokens)。デフォルトではイベントは転送されません — Watch All Changes を有効にするか、下でエンティティフィルタを追加してください。",
"Create a personal access token under Profile → Security → Personal Access Tokens, or create a bot account. Use the token as the MATTERMOST_TOKEN value.": "プロファイル → セキュリティ → Personal Access Tokens でパーソナルアクセストークンを作成するか、ボットアカウントを作成してください。そのトークンを MATTERMOST_TOKEN の値として使用します。",
"Create a profile to isolate config and skills.": "設定とスキルを分離するプロファイルを作成します。",
"Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.": "Discord の Developer Portal でアプリを作成し、Message Content と Server Members の intents を有効にしてから、ボットトークンをコピーしてください。OAuth2 URL ジェネレーターでボットをサーバーに招待します。",
"Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.": "Feishu/Lark Developer Console でアプリを作成し、ボタン応答が必要であれば Interactive Card を有効にして、App ID と App Secret をコピーしてください。WebSocket モード(推奨)ではパブリックエンドポイントは不要です。",
"Credential Pools": "資格情報プール",
"Credential Type": "資格情報の種類",
"Credentials": "資格情報",
"Cron": "Cron",
"Cron Jobs": "Cron ジョブ",
"Current: %@": "現在: %@",
"Custom…": "カスタム…",
"Daemon Endpoint": "デーモンエンドポイント",
"Daemon running": "デーモン実行中",
"Dashboard": "ダッシュボード",
"Default": "デフォルト",
"Default: ~/.hermes": "デフォルト: ~/.hermes",
"Defaults to ~/.ssh/config or current user": "デフォルトは ~/.ssh/config または現在のユーザー",
"Defined Personalities": "定義済みパーソナリティ",
"Delegation": "委譲",
"Delete": "削除",
"Delete %@?": "%@ を削除しますか?",
"Delete Session?": "セッションを削除しますか?",
"Delete profile '%@'?": "プロファイル '%@' を削除しますか?",
"Delete...": "削除...",
"Deliver: %@": "配信: %@",
"Details": "詳細",
"Diagnostic Output": "診断出力",
"Diagnostics": "診断",
"Disable": "無効化",
"Disabled": "無効",
"Display": "表示",
"Docs": "ドキュメント",
"Done": "完了",
"Edit": "編集",
"Edit %@": "%@ を編集",
"Edit /%@": "/%@ を編集",
"Edit Agent Memory": "エージェントメモリを編集",
"Edit User Profile": "ユーザープロファイルを編集",
"Edit config.yaml": "config.yaml を編集",
"Empty": "空",
"Enable": "有効化",
"Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.": "メールアカウントで 2FA を有効にし、アプリパスワードを生成してください。通常のアカウントパスワードは使用できません。許可された送信者を必ず設定してください — そうしないと、アドレスを知っている誰もがエージェントにメッセージを送れます。",
"Enable the webhook platform to accept event-driven agent triggers. The HMAC secret is used as a fallback when individual routes don't provide their own.": "webhook プラットフォームを有効化してイベント駆動のエージェントトリガーを受け付けます。個別のルートが独自のものを提供しない場合、HMAC シークレットがフォールバックとして使用されます。",
"Enabled": "有効",
"End-to-End Encryption (experimental)": "エンドツーエンド暗号化(実験的)",
"Entity Filters (config.yaml only)": "エンティティフィルタ(config.yaml のみ)",
"Env vars, headers, and tool filters can be edited after the server is added.": "環境変数、ヘッダー、ツールフィルタはサーバー追加後に編集できます。",
"Environment Variables": "環境変数",
"Error": "エラー",
"Errors": "エラー",
"Event Filters": "イベントフィルタ",
"Exclude": "除外",
"Execute": "実行",
"Expected at %@": "%@ に期待",
"Export All": "すべてエクスポート",
"Export...": "エクスポート...",
"Export…": "エクスポート…",
"Expose prompts": "プロンプトを公開",
"Expose resources": "リソースを公開",
"External Provider": "外部プロバイダー",
"Feedback": "フィードバック",
"Fetch": "取得",
"Files": "ファイル",
"Filter logs...": "ログをフィルタ...",
"Filter servers...": "サーバーをフィルタ...",
"Filter skills...": "スキルをフィルタ...",
"Filter to session %@": "セッション %@ にフィルタ",
"Flush Memories": "メモリをフラッシュ",
"Focus topic (optional)": "フォーカストピック(任意)",
"Full copy of active profile (all state)": "アクティブなプロファイルの完全コピー(すべての状態)",
"Gateway": "Gateway",
"Gateway Running": "Gateway 実行中",
"Gateway Stopped": "Gateway 停止",
"Gateway restart required": "Gateway の再起動が必要です",
"General": "一般",
"Global Settings": "グローバル設定",
"Header": "ヘッダー",
"Headers": "ヘッダー",
"Health": "状態",
"Hermes Not Found": "Hermes が見つかりません",
"Hermes Running": "Hermes 実行中",
"Hermes Stopped": "Hermes 停止",
"Hermes binary not found": "Hermes バイナリが見つかりません",
"Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually.": "サブスクリプションがトラフィックを受信する前に、Hermes にはグローバルな webhook シークレットとポートが必要です。ゲートウェイセットアップウィザードを実行するか、~/.hermes/config.yaml を手動で編集してください。",
"Hide": "非表示",
"Hide Output": "出力を非表示",
"Hide details": "詳細を非表示",
"Home Channel": "ホームチャンネル",
"Homeserver": "ホームサーバー",
"Host key changed": "ホストキーが変更されました",
"Human Delay": "ヒューマンディレイ",
"ID: %@": "ID: %@",
"If this is the first connection, ensure your key is loaded with `ssh-add` and that the remote accepts it.": "初回接続の場合は、`ssh-add` でキーがロードされていることと、リモートがそれを受け付けていることを確認してください。",
"If you trust the change, remove the stale entry and reconnect:": "変更を信頼する場合、古いエントリを削除して再接続してください:",
"Import": "インポート",
"Inactive": "非アクティブ",
"Include (comma-separated — if set, only these are exposed)": "含める(カンマ区切り — 設定した場合これらのみが公開されます)",
"Insights": "インサイト",
"Install": "インストール",
"Install BlueBubbles Server": "BlueBubbles Server をインストール",
"Install Plugin": "プラグインをインストール",
"Install a Plugin": "プラグインをインストール",
"Install signal-cli": "signal-cli をインストール",
"Installed": "インストール済み",
"Interact": "操作",
"Invalid URL": "無効な URL",
"Keep typing to send as a message, or press Esc.": "入力を続けるとメッセージとして送信されます。キャンセルするには Esc を押してください。",
"Label (optional)": "ラベル(任意)",
"Last Output": "最終出力",
"Last probe: %@": "最終確認: %@",
"Last run: %@": "最終実行: %@",
"Last updated: %@": "最終更新: %@",
"Layout": "レイアウト",
"Leave blank to infer from the model ID's prefix (\"openai/...\" → openai).": "モデル ID のプレフィックスから推定するには空のままにします(\"openai/...\" → openai)。",
"Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates.": "Hermes がデフォルト以外のパスにインストールされていない限り空のままにしてください(systemd サービスはしばしば /var/lib/hermes/.hermes にあり、Docker サイドカーは様々です)。テスト接続は既知の代替パスを検出すると自動的に値を提案します。",
"Level": "レベル",
"Link Device": "デバイスをリンク",
"Link the device first to generate and scan a QR code. Once linked, start the daemon — it must keep running for hermes to send/receive messages.": "まずデバイスをリンクして QR コードを生成・スキャンしてください。リンク後、デーモンを起動してください — hermes がメッセージを送受信するには動作し続ける必要があります。",
"Linking…": "リンク中…",
"Loaded": "読み込み済み",
"Loading session…": "セッションを読み込み中…",
"Local": "ローカル",
"Local (stdio)": "ローカル (stdio)",
"Locale": "ロケール",
"Log File": "ログファイル",
"Logging": "ログ",
"Logs": "ログ",
"MCP Servers": "MCP サーバー",
"MCP Servers (%lld)": "MCP サーバー (%lld)",
"Manage": "管理",
"Manage Servers…": "サーバーを管理…",
"Manage in Credential Pools": "資格情報プールで管理",
"Matrix uses either an access token (preferred) or username/password. Get an access token from Element: Settings → Help & About → Access Token.": "Matrix はアクセストークン(推奨)かユーザー名/パスワードを使用します。Element からアクセストークンを取得してください: 設定 → ヘルプ & 情報 → アクセストークン。",
"Memory": "メモリ",
"Memory is managed by %@. File contents shown here may be stale.": "メモリは %@ が管理します。ここに表示されるファイル内容は古い場合があります。",
"Message Hermes...": "Hermes にメッセージを送信...",
"Messages will appear here as the conversation progresses.": "会話が進むにつれてメッセージがここに表示されます。",
"Migrate": "移行",
"Missing required config:": "必須設定が不足しています:",
"Modal": "モーダル",
"Model": "モデル",
"Model ID": "モデル ID",
"Models": "モデル",
"Monitor": "モニター",
"Name": "名前",
"Name (no leading slash)": "名前(先頭のスラッシュなし)",
"Network": "ネットワーク",
"New Session": "新しいセッション",
"New Webhook Subscription": "新しい Webhook サブスクリプション",
"New name for '%@'": "'%@' の新しい名前",
"Next run: %@": "次回実行: %@",
"No AI provider credentials detected": "AI プロバイダーの資格情報が検出されません",
"No Active Session": "アクティブなセッションなし",
"No Activity": "アクティビティなし",
"No Cron Jobs": "Cron ジョブなし",
"No Dashboard": "ダッシュボードなし",
"No MCP servers configured": "MCP サーバーが設定されていません",
"No Models": "モデルなし",
"No Profiles": "プロファイルなし",
"No Projects": "プロジェクトなし",
"No Updates": "アップデートなし",
"No active session": "アクティブなセッションなし",
"No additional output. Check ~/.ssh/config and ssh-agent.": "追加出力はありません。~/.ssh/config と ssh-agent を確認してください。",
"No commands available": "利用可能なコマンドはありません",
"No credential pools configured": "資格情報プールが設定されていません",
"No data": "データなし",
"No env vars configured.": "環境変数が設定されていません。",
"No env vars. Add one with the button below.": "環境変数がありません。下のボタンで追加してください。",
"No headers configured.": "ヘッダーが設定されていません。",
"No headers. Add one with the button below.": "ヘッダーがありません。下のボタンで追加してください。",
"No matching commands": "一致するコマンドなし",
"No paired users": "ペア済みユーザーなし",
"No platforms connected": "接続されているプラットフォームなし",
"No plugins installed": "プラグインがインストールされていません",
"No quick commands configured": "クイックコマンドが設定されていません",
"No remote servers": "リモートサーバーなし",
"No scheduled jobs configured": "スケジュールされたジョブなし",
"No servers configured yet": "まだサーバーが設定されていません",
"No sessions found": "セッションが見つかりません",
"No tool calls found": "ツール呼び出しが見つかりません",
"No webhook subscriptions": "Webhook サブスクリプションなし",
"None": "なし",
"Notable Sessions": "注目のセッション",
"OAuth login for %@": "%@ の OAuth ログイン",
"OK": "OK",
"Open BotFather": "BotFather を開く",
"Open Developer Portal": "Developer Portal を開く",
"Open Local": "ローカルを開く",
"Open Other Server…": "他のサーバーを開く…",
"Open Scarf": "Scarf を開く",
"Open Server": "サーバーを開く",
"Open Slack API": "Slack API を開く",
"Open in Browser": "ブラウザで開く",
"Open in Editor": "エディタで開く",
"Open in new window": "新しいウィンドウで開く",
"Open session": "セッションを開く",
"Optional": "任意",
"Optional — defaults to hostname": "任意 — デフォルトはホスト名",
"Optionally focus the summary on a specific topic. Leave blank to compress evenly.": "要約を特定のトピックに絞ることができます。均等に圧縮するには空のままにしてください。",
"Other": "その他",
"Output": "出力",
"Overview": "概要",
"PID %d": "PID %d",
"PID %lld": "PID %lld",
"Pair Device": "デバイスをペアリング",
"Paired Users": "ペア済みユーザー",
"Paste code here…": "ここにコードを貼り付け…",
"Paths": "パス",
"Pause": "一時停止",
"Pending Approvals": "承認待ち",
"Per-route subscriptions (events, prompt template, delivery target) are managed in the Webhooks sidebar — not here. This panel only controls whether the webhook platform is listening at all.": "ルートごとのサブスクリプション(イベント、プロンプトテンプレート、配信先)は Webhooks サイドバーで管理します — ここではありません。このパネルは webhook プラットフォームが待ち受けるかどうかのみを制御します。",
"Period": "期間",
"Personalities": "パーソナリティ",
"Personality": "パーソナリティ",
"Pick an MCP server to add.": "追加する MCP サーバーを選択してください。",
"Pick one from the list, or add a new server from the toolbar.": "リストから選ぶか、ツールバーから新しいサーバーを追加してください。",
"Platforms": "プラットフォーム",
"Plugins": "プラグイン",
"Plugins extend hermes with custom tools, providers, or memory backends.": "プラグインは、カスタムツール、プロバイダー、メモリバックエンドで hermes を拡張します。",
"Pre-Run Script": "事前実行スクリプト",
"Preset:": "プリセット:",
"Probe": "プローブ",
"Profile": "プロファイル",
"Profiles": "プロファイル",
"Project Name": "プロジェクト名",
"Project Path": "プロジェクトパス",
"Projects": "プロジェクト",
"Prompt": "プロンプト",
"Provide a Git URL (https://github.com/...) or a shorthand like `owner/repo`.": "Git URL(https://github.com/...)または `owner/repo` のような省略形を指定してください。",
"Provider": "プロバイダー",
"Push to Talk": "押して話す",
"Push to talk (Ctrl+B)": "押して話す (Ctrl+B)",
"Push-to-Talk": "プッシュ・トゥ・トーク",
"Quick Commands": "クイックコマンド",
"Quick commands are shell shortcuts hermes exposes in chat as `/command_name`. They live under `quick_commands:` in config.yaml.": "クイックコマンドは、hermes がチャットで `/command_name` として公開するシェルショートカットです。config.yaml の `quick_commands:` 以下に記述します。",
"Quit Scarf": "Scarf を終了",
"Raw Config": "生の設定",
"Raw remote output (for debugging)": "生のリモート出力(デバッグ用)",
"Re-run": "再実行",
"Read": "読み取り",
"Reasoning": "推論",
"Recent Sessions": "最近のセッション",
"Reconnect": "再接続",
"Recording…": "録音中…",
"Redaction": "秘匿化",
"Refresh": "更新",
"Reload": "再読み込み",
"Remote (HTTP)": "リモート (HTTP)",
"Remote Diagnostics — %@": "リモート診断 — %@",
"Remove": "削除",
"Remove %@?": "%@ を削除しますか?",
"Remove credential for %@?": "%@ の資格情報を削除しますか?",
"Remove this server from Scarf.": "このサーバーを Scarf から削除します。",
"Remove this server?": "このサーバーを削除しますか?",
"Remove via config.yaml…": "config.yaml 経由で削除…",
"Remove webhook %@?": "webhook %@ を削除しますか?",
"Rename": "名前を変更",
"Rename Profile": "プロファイル名を変更",
"Rename Session": "セッション名を変更",
"Rename...": "名前を変更...",
"Required": "必須",
"Required Tokens": "必須トークン",
"Requires: %@": "必須: %@",
"Reset Cooldowns": "クールダウンをリセット",
"Restart": "再起動",
"Restart Gateway": "Gateway を再起動",
"Restart Hermes": "Hermes を再起動",
"Restart Now": "今すぐ再起動",
"Restore": "復元",
"Restore from backup?": "バックアップから復元しますか?",
"Restore…": "復元…",
"Result": "結果",
"Resume": "再開",
"Resume Session": "セッションを再開",
"Retry": "再試行",
"Return to Active Session (%@...)": "アクティブセッションに戻る (%@...)",
"Reveal": "表示",
"Revoke": "取り消し",
"Rich Chat": "リッチチャット",
"Run Diagnostics…": "診断を実行…",
"Run Dump": "ダンプを実行",
"Run Now": "今すぐ実行",
"Run Setup in Terminal": "ターミナルでセットアップを実行",
"Run `hermes memory setup` in Terminal for full provider configuration.": "プロバイダーの完全な設定には、ターミナルで `hermes memory setup` を実行してください。",
"Run remote diagnostics — check exactly which files are readable on this server.": "リモート診断を実行 — このサーバーで読み取れるファイルを正確に確認します。",
"Running a single shell session on %@ that exercises every path Scarf reads…": "Scarf が読み取るすべてのパスを確認する単一のシェルセッションを %@ で実行中…",
"Running checks…": "チェックを実行中…",
"SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.": "SOUL.md は ~/.hermes/SOUL.md でエージェントの話し方、価値観、パーソナリティを記述します。これはすべてのセッションのコンテキストに挿入されます。",
"SSH works but %@. Click for diagnostics.": "SSH は動作していますが %@。診断を表示するにはクリックしてください。",
"Save": "保存",
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:": "Scarf はパスフレーズを要求しません。ターミナルで ssh-agent にキーを追加してから、再試行をクリックしてください。キーが `id_ed25519` でない場合はパスを変更してください:",
"Scarf runs these over a single SSH session that mirrors the shell your dashboard reads from, so a green row here means Scarf can actually read that file at runtime.": "Scarf はダッシュボードが読み取るシェルと同じ単一の SSH セッションでこれらを実行します。ここで緑色の行は、Scarf が実行時にそのファイルを実際に読み取れることを意味します。",
"Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.": "Scarf は認証に ssh-agent を使用します。キーにパスフレーズがある場合は、接続前に `ssh-add` を実行してください — Scarf はパスフレーズを要求することも保存することもありません。",
"Scarf — %@": "Scarf — %@",
"Search": "検索",
"Search Results (%lld)": "検索結果 (%lld)",
"Search messages...": "メッセージを検索...",
"Search or browse skills published to registries like skills.sh, GitHub, and the official hub.": "skills.sh、GitHub、公式ハブなどのレジストリに公開されたスキルを検索または参照します。",
"Search registries": "レジストリを検索",
"Search…": "検索…",
"Security": "セキュリティ",
"Select": "選択",
"Select Model": "モデルを選択",
"Select a Job": "ジョブを選択",
"Select a Profile": "プロファイルを選択",
"Select a Project": "プロジェクトを選択",
"Select a Session": "セッションを選択",
"Select a Skill": "スキルを選択",
"Select a Tool Call": "ツール呼び出しを選択",
"Select an MCP Server": "MCP サーバーを選択",
"Send message (Enter)": "メッセージを送信 (Enter)",
"Series": "系列",
"Server": "サーバー",
"Server No Longer Exists": "サーバーは存在しません",
"Server name": "サーバー名",
"Servers": "サーバー",
"Service": "サービス",
"Service definition stale": "サービス定義が古くなっています",
"Session": "セッション",
"Session Search": "セッション検索",
"Session title": "セッションタイトル",
"Sessions": "セッション",
"Settings": "設定",
"Setup": "セットアップ",
"Share Debug Report…": "デバッグレポートを共有…",
"Shell Command": "シェルコマンド",
"Show": "表示",
"Show Output": "出力を表示",
"Show all %lld lines": "すべての %lld 行を表示",
"Show details": "詳細を表示",
"Show less": "折りたたむ",
"Show values": "値を表示",
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages.": "Signal 連携にはローカルにインストールされた signal-cli(Java 製)が必要です。この Mac を Signal デバイスとしてリンクしてから、デーモンを動作させ続けて hermes がメッセージを送受信できるようにします。",
"Site": "サイト",
"Skills": "スキル",
"Skills (%lld)": "スキル (%lld)",
"Skills Hub": "スキルハブ",
"Source": "ソース",
"Speech-to-Text": "音声認識",
"Start": "開始",
"Start Daemon": "デーモンを開始",
"Start Hermes": "Hermes を開始",
"Start OAuth": "OAuth を開始",
"Start Pairing": "ペアリングを開始",
"Start a new session or resume an existing one from the Session menu above.": "上のセッションメニューから新しいセッションを開始するか、既存のセッションを再開してください。",
"Status": "ステータス",
"Stop": "停止",
"Stop Hermes": "Hermes を停止",
"Subagent": "サブエージェント",
"Subagent Sessions (%lld)": "サブエージェントセッション (%lld)",
"Submit": "送信",
"Subscribe": "購読",
"Succeeded": "成功",
"Switch to This Profile": "このプロファイルに切り替え",
"Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.": "アクティブなプロファイルを切り替えると、hermes が使用する `~/.hermes` ディレクトリが変わります。切り替え後、Scarf を再起動して新しいプロファイルのファイルを読み込み直してください。",
"TTS Off": "TTS オフ",
"TTS On": "TTS オン",
"Terminal": "ターミナル",
"Test": "テスト",
"Test All": "すべてテスト",
"Test Connection": "接続テスト",
"Test failed": "テスト失敗",
"Test passed": "テスト成功",
"Text-to-Speech": "音声合成",
"The agent hasn't advertised any slash commands yet. Keep typing to send as a message, or press Esc.": "エージェントはまだスラッシュコマンドを提示していません。入力を続けるとメッセージとして送信されます。キャンセルするには Esc を押してください。",
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection.": "リモートの SSH フィンガープリントが `~/.ssh/known_hosts` の期待値と一致しません。通常はリモートが再インストールされたことを意味します — まれに通信が傍受されている可能性もあります。",
"The server this window was opened with has been removed from your registry.": "このウィンドウを開いたサーバーはレジストリから削除されました。",
"The server's SSH configuration is removed from Scarf. Your remote files are untouched.": "サーバーの SSH 設定は Scarf から削除されます。リモートファイルは変更されません。",
"The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".": "このターミナルは実際の TTY です — ⌘V で貼り付け、Return を押して、プロセスが「login succeeded」で終了するのを待ってください。",
"These list fields must be edited directly in config.yaml.": "これらのリストフィールドは config.yaml で直接編集する必要があります。",
"This provider has no catalogued models.": "このプロバイダーにはカタログ化されたモデルがありません。",
"This removes the credential from hermes. The upstream provider key is not revoked.": "これにより hermes から資格情報が削除されます。上流プロバイダーのキーは取り消されません。",
"This removes the profile directory and all data within it. This cannot be undone.": "これによりプロファイルディレクトリとその中のすべてのデータが削除されます。元に戻せません。",
"This removes the scheduled job permanently.": "これによりスケジュールされたジョブが恒久的に削除されます。",
"This removes the server from config.yaml and deletes any OAuth token.": "これにより config.yaml からサーバーが削除され、OAuth トークンも削除されます。",
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.": "これにより、ログ、設定(シークレットはマスク済み)、システム情報が Nous Research のサポートインフラにアップロードされます。返された URL を共有する前に下の出力を確認してください。",
"This will overwrite files under ~/.hermes/ with the archive contents.": "これにより ~/.hermes/ 以下のファイルがアーカイブの内容で上書きされます。",
"This will permanently delete the session and all its messages.": "これによりセッションとそのすべてのメッセージが恒久的に削除されます。",
"Timeout: %llds (%@)": "タイムアウト: %1$lld 秒 (%2$@)",
"Timeouts": "タイムアウト",
"Tirith Sandbox": "Tirith サンドボックス",
"To skip the passphrase prompt at every reboot, add `--apple-use-keychain` to cache it in macOS Keychain.": "毎回の再起動時にパスフレーズのプロンプトをスキップするには、`--apple-use-keychain` を追加して macOS キーチェーンにキャッシュしてください。",
"Toggle text-to-speech (/voice tts)": "テキスト読み上げを切り替え (/voice tts)",
"Toggle voice mode (/voice)": "音声モードを切り替え (/voice)",
"Token on disk. Clear to re-authenticate next time the gateway connects.": "トークンはディスク上にあります。消去すると、次回 gateway が接続する際に再認証します。",
"Tool Approval Required": "ツールの承認が必要",
"Tool Filters": "ツールフィルタ",
"Tool Progress": "ツールの進捗",
"Tools": "ツール",
"Top Tools": "トップツール",
"Turns & Reasoning": "ターンと推論",
"Uninstall": "アンインストール",
"Unknown: %@": "不明: %@",
"Update": "更新",
"Update All": "すべて更新",
"Updated: %@": "更新日時: %@",
"Updates": "アップデート",
"Upload": "アップロード",
"Upload debug report?": "デバッグレポートをアップロードしますか?",
"Usage Stats": "使用統計",
"Use": "使用",
"Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".": "カタログにないモデルを使用します。Hermes はプロバイダーが認識する任意の文字列を受け付けます(例: \"openrouter/anthropic/claude-opus-4.6\" のようなプロバイダープレフィックス形式も可)。",
"Use this": "これを使用",
"Use {dot.notation} to reference fields in the webhook payload.": "webhook ペイロードのフィールドを参照するには {dot.notation} を使用します。",
"Used as the YAML key. Lowercase, no spaces.": "YAML キーとして使用されます。小文字・空白なし。",
"View": "表示",
"View All": "すべて表示",
"Vision": "ビジョン",
"Voice": "音声",
"Voice Off": "音声オフ",
"Voice On": "音声オン",
"Waiting for authorization URL…": "認可 URL を待機中…",
"Waiting for first probe": "最初のプローブを待機中",
"Waiting for hermes to prompt for the code…": "hermes がコードを要求するのを待機中…",
"Web Extract": "Web 抽出",
"Webhook (advanced)": "Webhook(詳細)",
"Webhook (hermes side)": "Webhook(hermes 側)",
"Webhook Security": "Webhook セキュリティ",
"Webhook platform not enabled": "Webhook プラットフォームが有効ではありません",
"Webhooks": "Webhook",
"Webhooks let external services trigger agent responses. Each subscription gets its own URL endpoint.": "Webhook を使うと外部サービスがエージェントの応答をトリガーできます。各サブスクリプションは独自の URL エンドポイントを持ちます。",
"Website Blocklist": "ウェブサイトブロックリスト",
"WhatsApp uses the Baileys library to emulate a WhatsApp Web session. Pair this Mac as a linked device by running the pairing wizard and scanning the QR code with your phone (Settings → Linked Devices → Link a Device).": "WhatsApp は Baileys ライブラリで WhatsApp Web セッションをエミュレートします。ペアリングウィザードを実行し、スマートフォンで QR コードをスキャンしてこの Mac をリンク済みデバイスとしてペアリングしてください(設定 → リンク済みデバイス → デバイスをリンク)。",
"Working": "処理中",
"e.g. anthropic": "例: anthropic",
"e.g. deploy": "例: deploy",
"e.g. experimental": "例: experimental",
"e.g. github": "例: github",
"e.g. openai": "例: openai",
"e.g. openai/gpt-4o": "例: openai/gpt-4o",
"e.g. team-prod": "例: team-prod",
"exit code: %d": "終了コード: %d",
"hermes at %@": "%@ 上の hermes",
"iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.": "iMessage 連携は BlueBubbles Server を経由します。メッセージ App にサインインしたまま動作し続ける Mac が必要です — そこに BlueBubbles Server をインストールし、そのサーバーの URL を hermes に指定してください。",
"signal-cli is available on PATH": "signal-cli は PATH 上で利用可能です",
"signal-cli not found on PATH — install it first": "signal-cli が PATH にありません — 先にインストールしてください",
"ssh trace": "ssh トレース",
"ssh-agent (leave blank)": "ssh-agent(空のまま)",
"state.db not found at the configured path. Either Hermes hasn't run yet on this server, or it's installed at a non-default location — set the Hermes data directory field above.": "設定されたパスに state.db が見つかりません。Hermes がこのサーバーでまだ実行されていないか、デフォルトでない場所にインストールされている可能性があります — 上の Hermes データディレクトリフィールドを設定してください。",
"state.db not found at the default location, but Scarf found one at:": "デフォルトの場所には state.db がありませんが、Scarf が以下の場所に見つけました:",
"state.db readable": "state.db 読み取り可能",
"— or use user/password login —": "— またはユーザー名/パスワードでログイン —"
}
+585
View File
@@ -0,0 +1,585 @@
{
"%@ ctx": "%@ contexto",
"%@ in / %@ out": "%1$@ entrada / %2$@ saída",
"%@ reasoning": "%@ raciocínio",
"%@ tokens": "%@ tokens",
"%@s · %lld tools": "%1$@ s · %2$lld ferramentas",
"%lld %@": "%1$lld %2$@",
"%lld chars": "%lld caracteres",
"%lld delivery failure%@": "%1$lld falha de entrega%2$@",
"%lld entries": "%lld entradas",
"%lld files": "%lld arquivos",
"%lld messages": "%lld mensagens",
"%lld msgs": "%lld msgs",
"%lld of %lld enabled": "%1$lld de %2$lld ativadas",
"%lld reasoning": "%lld raciocínio",
"%lld req": "%lld obrig.",
"%lld required config": "%lld configurações obrigatórias",
"%lld sessions": "%lld sessões",
"%lld tokens": "%lld tokens",
"%lld tools": "%lld ferramentas",
"30 Days": "30 dias",
"7 Days": "7 dias",
"90 Days": "90 dias",
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts.": "Um QR code aparecerá abaixo. Escaneie com o WhatsApp do seu celular. A sessão é salva em ~/.hermes/platforms/whatsapp/ para não precisar escanear de novo após reinícios.",
"API Key": "Chave de API",
"API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.": "Chaves de API nunca são exibidas por completo. O Scarf mostra apenas os últimos 4 caracteres para identificação. Os valores completos são armazenados pelo hermes em ~/.hermes/auth.json.",
"Access Control": "Controle de acesso",
"Actions": "Ações",
"Active": "Ativa",
"Active Personality": "Personalidade ativa",
"Active profile": "Perfil ativo",
"Activity": "Atividade",
"Activity Patterns": "Padrões de atividade",
"Add": "Adicionar",
"Add Command": "Adicionar comando",
"Add Credential": "Adicionar credencial",
"Add Custom": "Adicionar personalizado",
"Add Custom MCP Server": "Adicionar servidor MCP personalizado",
"Add Project": "Adicionar projeto",
"Add Quick Command": "Adicionar comando rápido",
"Add Remote Server": "Adicionar servidor remoto",
"Add Server": "Adicionar servidor",
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.": "Adicione uma pasta de projeto para começar. Crie um arquivo .scarf/dashboard.json no seu projeto para definir widgets.",
"Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.": "Adicione credenciais em **Configurar → Pools de credenciais**, defina `ANTHROPIC_API_KEY` (ou similar) em `~/.hermes/.env` ou exporte no seu perfil de shell, e reinicie o Scarf.",
"Add from Preset": "Adicionar a partir de predefinição",
"Add rotation credentials so hermes can failover between keys when one hits rate limits.": "Adicione credenciais de rotação para que o hermes possa alternar entre chaves quando uma atingir o limite de taxa.",
"Add your first command": "Adicione seu primeiro comando",
"Advanced": "Avançado",
"After approving in your browser, the provider shows a code. Paste it below and submit.": "Após aprovar no navegador, o provedor mostra um código. Cole abaixo e envie.",
"Agent": "Agent",
"All": "Todos",
"All Levels": "Todos os níveis",
"All Sessions": "Todas as sessões",
"All Time": "Todo o período",
"All installed hub skills are up to date.": "Todas as habilidades instaladas do hub estão atualizadas.",
"App Credentials": "Credenciais do app",
"Approval": "Aprovação",
"Approvals": "Aprovações",
"Approve": "Aprovar",
"Archive": "Arquivar",
"Args (one per line)": "Argumentos (um por linha)",
"Arguments": "Argumentos",
"Assistant Message": "Mensagem do assistente",
"Auth": "Auth",
"Authentication": "Autenticação",
"Authentication uses ssh-agent": "A autenticação usa ssh-agent",
"Authorization Code": "Código de autorização",
"Authorization URL": "URL de autorização",
"Aux Models": "Modelos auxiliares",
"Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.": "Tarefas auxiliares usam modelos separados, geralmente mais baratos. Deixe Provedor em `auto` para herdar o provedor principal.",
"Back": "Voltar",
"Back to Catalog": "Voltar ao catálogo",
"Backend": "Backend",
"Backup & Restore": "Backup e restauração",
"Backup Now": "Fazer backup agora",
"Becomes the key under mcp_servers: in config.yaml.": "Vira a chave sob mcp_servers: em config.yaml.",
"Behavior": "Comportamento",
"Browse": "Explorar",
"Browse Hub": "Explorar hub",
"Browse the Hub": "Explorar o hub",
"Browse...": "Explorar...",
"Browser": "Navegador",
"Built-in Memory": "Memória integrada",
"By Day": "Por dia",
"By Hour": "Por hora",
"Call timeout": "Tempo limite da chamada",
"Can't read Hermes state on %@": "Não é possível ler o estado do Hermes em %@",
"Cancel": "Cancelar",
"Changes won't take effect until Hermes reloads the config.": "As alterações só têm efeito quando o Hermes recarregar a configuração.",
"Chat": "Chat",
"Chat Messages": "Mensagens do chat",
"Check": "Verificar",
"Check Now": "Verificar agora",
"Check for Updates": "Verificar atualizações",
"Check for Updates…": "Verificar atualizações…",
"Checking…": "Verificando…",
"Checkpoints": "Checkpoints",
"Choose a cron job from the list": "Escolha uma tarefa cron na lista",
"Choose a profile to inspect.": "Escolha um perfil para inspecionar.",
"Choose a project from the sidebar to view its dashboard.": "Escolha um projeto na barra lateral para ver o painel.",
"Choose a session from the list": "Escolha uma sessão na lista",
"Choose a skill from the list": "Escolha uma habilidade na lista",
"Choose an entry from the list": "Escolha uma entrada na lista",
"Choose…": "Escolher…",
"Clear Token": "Limpar token",
"Clear all skills on save": "Limpar todas as habilidades ao salvar",
"Click Add to connect to a remote Hermes installation over SSH.": "Clique em Adicionar para se conectar a uma instalação remota do Hermes via SSH.",
"Click for details": "Clique para ver detalhes",
"Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.": "Clicar em Iniciar OAuth abre a página de autorização do provedor no navegador. Após aprovar, copie o código exibido pelo provedor e cole no terminal que aparecerá em seguida.",
"Clone config, .env, SOUL.md from active profile": "Clonar config, .env, SOUL.md do perfil ativo",
"Close": "Fechar",
"Close Window": "Fechar janela",
"Code: %@": "Código: %@",
"Command": "Comando",
"Command Allowlist": "Lista de comandos permitidos",
"Command looks destructive. Double-check before saving.": "O comando parece destrutivo. Revise antes de salvar.",
"Component": "Componente",
"Compress": "Compactar",
"Compress Conversation": "Compactar conversa",
"Compress conversation (/compress)": "Compactar conversa (/compress)",
"Compression": "Compactação",
"Config Diagnostics": "Diagnóstico de configuração",
"Configure": "Configurar",
"Connect timeout": "Tempo limite de conexão",
"Connected": "Conectado",
"Connected — can't read Hermes state": "Conectado — não é possível ler o estado do Hermes",
"Connection": "Conexão",
"Container Limits": "Limites do contêiner",
"Context & Compression": "Contexto e compactação",
"Continue Last Session": "Continuar última sessão",
"Copied": "Copiado",
"Copy": "Copiar",
"Copy Full Report": "Copiar relatório completo",
"Copy a plain-text summary of every check (passes and fails) — paste into GitHub issues so we can see everything at once.": "Copia um resumo em texto simples de cada verificação (aprovadas e falhas) — cole em issues do GitHub para vermos tudo de uma vez.",
"Copy code": "Copiar código",
"Copy error details": "Copiar detalhes do erro",
"Create": "Criar",
"Create Profile": "Criar perfil",
"Create Subscription": "Criar assinatura",
"Create a Slack app at api.slack.com/apps, enable Socket Mode, grant bot scopes (chat:write, app_mentions:read, channels:history, etc.), then copy the Bot User OAuth Token (xoxb-) and the App-Level Token (xapp-).": "Crie um app Slack em api.slack.com/apps, habilite o Socket Mode, conceda os escopos de bot (chat:write, app_mentions:read, channels:history etc.) e copie o Bot User OAuth Token (xoxb-) e o App-Level Token (xapp-).",
"Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users.": "Crie um bot via @BotFather e obtenha seu ID numérico em @userinfobot. Cole o token e seu ID de usuário abaixo — o bot só responde a usuários permitidos.",
"Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.": "Crie um token de acesso de longa duração no Home Assistant (Perfil → Segurança → Long-Lived Access Tokens). Por padrão, nenhum evento é encaminhado — ative Watch All Changes ou adicione filtros de entidades abaixo.",
"Create a personal access token under Profile → Security → Personal Access Tokens, or create a bot account. Use the token as the MATTERMOST_TOKEN value.": "Crie um token de acesso pessoal em Perfil → Segurança → Personal Access Tokens ou crie uma conta de bot. Use o token como valor de MATTERMOST_TOKEN.",
"Create a profile to isolate config and skills.": "Crie um perfil para isolar configuração e habilidades.",
"Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.": "Crie um app no Developer Portal do Discord, ative os intents Message Content e Server Members e copie o token do bot. Convide o bot para o seu servidor via gerador de URL OAuth2.",
"Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.": "Crie um app no Feishu/Lark Developer Console, habilite Interactive Card se precisar de respostas por botão e copie o App ID e o App Secret. O modo WebSocket (recomendado) não requer endpoint público.",
"Credential Pools": "Pools de credenciais",
"Credential Type": "Tipo de credencial",
"Credentials": "Credenciais",
"Cron": "Cron",
"Cron Jobs": "Tarefas cron",
"Current: %@": "Atual: %@",
"Custom…": "Personalizado…",
"Daemon Endpoint": "Endpoint do daemon",
"Daemon running": "Daemon em execução",
"Dashboard": "Painel",
"Default": "Padrão",
"Default: ~/.hermes": "Padrão: ~/.hermes",
"Defaults to ~/.ssh/config or current user": "Padrão é ~/.ssh/config ou usuário atual",
"Defined Personalities": "Personalidades definidas",
"Delegation": "Delegação",
"Delete": "Excluir",
"Delete %@?": "Excluir %@?",
"Delete Session?": "Excluir sessão?",
"Delete profile '%@'?": "Excluir perfil '%@'?",
"Delete...": "Excluir...",
"Deliver: %@": "Entregar: %@",
"Details": "Detalhes",
"Diagnostic Output": "Saída de diagnóstico",
"Diagnostics": "Diagnóstico",
"Disable": "Desativar",
"Disabled": "Desativado",
"Display": "Exibição",
"Docs": "Docs",
"Done": "Concluído",
"Edit": "Editar",
"Edit %@": "Editar %@",
"Edit /%@": "Editar /%@",
"Edit Agent Memory": "Editar memória do agente",
"Edit User Profile": "Editar perfil de usuário",
"Edit config.yaml": "Editar config.yaml",
"Empty": "Vazio",
"Enable": "Ativar",
"Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.": "Ative o 2FA na sua conta de e-mail e gere uma senha de aplicativo. Senhas normais da conta não funcionam. Sempre defina remetentes permitidos — caso contrário, qualquer um que saiba o endereço pode enviar mensagens para o agente.",
"Enable the webhook platform to accept event-driven agent triggers. The HMAC secret is used as a fallback when individual routes don't provide their own.": "Ative a plataforma de webhook para aceitar gatilhos de agente orientados a eventos. O segredo HMAC é usado como fallback quando rotas individuais não fornecem o próprio.",
"Enabled": "Ativado",
"End-to-End Encryption (experimental)": "Criptografia ponta a ponta (experimental)",
"Entity Filters (config.yaml only)": "Filtros de entidade (somente config.yaml)",
"Env vars, headers, and tool filters can be edited after the server is added.": "Variáveis de ambiente, cabeçalhos e filtros de ferramentas podem ser editados após o servidor ser adicionado.",
"Environment Variables": "Variáveis de ambiente",
"Error": "Erro",
"Errors": "Erros",
"Event Filters": "Filtros de eventos",
"Exclude": "Excluir",
"Execute": "Executar",
"Expected at %@": "Esperado em %@",
"Export All": "Exportar tudo",
"Export...": "Exportar...",
"Export…": "Exportar…",
"Expose prompts": "Expor prompts",
"Expose resources": "Expor recursos",
"External Provider": "Provedor externo",
"Feedback": "Feedback",
"Fetch": "Buscar",
"Files": "Arquivos",
"Filter logs...": "Filtrar logs...",
"Filter servers...": "Filtrar servidores...",
"Filter skills...": "Filtrar habilidades...",
"Filter to session %@": "Filtrar para a sessão %@",
"Flush Memories": "Limpar memórias",
"Focus topic (optional)": "Tópico de foco (opcional)",
"Full copy of active profile (all state)": "Cópia completa do perfil ativo (todo o estado)",
"Gateway": "Gateway",
"Gateway Running": "Gateway em execução",
"Gateway Stopped": "Gateway parado",
"Gateway restart required": "Reinicialização do gateway necessária",
"General": "Geral",
"Global Settings": "Configurações globais",
"Header": "Cabeçalho",
"Headers": "Cabeçalhos",
"Health": "Saúde",
"Hermes Not Found": "Hermes não encontrado",
"Hermes Running": "Hermes em execução",
"Hermes Stopped": "Hermes parado",
"Hermes binary not found": "Binário do Hermes não encontrado",
"Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually.": "O Hermes precisa de um segredo global de webhook e uma porta antes que assinaturas possam receber tráfego. Execute o assistente de configuração do gateway ou edite ~/.hermes/config.yaml manualmente.",
"Hide": "Ocultar",
"Hide Output": "Ocultar saída",
"Hide details": "Ocultar detalhes",
"Home Channel": "Canal principal",
"Homeserver": "Homeserver",
"Host key changed": "Chave do host alterada",
"Human Delay": "Atraso humano",
"ID: %@": "ID: %@",
"If this is the first connection, ensure your key is loaded with `ssh-add` and that the remote accepts it.": "Se esta for a primeira conexão, garanta que sua chave esteja carregada com `ssh-add` e que o remoto a aceite.",
"If you trust the change, remove the stale entry and reconnect:": "Se você confia na mudança, remova a entrada antiga e reconecte:",
"Import": "Importar",
"Inactive": "Inativo",
"Include (comma-separated — if set, only these are exposed)": "Incluir (separados por vírgula — se definido, apenas estes são expostos)",
"Insights": "Insights",
"Install": "Instalar",
"Install BlueBubbles Server": "Instalar BlueBubbles Server",
"Install Plugin": "Instalar plugin",
"Install a Plugin": "Instalar um plugin",
"Install signal-cli": "Instalar signal-cli",
"Installed": "Instalado",
"Interact": "Interagir",
"Invalid URL": "URL inválida",
"Keep typing to send as a message, or press Esc.": "Continue digitando para enviar como mensagem, ou pressione Esc.",
"Label (optional)": "Rótulo (opcional)",
"Last Output": "Última saída",
"Last probe: %@": "Última verificação: %@",
"Last run: %@": "Última execução: %@",
"Last updated: %@": "Atualizado em: %@",
"Layout": "Layout",
"Leave blank to infer from the model ID's prefix (\"openai/...\" → openai).": "Deixe em branco para inferir pelo prefixo do ID do modelo (\"openai/...\" → openai).",
"Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates.": "Deixe em branco a menos que o Hermes esteja instalado em um caminho não padrão (serviços systemd geralmente ficam em /var/lib/hermes/.hermes; sidecars Docker variam). Testar conexão sugere automaticamente um valor ao detectar um dos caminhos alternativos conhecidos.",
"Level": "Nível",
"Link Device": "Vincular dispositivo",
"Link the device first to generate and scan a QR code. Once linked, start the daemon — it must keep running for hermes to send/receive messages.": "Vincule o dispositivo primeiro para gerar e escanear um QR code. Após vincular, inicie o daemon — ele precisa continuar rodando para o hermes enviar/receber mensagens.",
"Linking…": "Vinculando…",
"Loaded": "Carregado",
"Loading session…": "Carregando sessão…",
"Local": "Local",
"Local (stdio)": "Local (stdio)",
"Locale": "Localidade",
"Log File": "Arquivo de log",
"Logging": "Registro",
"Logs": "Logs",
"MCP Servers": "Servidores MCP",
"MCP Servers (%lld)": "Servidores MCP (%lld)",
"Manage": "Gerenciar",
"Manage Servers…": "Gerenciar servidores…",
"Manage in Credential Pools": "Gerenciar nos pools de credenciais",
"Matrix uses either an access token (preferred) or username/password. Get an access token from Element: Settings → Help & About → Access Token.": "O Matrix usa um token de acesso (preferido) ou usuário/senha. Obtenha um token de acesso no Element: Configurações → Ajuda e sobre → Access Token.",
"Memory": "Memória",
"Memory is managed by %@. File contents shown here may be stale.": "A memória é gerenciada por %@. O conteúdo dos arquivos exibido aqui pode estar desatualizado.",
"Message Hermes...": "Enviar mensagem ao Hermes...",
"Messages will appear here as the conversation progresses.": "As mensagens aparecerão aqui conforme a conversa progride.",
"Migrate": "Migrar",
"Missing required config:": "Configuração obrigatória ausente:",
"Modal": "Modal",
"Model": "Modelo",
"Model ID": "ID do modelo",
"Models": "Modelos",
"Monitor": "Monitor",
"Name": "Nome",
"Name (no leading slash)": "Nome (sem barra inicial)",
"Network": "Rede",
"New Session": "Nova sessão",
"New Webhook Subscription": "Nova assinatura de webhook",
"New name for '%@'": "Novo nome para '%@'",
"Next run: %@": "Próxima execução: %@",
"No AI provider credentials detected": "Nenhuma credencial de provedor de IA detectada",
"No Active Session": "Sem sessão ativa",
"No Activity": "Sem atividade",
"No Cron Jobs": "Sem tarefas cron",
"No Dashboard": "Sem painel",
"No MCP servers configured": "Nenhum servidor MCP configurado",
"No Models": "Sem modelos",
"No Profiles": "Sem perfis",
"No Projects": "Sem projetos",
"No Updates": "Sem atualizações",
"No active session": "Sem sessão ativa",
"No additional output. Check ~/.ssh/config and ssh-agent.": "Sem saída adicional. Verifique ~/.ssh/config e ssh-agent.",
"No commands available": "Sem comandos disponíveis",
"No credential pools configured": "Nenhum pool de credenciais configurado",
"No data": "Sem dados",
"No env vars configured.": "Nenhuma variável de ambiente configurada.",
"No env vars. Add one with the button below.": "Sem variáveis de ambiente. Adicione uma com o botão abaixo.",
"No headers configured.": "Nenhum cabeçalho configurado.",
"No headers. Add one with the button below.": "Sem cabeçalhos. Adicione um com o botão abaixo.",
"No matching commands": "Sem comandos correspondentes",
"No paired users": "Sem usuários pareados",
"No platforms connected": "Nenhuma plataforma conectada",
"No plugins installed": "Nenhum plugin instalado",
"No quick commands configured": "Nenhum comando rápido configurado",
"No remote servers": "Sem servidores remotos",
"No scheduled jobs configured": "Nenhuma tarefa agendada configurada",
"No servers configured yet": "Nenhum servidor configurado ainda",
"No sessions found": "Nenhuma sessão encontrada",
"No tool calls found": "Nenhuma chamada de ferramenta encontrada",
"No webhook subscriptions": "Sem assinaturas de webhook",
"None": "Nenhum",
"Notable Sessions": "Sessões notáveis",
"OAuth login for %@": "Login OAuth para %@",
"OK": "OK",
"Open BotFather": "Abrir BotFather",
"Open Developer Portal": "Abrir Developer Portal",
"Open Local": "Abrir local",
"Open Other Server…": "Abrir outro servidor…",
"Open Scarf": "Abrir Scarf",
"Open Server": "Abrir servidor",
"Open Slack API": "Abrir API do Slack",
"Open in Browser": "Abrir no navegador",
"Open in Editor": "Abrir no editor",
"Open in new window": "Abrir em nova janela",
"Open session": "Abrir sessão",
"Optional": "Opcional",
"Optional — defaults to hostname": "Opcional — padrão é o nome do host",
"Optionally focus the summary on a specific topic. Leave blank to compress evenly.": "Opcionalmente, foque o resumo em um tópico específico. Deixe em branco para compactar uniformemente.",
"Other": "Outro",
"Output": "Saída",
"Overview": "Visão geral",
"PID %d": "PID %d",
"PID %lld": "PID %lld",
"Pair Device": "Parear dispositivo",
"Paired Users": "Usuários pareados",
"Paste code here…": "Cole o código aqui…",
"Paths": "Caminhos",
"Pause": "Pausar",
"Pending Approvals": "Aprovações pendentes",
"Per-route subscriptions (events, prompt template, delivery target) are managed in the Webhooks sidebar — not here. This panel only controls whether the webhook platform is listening at all.": "As assinaturas por rota (eventos, template de prompt, alvo de entrega) são gerenciadas na barra lateral de Webhooks — não aqui. Este painel só controla se a plataforma de webhook está escutando.",
"Period": "Período",
"Personalities": "Personalidades",
"Personality": "Personalidade",
"Pick an MCP server to add.": "Escolha um servidor MCP para adicionar.",
"Pick one from the list, or add a new server from the toolbar.": "Escolha um da lista ou adicione um novo servidor pela barra de ferramentas.",
"Platforms": "Plataformas",
"Plugins": "Plugins",
"Plugins extend hermes with custom tools, providers, or memory backends.": "Plugins estendem o hermes com ferramentas, provedores ou backends de memória personalizados.",
"Pre-Run Script": "Script de pré-execução",
"Preset:": "Predefinição:",
"Probe": "Sondar",
"Profile": "Perfil",
"Profiles": "Perfis",
"Project Name": "Nome do projeto",
"Project Path": "Caminho do projeto",
"Projects": "Projetos",
"Prompt": "Prompt",
"Provide a Git URL (https://github.com/...) or a shorthand like `owner/repo`.": "Forneça uma URL do Git (https://github.com/...) ou um atalho como `owner/repo`.",
"Provider": "Provedor",
"Push to Talk": "Pressionar para falar",
"Push to talk (Ctrl+B)": "Pressionar para falar (Ctrl+B)",
"Push-to-Talk": "Pressionar para falar",
"Quick Commands": "Comandos rápidos",
"Quick commands are shell shortcuts hermes exposes in chat as `/command_name`. They live under `quick_commands:` in config.yaml.": "Comandos rápidos são atalhos de shell que o hermes expõe no chat como `/command_name`. Ficam em `quick_commands:` no config.yaml.",
"Quit Scarf": "Sair do Scarf",
"Raw Config": "Configuração bruta",
"Raw remote output (for debugging)": "Saída remota bruta (para depuração)",
"Re-run": "Re-executar",
"Read": "Ler",
"Reasoning": "Raciocínio",
"Recent Sessions": "Sessões recentes",
"Reconnect": "Reconectar",
"Recording…": "Gravando…",
"Redaction": "Redação",
"Refresh": "Atualizar",
"Reload": "Recarregar",
"Remote (HTTP)": "Remoto (HTTP)",
"Remote Diagnostics — %@": "Diagnóstico remoto — %@",
"Remove": "Remover",
"Remove %@?": "Remover %@?",
"Remove credential for %@?": "Remover credencial de %@?",
"Remove this server from Scarf.": "Remover este servidor do Scarf.",
"Remove this server?": "Remover este servidor?",
"Remove via config.yaml…": "Remover via config.yaml…",
"Remove webhook %@?": "Remover webhook %@?",
"Rename": "Renomear",
"Rename Profile": "Renomear perfil",
"Rename Session": "Renomear sessão",
"Rename...": "Renomear...",
"Required": "Obrigatório",
"Required Tokens": "Tokens obrigatórios",
"Requires: %@": "Requer: %@",
"Reset Cooldowns": "Redefinir cooldowns",
"Restart": "Reiniciar",
"Restart Gateway": "Reiniciar gateway",
"Restart Hermes": "Reiniciar Hermes",
"Restart Now": "Reiniciar agora",
"Restore": "Restaurar",
"Restore from backup?": "Restaurar a partir do backup?",
"Restore…": "Restaurar…",
"Result": "Resultado",
"Resume": "Retomar",
"Resume Session": "Retomar sessão",
"Retry": "Tentar novamente",
"Return to Active Session (%@...)": "Voltar à sessão ativa (%@...)",
"Reveal": "Revelar",
"Revoke": "Revogar",
"Rich Chat": "Chat avançado",
"Run Diagnostics…": "Executar diagnóstico…",
"Run Dump": "Executar dump",
"Run Now": "Executar agora",
"Run Setup in Terminal": "Executar instalação no terminal",
"Run `hermes memory setup` in Terminal for full provider configuration.": "Execute `hermes memory setup` no terminal para a configuração completa do provedor.",
"Run remote diagnostics — check exactly which files are readable on this server.": "Executar diagnóstico remoto — verifica exatamente quais arquivos podem ser lidos neste servidor.",
"Running a single shell session on %@ that exercises every path Scarf reads…": "Executando uma única sessão de shell em %@ que percorre todos os caminhos que o Scarf lê…",
"Running checks…": "Executando verificações…",
"SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.": "O SOUL.md descreve a voz, os valores e a personalidade do agente em ~/.hermes/SOUL.md. Ele é injetado no contexto de cada sessão.",
"SSH works but %@. Click for diagnostics.": "O SSH funciona, mas %@. Clique para ver diagnósticos.",
"Save": "Salvar",
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:": "O Scarf nunca pede frases secretas. Adicione sua chave ao ssh-agent no terminal e clique em Tentar novamente. Se sua chave não for `id_ed25519`, troque o caminho:",
"Scarf runs these over a single SSH session that mirrors the shell your dashboard reads from, so a green row here means Scarf can actually read that file at runtime.": "O Scarf executa esses comandos em uma única sessão SSH idêntica ao shell usado pelo seu painel. Uma linha verde aqui significa que o Scarf consegue ler aquele arquivo em tempo de execução.",
"Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.": "O Scarf usa ssh-agent para autenticação. Se sua chave tiver uma frase secreta, rode `ssh-add` antes de conectar — o Scarf nunca pede nem armazena frases secretas.",
"Scarf — %@": "Scarf — %@",
"Search": "Pesquisar",
"Search Results (%lld)": "Resultados da pesquisa (%lld)",
"Search messages...": "Pesquisar mensagens...",
"Search or browse skills published to registries like skills.sh, GitHub, and the official hub.": "Pesquise ou navegue por habilidades publicadas em registros como skills.sh, GitHub e o hub oficial.",
"Search registries": "Pesquisar registros",
"Search…": "Pesquisar…",
"Security": "Segurança",
"Select": "Selecionar",
"Select Model": "Selecionar modelo",
"Select a Job": "Selecionar uma tarefa",
"Select a Profile": "Selecionar um perfil",
"Select a Project": "Selecionar um projeto",
"Select a Session": "Selecionar uma sessão",
"Select a Skill": "Selecionar uma habilidade",
"Select a Tool Call": "Selecionar uma chamada de ferramenta",
"Select an MCP Server": "Selecionar um servidor MCP",
"Send message (Enter)": "Enviar mensagem (Enter)",
"Series": "Série",
"Server": "Servidor",
"Server No Longer Exists": "Servidor não existe mais",
"Server name": "Nome do servidor",
"Servers": "Servidores",
"Service": "Serviço",
"Service definition stale": "Definição de serviço desatualizada",
"Session": "Sessão",
"Session Search": "Busca de sessões",
"Session title": "Título da sessão",
"Sessions": "Sessões",
"Settings": "Configurações",
"Setup": "Configuração",
"Share Debug Report…": "Compartilhar relatório de depuração…",
"Shell Command": "Comando de shell",
"Show": "Mostrar",
"Show Output": "Mostrar saída",
"Show all %lld lines": "Mostrar todas as %lld linhas",
"Show details": "Mostrar detalhes",
"Show less": "Mostrar menos",
"Show values": "Mostrar valores",
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages.": "A integração com Signal requer signal-cli (baseado em Java) instalado localmente. Vincule este Mac como dispositivo Signal e mantenha o daemon em execução para o hermes enviar/receber mensagens.",
"Site": "Site",
"Skills": "Habilidades",
"Skills (%lld)": "Habilidades (%lld)",
"Skills Hub": "Hub de habilidades",
"Source": "Origem",
"Speech-to-Text": "Voz para texto",
"Start": "Iniciar",
"Start Daemon": "Iniciar daemon",
"Start Hermes": "Iniciar Hermes",
"Start OAuth": "Iniciar OAuth",
"Start Pairing": "Iniciar pareamento",
"Start a new session or resume an existing one from the Session menu above.": "Inicie uma nova sessão ou retome uma existente no menu Sessão acima.",
"Status": "Status",
"Stop": "Parar",
"Stop Hermes": "Parar Hermes",
"Subagent": "Subagente",
"Subagent Sessions (%lld)": "Sessões de subagente (%lld)",
"Submit": "Enviar",
"Subscribe": "Assinar",
"Succeeded": "Sucesso",
"Switch to This Profile": "Mudar para este perfil",
"Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.": "Trocar o perfil ativo muda o diretório `~/.hermes` usado pelo hermes. Reinicie o Scarf após trocar para ele reler os arquivos do novo perfil.",
"TTS Off": "TTS desligado",
"TTS On": "TTS ligado",
"Terminal": "Terminal",
"Test": "Testar",
"Test All": "Testar todos",
"Test Connection": "Testar conexão",
"Test failed": "Teste falhou",
"Test passed": "Teste aprovado",
"Text-to-Speech": "Texto para voz",
"The agent hasn't advertised any slash commands yet. Keep typing to send as a message, or press Esc.": "O agente ainda não anunciou nenhum comando slash. Continue digitando para enviar como mensagem, ou pressione Esc.",
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection.": "A impressão SSH do remoto não corresponde mais ao que seu arquivo `~/.ssh/known_hosts` esperava. Normalmente isso significa que o remoto foi reinstalado — ou, mais raramente, que alguém está interceptando a conexão.",
"The server this window was opened with has been removed from your registry.": "O servidor com o qual esta janela foi aberta foi removido do seu registro.",
"The server's SSH configuration is removed from Scarf. Your remote files are untouched.": "A configuração SSH do servidor é removida do Scarf. Seus arquivos remotos permanecem intocados.",
"The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".": "O terminal é um TTY real — cole com ⌘V, pressione Return e aguarde o processo encerrar com \"login succeeded\".",
"These list fields must be edited directly in config.yaml.": "Esses campos de lista precisam ser editados diretamente em config.yaml.",
"This provider has no catalogued models.": "Este provedor não tem modelos catalogados.",
"This removes the credential from hermes. The upstream provider key is not revoked.": "Isso remove a credencial do hermes. A chave do provedor original não é revogada.",
"This removes the profile directory and all data within it. This cannot be undone.": "Isso remove o diretório do perfil e todos os dados dentro dele. Não pode ser desfeito.",
"This removes the scheduled job permanently.": "Isso remove a tarefa agendada permanentemente.",
"This removes the server from config.yaml and deletes any OAuth token.": "Isso remove o servidor do config.yaml e apaga qualquer token OAuth.",
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.": "Isso envia logs, configuração (com segredos mascarados) e informações do sistema para a infraestrutura de suporte da Nous Research. Revise a saída antes de compartilhar a URL retornada.",
"This will overwrite files under ~/.hermes/ with the archive contents.": "Isso sobrescreverá arquivos em ~/.hermes/ com o conteúdo do arquivo.",
"This will permanently delete the session and all its messages.": "Isso excluirá permanentemente a sessão e todas as suas mensagens.",
"Timeout: %llds (%@)": "Tempo limite: %1$lld s (%2$@)",
"Timeouts": "Tempos limite",
"Tirith Sandbox": "Sandbox Tirith",
"To skip the passphrase prompt at every reboot, add `--apple-use-keychain` to cache it in macOS Keychain.": "Para pular a solicitação de frase secreta a cada reinicialização, adicione `--apple-use-keychain` para armazená-la no Keychain do macOS.",
"Toggle text-to-speech (/voice tts)": "Alternar texto-para-fala (/voice tts)",
"Toggle voice mode (/voice)": "Alternar modo de voz (/voice)",
"Token on disk. Clear to re-authenticate next time the gateway connects.": "Token no disco. Limpe para reautenticar na próxima vez que o gateway conectar.",
"Tool Approval Required": "Aprovação de ferramenta necessária",
"Tool Filters": "Filtros de ferramentas",
"Tool Progress": "Progresso da ferramenta",
"Tools": "Ferramentas",
"Top Tools": "Principais ferramentas",
"Turns & Reasoning": "Turnos e raciocínio",
"Uninstall": "Desinstalar",
"Unknown: %@": "Desconhecido: %@",
"Update": "Atualizar",
"Update All": "Atualizar todos",
"Updated: %@": "Atualizado: %@",
"Updates": "Atualizações",
"Upload": "Enviar",
"Upload debug report?": "Enviar relatório de depuração?",
"Usage Stats": "Estatísticas de uso",
"Use": "Usar",
"Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".": "Use um modelo fora do catálogo. O Hermes aceita qualquer string reconhecida pelo provedor, inclusive formas com prefixo do provedor como \"openrouter/anthropic/claude-opus-4.6\".",
"Use this": "Usar este",
"Use {dot.notation} to reference fields in the webhook payload.": "Use {dot.notation} para referenciar campos no payload do webhook.",
"Used as the YAML key. Lowercase, no spaces.": "Usado como chave YAML. Minúsculas, sem espaços.",
"View": "Ver",
"View All": "Ver tudo",
"Vision": "Visão",
"Voice": "Voz",
"Voice Off": "Voz desligada",
"Voice On": "Voz ligada",
"Waiting for authorization URL…": "Aguardando URL de autorização…",
"Waiting for first probe": "Aguardando primeira verificação",
"Waiting for hermes to prompt for the code…": "Aguardando o hermes solicitar o código…",
"Web Extract": "Extração da Web",
"Webhook (advanced)": "Webhook (avançado)",
"Webhook (hermes side)": "Webhook (lado hermes)",
"Webhook Security": "Segurança de webhook",
"Webhook platform not enabled": "Plataforma de webhook não ativada",
"Webhooks": "Webhooks",
"Webhooks let external services trigger agent responses. Each subscription gets its own URL endpoint.": "Webhooks permitem que serviços externos disparem respostas do agente. Cada assinatura tem seu próprio endpoint de URL.",
"Website Blocklist": "Lista de bloqueio de sites",
"WhatsApp uses the Baileys library to emulate a WhatsApp Web session. Pair this Mac as a linked device by running the pairing wizard and scanning the QR code with your phone (Settings → Linked Devices → Link a Device).": "O WhatsApp usa a biblioteca Baileys para emular uma sessão do WhatsApp Web. Pareie este Mac como dispositivo vinculado executando o assistente de pareamento e escaneando o QR code com seu telefone (Ajustes → Dispositivos vinculados → Vincular um dispositivo).",
"Working": "Trabalhando",
"e.g. anthropic": "ex: anthropic",
"e.g. deploy": "ex: deploy",
"e.g. experimental": "ex: experimental",
"e.g. github": "ex: github",
"e.g. openai": "ex: openai",
"e.g. openai/gpt-4o": "ex: openai/gpt-4o",
"e.g. team-prod": "ex: team-prod",
"exit code: %d": "código de saída: %d",
"hermes at %@": "hermes em %@",
"iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.": "A integração com iMessage usa o BlueBubbles Server. Você precisa de um Mac ligado e com Messages.app conectado — instale o BlueBubbles Server nele e aponte o hermes para a URL desse servidor.",
"signal-cli is available on PATH": "signal-cli está disponível no PATH",
"signal-cli not found on PATH — install it first": "signal-cli não encontrado no PATH — instale-o primeiro",
"ssh trace": "trace do ssh",
"ssh-agent (leave blank)": "ssh-agent (deixe em branco)",
"state.db not found at the configured path. Either Hermes hasn't run yet on this server, or it's installed at a non-default location — set the Hermes data directory field above.": "state.db não encontrado no caminho configurado. Ou o Hermes ainda não rodou neste servidor, ou está instalado em um local não padrão — defina o campo de diretório de dados do Hermes acima.",
"state.db not found at the default location, but Scarf found one at:": "state.db não encontrado no local padrão, mas o Scarf encontrou um em:",
"state.db readable": "state.db legível",
"— or use user/password login —": "— ou use login com usuário/senha —"
}
+585
View File
@@ -0,0 +1,585 @@
{
"%@ ctx": "%@ 上下文",
"%@ in / %@ out": "输入 %1$@ / 输出 %2$@",
"%@ reasoning": "%@ 推理",
"%@ tokens": "%@ 个令牌",
"%@s · %lld tools": "%1$@秒 · %2$lld 个工具",
"%lld %@": "%1$lld %2$@",
"%lld chars": "%lld 个字符",
"%lld delivery failure%@": "%1$lld 次投递失败%2$@",
"%lld entries": "%lld 条记录",
"%lld files": "%lld 个文件",
"%lld messages": "%lld 条消息",
"%lld msgs": "%lld 条",
"%lld of %lld enabled": "已启用 %1$lld / %2$lld",
"%lld reasoning": "%lld 次推理",
"%lld req": "%lld 个必填",
"%lld required config": "%lld 项必需配置",
"%lld sessions": "%lld 次会话",
"%lld tokens": "%lld 个令牌",
"%lld tools": "%lld 个工具",
"30 Days": "30 天",
"7 Days": "7 天",
"90 Days": "90 天",
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts.": "二维码将在下方显示。用手机上的 WhatsApp 扫描。会话保存在 ~/.hermes/platforms/whatsapp/,重启后无需再次扫描。",
"API Key": "API 密钥",
"API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.": "API 密钥永远不会完整显示。Scarf 仅显示后 4 位用于识别。完整密钥由 hermes 存储在 ~/.hermes/auth.json。",
"Access Control": "访问控制",
"Actions": "操作",
"Active": "活跃",
"Active Personality": "活跃人格",
"Active profile": "当前配置",
"Activity": "活动",
"Activity Patterns": "活动模式",
"Add": "添加",
"Add Command": "添加命令",
"Add Credential": "添加凭证",
"Add Custom": "自定义添加",
"Add Custom MCP Server": "添加自定义 MCP 服务器",
"Add Project": "添加项目",
"Add Quick Command": "添加快捷命令",
"Add Remote Server": "添加远程服务器",
"Add Server": "添加服务器",
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.": "添加项目文件夹以开始使用。在项目中创建 .scarf/dashboard.json 文件来定义小部件。",
"Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.": "在 **配置 → 凭证池** 中添加凭证,在 `~/.hermes/.env` 中设置 `ANTHROPIC_API_KEY`(或类似变量),或在 shell 配置中导出该变量,然后重启 Scarf。",
"Add from Preset": "从预设添加",
"Add rotation credentials so hermes can failover between keys when one hits rate limits.": "添加轮换凭证,以便 hermes 在某个密钥达到速率限制时能切换到其他密钥。",
"Add your first command": "添加第一个命令",
"Advanced": "高级",
"After approving in your browser, the provider shows a code. Paste it below and submit.": "在浏览器中批准后,提供方会显示一个代码。将其粘贴到下方并提交。",
"Agent": "Agent",
"All": "全部",
"All Levels": "所有级别",
"All Sessions": "所有会话",
"All Time": "全部时间",
"All installed hub skills are up to date.": "所有已安装的 Hub 技能均为最新。",
"App Credentials": "应用凭证",
"Approval": "审批",
"Approvals": "审批",
"Approve": "批准",
"Archive": "归档",
"Args (one per line)": "参数(每行一个)",
"Arguments": "参数",
"Assistant Message": "助手消息",
"Auth": "认证",
"Authentication": "认证",
"Authentication uses ssh-agent": "使用 ssh-agent 进行认证",
"Authorization Code": "授权码",
"Authorization URL": "授权 URL",
"Aux Models": "辅助模型",
"Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.": "辅助任务使用独立的、通常更便宜的模型。将 Provider 保持为 `auto` 以继承主提供方。",
"Back": "返回",
"Back to Catalog": "返回目录",
"Backend": "后端",
"Backup & Restore": "备份与恢复",
"Backup Now": "立即备份",
"Becomes the key under mcp_servers: in config.yaml.": "将作为 config.yaml 中 mcp_servers: 下的键。",
"Behavior": "行为",
"Browse": "浏览",
"Browse Hub": "浏览 Hub",
"Browse the Hub": "浏览 Hub",
"Browse...": "浏览...",
"Browser": "浏览器",
"Built-in Memory": "内置记忆",
"By Day": "按天",
"By Hour": "按小时",
"Call timeout": "调用超时",
"Can't read Hermes state on %@": "无法读取 %@ 上的 Hermes 状态",
"Cancel": "取消",
"Changes won't take effect until Hermes reloads the config.": "更改在 Hermes 重新加载配置前不会生效。",
"Chat": "聊天",
"Chat Messages": "聊天消息",
"Check": "检查",
"Check Now": "立即检查",
"Check for Updates": "检查更新",
"Check for Updates…": "检查更新…",
"Checking…": "检查中…",
"Checkpoints": "检查点",
"Choose a cron job from the list": "从列表中选择一个定时任务",
"Choose a profile to inspect.": "选择一个配置进行查看。",
"Choose a project from the sidebar to view its dashboard.": "从侧边栏选择项目以查看其仪表盘。",
"Choose a session from the list": "从列表中选择一个会话",
"Choose a skill from the list": "从列表中选择一个技能",
"Choose an entry from the list": "从列表中选择一个条目",
"Choose…": "选择…",
"Clear Token": "清除令牌",
"Clear all skills on save": "保存时清除所有技能",
"Click Add to connect to a remote Hermes installation over SSH.": "点击添加以通过 SSH 连接到远程 Hermes 安装。",
"Click for details": "点击查看详情",
"Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.": "点击开始 OAuth 会在浏览器中打开提供方的授权页面。批准后,复制提供方显示的代码并粘贴到接下来出现的终端中。",
"Clone config, .env, SOUL.md from active profile": "从当前配置克隆 config、.env、SOUL.md",
"Close": "关闭",
"Close Window": "关闭窗口",
"Code: %@": "代码:%@",
"Command": "命令",
"Command Allowlist": "命令白名单",
"Command looks destructive. Double-check before saving.": "该命令看起来具有破坏性。保存前请仔细确认。",
"Component": "组件",
"Compress": "压缩",
"Compress Conversation": "压缩对话",
"Compress conversation (/compress)": "压缩对话 (/compress)",
"Compression": "压缩",
"Config Diagnostics": "配置诊断",
"Configure": "配置",
"Connect timeout": "连接超时",
"Connected": "已连接",
"Connected — can't read Hermes state": "已连接 — 无法读取 Hermes 状态",
"Connection": "连接",
"Container Limits": "容器限制",
"Context & Compression": "上下文与压缩",
"Continue Last Session": "继续上次会话",
"Copied": "已复制",
"Copy": "复制",
"Copy Full Report": "复制完整报告",
"Copy a plain-text summary of every check (passes and fails) — paste into GitHub issues so we can see everything at once.": "复制每项检查(通过和失败)的纯文本摘要 — 粘贴到 GitHub issue 中以便我们一次查看所有内容。",
"Copy code": "复制代码",
"Copy error details": "复制错误详情",
"Create": "创建",
"Create Profile": "创建配置",
"Create Subscription": "创建订阅",
"Create a Slack app at api.slack.com/apps, enable Socket Mode, grant bot scopes (chat:write, app_mentions:read, channels:history, etc.), then copy the Bot User OAuth Token (xoxb-) and the App-Level Token (xapp-).": "在 api.slack.com/apps 创建 Slack 应用,启用 Socket Mode,授予 bot 权限(chat:write、app_mentions:read、channels:history 等),然后复制 Bot User OAuth Token(xoxb-)和 App-Level Token(xapp-)。",
"Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users.": "通过 @BotFather 创建机器人,并从 @userinfobot 获取你的数字用户 ID。将令牌和用户 ID 粘贴到下方 — 机器人只会响应允许的用户。",
"Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.": "在 Home Assistant 中创建长期访问令牌(配置 → 安全 → 长期访问令牌)。默认不转发任何事件 — 启用 Watch All Changes 或在下方添加实体过滤器。",
"Create a personal access token under Profile → Security → Personal Access Tokens, or create a bot account. Use the token as the MATTERMOST_TOKEN value.": "在 配置 → 安全 → 个人访问令牌 下创建个人访问令牌,或创建机器人账号。将该令牌用作 MATTERMOST_TOKEN 的值。",
"Create a profile to isolate config and skills.": "创建配置以隔离 config 和技能。",
"Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.": "在 Discord 开发者门户创建应用,启用 Message Content 和 Server Members intents,然后复制机器人令牌。通过 OAuth2 URL 生成器将机器人邀请到你的服务器。",
"Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.": "在飞书/Lark 开发者后台创建应用,如需按钮交互请启用 Interactive Card,然后复制 App ID 和 App Secret。推荐使用 WebSocket 模式,无需公网地址。",
"Credential Pools": "凭证池",
"Credential Type": "凭证类型",
"Credentials": "凭证",
"Cron": "定时任务",
"Cron Jobs": "定时任务",
"Current: %@": "当前:%@",
"Custom…": "自定义…",
"Daemon Endpoint": "守护进程端点",
"Daemon running": "守护进程运行中",
"Dashboard": "仪表盘",
"Default": "默认",
"Default: ~/.hermes": "默认:~/.hermes",
"Defaults to ~/.ssh/config or current user": "默认使用 ~/.ssh/config 或当前用户",
"Defined Personalities": "已定义人格",
"Delegation": "委派",
"Delete": "删除",
"Delete %@?": "删除 %@?",
"Delete Session?": "删除会话?",
"Delete profile '%@'?": "删除配置 '%@'?",
"Delete...": "删除...",
"Deliver: %@": "投递:%@",
"Details": "详情",
"Diagnostic Output": "诊断输出",
"Diagnostics": "诊断",
"Disable": "禁用",
"Disabled": "已禁用",
"Display": "显示",
"Docs": "文档",
"Done": "完成",
"Edit": "编辑",
"Edit %@": "编辑 %@",
"Edit /%@": "编辑 /%@",
"Edit Agent Memory": "编辑 Agent 记忆",
"Edit User Profile": "编辑用户配置",
"Edit config.yaml": "编辑 config.yaml",
"Empty": "空",
"Enable": "启用",
"Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.": "为你的邮箱账户启用双重验证并生成应用专用密码。普通账户密码将无法使用。务必设置允许的发件人 — 否则任何知道邮件地址的人都可以向 agent 发消息。",
"Enable the webhook platform to accept event-driven agent triggers. The HMAC secret is used as a fallback when individual routes don't provide their own.": "启用 webhook 平台以接受事件驱动的 agent 触发。当单独的路由未提供自己的密钥时,使用 HMAC 密钥作为回退。",
"Enabled": "已启用",
"End-to-End Encryption (experimental)": "端到端加密(实验性)",
"Entity Filters (config.yaml only)": "实体过滤器(仅限 config.yaml)",
"Env vars, headers, and tool filters can be edited after the server is added.": "添加服务器后可以编辑环境变量、请求头和工具过滤器。",
"Environment Variables": "环境变量",
"Error": "错误",
"Errors": "错误",
"Event Filters": "事件过滤器",
"Exclude": "排除",
"Execute": "执行",
"Expected at %@": "预期位于 %@",
"Export All": "全部导出",
"Export...": "导出...",
"Export…": "导出…",
"Expose prompts": "暴露提示",
"Expose resources": "暴露资源",
"External Provider": "外部提供方",
"Feedback": "反馈",
"Fetch": "拉取",
"Files": "文件",
"Filter logs...": "筛选日志...",
"Filter servers...": "筛选服务器...",
"Filter skills...": "筛选技能...",
"Filter to session %@": "筛选到会话 %@",
"Flush Memories": "清空记忆",
"Focus topic (optional)": "聚焦主题(可选)",
"Full copy of active profile (all state)": "当前配置的完整副本(所有状态)",
"Gateway": "Gateway",
"Gateway Running": "Gateway 运行中",
"Gateway Stopped": "Gateway 已停止",
"Gateway restart required": "需要重启 Gateway",
"General": "常规",
"Global Settings": "全局设置",
"Header": "请求头",
"Headers": "请求头",
"Health": "健康",
"Hermes Not Found": "未找到 Hermes",
"Hermes Running": "Hermes 运行中",
"Hermes Stopped": "Hermes 已停止",
"Hermes binary not found": "未找到 Hermes 可执行文件",
"Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually.": "订阅接收流量前,Hermes 需要全局 webhook 密钥和端口。运行 gateway 设置向导或手动编辑 ~/.hermes/config.yaml。",
"Hide": "隐藏",
"Hide Output": "隐藏输出",
"Hide details": "隐藏详情",
"Home Channel": "主频道",
"Homeserver": "主服务器",
"Host key changed": "主机密钥已变更",
"Human Delay": "人类延迟",
"ID: %@": "ID:%@",
"If this is the first connection, ensure your key is loaded with `ssh-add` and that the remote accepts it.": "如果这是首次连接,请确保已通过 `ssh-add` 加载你的密钥,并且远端接受该密钥。",
"If you trust the change, remove the stale entry and reconnect:": "如果你信任此变更,请移除过期条目并重新连接:",
"Import": "导入",
"Inactive": "未激活",
"Include (comma-separated — if set, only these are exposed)": "包含(逗号分隔 — 若设置,仅暴露这些)",
"Insights": "洞察",
"Install": "安装",
"Install BlueBubbles Server": "安装 BlueBubbles 服务器",
"Install Plugin": "安装插件",
"Install a Plugin": "安装插件",
"Install signal-cli": "安装 signal-cli",
"Installed": "已安装",
"Interact": "交互",
"Invalid URL": "无效 URL",
"Keep typing to send as a message, or press Esc.": "继续输入作为消息发送,或按 Esc 取消。",
"Label (optional)": "标签(可选)",
"Last Output": "最后输出",
"Last probe: %@": "上次探测:%@",
"Last run: %@": "上次运行:%@",
"Last updated: %@": "最后更新:%@",
"Layout": "布局",
"Leave blank to infer from the model ID's prefix (\"openai/...\" → openai).": "留空则从模型 ID 的前缀推断(\"openai/...\" → openai)。",
"Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates.": "除非 Hermes 安装在非默认路径,否则请留空(systemd 服务通常在 /var/lib/hermes/.hermes,Docker sidecar 则各不相同)。检测到已知替代路径时,测试连接会自动建议值。",
"Level": "级别",
"Link Device": "关联设备",
"Link the device first to generate and scan a QR code. Once linked, start the daemon — it must keep running for hermes to send/receive messages.": "首先关联设备以生成并扫描二维码。关联后启动守护进程 — 必须保持运行以便 hermes 收发消息。",
"Linking…": "关联中…",
"Loaded": "已加载",
"Loading session…": "正在加载会话…",
"Local": "本地",
"Local (stdio)": "本地 (stdio)",
"Locale": "区域",
"Log File": "日志文件",
"Logging": "日志",
"Logs": "日志",
"MCP Servers": "MCP 服务器",
"MCP Servers (%lld)": "MCP 服务器 (%lld)",
"Manage": "管理",
"Manage Servers…": "管理服务器…",
"Manage in Credential Pools": "在凭证池中管理",
"Matrix uses either an access token (preferred) or username/password. Get an access token from Element: Settings → Help & About → Access Token.": "Matrix 使用访问令牌(推荐)或用户名/密码。从 Element 获取访问令牌:设置 → 帮助与关于 → 访问令牌。",
"Memory": "记忆",
"Memory is managed by %@. File contents shown here may be stale.": "记忆由 %@ 管理。此处显示的文件内容可能已过时。",
"Message Hermes...": "向 Hermes 发送消息...",
"Messages will appear here as the conversation progresses.": "消息将随对话进行显示在此处。",
"Migrate": "迁移",
"Missing required config:": "缺少必需的配置:",
"Modal": "模态",
"Model": "模型",
"Model ID": "模型 ID",
"Models": "模型",
"Monitor": "监控",
"Name": "名称",
"Name (no leading slash)": "名称(不带前导斜杠)",
"Network": "网络",
"New Session": "新建会话",
"New Webhook Subscription": "新建 Webhook 订阅",
"New name for '%@'": "'%@' 的新名称",
"Next run: %@": "下次运行:%@",
"No AI provider credentials detected": "未检测到 AI 提供方凭证",
"No Active Session": "无活跃会话",
"No Activity": "无活动",
"No Cron Jobs": "无定时任务",
"No Dashboard": "无仪表盘",
"No MCP servers configured": "未配置 MCP 服务器",
"No Models": "无模型",
"No Profiles": "无配置",
"No Projects": "无项目",
"No Updates": "无更新",
"No active session": "无活跃会话",
"No additional output. Check ~/.ssh/config and ssh-agent.": "无额外输出。请检查 ~/.ssh/config 和 ssh-agent。",
"No commands available": "无可用命令",
"No credential pools configured": "未配置凭证池",
"No data": "无数据",
"No env vars configured.": "未配置环境变量。",
"No env vars. Add one with the button below.": "无环境变量。点击下方按钮添加。",
"No headers configured.": "未配置请求头。",
"No headers. Add one with the button below.": "无请求头。点击下方按钮添加。",
"No matching commands": "无匹配命令",
"No paired users": "无配对用户",
"No platforms connected": "未连接平台",
"No plugins installed": "未安装插件",
"No quick commands configured": "未配置快捷命令",
"No remote servers": "无远程服务器",
"No scheduled jobs configured": "未配置定时任务",
"No servers configured yet": "尚未配置服务器",
"No sessions found": "未找到会话",
"No tool calls found": "未找到工具调用",
"No webhook subscriptions": "无 Webhook 订阅",
"None": "无",
"Notable Sessions": "重要会话",
"OAuth login for %@": "%@ 的 OAuth 登录",
"OK": "确定",
"Open BotFather": "打开 BotFather",
"Open Developer Portal": "打开开发者门户",
"Open Local": "打开本地",
"Open Other Server…": "打开其他服务器…",
"Open Scarf": "打开 Scarf",
"Open Server": "打开服务器",
"Open Slack API": "打开 Slack API",
"Open in Browser": "在浏览器中打开",
"Open in Editor": "在编辑器中打开",
"Open in new window": "在新窗口中打开",
"Open session": "打开会话",
"Optional": "可选",
"Optional — defaults to hostname": "可选 — 默认为主机名",
"Optionally focus the summary on a specific topic. Leave blank to compress evenly.": "可选地将摘要聚焦到特定主题。留空则均匀压缩。",
"Other": "其他",
"Output": "输出",
"Overview": "概览",
"PID %d": "PID %d",
"PID %lld": "PID %lld",
"Pair Device": "配对设备",
"Paired Users": "已配对用户",
"Paste code here…": "在此粘贴代码…",
"Paths": "路径",
"Pause": "暂停",
"Pending Approvals": "待批准",
"Per-route subscriptions (events, prompt template, delivery target) are managed in the Webhooks sidebar — not here. This panel only controls whether the webhook platform is listening at all.": "按路由的订阅(事件、提示模板、投递目标)在 Webhooks 侧边栏中管理 — 不在此处。本面板仅控制 webhook 平台是否监听。",
"Period": "周期",
"Personalities": "人格",
"Personality": "人格",
"Pick an MCP server to add.": "选择要添加的 MCP 服务器。",
"Pick one from the list, or add a new server from the toolbar.": "从列表中选择,或从工具栏添加新服务器。",
"Platforms": "平台",
"Plugins": "插件",
"Plugins extend hermes with custom tools, providers, or memory backends.": "插件通过自定义工具、提供方或记忆后端扩展 hermes。",
"Pre-Run Script": "运行前脚本",
"Preset:": "预设:",
"Probe": "探测",
"Profile": "配置",
"Profiles": "配置",
"Project Name": "项目名称",
"Project Path": "项目路径",
"Projects": "项目",
"Prompt": "提示",
"Provide a Git URL (https://github.com/...) or a shorthand like `owner/repo`.": "提供 Git URL(https://github.com/...)或简写如 `owner/repo`。",
"Provider": "提供方",
"Push to Talk": "按住说话",
"Push to talk (Ctrl+B)": "按住说话 (Ctrl+B)",
"Push-to-Talk": "按住说话",
"Quick Commands": "快捷命令",
"Quick commands are shell shortcuts hermes exposes in chat as `/command_name`. They live under `quick_commands:` in config.yaml.": "快捷命令是 hermes 在聊天中以 `/command_name` 形式暴露的 shell 快捷方式。它们位于 config.yaml 的 `quick_commands:` 下。",
"Quit Scarf": "退出 Scarf",
"Raw Config": "原始配置",
"Raw remote output (for debugging)": "远程原始输出(用于调试)",
"Re-run": "重新运行",
"Read": "读取",
"Reasoning": "推理",
"Recent Sessions": "最近会话",
"Reconnect": "重新连接",
"Recording…": "录制中…",
"Redaction": "脱敏",
"Refresh": "刷新",
"Reload": "重新加载",
"Remote (HTTP)": "远程 (HTTP)",
"Remote Diagnostics — %@": "远程诊断 — %@",
"Remove": "移除",
"Remove %@?": "移除 %@?",
"Remove credential for %@?": "移除 %@ 的凭证?",
"Remove this server from Scarf.": "从 Scarf 中移除此服务器。",
"Remove this server?": "移除此服务器?",
"Remove via config.yaml…": "通过 config.yaml 移除…",
"Remove webhook %@?": "移除 webhook %@?",
"Rename": "重命名",
"Rename Profile": "重命名配置",
"Rename Session": "重命名会话",
"Rename...": "重命名...",
"Required": "必需",
"Required Tokens": "必需令牌",
"Requires: %@": "需要:%@",
"Reset Cooldowns": "重置冷却",
"Restart": "重启",
"Restart Gateway": "重启 Gateway",
"Restart Hermes": "重启 Hermes",
"Restart Now": "立即重启",
"Restore": "恢复",
"Restore from backup?": "从备份恢复?",
"Restore…": "恢复…",
"Result": "结果",
"Resume": "继续",
"Resume Session": "恢复会话",
"Retry": "重试",
"Return to Active Session (%@...)": "返回活跃会话 (%@...)",
"Reveal": "显示",
"Revoke": "撤销",
"Rich Chat": "富文本聊天",
"Run Diagnostics…": "运行诊断…",
"Run Dump": "运行 Dump",
"Run Now": "立即运行",
"Run Setup in Terminal": "在终端中运行设置",
"Run `hermes memory setup` in Terminal for full provider configuration.": "在终端中运行 `hermes memory setup` 以完成完整的提供方配置。",
"Run remote diagnostics — check exactly which files are readable on this server.": "运行远程诊断 — 准确检查此服务器上哪些文件可读。",
"Running a single shell session on %@ that exercises every path Scarf reads…": "在 %@ 上运行单一 shell 会话,测试 Scarf 读取的每个路径…",
"Running checks…": "正在运行检查…",
"SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.": "SOUL.md 位于 ~/.hermes/SOUL.md,描述 agent 的语气、价值观与人格。它会被注入到每个会话的上下文中。",
"SSH works but %@. Click for diagnostics.": "SSH 正常,但 %@。点击查看诊断。",
"Save": "保存",
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:": "Scarf 永远不会提示输入密码短语。请在终端中将密钥添加到 ssh-agent,然后点击重试。如果你的密钥不是 `id_ed25519`,请替换路径:",
"Scarf runs these over a single SSH session that mirrors the shell your dashboard reads from, so a green row here means Scarf can actually read that file at runtime.": "Scarf 在单一 SSH 会话中运行这些命令,该会话与仪表盘读取的 shell 一致。因此这里绿色行表示 Scarf 在运行时确实可以读取该文件。",
"Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.": "Scarf 使用 ssh-agent 进行认证。如果你的密钥带有密码短语,请在连接前运行 `ssh-add` — Scarf 永远不会提示或存储密码短语。",
"Scarf — %@": "Scarf — %@",
"Search": "搜索",
"Search Results (%lld)": "搜索结果 (%lld)",
"Search messages...": "搜索消息...",
"Search or browse skills published to registries like skills.sh, GitHub, and the official hub.": "搜索或浏览发布到 skills.sh、GitHub 和官方 Hub 等注册表的技能。",
"Search registries": "搜索注册表",
"Search…": "搜索…",
"Security": "安全",
"Select": "选择",
"Select Model": "选择模型",
"Select a Job": "选择任务",
"Select a Profile": "选择配置",
"Select a Project": "选择项目",
"Select a Session": "选择会话",
"Select a Skill": "选择技能",
"Select a Tool Call": "选择工具调用",
"Select an MCP Server": "选择 MCP 服务器",
"Send message (Enter)": "发送消息 (Enter)",
"Series": "系列",
"Server": "服务器",
"Server No Longer Exists": "服务器已不存在",
"Server name": "服务器名称",
"Servers": "服务器",
"Service": "服务",
"Service definition stale": "服务定义已过期",
"Session": "会话",
"Session Search": "会话搜索",
"Session title": "会话标题",
"Sessions": "会话",
"Settings": "设置",
"Setup": "设置",
"Share Debug Report…": "分享调试报告…",
"Shell Command": "Shell 命令",
"Show": "显示",
"Show Output": "显示输出",
"Show all %lld lines": "显示全部 %lld 行",
"Show details": "显示详情",
"Show less": "收起",
"Show values": "显示值",
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages.": "Signal 集成需要在本地安装 signal-cli(基于 Java)。将此 Mac 关联为 Signal 设备,然后保持守护进程运行,以便 hermes 收发消息。",
"Site": "站点",
"Skills": "技能",
"Skills (%lld)": "技能 (%lld)",
"Skills Hub": "技能 Hub",
"Source": "来源",
"Speech-to-Text": "语音转文字",
"Start": "启动",
"Start Daemon": "启动守护进程",
"Start Hermes": "启动 Hermes",
"Start OAuth": "开始 OAuth",
"Start Pairing": "开始配对",
"Start a new session or resume an existing one from the Session menu above.": "从上方会话菜单启动新会话或恢复已有会话。",
"Status": "状态",
"Stop": "停止",
"Stop Hermes": "停止 Hermes",
"Subagent": "子 agent",
"Subagent Sessions (%lld)": "子 agent 会话 (%lld)",
"Submit": "提交",
"Subscribe": "订阅",
"Succeeded": "成功",
"Switch to This Profile": "切换到此配置",
"Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.": "切换当前配置会改变 hermes 使用的 `~/.hermes` 目录。切换后请重启 Scarf,以便它从新配置的文件中重新读取。",
"TTS Off": "TTS 关",
"TTS On": "TTS 开",
"Terminal": "终端",
"Test": "测试",
"Test All": "测试全部",
"Test Connection": "测试连接",
"Test failed": "测试失败",
"Test passed": "测试通过",
"Text-to-Speech": "文字转语音",
"The agent hasn't advertised any slash commands yet. Keep typing to send as a message, or press Esc.": "agent 尚未公布任何斜杠命令。继续输入作为消息发送,或按 Esc。",
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection.": "远端的 SSH 指纹与你 `~/.ssh/known_hosts` 文件中预期的不再匹配。这通常意味着远端已重装 — 较少见的情况是有人在拦截连接。",
"The server this window was opened with has been removed from your registry.": "打开此窗口所用的服务器已从你的注册表中移除。",
"The server's SSH configuration is removed from Scarf. Your remote files are untouched.": "服务器的 SSH 配置已从 Scarf 中移除。你的远程文件未被改动。",
"The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".": "终端是真正的 TTY — 使用 ⌘V 粘贴,按 Return,等待进程以 \"login succeeded\" 退出。",
"These list fields must be edited directly in config.yaml.": "这些列表字段必须直接在 config.yaml 中编辑。",
"This provider has no catalogued models.": "此提供方没有已登记的模型。",
"This removes the credential from hermes. The upstream provider key is not revoked.": "这将从 hermes 中移除该凭证。上游提供方的密钥不会被撤销。",
"This removes the profile directory and all data within it. This cannot be undone.": "这将移除配置目录及其中所有数据。此操作无法撤销。",
"This removes the scheduled job permanently.": "这将永久移除该定时任务。",
"This removes the server from config.yaml and deletes any OAuth token.": "这将从 config.yaml 中移除服务器并删除任何 OAuth 令牌。",
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.": "这会将日志、配置(密钥已脱敏)和系统信息上传到 Nous Research 支持基础设施。分享返回的 URL 前请查看下方输出。",
"This will overwrite files under ~/.hermes/ with the archive contents.": "这将使用归档内容覆盖 ~/.hermes/ 下的文件。",
"This will permanently delete the session and all its messages.": "这将永久删除该会话及其所有消息。",
"Timeout: %llds (%@)": "超时:%1$lld 秒 (%2$@)",
"Timeouts": "超时",
"Tirith Sandbox": "Tirith 沙箱",
"To skip the passphrase prompt at every reboot, add `--apple-use-keychain` to cache it in macOS Keychain.": "要跳过每次重启时的密码短语提示,添加 `--apple-use-keychain` 以将其缓存到 macOS Keychain 中。",
"Toggle text-to-speech (/voice tts)": "切换文字转语音 (/voice tts)",
"Toggle voice mode (/voice)": "切换语音模式 (/voice)",
"Token on disk. Clear to re-authenticate next time the gateway connects.": "令牌已保存到磁盘。清除后,gateway 下次连接时将重新认证。",
"Tool Approval Required": "需要工具批准",
"Tool Filters": "工具过滤器",
"Tool Progress": "工具进度",
"Tools": "工具",
"Top Tools": "常用工具",
"Turns & Reasoning": "轮次与推理",
"Uninstall": "卸载",
"Unknown: %@": "未知:%@",
"Update": "更新",
"Update All": "全部更新",
"Updated: %@": "已更新:%@",
"Updates": "更新",
"Upload": "上传",
"Upload debug report?": "上传调试报告?",
"Usage Stats": "使用统计",
"Use": "使用",
"Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".": "使用未登记在目录中的模型。Hermes 接受提供方识别的任何字符串,包括带有提供方前缀的形式,如 \"openrouter/anthropic/claude-opus-4.6\"。",
"Use this": "使用此项",
"Use {dot.notation} to reference fields in the webhook payload.": "使用 {dot.notation} 引用 webhook payload 中的字段。",
"Used as the YAML key. Lowercase, no spaces.": "用作 YAML 键。小写,不含空格。",
"View": "查看",
"View All": "查看全部",
"Vision": "视觉",
"Voice": "语音",
"Voice Off": "语音关",
"Voice On": "语音开",
"Waiting for authorization URL…": "等待授权 URL…",
"Waiting for first probe": "等待首次探测",
"Waiting for hermes to prompt for the code…": "等待 hermes 提示输入代码…",
"Web Extract": "网页提取",
"Webhook (advanced)": "Webhook(高级)",
"Webhook (hermes side)": "Webhook(hermes 侧)",
"Webhook Security": "Webhook 安全",
"Webhook platform not enabled": "未启用 webhook 平台",
"Webhooks": "Webhooks",
"Webhooks let external services trigger agent responses. Each subscription gets its own URL endpoint.": "Webhooks 允许外部服务触发 agent 响应。每个订阅都有自己的 URL 端点。",
"Website Blocklist": "网站黑名单",
"WhatsApp uses the Baileys library to emulate a WhatsApp Web session. Pair this Mac as a linked device by running the pairing wizard and scanning the QR code with your phone (Settings → Linked Devices → Link a Device).": "WhatsApp 使用 Baileys 库模拟 WhatsApp Web 会话。通过运行配对向导并用手机扫描二维码,将此 Mac 配对为关联设备(设置 → 关联设备 → 关联一台设备)。",
"Working": "工作中",
"e.g. anthropic": "例如 anthropic",
"e.g. deploy": "例如 deploy",
"e.g. experimental": "例如 experimental",
"e.g. github": "例如 github",
"e.g. openai": "例如 openai",
"e.g. openai/gpt-4o": "例如 openai/gpt-4o",
"e.g. team-prod": "例如 team-prod",
"exit code: %d": "退出码:%d",
"hermes at %@": "%@ 上的 hermes",
"iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.": "iMessage 集成通过 BlueBubbles Server 运行。你需要一台保持开机且登录 Messages.app 的 Mac — 在其上安装 BlueBubbles Server,然后将 hermes 指向该服务器的 URL。",
"signal-cli is available on PATH": "signal-cli 已在 PATH 中",
"signal-cli not found on PATH — install it first": "PATH 中未找到 signal-cli — 请先安装",
"ssh trace": "ssh 跟踪",
"ssh-agent (leave blank)": "ssh-agent(留空)",
"state.db not found at the configured path. Either Hermes hasn't run yet on this server, or it's installed at a non-default location — set the Hermes data directory field above.": "在配置的路径下未找到 state.db。要么 Hermes 尚未在此服务器上运行过,要么它安装在非默认位置 — 请在上方设置 Hermes 数据目录字段。",
"state.db not found at the default location, but Scarf found one at:": "默认位置下未找到 state.db,但 Scarf 在此处找到了一个:",
"state.db readable": "state.db 可读",
"— or use user/password login —": "— 或使用用户名/密码登录 —"
}