Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ Notable changes, newest first. Dates are the day the work landed on `main`.

The versions are what a release is tagged as; between tags, `main` is what is being used daily.

## Unreleased

### Added

- **Sort and filter the list by any frontmatter key.** Every key a file happens to set — `team`,
`model`, `allowed-tools`, anything — now appears on its own in the funnel and the sort menu, and
the one you pick becomes a small column on each row so a whole set can be read at a glance.
Nothing is hard-coded: a Claude-only field simply does not appear on a Codex skill. Suggested by
u/CacheInvalidation on Reddit.
- **An On filter beside the Off one, on every kind.** Skills, commands, subagents and MCP servers
can each be narrowed to what is actually loaded right now, which is the question the Off filter
only ever answered backwards.
- **One download that runs on Apple silicon and Intel Macs.** The release was built on an Apple
silicon machine and would not open on an Intel one. The app and the Sparkle framework inside it
are now both universal, and the build refuses to assemble a bundle that is not. Requested on
Reddit.

### Fixed

- **Turning a plugin off now turns off what it ships.** Its skills, commands and subagents kept
their rows lit and their switches on, so the list claimed Claude was loading things it was not.
They stay visible — that is where you put them back from — but they read as off, and their
switches stay still until the plugin itself is back on.
- **Two skills of the same name no longer leave a blank row.** A skill live in one assistant and
parked in another is two different things wearing one name; the list drew one of them and left a
gap the size of the other, and every count was one too high.
- **Disabling a shared skill twice in the same second no longer fails.** The second attempt was
refused with "an item with the same name already exists", because the first snapshot had become
a link pointing at a file that had moved, and the check for a free filename could not see it.

## 0.3.5 — 2026-08-30

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Or download the [latest release](../../releases/latest) — the [website](https:
points at the same file — and drag `Loadout.app` into `/Applications`. The disk image is signed and
notarised, so it opens without a Gatekeeper warning and without a trip through System Settings.

macOS 15 or later.
macOS 15 or later, on Apple silicon or Intel.

Or build it yourself, which needs Xcode:

Expand Down
23 changes: 21 additions & 2 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ VERSION="${VERSION#v}"
SIGN_IDENTITY="${LOADOUT_SIGN_IDENTITY:-}"

echo "→ Building ($CONFIG)"
swift build -c "$CONFIG" --product LoadoutApp
BUILD_ARGS=(-c "$CONFIG" --product LoadoutApp)
# Releases are one app for both kinds of Mac. Debug builds stay native so the edit/build loop is
# fast; everything that can leave this machine goes through the default release path.
if [[ "$CONFIG" == "release" ]]; then
BUILD_ARGS+=(--arch arm64 --arch x86_64)
fi
swift build "${BUILD_ARGS[@]}"

BINARY="$(swift build -c "$CONFIG" --product LoadoutApp --show-bin-path)/LoadoutApp"
BINARY="$(swift build "${BUILD_ARGS[@]}" --show-bin-path)/LoadoutApp"
[[ -x "$BINARY" ]] || { echo "no binary at $BINARY"; exit 1; }

echo "→ Assembling the bundle"
Expand All @@ -40,6 +46,19 @@ cp "$BINARY" "$APP/Contents/MacOS/Loadout"
SPARKLE="$APP/Contents/Frameworks/Sparkle.framework"
BUILT_SPARKLE="$(dirname "$BINARY")/Sparkle.framework"
[[ -d "$BUILT_SPARKLE" ]] || { echo "no Sparkle.framework beside $BINARY — run 'swift package resolve'"; exit 1; }

if [[ "$CONFIG" == "release" ]]; then
require_universal() {
local file="$1" label="$2" archs
archs="$(lipo -archs "$file")"
[[ " $archs " == *" arm64 "* && " $archs " == *" x86_64 "* ]] || {
echo "$label is not universal (found: $archs)"
exit 1
}
}
require_universal "$BINARY" "Loadout"
require_universal "$BUILT_SPARKLE/Versions/B/Sparkle" "Sparkle.framework"
fi
rm -rf "$SPARKLE"
ditto "$BUILT_SPARKLE" "$SPARKLE"
# Loadout is not sandboxed. Sparkle's XPC services exist only to carry the installer across a
Expand Down
4 changes: 4 additions & 0 deletions Scripts/test-update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ check "the framework and its helpers are signed" codesign --verify --deep --stri
check "the app and everything in it are signed" codesign --verify --deep --strict "$APP"
check "the app can find the embedded framework" sh -c \
"otool -l '$APP/Contents/MacOS/Loadout' | grep -q '@loader_path/../Frameworks'"
check "the app runs on Apple silicon and Intel" sh -c \
"archs=\$(lipo -archs '$APP/Contents/MacOS/Loadout'); case \" \$archs \" in *' arm64 '*) ;; *) exit 1;; esac; case \" \$archs \" in *' x86_64 '*) ;; *) exit 1;; esac"
check "Sparkle runs on Apple silicon and Intel" sh -c \
"archs=\$(lipo -archs '$FRAMEWORK/Versions/B/Sparkle'); case \" \$archs \" in *' arm64 '*) ;; *) exit 1;; esac; case \" \$archs \" in *' x86_64 '*) ;; *) exit 1;; esac"
check "the feed has one stable address" sh -c \
"test \"\$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' '$INFO')\" = \
'https://github.com/migsilva89/loadout/releases/latest/download/appcast.xml'"
Expand Down
57 changes: 55 additions & 2 deletions Sources/LoadoutApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ final class AppModel {
var filter: ItemFilter = .all {
didSet { followSelectionIntoView() }
}
/// The generic frontmatter key chosen in the filter funnel. Nil for every ordinary filter.
var frontmatterFilterKey: String? {
didSet { followSelectionIntoView() }
}
/// The assistant menu next to sort. Independent of `filter`, and only meaningful for
/// skills — resets to `.any` alongside `filter` whenever the sidebar row changes.
var assistantFilter: AssistantFilter = .any {
Expand All @@ -110,6 +114,8 @@ final class AppModel {
didSet { followSelectionIntoView() }
}
var order: ItemSort = .usage
/// The generic frontmatter key chosen in the sort menu.
var frontmatterSortKey: String?
/// Bridges ⌘F (a window-level command, with no view of its own) to the search field's
/// `@FocusState`, which can only live inside the view that owns the field.
var searchFocused: Bool = false
Expand Down Expand Up @@ -546,10 +552,44 @@ final class AppModel {
var visibleItems: [Item] {
Filtering.apply(
items, selection: selection, filter: filter,
assistant: assistantFilter, query: query, order: order
assistant: assistantFilter, query: query, order: order,
frontmatterFilterKey: frontmatterFilterKey,
frontmatterSortKey: frontmatterSortKey,
disabledPluginIDs: disabledPluginIDs
)
}

/// Every key that exists in the current kind. Nothing here knows Claude's or Codex's schema:
/// a new field appears in the menus as soon as a file contains it.
var frontmatterKeys: [String] {
Set(Filtering.slice(items, for: selection).flatMap { $0.frontmatter.keys })
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}

/// The key shown as a small column on each row while frontmatter drives the list.
var visibleFrontmatterKey: String? {
order == .frontmatter ? frontmatterSortKey
: (filter == .frontmatter ? frontmatterFilterKey : nil)
}

private var disabledPluginIDs: Set<String> {
Set(plugins.filter { !$0.enabled }.map(\.id))
}

func isEffectivelyEnabled(_ item: Item) -> Bool {
Filtering.isEffectivelyEnabled(item, disabledPluginIDs: disabledPluginIDs)
}

func filterByFrontmatter(_ key: String) {
frontmatterFilterKey = key
filter = .frontmatter
}

func sortByFrontmatter(_ key: String) {
frontmatterSortKey = key
order = .frontmatter
}

var selected: Item? {
guard let selectedID else { return nil }
return items.first { $0.id == selectedID }
Expand All @@ -568,7 +608,20 @@ final class AppModel {
func count(for chip: ItemFilter) -> Int {
Filtering.apply(
items, selection: selection, filter: chip,
assistant: assistantFilter, query: query, order: order
assistant: assistantFilter, query: query, order: order,
frontmatterFilterKey: frontmatterFilterKey,
frontmatterSortKey: frontmatterSortKey,
disabledPluginIDs: disabledPluginIDs
).count
}

func count(frontmatterKey: String) -> Int {
Filtering.apply(
items, selection: selection, filter: .frontmatter,
assistant: assistantFilter, query: query, order: order,
frontmatterFilterKey: frontmatterKey,
frontmatterSortKey: frontmatterSortKey,
disabledPluginIDs: disabledPluginIDs
).count
}

Expand Down
17 changes: 12 additions & 5 deletions Sources/LoadoutApp/DetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ struct DetailView: View {

/// Who the Assistants card is for: your own skills and commands, while they are switched on.
private func showsAssistants(_ item: Item) -> Bool {
(item.kind == .skill || item.kind == .command) && item.origin == .personal && item.enabled
(item.kind == .skill || item.kind == .command)
&& item.origin == .personal && model.isEffectivelyEnabled(item)
}

/// The one gesture behind all three controls — the chip, the seam and ⌥⌘I — so they cannot
Expand Down Expand Up @@ -301,7 +302,9 @@ struct DetailView: View {
// MARK: - Header

private func header(_ item: Item) -> some View {
HStack(alignment: .center, spacing: 12) {
let effectivelyEnabled = model.isEffectivelyEnabled(item)
let parentPluginIsOff = item.pluginID != nil && model.pluginIsOff(for: item)
return HStack(alignment: .center, spacing: 12) {
// The theme's own gradient, the same one the app icon is drawn with — the tile is
// the item's icon, and which kind it is comes from the glyph on it and the tab you
// are on, not from a hue borrowed from the system palette.
Expand All @@ -314,7 +317,7 @@ struct DetailView: View {
.foregroundStyle(.white)
}
.shadow(color: .black.opacity(0.5), radius: 1.5, y: 1)
.saturation(item.enabled ? 1 : 0.2)
.saturation(effectivelyEnabled ? 1 : 0.2)
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(.system(size: 19, weight: .semibold))
Expand All @@ -333,11 +336,12 @@ struct DetailView: View {
// Every skill has this switch now, whoever it belongs to: a plugin shipping 38 of them
// used to mean all 38 or none.
if item.kind != .plugin {
Text(item.enabled ? "Enabled" : "Disabled")
Text(effectivelyEnabled ? "Enabled" : "Disabled")
.font(.system(size: 12.5))
.foregroundStyle(V2.textMid)
.help(switchHelp(item))
MiniSwitch(on: item.enabled, width: 40, height: 24) { model.toggle(item) }
MiniSwitch(on: effectivelyEnabled, width: 40, height: 24) { model.toggle(item) }
.disabled(parentPluginIsOff)
.help(switchHelp(item))
}
}
Expand Down Expand Up @@ -367,6 +371,9 @@ struct DetailView: View {
/// worth saying, because a switch beside a file is read as one that might.
private func switchHelp(_ item: Item) -> String {
let noun = item.kind.briefingNoun
if item.pluginID != nil, model.pluginIsOff(for: item) {
return "Turn on the plugin before changing this \(noun)"
}
if item.kind == .mcp {
return item.enabled
? "Turn off to lift this server out of the assistant's configuration, keeping what it said so you can put it back"
Expand Down
9 changes: 9 additions & 0 deletions Sources/LoadoutApp/DriveScript.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ enum DriveScript {
guard let order = ItemSort(rawValue: argument) else { return "no such order" }
model.order = order
return "ok"
case "sort-frontmatter":
guard model.frontmatterKeys.contains(argument) else { return "no such frontmatter key" }
model.sortByFrontmatter(argument)
return "ok"
case "toggle-plugin":
// The plugin's own switch, which is a different control from an item's.
guard let plugin = model.plugins.first(where: { $0.name == argument })
Expand All @@ -79,6 +83,10 @@ enum DriveScript {
guard let filter = ItemFilter(rawValue: argument) else { return "no such filter" }
model.filter = filter
return "ok"
case "filter-frontmatter":
guard model.frontmatterKeys.contains(argument) else { return "no such frontmatter key" }
model.filterByFrontmatter(argument)
return "ok"
case "select":
// By id when one is given, because two projects may hold a row of the same name and a
// script that picks the first match is a script that proves nothing about the second.
Expand Down Expand Up @@ -238,6 +246,7 @@ enum DriveScript {
"kind": item.kind.rawValue,
"origin": item.origin.label,
"enabled": item.enabled,
"effectivelyEnabled": model.isEffectivelyEnabled(item),
"assistants": item.assistants.sorted(),
"path": item.path?.path ?? "",
// Straight from the file system, so a row that claims something the disk does not
Expand Down
34 changes: 23 additions & 11 deletions Sources/LoadoutApp/PluginManagerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,14 @@ struct PluginDetailView: View {
let plugin: PluginInfo

private var items: [Item] { model.itemsOfPlugin(plugin) }
/// Skills and commands both have a switch of their own now, so they belong in the same list —
/// splitting them left a plugin command switchable in one place and not in the other.
private var switchable: [Item] { items.filter { $0.kind == .skill || $0.kind == .command } }
private var rest: [Item] { items.filter { $0.kind != .skill && $0.kind != .command } }
/// Every file-backed item has the same reversible switch. MCP entries are not currently read
/// from plugin manifests, so anything else stays informational.
private var switchable: [Item] {
items.filter { $0.kind == .skill || $0.kind == .command || $0.kind == .agent }
}
private var rest: [Item] {
items.filter { $0.kind != .skill && $0.kind != .command && $0.kind != .agent }
}

var body: some View {
ScrollView {
Expand All @@ -153,7 +157,7 @@ struct PluginDetailView: View {
.fixedSize(horizontal: false, vertical: true)
}
if !switchable.isEmpty {
section("Skills and commands", items: switchable, switchable: true)
section("Skills, commands and agents", items: switchable, switchable: true)
}
whereItLives
if !rest.isEmpty {
Expand Down Expand Up @@ -298,11 +302,13 @@ struct PluginDetailView: View {
/// there is nothing to flip here, or it reads as a switch that failed to draw.
@ViewBuilder
private func itemRow(_ item: Item, switchable: Bool) -> some View {
let effectivelyEnabled = model.isEffectivelyEnabled(item)
let pluginIsOff = !plugin.enabled
let row = HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(item.enabled ? V2.text : V2.textDim)
.foregroundStyle(effectivelyEnabled ? V2.text : V2.textDim)
Text(item.description)
.font(.system(size: 11.5))
.foregroundStyle(Color.white.opacity(0.42))
Expand All @@ -311,12 +317,18 @@ struct PluginDetailView: View {
}
Spacer(minLength: 6)
if switchable {
MiniSwitch(on: item.enabled) { model.toggle(item) }
// While the plugin itself is off nothing here is loaded, so the switch reads off
// and does not move: this is the one page showing both switches at once, and a
// child left on under a parent turned off is the page contradicting itself.
MiniSwitch(on: effectivelyEnabled) { model.toggle(item) }
.disabled(pluginIsOff)
.help(
item.enabled
? "Stop Claude loading \(item.name), leaving the rest of the plugin on. "
+ "It stays off when the plugin updates."
: "Let Claude load \(item.name) again. A plugin update leaves it on from now on."
pluginIsOff
? "Turn on the \(plugin.name) plugin before changing this \(item.kind.briefingNoun)"
: item.enabled
? "Stop Claude loading \(item.name), leaving the rest of the plugin on. "
+ "It stays off when the plugin updates."
: "Let Claude load \(item.name) again. A plugin update leaves it on from now on."
)
}
}
Expand Down
Loading