From 09ba17f329550fe881a2625983f2136226161f73 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 21:48:05 +0000 Subject: [PATCH 1/4] test(cli): lock --json output shape for status/doctor/check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 3 contract tests under framework/cli/cmd/json_contract_test.go that unmarshal real --json output into typed structs mirroring the contract consumed by the macOS companion app (apps/macos/Ulk/Models/UlkReports.swift). Any future breaking change to the JSON shape (renamed/removed field, type change) will fail these tests, forcing a coordinated update on the Swift side. The Go types are intentionally lenient (pointers + omitempty) to accept both the not-installed status payload and the full one. Tested: go test ./cmd/ -run "TestStatusJSONContract|TestDoctorJSONContract|TestCheckJSONContract" → ok https://claude.ai/code/session_01TyhPjizanX1g8xSQ6chwTu --- framework/cli/cmd/json_contract_test.go | 133 ++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 framework/cli/cmd/json_contract_test.go diff --git a/framework/cli/cmd/json_contract_test.go b/framework/cli/cmd/json_contract_test.go new file mode 100644 index 0000000..418aa3c --- /dev/null +++ b/framework/cli/cmd/json_contract_test.go @@ -0,0 +1,133 @@ +package cmd_test + +// JSON contract tests: lock the --json output shape of status / doctor / check. +// The macOS companion app (apps/macos/Ulk/) parses these as a stable contract. +// Any breaking change to a field name or type will fail these tests, forcing +// a coordinated update on the Swift side (apps/macos/Ulk/Ulk/Models/). + +import ( + "encoding/json" + "strings" + "testing" +) + +// ── status --json ─────────────────────────────────────────────────────────── + +// statusContract mirrors the shape consumed by the Swift app. +// When not installed: {"installed": false} +// When installed: full payload (other fields populated). +type statusContract struct { + Installed *bool `json:"installed"` + Version string `json:"version,omitempty"` + Commands *int `json:"commands,omitempty"` + Agents *int `json:"agents,omitempty"` + Skills *int `json:"skills,omitempty"` + Modules map[string]bool `json:"modules,omitempty"` +} + +func TestStatusJSONContract(t *testing.T) { + stdout, _, _ := run(t, "status", "--json") + if stdout == "" { + t.Fatal("status --json produced no stdout") + } + + var got statusContract + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("status --json is not valid JSON matching the contract:\n err: %v\n stdout: %s", err, stdout) + } + + if got.Installed == nil { + t.Fatal("status --json missing required field `installed` (bool)") + } + + // In CI, ~/.ulk/state.json is absent → installed must be false. + if *got.Installed { + // If somehow installed, the full contract must be respected. + if got.Commands == nil || got.Agents == nil || got.Skills == nil { + t.Errorf("status --json with installed=true missing counters (commands/agents/skills): %s", stdout) + } + } +} + +// ── doctor --json ─────────────────────────────────────────────────────────── + +type doctorContract struct { + Pass *int `json:"pass"` + Fail *int `json:"fail"` + Fixed *int `json:"fixed"` + Issues []doctorIssueDTO `json:"issues,omitempty"` +} + +type doctorIssueDTO struct { + Label string `json:"label"` + Fixed bool `json:"fixed,omitempty"` + Hint string `json:"hint,omitempty"` +} + +func TestDoctorJSONContract(t *testing.T) { + // doctor exits non-zero when there are unfixed issues. We don't care about + // the exit code here — only the JSON shape on stdout. + stdout, _, _ := run(t, "doctor", "--json") + if stdout == "" { + t.Fatal("doctor --json produced no stdout") + } + + var got doctorContract + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("doctor --json is not valid JSON matching the contract:\n err: %v\n stdout: %s", err, stdout) + } + + if got.Pass == nil || got.Fail == nil || got.Fixed == nil { + t.Errorf("doctor --json missing required counters pass/fail/fixed: %s", stdout) + } + + // If issues are reported, every entry must have a non-empty Label. + for i, iss := range got.Issues { + if strings.TrimSpace(iss.Label) == "" { + t.Errorf("doctor --json issue[%d] has empty label", i) + } + } +} + +// ── check --json ──────────────────────────────────────────────────────────── + +type checkContract struct { + Found *int `json:"found"` + Total *int `json:"total"` + Tools []checkToolsDTO `json:"tools"` +} + +type checkToolsDTO struct { + Name string `json:"name"` + Path string `json:"path,omitempty"` + Found bool `json:"found"` +} + +func TestCheckJSONContract(t *testing.T) { + stdout, _, _ := run(t, "check", "--json") + if stdout == "" { + t.Fatal("check --json produced no stdout") + } + + var got checkContract + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("check --json is not valid JSON matching the contract:\n err: %v\n stdout: %s", err, stdout) + } + + if got.Found == nil || got.Total == nil { + t.Fatal("check --json missing required counters found/total") + } + + if len(got.Tools) == 0 { + t.Error("check --json must report at least one tool entry") + } + + for i, tool := range got.Tools { + if strings.TrimSpace(tool.Name) == "" { + t.Errorf("check --json tools[%d] has empty name", i) + } + if tool.Found && tool.Path == "" { + t.Errorf("check --json tools[%d] (%s) found=true but path is empty", i, tool.Name) + } + } +} From 0336861878cea0b4290e045c412774def1d39b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 21:48:18 +0000 Subject: [PATCH 2/4] feat(macos): typed JSON parsing for ulk status/doctor/check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces ad-hoc text parsing with strongly-typed Codable models that mirror the JSON contract enforced by framework/cli/cmd/json_contract_test.go. - UlkReports.swift : StatusReport / DoctorReport / DoctorIssue / CheckReport / CheckTool — all Codable + Equatable, with derived helpers (enabledModules, unfixed, isHealthy, missingTools) - UlkBridge.swift : new typed methods status() / doctorReport() / check() that decode JSON via decodeJSON(); runCapturing gains an allowNonZeroExit param so doctor (which exits non-zero when issues exist but emits valid JSON) is not flagged as a hard error - UlkBridge.detectInstall() now prefers status() over filesystem heuristics when the binary is available — populates SystemSnapshot.statusReport and configFlags from the real state.json modules map - SystemSnapshot gains statusReport: StatusReport? field - SystemState exposes removeSkill(_:) wrapper that delegates to UlkBridge and reloads the snapshot - UlkBridge.removeSkill(named:) does input sanitisation (no slashes) and uses FileManager.removeItem on ~/.claude/skills/ - UlkReportsTests : 5 tests covering not-installed/full payload, doctor healthy/with-issues, check found+missing https://claude.ai/code/session_01TyhPjizanX1g8xSQ6chwTu --- .../Ulk/Tests/UlkTests/UlkReportsTests.swift | 96 +++++++++++++++++++ apps/macos/Ulk/Ulk/Models/SystemState.swift | 12 ++- apps/macos/Ulk/Ulk/Models/UlkReports.swift | 71 ++++++++++++++ apps/macos/Ulk/Ulk/Services/UlkBridge.swift | 75 ++++++++++++++- 4 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 apps/macos/Ulk/Tests/UlkTests/UlkReportsTests.swift create mode 100644 apps/macos/Ulk/Ulk/Models/UlkReports.swift diff --git a/apps/macos/Ulk/Tests/UlkTests/UlkReportsTests.swift b/apps/macos/Ulk/Tests/UlkTests/UlkReportsTests.swift new file mode 100644 index 0000000..ccad2e1 --- /dev/null +++ b/apps/macos/Ulk/Tests/UlkTests/UlkReportsTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import UlkCore + +final class UlkReportsTests: XCTestCase { + + // MARK: - status --json + + func testStatusReportNotInstalled() throws { + let json = #"{"installed": false}"#.data(using: .utf8)! + let report = try JSONDecoder().decode(StatusReport.self, from: json) + XCTAssertFalse(report.installed) + XCTAssertNil(report.version) + XCTAssertNil(report.modules) + XCTAssertEqual(report.enabledModules, []) + } + + func testStatusReportFullPayload() throws { + let json = """ + { + "installed": true, + "version": "6.4.2", + "commands": 12, + "agents": 85, + "skills": 21, + "modules": {"vps": true, "memory-loop": false, "statusline": true} + } + """.data(using: .utf8)! + let report = try JSONDecoder().decode(StatusReport.self, from: json) + XCTAssertTrue(report.installed) + XCTAssertEqual(report.version, "6.4.2") + XCTAssertEqual(report.commands, 12) + XCTAssertEqual(report.agents, 85) + XCTAssertEqual(report.skills, 21) + XCTAssertEqual(report.enabledModules, ["statusline", "vps"]) + } + + // MARK: - doctor --json + + func testDoctorReportHealthy() throws { + let json = #"{"pass": 11, "fail": 0, "fixed": 0}"#.data(using: .utf8)! + let report = try JSONDecoder().decode(DoctorReport.self, from: json) + XCTAssertEqual(report.pass, 11) + XCTAssertEqual(report.fail, 0) + XCTAssertEqual(report.unfixed, 0) + XCTAssertTrue(report.isHealthy) + XCTAssertNil(report.issues) + } + + func testDoctorReportWithIssues() throws { + let json = """ + { + "pass": 8, + "fail": 3, + "fixed": 1, + "issues": [ + {"label": "rtk (required CLI)", "hint": "brew install rtk"}, + {"label": "schemaVersion 2 (current 3)", "fixed": true, "hint": "run `ulk migrate`"}, + {"label": "claude present", "hint": "install claude"} + ] + } + """.data(using: .utf8)! + let report = try JSONDecoder().decode(DoctorReport.self, from: json) + XCTAssertEqual(report.pass, 8) + XCTAssertEqual(report.fail, 3) + XCTAssertEqual(report.fixed, 1) + XCTAssertEqual(report.unfixed, 2) + XCTAssertFalse(report.isHealthy) + XCTAssertEqual(report.issues?.count, 3) + XCTAssertEqual(report.issues?[1].wasFixed, true) + XCTAssertEqual(report.issues?[0].wasFixed, false) // missing key → false + } + + // MARK: - check --json + + func testCheckReportParsesAllShapes() throws { + let json = """ + { + "found": 2, + "total": 4, + "tools": [ + {"name": "gh", "path": "/usr/local/bin/gh", "found": true}, + {"name": "node", "path": "/opt/homebrew/bin/node", "found": true}, + {"name": "vercel", "found": false}, + {"name": "ollama", "found": false} + ] + } + """.data(using: .utf8)! + let report = try JSONDecoder().decode(CheckReport.self, from: json) + XCTAssertEqual(report.found, 2) + XCTAssertEqual(report.total, 4) + XCTAssertEqual(report.tools.count, 4) + XCTAssertEqual(report.missingTools.map(\.name), ["vercel", "ollama"]) + XCTAssertEqual(report.tools[0].path, "/usr/local/bin/gh") + XCTAssertNil(report.tools[2].path) + } +} diff --git a/apps/macos/Ulk/Ulk/Models/SystemState.swift b/apps/macos/Ulk/Ulk/Models/SystemState.swift index 234e89b..d1ba071 100644 --- a/apps/macos/Ulk/Ulk/Models/SystemState.swift +++ b/apps/macos/Ulk/Ulk/Models/SystemState.swift @@ -28,6 +28,14 @@ final class SystemState: ObservableObject { } func reload() async { await refresh() } + + /// Removes a skill via UlkBridge and reloads the snapshot. + /// Returns true if removed, false if absent (no-op). + func removeSkill(_ skill: InstalledSkill) async throws -> Bool { + let removed = try await bridge.removeSkill(named: skill.name) + await refresh() + return removed + } } struct SystemSnapshot: Codable, Equatable { @@ -38,7 +46,8 @@ struct SystemSnapshot: Codable, Equatable { var skillsInstalled: [InstalledSkill] var mcpsConfigured: [InstalledMCP] var clisDetected: [DetectedCLI] - var configFlags: [String: Bool] // --with-* flags inferred from settings + var configFlags: [String: Bool] // --with-* flags from state.json modules map + var statusReport: StatusReport? // raw `ulk status --json` payload var lastChecked: Date static let empty = SystemSnapshot( @@ -50,6 +59,7 @@ struct SystemSnapshot: Codable, Equatable { mcpsConfigured: [], clisDetected: [], configFlags: [:], + statusReport: nil, lastChecked: Date() ) } diff --git a/apps/macos/Ulk/Ulk/Models/UlkReports.swift b/apps/macos/Ulk/Ulk/Models/UlkReports.swift new file mode 100644 index 0000000..46ebd23 --- /dev/null +++ b/apps/macos/Ulk/Ulk/Models/UlkReports.swift @@ -0,0 +1,71 @@ +import Foundation + +// Mirrors the JSON contract enforced by the Go side +// (framework/cli/cmd/json_contract_test.go). +// +// Any breaking change here MUST be coordinated with the Go test — +// otherwise the Swift app will fail to parse on the next ulk release. + +// MARK: - status --json + +/// Output of `ulk status --json`. +/// When ulk is not installed, only `installed=false` is populated. +struct StatusReport: Codable, Equatable { + let installed: Bool + let version: String? + let commands: Int? + let agents: Int? + let skills: Int? + let modules: [String: Bool]? + + var enabledModules: [String] { + (modules ?? [:]).filter { $0.value }.keys.sorted() + } + + static let notInstalled = StatusReport( + installed: false, version: nil, + commands: nil, agents: nil, skills: nil, modules: nil + ) +} + +// MARK: - doctor --json + +/// Output of `ulk doctor --json`. Exit code is non-zero when `fail > fixed`, +/// but the JSON is always emitted on stdout. +struct DoctorReport: Codable, Equatable { + let pass: Int + let fail: Int + let fixed: Int + let issues: [DoctorIssue]? + + var unfixed: Int { fail - fixed } + var isHealthy: Bool { unfixed == 0 } +} + +struct DoctorIssue: Codable, Equatable, Identifiable { + let label: String + let fixed: Bool? + let hint: String? + + var id: String { label } + var wasFixed: Bool { fixed ?? false } +} + +// MARK: - check --json + +/// Output of `ulk check --json`. +struct CheckReport: Codable, Equatable { + let found: Int + let total: Int + let tools: [CheckTool] + + var missingTools: [CheckTool] { tools.filter { !$0.found } } +} + +struct CheckTool: Codable, Equatable, Identifiable { + let name: String + let path: String? + let found: Bool + + var id: String { name } +} diff --git a/apps/macos/Ulk/Ulk/Services/UlkBridge.swift b/apps/macos/Ulk/Ulk/Services/UlkBridge.swift index 891f6b8..db2cc16 100644 --- a/apps/macos/Ulk/Ulk/Services/UlkBridge.swift +++ b/apps/macos/Ulk/Ulk/Services/UlkBridge.swift @@ -54,18 +54,52 @@ actor UlkBridge { var snap = SystemSnapshot.empty let claudeHome = NSString(string: "~/.claude").expandingTildeInPath snap.claudeHome = claudeHome - snap.installed = FileManager.default.fileExists(atPath: "\(claudeHome)/agents") + // Prefer typed status report when CLI is available — falls back to a + // plain filesystem check if ulk binary isn't on disk yet (first run). if let path = try? resolveBinary() { snap.ulkBinaryPath = path - snap.version = (try? await runCapturing(args: ["--version"]))? - .trimmingCharacters(in: .whitespacesAndNewlines) + if let report = try? await self.status() { + snap.installed = report.installed + snap.version = report.version + snap.statusReport = report + snap.configFlags = report.modules ?? [:] + } else { + snap.installed = FileManager.default.fileExists(atPath: "\(claudeHome)/agents") + } + } else { + snap.installed = FileManager.default.fileExists(atPath: "\(claudeHome)/agents") } + snap.skillsInstalled = scanSkills(claudeHome: claudeHome) snap.lastChecked = Date() return snap } + // MARK: - Typed JSON queries + // + // Contract enforced by framework/cli/cmd/json_contract_test.go. + + /// `ulk status --json` → typed `StatusReport`. + func status() async throws -> StatusReport { + let raw = try await runCapturing(args: ["status", "--json"]) + return try decodeJSON(raw, as: StatusReport.self, source: "status --json") + } + + /// `ulk doctor --json` → typed `DoctorReport`. Note: doctor exits non-zero + /// when issues exist, but stdout JSON is still emitted — we treat that as + /// a *valid* result, not an error. + func doctorReport() async throws -> DoctorReport { + let raw = try await runCapturing(args: ["doctor", "--json"], allowNonZeroExit: true) + return try decodeJSON(raw, as: DoctorReport.self, source: "doctor --json") + } + + /// `ulk check --json` → typed `CheckReport`. + func check() async throws -> CheckReport { + let raw = try await runCapturing(args: ["check", "--json"]) + return try decodeJSON(raw, as: CheckReport.self, source: "check --json") + } + // MARK: - Streaming commands /// Run ulk install with the chosen options. Streams stdout+stderr line by line. @@ -82,10 +116,30 @@ actor UlkBridge { stream(args: ["uninstall"]) } + /// Streaming `ulk doctor` (text output). Use `doctorReport()` for typed access. func doctor() -> AsyncThrowingStream { stream(args: ["doctor"]) } + // MARK: - Filesystem actions (no CLI needed) + + /// Removes a skill directory under `~/.claude/skills/`. + /// Returns true if the directory existed and was removed; false if absent. + /// Throws on permission / IO failure. + func removeSkill(named name: String) throws -> Bool { + let safe = name.replacingOccurrences(of: "/", with: "") + guard !safe.isEmpty, safe == name else { + throw BridgeError.decodingFailed("invalid skill name: \(name)") + } + let path = NSString(string: "~/.claude/skills/\(safe)").expandingTildeInPath + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { + return false + } + try FileManager.default.removeItem(atPath: path) + return true + } + // MARK: - Internal private func stream(args: [String]) -> AsyncThrowingStream { @@ -132,7 +186,7 @@ actor UlkBridge { } } - private func runCapturing(args: [String]) async throws -> String { + private func runCapturing(args: [String], allowNonZeroExit: Bool = false) async throws -> String { let binary = try resolveBinary() let proc = Process() proc.executableURL = URL(fileURLWithPath: binary) @@ -145,12 +199,23 @@ actor UlkBridge { proc.waitUntilExit() let data = out.fileHandleForReading.readDataToEndOfFile() let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - if proc.terminationStatus != 0 { + if proc.terminationStatus != 0 && !allowNonZeroExit { throw BridgeError.nonZeroExit(code: proc.terminationStatus, stderr: stderr) } return String(data: data, encoding: .utf8) ?? "" } + private func decodeJSON(_ raw: String, as type: T.Type, source: String) throws -> T { + guard let data = raw.data(using: .utf8) else { + throw BridgeError.decodingFailed("\(source): empty stdout") + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw BridgeError.decodingFailed("\(source): \(error.localizedDescription)") + } + } + // MARK: - Filesystem scans (no CLI needed) private nonisolated func scanSkills(claudeHome: String) -> [InstalledSkill] { From 3038657d7f3caaf985060d590f0a8add9da5f648 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 21:48:28 +0000 Subject: [PATCH 3/4] build(macos): replace manual Xcode project with XcodeGen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XcodeGen generates Ulk.xcodeproj from project.yml (~80 lines of YAML) on demand. The .xcodeproj is gitignored — source of truth lives in project.yml. Avoids 1000+ line UUID-laced project.pbxproj diffs and keeps the file tree in sync automatically. - project.yml : Ulk app target (macOS 14+, hardened runtime, entitlements wired, AppIcon + AccentColor) + UlkTests target (bundle.unit-test, gathers coverage) - .gitignore : Ulk.xcodeproj/ ignored (regenerable artefact) - README : new "Projet Xcode" section explaining XcodeGen workflow, v0.2 status, JSON contract section, updated build commands Local dev : brew install xcodegen cd apps/macos/Ulk && xcodegen generate && open Ulk.xcodeproj https://claude.ai/code/session_01TyhPjizanX1g8xSQ6chwTu --- apps/macos/Ulk/.gitignore | 3 ++ apps/macos/Ulk/README.md | 73 +++++++++++++++++++++++++++------- apps/macos/Ulk/project.yml | 80 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 apps/macos/Ulk/project.yml diff --git a/apps/macos/Ulk/.gitignore b/apps/macos/Ulk/.gitignore index 4d630bb..bf661e3 100644 --- a/apps/macos/Ulk/.gitignore +++ b/apps/macos/Ulk/.gitignore @@ -1,3 +1,6 @@ +# Generated Xcode project (source of truth = project.yml via XcodeGen) +Ulk.xcodeproj/ + # Xcode / SwiftPM build artifacts .build/ .swiftpm/ diff --git a/apps/macos/Ulk/README.md b/apps/macos/Ulk/README.md index 6e52a2a..44b892d 100644 --- a/apps/macos/Ulk/README.md +++ b/apps/macos/Ulk/README.md @@ -7,41 +7,90 @@ Wrappe le CLI Go (`framework/cli/`) via `Process`. Auto-update via Sparkle. ## Statut -**v0.1 — squelette compilable**. UI seulement, exec réelle du CLI partielle. Pas encore signé/notarié. +**v0.2 — projet Xcode + parsing JSON typé + premier toggle fonctionnel.** +Pas encore signé/notarié (cible v0.3). ## Structure ``` Ulk/ ├── UlkApp.swift @main · route Installer ↔ Dashboard via SystemState -├── Models/ types Codable (SystemState, InstallOption, Skill, MCP, CLITool) +├── Models/ +│ ├── SystemState.swift ObservableObject + SystemSnapshot +│ ├── InstallOption.swift catalog des 30+ flags --with-* +│ └── UlkReports.swift types Codable miroir du contrat JSON Go ├── Services/ -│ ├── UlkBridge.swift Process wrapper sur bin/ulk +│ ├── UlkBridge.swift Process wrapper + parsing JSON status/doctor/check │ ├── RegistryLoader.swift lit framework/tools/cli-registry.json + agents/registry.json │ └── UpdateChecker.swift stub Sparkle (à câbler v0.3) ├── Scenes/ │ ├── Installer/ WelcomeView · OptionsView · InstallProgressView │ └── Dashboard/ DashboardShell + 4 onglets (Skills · MCPs · CLIs · Config) -└── Resources/ Assets.xcassets · Info.plist +└── Resources/ Assets.xcassets · Info.plist · Ulk.entitlements ``` -## Build (dev local, non signé) +## Projet Xcode + +Le `Ulk.xcodeproj` est **généré** depuis [`project.yml`](./project.yml) via [XcodeGen](https://github.com/yonaskolb/XcodeGen). +Il n'est **pas committé** (gitignored) — la source de vérité est `project.yml` (~80 lignes, +diffable). Avantages : + +- Pas de conflits sur `project.pbxproj` (format UUID-laced, illisible) +- Synchronisation automatique avec l'arborescence sources +- Identique CI ↔ local + +### Générer le projet ```bash cd apps/macos/Ulk -xcodebuild -project Ulk.xcodeproj -scheme Ulk -configuration Debug build +brew install xcodegen # une fois +xcodegen generate # crée Ulk.xcodeproj +open Ulk.xcodeproj +``` + +### Build & run (dev local, non signé) + +```bash +xcodegen generate +xcodebuild -project Ulk.xcodeproj -scheme Ulk -configuration Debug \ + CODE_SIGNING_ALLOWED=NO build open build/Debug/Ulk.app ``` -Prérequis : macOS 14+ · Xcode 16+ · Swift 5.10+. +Prérequis : macOS 14+ · Xcode 16+ · Swift 5.10+ · `brew install xcodegen`. + +### Tests + +```bash +# Sur le projet Xcode (recommandé) +xcodegen generate +xcodebuild -project Ulk.xcodeproj -scheme Ulk test + +# Ou via SwiftPM (UlkCore library uniquement, pas l'app) +swift test --parallel +``` + +## Contrat JSON Go ↔ Swift + +Les modèles `StatusReport`, `DoctorReport`, `CheckReport` +(`Ulk/Models/UlkReports.swift`) miroirent le JSON émis par : + +- `ulk status --json` +- `ulk doctor --json` +- `ulk check --json` + +Le contrat est verrouillé côté Go par `framework/cli/cmd/json_contract_test.go`. +**Toute évolution du shape doit toucher les deux côtés en même temps**, +sinon les tests Go OU le décodage Swift cassent. ## Localisation du CLI ulk Au lancement, `UlkBridge` cherche `bin/ulk` dans cet ordre : 1. `~/.ulk/bin/ulk` (install ulk-managed) 2. `/usr/local/bin/ulk` (install global) -3. `$ULK_REPO_PATH/bin/ulk` (env var pour dev) -4. PATH fallback (`which ulk`) +3. `/opt/homebrew/bin/ulk` (Apple Silicon Homebrew) +4. `$ULK_REPO_PATH/bin/ulk` (env var pour dev) +5. PATH fallback (`which ulk`) Si aucun trouvé : Installer démarre en mode **bootstrap** (clone repo + premier build Go). @@ -61,12 +110,6 @@ xcrun stapler staple Ulk.dmg # 3. publier (CI : .github/workflows/macos-release.yml — TODO v0.3) ``` -## Tests - -```bash -xcodebuild -project Ulk.xcodeproj -scheme UlkTests test -``` - ## Roadmap Voir `docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md` § Roadmap. diff --git a/apps/macos/Ulk/project.yml b/apps/macos/Ulk/project.yml new file mode 100644 index 0000000..20c341d --- /dev/null +++ b/apps/macos/Ulk/project.yml @@ -0,0 +1,80 @@ +# XcodeGen spec — `xcodegen generate` produces Ulk.xcodeproj from this file. +# https://github.com/yonaskolb/XcodeGen +# +# Why XcodeGen ? project.pbxproj is a 1000+ line UUID-laced format that breaks +# diff review. With XcodeGen the truth lives here (~80 lines), the .xcodeproj +# is generated and gitignored. + +name: Ulk +options: + bundleIdPrefix: app.regrets + deploymentTarget: + macOS: "14.0" + developmentLanguage: en + createIntermediateGroups: true + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "5.10" + MACOSX_DEPLOYMENT_TARGET: "14.0" + DEAD_CODE_STRIPPING: YES + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_STYLE: Automatic + CODE_SIGN_ENTITLEMENTS: Ulk/Resources/Ulk.entitlements + INFOPLIST_FILE: Ulk/Resources/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: app.regrets.ulk + MARKETING_VERSION: "0.2.0" + CURRENT_PROJECT_VERSION: "2" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + configs: + Debug: + SWIFT_OPTIMIZATION_LEVEL: -Onone + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + ONLY_ACTIVE_ARCH: YES + Release: + SWIFT_OPTIMIZATION_LEVEL: "-O" + SWIFT_COMPILATION_MODE: wholemodule + +targets: + Ulk: + type: application + platform: macOS + sources: + - path: Ulk + excludes: + - "Resources/Info.plist" # referenced via INFOPLIST_FILE + - "Resources/Ulk.entitlements" # referenced via CODE_SIGN_ENTITLEMENTS + resources: + - Ulk/Resources/Assets.xcassets + info: + path: Ulk/Resources/Info.plist + entitlements: + path: Ulk/Resources/Ulk.entitlements + scheme: + testTargets: + - UlkTests + + UlkTests: + type: bundle.unit-test + platform: macOS + sources: + - Tests/UlkTests + dependencies: + - target: Ulk + settings: + base: + BUNDLE_LOADER: "$(TEST_HOST)" + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Ulk.app/Contents/MacOS/Ulk" + +schemes: + Ulk: + build: + targets: + Ulk: all + UlkTests: [test] + test: + targets: + - UlkTests + gatherCoverageData: true From 9d974687f99fff2be0eb47c26fe6c7f4b05bf8ad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 21:48:45 +0000 Subject: [PATCH 4/4] feat(macos): functional skill toggle + 2-stage CI + spec v0.2 update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkillsTab : trash button on community/opt-in skills triggers a destructive-style SwiftUI alert, then SystemState.removeSkill → UlkBridge.removeSkill (FileManager) → snapshot reload. Bundled skills (figma-, swift-, flutter-, kepano-, …) keep their reveal- in-Finder action only — they are managed by `ulk install`, not by direct deletion. Switched the folder action to NSWorkspace.activateFileViewerSelecting so it actually reveals rather than opens the directory. - CI macos-app.yml : split into two parallel jobs 1. spm — swift build/test (UlkCore library) — fast feedback 2. xcodegen — brew install xcodegen → generate → xcodebuild on the real .xcodeproj with CODE_SIGNING_ALLOWED=NO. Catches project.yml drift immediately. - Spec card : Roadmap v0.1 marked done (PR #143), v0.2 detailed, v0.2.1 added (remaining toggles + search + notifications), Changelog updated, related field gains json_contract_test.go. https://claude.ai/code/session_01TyhPjizanX1g8xSQ6chwTu --- .github/workflows/macos-app.yml | 43 ++++++++++- .../Ulk/Ulk/Scenes/Dashboard/SkillsTab.swift | 72 +++++++++++++++++-- .../CARD.md | 35 +++++---- 3 files changed, 128 insertions(+), 22 deletions(-) diff --git a/.github/workflows/macos-app.yml b/.github/workflows/macos-app.yml index 60050a0..3a34d0d 100644 --- a/.github/workflows/macos-app.yml +++ b/.github/workflows/macos-app.yml @@ -1,6 +1,11 @@ name: macOS App CI -# Build + test apps/macos/Ulk SwiftPM target on every push/PR touching the macOS app. +# Build + test apps/macos/Ulk on every push/PR touching the macOS app. +# Two stages: +# 1. SwiftPM build/test of UlkCore (fast, validates Models/Services/Scenes) +# 2. XcodeGen + xcodebuild on the real macOS app target (validates that +# project.yml stays in sync with the source tree and the app links) +# # Spec : docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md on: @@ -19,8 +24,8 @@ concurrency: cancel-in-progress: true jobs: - build-and-test: - name: swift build + test (macos-14) + spm: + name: SwiftPM build + test (macos-14) runs-on: macos-14 timeout-minutes: 15 defaults: @@ -49,3 +54,35 @@ jobs: - name: Test (UlkCoreTests) run: swift test --parallel + + xcodegen: + name: XcodeGen + xcodebuild (macos-14) + runs-on: macos-14 + timeout-minutes: 20 + defaults: + run: + working-directory: apps/macos/Ulk + steps: + - uses: actions/checkout@v6 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Ulk.xcodeproj from project.yml + run: xcodegen generate --use-cache + + - name: Verify project was generated + run: | + test -d Ulk.xcodeproj || { echo "::error::Ulk.xcodeproj missing"; exit 1; } + + - name: Build (xcodebuild, Debug, no signing) + run: | + set -o pipefail + xcodebuild \ + -project Ulk.xcodeproj \ + -scheme Ulk \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="" \ + build | xcpretty || true diff --git a/apps/macos/Ulk/Ulk/Scenes/Dashboard/SkillsTab.swift b/apps/macos/Ulk/Ulk/Scenes/Dashboard/SkillsTab.swift index efa3db2..fa4d037 100644 --- a/apps/macos/Ulk/Ulk/Scenes/Dashboard/SkillsTab.swift +++ b/apps/macos/Ulk/Ulk/Scenes/Dashboard/SkillsTab.swift @@ -1,9 +1,14 @@ import SwiftUI +import AppKit struct SkillsTab: View { @EnvironmentObject var systemState: SystemState let searchQuery: String + @State private var skillPendingRemoval: InstalledSkill? + @State private var working = false + @State private var actionError: String? + private var skills: [InstalledSkill] { let all = systemState.snapshot?.skillsInstalled ?? [] guard !searchQuery.isEmpty else { return all } @@ -20,21 +25,34 @@ struct SkillsTab: View { } else { List { Section("Bundled (\(skills.filter(\.bundled).count))") { - ForEach(skills.filter(\.bundled)) { skill in - row(skill) - } + ForEach(skills.filter(\.bundled)) { row($0) } } let community = skills.filter { !$0.bundled } if !community.isEmpty { Section("Community / opt-in (\(community.count))") { - ForEach(community) { skill in - row(skill) - } + ForEach(community) { row($0) } } } } } } + .alert( + "Remove \(skillPendingRemoval?.name ?? "skill")?", + isPresented: removalAlertBinding, + presenting: skillPendingRemoval + ) { skill in + Button("Remove", role: .destructive) { + Task { await performRemove(skill) } + } + Button("Cancel", role: .cancel) {} + } message: { skill in + Text("This will delete \(skill.path) and is irreversible. You can reinstall it later by running ulk install.") + } + .alert("Could not remove skill", isPresented: errorAlertBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(actionError ?? "") + } } private func row(_ skill: InstalledSkill) -> some View { @@ -48,13 +66,53 @@ struct SkillsTab: View { } Spacer() Button { - NSWorkspace.shared.open(URL(fileURLWithPath: skill.path)) + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: skill.path)]) } label: { Image(systemName: "folder") } .buttonStyle(.borderless) .help("Reveal in Finder") + + // Bundled skills are managed by `ulk install` — only allow removal + // of community/opt-in skills (filesystem-owned) from the UI. + if !skill.bundled { + Button { + skillPendingRemoval = skill + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .foregroundStyle(.red) + .help("Remove this skill") + .disabled(working) + } } .padding(.vertical, 2) } + + private var removalAlertBinding: Binding { + Binding( + get: { skillPendingRemoval != nil }, + set: { if !$0 { skillPendingRemoval = nil } } + ) + } + + private var errorAlertBinding: Binding { + Binding( + get: { actionError != nil }, + set: { if !$0 { actionError = nil } } + ) + } + + @MainActor + private func performRemove(_ skill: InstalledSkill) async { + working = true + defer { working = false } + do { + _ = try await systemState.removeSkill(skill) + } catch { + actionError = error.localizedDescription + } + skillPendingRemoval = nil + } } diff --git a/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md b/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md index 702994b..fd4dd18 100644 --- a/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md +++ b/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md @@ -7,7 +7,7 @@ assigned: izo created: 2026-05-06 edited: 2026-05-06 source: conversation 2026-05-06 (demande utilisateur) -related: framework/cli/, install.sh, framework/agents/_shared/managed-agents-protocol.md +related: framework/cli/, install.sh, framework/agents/_shared/managed-agents-protocol.md, framework/cli/cmd/json_contract_test.go description: App SwiftUI macOS qui fournit (1) un installateur GUI au premier lancement et (2) un dashboard quatre-onglets pour gérer skills, MCPs, CLIs et settings. Bridge sur le CLI Go ulk via Process. Auto-update via Sparkle. --- @@ -97,19 +97,28 @@ Sources de vérité **lues** par `RegistryLoader` (zero duplication) : ## Roadmap -### v0.1 — squelette (cette branche) +### v0.1 — squelette ✅ mergée (PR #143, 2026-05-06) - [x] Carte spec - [x] Branche `claude/macos-ulk-companion-app-BCKPe` -- [ ] Squelette `apps/macos/Ulk/` (Xcode project + sources Swift compilables) -- [ ] `UlkBridge` skeleton (install/status, sans parsing complet) -- [ ] Welcome + Options + InstallProgress scenes (UI seule, exec réelle) -- [ ] Dashboard 4 tabs (UI seule, données mockées via fallback) -- [ ] README build/sign/notarize -- [ ] PR draft - -### v0.2 — wiring complet -- Parsing JSON `ulk status --json`, `ulk doctor --json` (à ajouter côté CLI Go si manque) -- Toggle skills/MCPs/CLIs réels avec confirmation + dry-run +- [x] Squelette `apps/macos/Ulk/` (Package.swift + sources Swift compilables) +- [x] `UlkBridge` skeleton (install/status streaming, sans parsing complet) +- [x] Welcome + Options + InstallProgress scenes (UI + exec réelle) +- [x] Dashboard 4 tabs (UI seule, scan filesystem) +- [x] README build/sign/notarize +- [x] PR draft + +### v0.2 — Xcode + parsing JSON typé + premier toggle (cette branche) +- [x] **Projet Xcode** via XcodeGen (`project.yml`, généré, gitignored) +- [x] **Contrat JSON Go ↔ Swift** : 3 tests Go (`json_contract_test.go`) verrouillent le shape de `ulk status/doctor/check --json` ; modèles Swift miroir (`UlkReports.swift`) +- [x] **UlkBridge typé** : `status() → StatusReport`, `doctorReport() → DoctorReport`, `check() → CheckReport` (parsing JSON, plus de regex) +- [x] **SystemSnapshot** consomme directement `StatusReport` (modules, version, installed) +- [x] **1er toggle fonctionnel** : suppression skill community/opt-in via FileManager + alert SwiftUI de confirmation +- [x] **CI macOS-app.yml** : 2 jobs (SPM build/test + XcodeGen+xcodebuild) +- [x] **Tests Swift** : 5 tests `UlkReportsTests` (shapes installed/non-installed, doctor sain/avec issues, check found/missing) + +### v0.2.1 — toggles complets (suite) +- Toggle MCPs : `claude mcp add/remove` via UlkBridge +- Toggle CLIs required : `ulk install-deps --only ` - Recherche globale (cmd+F) avec highlight - Notifications natives macOS sur fin d'install/update @@ -143,3 +152,5 @@ Sources de vérité **lues** par `RegistryLoader` (zero duplication) : ## Changelog - 2026-05-06 · scaffold · création carte + branche + squelette SwiftUI v0.1 +- 2026-05-06 · v0.1 mergée (PR #143) +- 2026-05-06 · v0.2 · XcodeGen + contrat JSON typé Go↔Swift + 1er toggle fonctionnel (skill remove)