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
43 changes: 40 additions & 3 deletions .github/workflows/macos-app.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions apps/macos/Ulk/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Generated Xcode project (source of truth = project.yml via XcodeGen)
Ulk.xcodeproj/

# Xcode / SwiftPM build artifacts
.build/
.swiftpm/
Expand Down
73 changes: 58 additions & 15 deletions apps/macos/Ulk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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.
96 changes: 96 additions & 0 deletions apps/macos/Ulk/Tests/UlkTests/UlkReportsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
12 changes: 11 additions & 1 deletion apps/macos/Ulk/Ulk/Models/SystemState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand All @@ -50,6 +59,7 @@ struct SystemSnapshot: Codable, Equatable {
mcpsConfigured: [],
clisDetected: [],
configFlags: [:],
statusReport: nil,
lastChecked: Date()
)
}
Expand Down
71 changes: 71 additions & 0 deletions apps/macos/Ulk/Ulk/Models/UlkReports.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading