A native macOS database client (SwiftUI + AppKit), a lightweight alternative to TablePlus. macOS 13.0+, Swift 6 language mode for the app targets (Configs/Base.xcconfig), Universal Binary. TableProMobile/ is the iOS app.
This file is the project guide for every agent, and it covers the macOS and iOS apps. An app for another platform keeps its own guide beside its code, and that guide is the one to follow there. Claude Code loads this file through CLAUDE.md; Codex loads it directly. Area rules live in .claude/rules/ (index at the end), and Claude Code loads each one automatically when you touch a file its paths: names. Codex does not, so read the matching rule before editing those paths.
- Security first. Validate at system boundaries; never introduce injection, credential exposure or a widened permission by accident.
- Native only. AppKit, SwiftUI and system frameworks. No cross-platform layers, no web views for native UI. Follow the documented Apple API and the HIG, not a hand-rolled equivalent.
- Fix the cause, not the symptom. Diagnose (add OSLog if needed), then fix the actual cause. No temporary workarounds left in place.
- Clean architecture. Separation of concerns, protocol-oriented design, dependency injection where it earns its place. Consider the effect of every change on the design, not just the immediate problem.
- Tests define behavior. Every testable change has a test. When a test fails, fix the code, never the test.
- Open plugin domain.
DatabaseTypeis a string-backed struct, so unknown types from future plugins round-trip. Aswitchover an open type keeps adefault:; aswitchover a closed enum stays exhaustive so the compiler flags new cases.
Every capability is core, a module or a plugin. The first rule that fits decides:
- Used weekly by an app or backend developer or an AI-first user, or only a row that runs nothing until clicked: core, with no toggle.
- UI for one engine: core, shown only for the drivers that support it.
- A database driver or a heavy native dependency: plugin, shipped through the plugin registry and signed by TablePro.
- Any other advanced tool that needs a session, the grid, the editor or native UI: module, compiled in and turned on or off as a unit.
- A duplicate, or not worth maintaining: delete it. Never move it to the registry as a halfway step.
- A new database engine ships only as a registry plugin. Engine-specific code never goes into
TablePro/; engine UI goes through a PluginKit capability or plugin metadata. - A new advanced tool ships as a module, off by default.
- A module PR declares its module id, its default, the sink that refuses it while it is off, its residue test and its docs page.
- An off module leaves nothing behind: no menu item, key equivalent, context item, Settings section or Keyboard row; no launch work, observer, timer, listener or child process; no Keychain read or network call. A URL, AppleScript or MCP request for it gets an error that says where to turn it on, and only a click in TablePro turns it on.
TablePro/: the app.Core/(services, business logic),Views/,Models/,ViewModels/,Extensions/,Theme/.Plugins/: every database driver and import/export format is a.tablepluginbundle, plusTableProPluginKit, the shared framework. Bundled plugins are the targets in the app'scopy: { destination: plugins }phase inproject.yml; the rest are registry-only and ship through TableProApp/plugins. Some bundled plugins also have a registry entry ("bundled": truein.github/plugin-registry.json), but the app never installs or offers a registry update over a bundled plugin; a published build reaches a user only through a manual install of its zip.Packages/: local SwiftPM packages (TableProCore,TableProOracle,TableProEditor,TableProGrammars). Each sets its own Swift language mode in its manifest;SWIFT_VERSIONin the xcconfig does not reach them, and passingSWIFT_VERSION=toxcodebuildreaches every package and reports their errors as yours.Packages/TableProCore/Sources/TableProPluginKitis a symlink: editPlugins/TableProPluginKit/only.Native/: first-party Rust and Go bridges (see.claude/rules/native-bridges.md).Libs/: prebuilt static libraries and iOS xcframeworks, downloaded byscripts/download-libs.sh, not in git.- The docs site (docs.tablepro.app) is its own repository, TableProApp/docs, cloned beside this one as
../tablepro-docs. ItsAGENTS.mdandSTYLE.mdgovern its pages. TablePro.xcodeprojandTableProMobile/TableProMobile.xcodeprojare generated fromproject.ymlandTableProMobile/project.ymlby XcodeGen. They are gitignored, except each one's trackedPackage.resolved. Never hand-edit them. Signing overrides and secrets go inConfigs/Secrets.xcconfig(template:Secrets.xcconfig.example).
scripts/download-libs.sh # first time, and after a libs update
scripts/generate-project.sh # after editing project.yml or Configs/, or adding, moving or deleting a source fileBuild, test and lint through the wrapper, which keeps the full log on disk and prints a short verdict (PASS, FAIL, INCONCLUSIVE):
.claude/skills/fix-issue/scripts/verify.sh build
.claude/skills/fix-issue/scripts/verify.sh test <SuiteType> [SuiteType...]
.claude/skills/fix-issue/scripts/verify.sh lint <file.swift> [...]It also has generate, uitest, package, ios, plugins, abi, l10n, docs and agent-docs; verify.sh --help lists them. The steps are the platform's: on a Mac they are these, and --platform <name> picks another app's. The raw equivalents always pass -skipPackagePluginValidation:
xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation
xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/<SuiteType>
swiftlint lint --strict <files>- Tests are Swift Testing.
-only-testingmatches the Swift type name of a suite (one case:<SuiteType>/<test>()), and a filter that matches nothing still printsTEST SUCCEEDED, so check the executed count. - A full
TableProTestsrun is not a gate: run the suites that own the types you changed. - Release builds:
scripts/build-release.sh arm64|x86_64|bothandscripts/create-dmg.sh; thereleaseskill runs the whole release. - SwiftLint (
.swiftlint.yml) is the enforced style and runs on every PR. Pass it file paths: a directory outside itsincluded:scope lints nothing and reports zero violations.
Plugins. PluginManager (Core/Plugins/) loads bundles at runtime. PluginDatabaseDriver and DriverPlugin live in TableProPluginKit; PluginDriverAdapter bridges a plugin driver to the app's DatabaseDriver; DatabaseDriverFactory finds a plugin by DatabaseType.pluginTypeId; DatabaseManager owns sessions and is what views and coordinators talk to. A new protocol method goes on PluginDatabaseDriver with a default implementation, then into PluginDriverAdapter. PluginKit is ABI-resilient and its compatibility rules are in .claude/rules/plugin-system.md; read them before touching it.
DatabaseType. A string-backed struct, not an enum. Use the static constants (.mysql, .postgresql) for known types and DatabaseType.allKnownTypes for the canonical list.
Main window. MainContentCoordinator is the central coordinator, split into extension files in Views/Main/Extensions/. Each connection a window hosts is a ConnectionWorkspace with its own phase; see .claude/rules/connection-window.md.
Change tracking. A cell edit goes to DataChangeManager; Save turns it into statements through SQLStatementGenerator; AnyChangeManager abstracts the concrete managers. Undo comes from StructureChangeManager's private UndoManager and ConnectionWorkspace.undoManager.
Editor and theme. ThemeCatalog owns the theme list, ThemeResolver picks the light and dark pair, and ThemeEngine holds the palette and the font caches; the fonts themselves are TypographySettings. CompletionEngine is framework-agnostic and QueryCompletionAdapter bridges it to the editor. Editor tabs are drawn by EditorTabStrip, not native window tabs. Details: .claude/rules/editor.md.
Storage. Passwords in the Keychain, preferences in UserDefaults, query history in SQLite FTS5, tab state as JSON. The full table of which store owns what is in .claude/rules/data-sync-storage.md.
- The app runs the AppKit lifecycle.
main.swiftstarts it,MainMenuBuilder.installbuilds the menu bar inapplicationWillFinishLaunching, and every window is anNSWindowController. Never add a SwiftUIApp: it rewritesNSApp.mainMenuafter launch. - A refresh never clears the cache it refreshes. Fetch, then commit over the old value. Enter
.loadingonly when nothing is loaded, keep the good data when a refresh fails, and useprepareForReloadfor a reload, keepinginvalidatefor disconnect. - Canceling a connect does not stop the driver.
Task.cancel()cannot interrupt a blocking C call, so a connect must be abortable (poll it, or resume throughrunCancellableBlockingand let the late call close its own handle), and every attempt checks itsConnectionAttemptRegistrygeneration before adopting a driver.
.swiftlint.yml is the source of truth. Beyond it:
- Comments say why, never what. A short
///(one or two lines) belongs where the code would surprise a careful reader: a platform quirk, an invariant, a workaround and the reason for it. Never narrate what the code does, cite tickets, or describe callers. Remove a what-comment when you touch its code. - Early returns with
guard; small focused functions; self-explanatory names. - Explicit access control, declared on the extension rather than on each member.
- No force unwrapping or force casting.
- OSLog (
Logger(subsystem: "com.TablePro", category: "...")), neverprint(). - Imports: system frameworks alphabetically, then third-party, then local.
- Approaching a SwiftLint size limit: extract into
TypeName+Category.swift, grouped by domain.
- Never
ForEach($bindable.array) { $item in }on an@Observablearray that can shrink: index bindings crash. UseForEach(array)and a manual binding. - On large strings, use
(string as NSString).lengthandcharacter(at:), never.countorindex(_:offsetBy:)in a loop. - Never call
ensureLayout(forCharacterRange:); it defeats non-contiguous layout. - A SQL dump can have one line of millions of characters: cap regex and highlight ranges at 10k characters.
- Tests. Unit tests for testable behavior;
TableProUITestsautomation for a user flow that runs deterministically, or the reason in the PR. UI suites subclassUITestCase. Details:.claude/rules/tests.md. - CHANGELOG.md. A user-visible change gets one fragment under
[Unreleased], in the existing canonical section. Format:.claude/rules/changelog.md. - Localization.
String(localized:)for user-facing strings outside SwiftUI literals, never with interpolation (useString(format: String(localized: "Preview %@"), name)). Do not localize technical terms. Plugin and package messages must be in the app catalog, managed manually:python3 scripts/localization.py plugins --add. Never hand-edit a.xcstrings; translations go throughxcodebuild -exportLocalizationsand-importLocalizations. - Docs. A new shortcut, UI or settings change, or driver change gets a paired draft pull request in TableProApp/docs that names this one. Its CI checks the app's
main, so it merges after this one does;verify.sh docsruns the same checks against this tree first. - Lint the changed Swift files with
swiftlint lint --strict. - Atomic API changes. A rename or signature change updates every caller and test in the same commit.
- Commits and pull requests. Squash merges use the PR title as the commit subject, so the title is the record: Conventional Commits (
<type>(<scope>): <description>,!for breaking), at most 72 characters, a canonical scope when one fits. CI checks it. Branch commits follow the same format; put the explanation in the PR description, which follows.github/pull_request_template.md.- Types:
feat,fix,refactor,perf,test,docs,build,ci,chore,style,revert. Thereleaseskill alone writesrelease: v<version>. - Scopes:
ai-chat,ai-providers,mcp,copilot,inline-suggest,editor,datagrid,tabs,coordinator,sidebar,connections,connection-form,welcome,settings,toolbar,hig,ssh,ios,windowing,perf,launch,plugins,plugin-<name>,changelog,claude-md,docs,ci,release. - Platform scopes:
iosfor the iOS app,linuxorlinux-<area>for the Linux app.windowsis kept for a Windows app, which is why window management iswindowing.
- Types:
For everything: docs, commits, CHANGELOG, UI strings, errors, PR descriptions. Short sentences, plain words, specific numbers and names. No em dashes, no filler. scripts/banned-words.txt is the list, and scripts/check-banned-words.sh --staged checks the added lines of a staged change.
- PRs:
macos-tests.yml,ios-tests.yml,swiftlint.yml(pinned,--strict),pr-title.yml(Conventional Commits, at most 72 characters) andrepo-hygiene.yml(actionlint, shellcheck and the repo's source-scanning checks). - Releases:
build.ymlruns onv*tags and produces the DMG, the tar.xz update archive, its deltas and the Sparkle feed, with notes taken fromCHANGELOG.md. - Plugins:
build-plugin.ymlruns onplugin-<slug>-v*tags, where the slug is a key in.github/plugin-registry.json.
| Rule | Covers |
|---|---|
ai-mcp-security.md |
TablePro/Core/AI, TablePro/Core/MCP |
changelog.md |
CHANGELOG.md |
connection-window.md |
connection windows, workspaces, sessions, DatabaseManager |
data-grid.md |
the data grid under TablePro/Views/Results |
data-sync-storage.md |
storage, CloudKit sync, the database core |
editor.md |
Packages/TableProEditor, TablePro/Theme, editor views |
libs.md |
Libs/ and the libs scripts |
mongodb-driver.md, mysql-driver.md, redis-driver.md |
those drivers |
native-bridges.md |
Native/ and the Dameng and HANA plugins |
plugin-system.md |
Plugins/, TableProPluginKit, project.yml, plugin CI |
split-views.md |
split view controllers and workspace panes |
tests.md |
TableProTests, TableProUITests |
ui-lifecycle.md |
views, view models, UI tests |
welcome-library.md |
the welcome window's connection list |
Skills live in .claude/skills/; Codex sees them through .agents/skills/. fix-issue takes an issue to a pull request; release ships a version.