Add package manager selector and hide uninstalled tools
This commit is contained in:
@@ -1,11 +1,35 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum NodePackageManager: String, CaseIterable, Identifiable {
|
||||||
|
case npm
|
||||||
|
case pnpm
|
||||||
|
case yarn
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .npm: "NPM"
|
||||||
|
case .pnpm: "PNPM"
|
||||||
|
case .yarn: "Yarn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var installCommandPrefix: String {
|
||||||
|
switch self {
|
||||||
|
case .npm: "npm install -g"
|
||||||
|
case .pnpm: "pnpm add -g"
|
||||||
|
case .yarn: "yarn global add"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Data models
|
// MARK: - Data models
|
||||||
|
|
||||||
private enum InstallMethod: Equatable {
|
private enum InstallMethod: Equatable {
|
||||||
case brew(formula: String, binary: String)
|
case brew(formula: String, binary: String)
|
||||||
case npm(package: String, binary: String)
|
case node(package: String, binary: String)
|
||||||
case go(module: String, binary: String)
|
case go(module: String, binary: String)
|
||||||
case pnpm(repoPath: String, script: String, binary: String)
|
case pnpm(repoPath: String, script: String, binary: String)
|
||||||
case gitClone(url: String, destination: String)
|
case gitClone(url: String, destination: String)
|
||||||
@@ -14,7 +38,7 @@ private enum InstallMethod: Equatable {
|
|||||||
var binary: String? {
|
var binary: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .brew(_, binary),
|
case let .brew(_, binary),
|
||||||
let .npm(_, binary),
|
let .node(_, binary),
|
||||||
let .go(_, binary),
|
let .go(_, binary),
|
||||||
let .pnpm(_, _, binary):
|
let .pnpm(_, _, binary):
|
||||||
binary
|
binary
|
||||||
@@ -57,7 +81,7 @@ struct ToolsSettings: View {
|
|||||||
name: "mcporter",
|
name: "mcporter",
|
||||||
url: URL(string: "https://github.com/steipete/mcporter")!,
|
url: URL(string: "https://github.com/steipete/mcporter")!,
|
||||||
description: "MCP runtime/CLI to discover servers, run tools, and sync configs across AI clients.",
|
description: "MCP runtime/CLI to discover servers, run tools, and sync configs across AI clients.",
|
||||||
method: .npm(package: "mcporter", binary: "mcporter"),
|
method: .node(package: "mcporter", binary: "mcporter"),
|
||||||
kind: .tool),
|
kind: .tool),
|
||||||
ToolEntry(
|
ToolEntry(
|
||||||
id: "peekaboo",
|
id: "peekaboo",
|
||||||
@@ -78,7 +102,14 @@ struct ToolsSettings: View {
|
|||||||
name: "oracle",
|
name: "oracle",
|
||||||
url: URL(string: "https://github.com/steipete/oracle")!,
|
url: URL(string: "https://github.com/steipete/oracle")!,
|
||||||
description: "Runs OpenAI-ready agent workflows from the CLI with session replay and browser control.",
|
description: "Runs OpenAI-ready agent workflows from the CLI with session replay and browser control.",
|
||||||
method: .npm(package: "@steipete/oracle", binary: "oracle"),
|
method: .node(package: "@steipete/oracle", binary: "oracle"),
|
||||||
|
kind: .tool),
|
||||||
|
ToolEntry(
|
||||||
|
id: "qmd",
|
||||||
|
name: "qmd",
|
||||||
|
url: URL(string: "https://github.com/tobi/qmd")!,
|
||||||
|
description: "Hybrid markdown search (BM25 + vectors + rerank) with an MCP server for agents.",
|
||||||
|
method: .node(package: "https://github.com/tobi/qmd", binary: "qmd"),
|
||||||
kind: .tool),
|
kind: .tool),
|
||||||
ToolEntry(
|
ToolEntry(
|
||||||
id: "eightctl",
|
id: "eightctl",
|
||||||
@@ -170,8 +201,13 @@ struct ToolsSettings: View {
|
|||||||
kind: .mcp),
|
kind: .mcp),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@AppStorage("tools.packageManager") private var packageManagerRaw = NodePackageManager.npm.rawValue
|
||||||
|
@State private var installStates: [String: InstallState] = [:]
|
||||||
|
private let isPreview = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
self.packageManagerPicker
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 12) {
|
LazyVStack(spacing: 12) {
|
||||||
self.section(for: .tool, title: "CLI Tools")
|
self.section(for: .tool, title: "CLI Tools")
|
||||||
@@ -181,10 +217,34 @@ struct ToolsSettings: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
.onChange(of: self.packageManagerRaw) { _, _ in
|
||||||
|
self.refreshAll()
|
||||||
|
}
|
||||||
|
.task { self.refreshAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var packageManager: NodePackageManager {
|
||||||
|
NodePackageManager(rawValue: self.packageManagerRaw) ?? .npm
|
||||||
|
}
|
||||||
|
|
||||||
|
private var packageManagerPicker: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Preferred package manager")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker("Preferred package manager", selection: self.$packageManagerRaw) {
|
||||||
|
ForEach(NodePackageManager.allCases) { manager in
|
||||||
|
Text(manager.label).tag(manager.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 340)
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func section(for kind: ToolEntry.Kind, title: String) -> some View {
|
private func section(for kind: ToolEntry.Kind, title: String) -> some View {
|
||||||
let filtered = self.tools.filter { $0.kind == kind }
|
let filtered = self.tools.filter { $0.kind == kind && self.shouldShow(tool: $0) }
|
||||||
return VStack(alignment: .leading, spacing: 10) {
|
return VStack(alignment: .leading, spacing: 10) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
@@ -192,7 +252,11 @@ struct ToolsSettings: View {
|
|||||||
|
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
ForEach(filtered) { tool in
|
ForEach(filtered) { tool in
|
||||||
ToolRow(tool: tool)
|
ToolRow(
|
||||||
|
tool: tool,
|
||||||
|
state: self.binding(for: tool),
|
||||||
|
packageManager: self.packageManager,
|
||||||
|
refreshState: { await self.refresh(tool: tool) })
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.background(Color(nsColor: .controlBackgroundColor))
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
@@ -203,15 +267,45 @@ struct ToolsSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func binding(for tool: ToolEntry) -> Binding<InstallState> {
|
||||||
|
let current = self.installStates[tool.id] ?? .checking
|
||||||
|
return Binding(
|
||||||
|
get: { self.installStates[tool.id] ?? current },
|
||||||
|
set: { self.installStates[tool.id] = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldShow(tool: ToolEntry) -> Bool {
|
||||||
|
if self.isPreview { return true }
|
||||||
|
guard let state = self.installStates[tool.id] else { return false }
|
||||||
|
return state == .installed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAll() {
|
||||||
|
Task {
|
||||||
|
for tool in self.tools {
|
||||||
|
await self.refresh(tool: tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func refresh(tool: ToolEntry) async {
|
||||||
|
let installed = await ToolInstaller.isInstalled(tool.method, packageManager: self.packageManager)
|
||||||
|
self.installStates[tool.id] = installed ? .installed : .notInstalled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Row
|
// MARK: - Row
|
||||||
|
|
||||||
private struct ToolRow: View {
|
private struct ToolRow: View {
|
||||||
let tool: ToolEntry
|
let tool: ToolEntry
|
||||||
@State private var state: InstallState = .checking
|
@Binding var state: InstallState
|
||||||
@State private var statusMessage: String?
|
@State private var statusMessage: String?
|
||||||
@State private var linkHovering = false
|
@State private var linkHovering = false
|
||||||
|
let packageManager: NodePackageManager
|
||||||
|
let refreshState: () async -> Void
|
||||||
|
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
// Ensure progress indicators and buttons occupy the same space so the row doesn't shift.
|
// Ensure progress indicators and buttons occupy the same space so the row doesn't shift.
|
||||||
@@ -272,7 +366,7 @@ private struct ToolRow: View {
|
|||||||
private func refresh() {
|
private func refresh() {
|
||||||
Task {
|
Task {
|
||||||
self.state = .checking
|
self.state = .checking
|
||||||
let installed = await ToolInstaller.isInstalled(self.tool.method)
|
let installed = await ToolInstaller.isInstalled(self.tool.method, packageManager: self.packageManager)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.state = installed ? .installed : .notInstalled
|
self.state = installed ? .installed : .notInstalled
|
||||||
}
|
}
|
||||||
@@ -282,10 +376,11 @@ private struct ToolRow: View {
|
|||||||
private func install() {
|
private func install() {
|
||||||
Task {
|
Task {
|
||||||
self.state = .installing
|
self.state = .installing
|
||||||
let result = await ToolInstaller.install(self.tool.method)
|
let result = await ToolInstaller.install(self.tool.method, packageManager: self.packageManager)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.statusMessage = result.message
|
self.statusMessage = result.message
|
||||||
self.state = result.installed ? .installed : .failed(result.message)
|
self.state = result.installed ? .installed : .failed(result.message)
|
||||||
|
if result.installed { Task { await self.refreshState() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,11 +394,11 @@ private enum ToolInstaller {
|
|||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isInstalled(_ method: InstallMethod) async -> Bool {
|
static func isInstalled(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> Bool {
|
||||||
switch method {
|
switch method {
|
||||||
case let .brew(formula, _):
|
case let .brew(formula, _):
|
||||||
return await self.shellSucceeds("brew list --versions \(formula)")
|
return await self.shellSucceeds("brew list --versions \(formula)")
|
||||||
case let .npm(_, binary),
|
case let .node(_, binary),
|
||||||
let .go(_, binary),
|
let .go(_, binary),
|
||||||
let .pnpm(_, _, binary):
|
let .pnpm(_, _, binary):
|
||||||
return await self.commandExists(binary)
|
return await self.commandExists(binary)
|
||||||
@@ -315,12 +410,12 @@ private enum ToolInstaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func install(_ method: InstallMethod) async -> InstallResult {
|
static func install(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> InstallResult {
|
||||||
switch method {
|
switch method {
|
||||||
case let .brew(formula, _):
|
case let .brew(formula, _):
|
||||||
return await self.runInstall("brew install \(formula)")
|
return await self.runInstall("brew install \(formula)")
|
||||||
case let .npm(package, _):
|
case let .node(package, _):
|
||||||
return await self.runInstall("npm install -g \(package)")
|
return await self.runInstall("\(packageManager.installCommandPrefix) \(package)")
|
||||||
case let .go(module, _):
|
case let .go(module, _):
|
||||||
return await self.runInstall("GO111MODULE=on go install \(module)")
|
return await self.runInstall("GO111MODULE=on go install \(module)")
|
||||||
case let .pnpm(repoPath, script, _):
|
case let .pnpm(repoPath, script, _):
|
||||||
|
|||||||
Reference in New Issue
Block a user