From macos-app-scaffold
Adds production features like CI/CD, auto-updates, logging, SwiftLint, localization, Launch at Login to existing macOS Swift apps after analyzing project status.
npx claudepluginhub xueshiqiao/macos-app-scaffold[feature-name]This skill is limited to using the following tools:
Add production-ready features to an existing macOS project. This skill analyzes the current project, shows what's already in place, and surgically adds only the new pieces.
Scaffolds production-ready macOS apps interactively using XcodeGen for project generation, SwiftUI starter code, GitHub Actions CI/CD, code signing, notarization, and Sparkle auto-updates. Chooses archetype: menu bar, windowed, or hybrid.
Builds native macOS apps in Swift with SwiftUI and AppKit using CLI tools. Handles full lifecycle: build, debug, test, optimize, ship without Xcode.
Scaffolds, builds, and packages SwiftPM-based macOS apps without Xcode. Covers custom layouts, bundling scripts, signing, notarization, and appcasts.
Share bugs, ideas, or general feedback.
Add production-ready features to an existing macOS project. This skill analyzes the current project, shows what's already in place, and surgically adds only the new pieces.
Companion to /new-macos-app (which scaffolds from scratch).
$ARGUMENTS = Optional feature name to add directly (e.g., ci-cd, auto-update, logging)If no argument given, run the full analysis and let the user pick.
Before asking anything, scan the current directory for:
Check for:
├── project.yml → XcodeGen?
├── *.xcodeproj/ → Xcode project exists?
├── Package.swift → SPM package?
├── .github/workflows/*.yml → CI/CD?
├── .swiftlint.yml → SwiftLint?
├── .gitignore → Git initialized?
├── AGENTS.md / CLAUDE.md → Agent config?
├── LICENSE → License file?
├── README.md → README?
├── Casks/*.rb → Homebrew Cask?
├── **/Sources/**App.swift → App entry point?
├── **/*.entitlements → Entitlements?
├── **/Info.plist → App metadata?
└── **/*.xcstrings or *.strings → Localization?
Also read project.yml (or .pbxproj info) to determine:
Present a clear dashboard of what exists vs what can be added:
## Project: {{AppName}} ({{BundleID}})
### Already in place
✓ XcodeGen (project.yml)
✓ Git initialized
✓ GitHub Actions CI/CD
✓ Entitlements (no sandbox)
### Available to add
1. Auto-update mechanism (GitHub API polling / Sparkle)
2. File-based logging
3. SwiftLint config
4. Unit test target
5. Launch at Login
6. Accessibility permission gate
7. Screen Recording permission flow
8. Localization
9. Settings/Preferences window
10. Analytics (Aptabase)
11. Onboarding/Welcome window
12. Homebrew Cask formula
13. README.md with badges
14. License file
15. AGENTS.md + CLAUDE.md symlink
Which features would you like to add? (comma-separated numbers, or "all")
Only show features that are NOT already detected. If a feature partially exists, note it (e.g., "CI/CD exists but missing notarization steps").
If the user provided an argument (e.g., /enhance-macos-app auto-update), skip the dashboard and go directly to that feature.
For each selected feature, follow these rules:
project.yml exists, modify it instead of the Xcode project. Remind the user to run xcodegen generate after.Adds: .github/workflows/build.yml
Before generating, ask:
Generate the workflow from the template in /new-macos-app skill, adapted to the existing project:
project.yml or project settings for app name, scheme name, entitlements pathAlso check if .gitignore needs updating for build artifacts.
Prerequisite: CI/CD must exist (check .github/workflows/). If not, offer to add CI/CD first.
Ask: Which approach?
UpdateChecker.swiftSPUStandardUpdaterController setupFor GitHub API polling:
git remote)UpdateChecker.swift (see new-macos-app templates)For Sparkle:
Sparkle (v2.9.0+) to project.yml packages sectionSUFeedURL to Info.plist properties in project.ymlSPUStandardUpdaterController setup in AppDelegateEdDSA key generation — instruct the user to run locally:
# Download Sparkle, extract, then:
./bin/generate_keys
# This prints the public key (add to Info.plist as SUPublicEDKey)
# and saves the private key to Keychain.
# Export the private key and add as GitHub secret: SPARKLE_EDDSA_KEY
CI/CD appcast generation — add these steps to the existing build.yml:
- name: Download Sparkle tools
if: env.HAS_APPLE_SECRETS == 'true'
run: |
SPARKLE_VERSION="2.9.0"
curl -sL -o "$RUNNER_TEMP/sparkle.tar.xz" \
"https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
mkdir -p "$RUNNER_TEMP/sparkle"
tar -xf "$RUNNER_TEMP/sparkle.tar.xz" -C "$RUNNER_TEMP/sparkle"
- name: Sign DMG and generate appcast
if: env.HAS_APPLE_SECRETS == 'true'
env:
SPARKLE_EDDSA_KEY: ${{ secrets.SPARKLE_EDDSA_KEY }}
run: |
echo -n "$SPARKLE_EDDSA_KEY" > "$RUNNER_TEMP/sparkle_eddsa.key"
SIGN_OUTPUT=$("$RUNNER_TEMP/sparkle/bin/sign_update" "$APP_NAME.dmg" -f "$RUNNER_TEMP/sparkle_eddsa.key")
rm -f "$RUNNER_TEMP/sparkle_eddsa.key"
ED_SIGNATURE=$(echo "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')
# Anchor the grep so it matches the SETTING line (MARKETING_VERSION: "1.2.3")
# and skips reference lines like CFBundleShortVersionString: $(MARKETING_VERSION).
# Without the anchor, `head -1` may pick a reference line that has no quotes
# and `awk -F'"' '{print $2}'` returns empty — silently producing an appcast
# with empty <sparkle:version>/<sparkle:shortVersionString>.
VERSION=$(grep -E '^\s*MARKETING_VERSION: ' project.yml | head -1 | awk -F'"' '{print $2}')
BUILD=$(grep -E '^\s*CURRENT_PROJECT_VERSION: ' project.yml | head -1 | awk -F'"' '{print $2}')
if [ -z "$VERSION" ] || [ -z "$BUILD" ]; then
echo "ERROR: failed to parse MARKETING_VERSION or CURRENT_PROJECT_VERSION from project.yml"
exit 1
fi
FILE_LENGTH=$(stat -f%z "$APP_NAME.dmg")
TAG="${GITHUB_REF_NAME}"
DOWNLOAD_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${APP_NAME}.dmg"
{
echo '<?xml version="1.0" encoding="utf-8"?>'
echo '<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">'
echo ' <channel>'
echo " <title>${APP_NAME} Updates</title>"
echo ' <item>'
echo " <title>Version ${VERSION}</title>"
echo " <sparkle:version>${BUILD}</sparkle:version>"
echo " <sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>"
echo " <enclosure url=\"${DOWNLOAD_URL}\" length=\"${FILE_LENGTH}\" type=\"application/octet-stream\" sparkle:edSignature=\"${ED_SIGNATURE}\" />"
echo ' </item>'
echo ' </channel>'
echo '</rss>'
} > appcast.xml
Additional GitHub secrets for Sparkle:
SPARKLE_EDDSA_KEY — EdDSA private key exported from generate_keysAppcast hosting: Upload appcast.xml alongside the DMG in the GitHub Release, or host it separately (e.g., GitHub Pages, S3). Set SUFeedURL in Info.plist to point to the hosted appcast.
Adds: FileLog.swift in the Sources directory.
FileLog.swift with the app name in the log pathprivate let log = FileLog("ClassName")Adds: .swiftlint.yml at project root.
Generate sensible defaults (see new-macos-app template). Ask if they want strict or relaxed rules.
Modifies: project.yml (if XcodeGen) to add test target.
Adds: {{AppName}}Tests/{{AppName}}Tests.swift
Adds: LaunchAtLoginManager.swift
Modifies: Existing SettingsView (if found) to add toggle.
SMAppService (macOS 13+)Advanced. Almost no app needs this. The Launch at Login feature above is the right answer for "I want my app to start at login". This feature is for apps that need a separate helper process running in the background.
Decision table (show this verbatim when the user asks for this feature):
| Option | What you get | Runs as | Approval | Pick when… |
|---|---|---|---|---|
User Agent (SMAppService.agent) | Separate helper binary; launchd starts it on demand once the user has logged in. App ↔ helper via XPC. | user | none | Background work that does not need root: clipboard watcher, sync engine, hotkey daemon, on-device AI worker. |
Privileged Daemon (SMAppService.daemon) | Separate helper binary; launchd starts it on demand at the system level (no login required). App ↔ helper via privileged XPC. | root | user must approve in System Settings | VPN, packet filter, kext-adjacent, system-wide proxy, services that must run before any user logs in (set RunAtLoad for that). |
Default: User Agent. Confirm explicitly before adding the daemon variant. If the user picks daemon, ask one verification question: "Which specific operation needs root?" — if they cannot name one, steer them to agent.
Adds (User Agent):
Shared/HelperProtocol.swift (compiled into both targets)Helper/HelperMain.swiftHelper/LaunchAgents/<HelperBundleID>.plistHelper/<HelperExecutableName>.entitlementsSources/HelperManager.swiftAdds (Privileged Daemon):
Helper/LaunchDaemons/SMAuthorizedClients with the app's designated requirementHelperManager.swift includes status polling and openSystemSettings()Modifies:
project.yml — adds a tool-type helper target, copies the plist into
Contents/Library/Launch{Agents,Daemons}/, and lists the helper as a
dependency of the main app with copy.destination: executables.github/workflows/build.yml — extends the codesign loop to sign
Contents/MacOS/<helper> with <helper>.entitlements if presentHelperManager.sharedTemplates location. The agent and daemon templates ship with the
companion macos-app-scaffold-new skill. From this skill's directory the
relative path is ../macos-app-scaffold-new/templates/{agent,daemon}/; from
the repo root it is skills/macos-app-scaffold-new/templates/{agent,daemon}/.
If you cannot find them locally (e.g., user installed only one skill), fetch
from the upstream repo at
https://github.com/XueshiQiao/macos-app-scaffold/tree/main/skills/macos-app-scaffold-new/templates.
Read each template's README.md before copying.
Placeholders to substitute:
{{AppName}}, {{AppBundleID}} — read from project.yml{{HelperBundleID}} — default <AppBundleID>.helper, ask to confirm{{HelperExecutableName}} — default <AppName>Helper, ask to confirm{{TeamID}} — daemon only; ask the user, or run
security find-identity -p codesigning -v and offer the matchesAfter generating, tell the user:
xcodegen generate and build.try HelperManager.shared.register() from a Settings toggle. Helper launches on first XPC call.try HelperManager.shared.register(). If status becomes .requiresApproval, surface a button that calls HelperManager.shared.openSystemSettings(). After the user flips the switch, status transitions to .enabled.launchctl kickstart -k gui/$(id -u)/<HelperBundleID>sudo launchctl kickstart -k system/<HelperBundleID>log stream --predicate 'subsystem == "<HelperBundleID>"'Adds: PermissionManager.swift
Modifies: AppDelegate.swift to call permission check on launch.
AXIsProcessTrustedWithOptions checkDo not bundle this with the Accessibility gate. They look similar but the first-grant semantics are fundamentally different. ScreenCaptureKit requires a full app relaunch after first grant — Accessibility does not. An inline-style flow that polls
SCShareableContentafter grant is the single most common bug in this corner of the API.
Adds:
Sources/ScreenRecordingPermission.swift — manager with the relaunch invariant encoded in its Status enum (notGranted / grantedPendingRelaunch / granted)Sources/ScreenRecordingPromptView.swift — three-state SwiftUI modal: explain → "Open Settings" → poll → "Relaunch Now"Templates location. ../macos-app-scaffold-new/templates/screen-recording/
(repo path: skills/macos-app-scaffold-new/templates/screen-recording/).
Read its README.md before generating — it documents the dev-loop gotchas
the templates exist to prevent.
Modifies:
ScreenRecordingPromptView as a sheet when the user first reaches a feature that captures the screenSCShareableContent / SCStream / SCScreenshotManager — must gate on ScreenRecordingPermission.shared.isReadyForCapture (NEVER on status != .notGranted, which lets .grantedPendingRelaunch through and produces silent failures)Detect-and-replace: if the project already contains a
ScreenRecordingChecker.swift (an earlier version of macos-app-starter
shipped one), replace it. That earlier file calls
CGRequestScreenCaptureAccess and immediately uses SCShareableContent
without any relaunch handling — the textbook bug this template exists to
fix. Migrate call sites from ScreenRecordingChecker.shared.isGranted to
ScreenRecordingPermission.shared.isReadyForCapture.
Placeholders to substitute:
{{AppName}} — replace in ScreenRecordingPromptView.swift (UI string)Tell the user about the dev-loop gotcha (mandatory):
CODE_SIGN_IDENTITY: "Apple Development" (NOT -) in project.yml for
the Debug config, and pin DEVELOPMENT_TEAM. Re-signing with a different
identity silently wipes the TCC entry, the System Settings toggle
disappears, and ScreenCaptureKit returns nothing — without any error.tccutil reset ScreenCapture <BundleID>.NSScreenRecordingUsageDescription to Info.plist. It is not
consulted by this API; the system dialog uses CFBundleDisplayName.App Sandbox note: Unlike Accessibility (which is incompatible with sandbox), Screen Recording works in sandboxed apps. Mention this if the user is on the App Store path.
Adds: Localizable.xcstrings (and InfoPlist.xcstrings for display name), plus LocalizationManager.swift for runtime language switch.
Modifies: project.yml to add Resources/ path, LOCALIZATION_PREFERS_STRING_CATALOGS: YES, SWIFT_EMIT_LOC_STRINGS: YES, DEVELOPMENT_LANGUAGE, and knownRegions (must include all selected languages plus Base). AGENTS.md to add the "no bare UI string literals" convention.
See macos-app-scaffold-new/SKILL.md "Localization (if selected)" section for the full template — same xcstrings JSON shape, same project.yml settings, same LocalizationManager helper, same AGENTS.md text. Follow the same approach when enhancing.
Ask: Which languages? (always includes English)
String(localized:) or NSLocalizedStringAdds: SettingsView.swift
Modifies: App entry point to add Settings scene.
Modifies: project.yml to add Aptabase SPM dependency.
Adds: Analytics initialization in AppDelegate.
Aptabase.shared.initialize(appKey: "YOUR_KEY") in AppDelegateAdds: WelcomeView.swift + window management code.
UserDefaults check for first launchAdds: A working cask .rb file at the chosen tap location, plus (for
topology B) a sibling tap repo skeleton, plus (for topology C) a PR checklist.
Detect first, then ask:
git remote get-url origin) → owner/repo for the URL.project.yml (MARKETING_VERSION or top-level version).project.yml (DEPLOYMENT_TARGET /
MACOSX_DEPLOYMENT_TARGET) → maps to depends_on macos: codename.Sparkle SPM dep, or an
appcast.xml file → Sparkle; else look for any GitHub-release polling code
→ :github_latest; else default to :github_latest for livecheck and omit
auto_updates true.Then ask the tap-topology question — same four options as the new-app
skill (see macos-app-scaffold-new/SKILL.md Step 4a). Default to A if the
user mentions an existing tap, otherwise B. Capture cask_topology,
tap_owner, tap_repo_name.
Generate using the template in macos-app-scaffold-new/SKILL.md "Homebrew
Cask (if selected)" — same fields (desc, livecheck, auto_updates,
depends_on, expanded zap), same per-topology destination logic.
If the project has a CI workflow (.github/workflows/*.yml) AND topology is
A or B, also append the cask-bump step described in the new-app skill (search
for Update Homebrew cask in tap repo). Tell the user to add the
HOMEBREW_TAP_TOKEN secret. Do not append the step for C or D.
After writing files, run validation: brew style ./Casks/<name>.rb. Report
the result. Defer brew audit --cask --online to the user since it requires
the cask to be reachable via a tap.
Adds: README.md with badges.
Adds: LICENSE file.
Ask: MIT / GPL-3.0 / Apache-2.0 / None
Adds: AGENTS.md and CLAUDE.md symlink.
ln -s AGENTS.md CLAUDE.mdAfter all features are added, print:
xcodegen generate (if project.yml was modified)For argument-based invocation, accept these aliases:
| Argument | Feature |
|---|---|
ci-cd, ci, github-actions | CI/CD |
auto-update, update, sparkle | Auto-Update |
logging, log, filelog | File-Based Logging |
swiftlint, lint | SwiftLint |
test, tests, unit-test | Unit Test Target |
launch-at-login, login, autostart | Launch at Login |
helper, agent, xpc-helper, background-helper | Background Helper — User Agent |
daemon, privileged-helper, root-helper | Background Helper — Privileged Daemon |
accessibility, a11y, permission | Accessibility Gate |
screen-recording, screencapture, sckit, screencapturekit | Screen Recording Permission |
localization, l10n, i18n | Localization |
settings, preferences, prefs | Settings Window |
analytics, aptabase | Analytics |
onboarding, welcome | Onboarding Window |
homebrew, cask, brew | Homebrew Cask |
readme | README.md |
license | License |
agents, agents-md, claude-md | AGENTS.md + CLAUDE.md |
project.yml exists, it is the single source of truth. Modify it, not .pbxproj.project.yml, remind the user to run xcodegen generate.@MainActor, Sendable) in all generated code.ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO.