Skip to content
Open
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ let package = Package(
.product(name: "Sparkle", package: "Sparkle")
],
path: "Planit",
exclude: ["Info.plist", "Planit.entitlements"],
exclude: ["Info.plist", "Planit.entitlements", "Services/README-ai-providers.md"],
resources: [
.copy("Resources/PrivacyInfo.xcprivacy"),
.copy("Resources/AppIcon.icns"),
Expand Down
5 changes: 5 additions & 0 deletions Planit/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,12 @@
"settings.ai.claude.install.hint" = "Required: brew install claude-code";
"settings.ai.codex.installed.detail" = "Installed — Optimized for code";
"settings.ai.codex.install.hint" = "Required: npm install -g @openai/codex";
"settings.ai.hermes.installed.detail" = "Installed — available after integration is implemented";
"settings.ai.hermes.install.hint" = "Install Hermes later, then re-detect the CLI";
"settings.ai.save.button" = "Save AI Settings";
"settings.ai.claude.desc" = "Excellent natural language understanding. Ideal for complex scheduling and goal analysis.";
"settings.ai.codex.desc" = "Specialized for code-based optimization. Great for fast rule-based planning.";
"settings.ai.hermes.desc" = "Stateful gateway-based agent. Prepared for future use after Hermes is installed.";
"settings.aggressiveness.manual.desc" = "Manage all schedules manually. AI stays quiet.";
"settings.aggressiveness.assist.desc" = "AI suggests only. You decide to accept.";
"settings.aggressiveness.semiauto.desc" = "Automatically registers approved suggestions to calendar.";
Expand Down Expand Up @@ -388,6 +391,8 @@
"setup.step2.title" = "Connect Google Calendar";
"setup.claude.desc" = "Ask Claude to help plan and organize your day";
"setup.codex.desc" = "Use Codex to generate smart suggestions";
"setup.hermes.desc" = "Use Hermes after installing the stateful gateway agent";
"setup.hermes.install.after" = "Install Hermes when you are ready to enable this provider";
"setup.google.desc" = "Sync your calendar events for smarter scheduling";
"setup.google.connect.button" = "Connect Google Calendar";
"setup.google.skip.hint" = "You can connect later in Settings";
Expand Down
5 changes: 5 additions & 0 deletions Planit/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,12 @@
"settings.ai.claude.install.hint" = "설치 필요: brew install claude-code";
"settings.ai.codex.installed.detail" = "설치됨 — 코드 최적화 특화";
"settings.ai.codex.install.hint" = "설치 필요: npm install -g @openai/codex";
"settings.ai.hermes.installed.detail" = "설치됨 — 실제 연동 구현 후 사용 가능";
"settings.ai.hermes.install.hint" = "나중에 Hermes 설치 후 CLI를 다시 감지하세요";
"settings.ai.save.button" = "AI 설정 저장";
"settings.ai.claude.desc" = "자연어 이해 능력이 뛰어납니다. 복잡한 일정 조정과 목표 분석에 적합합니다.";
"settings.ai.codex.desc" = "코드 기반 최적화에 특화되어 있습니다. 빠른 규칙 기반 계획 생성에 유용합니다.";
"settings.ai.hermes.desc" = "세션을 유지하는 gateway 기반 에이전트입니다. Hermes 설치 후 향후 사용을 위해 준비되어 있습니다.";
"settings.aggressiveness.manual.desc" = "모든 일정을 직접 관리합니다. AI는 조용합니다.";
"settings.aggressiveness.assist.desc" = "AI가 제안만 합니다. 수락은 직접 결정합니다.";
"settings.aggressiveness.semiauto.desc" = "승인된 제안을 자동으로 캘린더에 등록합니다.";
Expand Down Expand Up @@ -388,6 +391,8 @@
"setup.step2.title" = "Google 캘린더 연동";
"setup.claude.desc" = "Claude로 하루 일정을 계획하고 정리해보세요";
"setup.codex.desc" = "Codex로 스마트한 제안을 받아보세요";
"setup.hermes.desc" = "상태를 유지하는 Hermes gateway 에이전트 설치 후 사용하세요";
"setup.hermes.install.after" = "이 provider를 활성화할 준비가 되면 Hermes를 설치하세요";
"setup.google.desc" = "캘린더 일정을 연동하면 더 스마트한 스케줄링이 가능합니다";
"setup.google.connect.button" = "Google 캘린더 연동";
"setup.google.skip.hint" = "나중에 설정에서 연동할 수 있습니다";
Expand Down
51 changes: 44 additions & 7 deletions Planit/Services/AIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,36 @@ import CoreGraphics
enum AIProvider: String, CaseIterable, Codable {
case claude = "Claude Code"
case codex = "Codex"
case hermes = "Hermes"

var icon: String {
switch self {
case .claude: return "c.circle.fill"
case .codex: return "o.circle.fill"
case .hermes: return "h.circle.fill"
}
}

var defaultModel: String {
switch self {
case .claude: return "claude-sonnet-4-20250514"
case .codex: return "gpt-5.4"
case .hermes: return "provider:model"
}
}

var cliName: String {
switch self {
case .claude: return "claude"
case .codex: return "codex"
case .hermes: return "hermes"
}
}

var isStateful: Bool {
switch self {
case .claude, .codex: return false
case .hermes: return true
}
}
}
Expand Down Expand Up @@ -175,6 +193,7 @@ final class AIService: ObservableObject {
@Published var isLoading: Bool = false
@Published var claudeAvailable: Bool = false
@Published var codexAvailable: Bool = false
@Published var hermesAvailable: Bool = false
/// Pending actions awaiting user approval before execution
@Published var pendingActions: [CalendarAction] = []
@Published var pendingMessage: String?
Expand All @@ -189,6 +208,7 @@ final class AIService: ObservableObject {
/// Cached absolute paths for CLI tools (resolved once)
private var claudePath: String?
private var codexPath: String?
private var hermesPath: String?

/// Known valid event IDs from the last calendar context fetch
private var knownEventIds: Set<String> = []
Expand Down Expand Up @@ -224,9 +244,14 @@ final class AIService: ObservableObject {
private static let externalContextConsentKey = "planit.aiExternalContextConsentGranted.v1"

var isConfigured: Bool {
isAvailable(provider)
}

func isAvailable(_ provider: AIProvider) -> Bool {
switch provider {
case .claude: return claudeAvailable
case .codex: return codexAvailable
case .hermes: return hermesAvailable
}
}

Expand All @@ -246,20 +271,23 @@ final class AIService: ObservableObject {

private func checkCLIAvailability() {
Task.detached { [weak self] in
let claudeResolved = Self.resolvePath("claude")
let codexResolved = Self.resolvePath("codex")
let claudeResolved = Self.resolvePath(AIProvider.claude.cliName)
let codexResolved = Self.resolvePath(AIProvider.codex.cliName)
let hermesResolved = Self.resolvePath(AIProvider.hermes.cliName)
guard let self else { return }
await MainActor.run { [self] in
self.claudePath = claudeResolved
self.claudeAvailable = claudeResolved != nil
self.codexPath = codexResolved
self.codexAvailable = codexResolved != nil
self.hermesPath = hermesResolved
self.hermesAvailable = hermesResolved != nil
}
}
}

/// Review planner 등 외부 서비스에서 Claude 경로 탐색용
nonisolated static func findClaudePath() -> String? { resolvePath("claude") }
nonisolated static func findClaudePath() -> String? { resolvePath(AIProvider.claude.cliName) }

/// Review planner 등 외부 서비스에서 Claude 단발 호출용
nonisolated static func runClaudeOneShot(prompt: String, claudePath: String) -> String {
Expand All @@ -270,7 +298,7 @@ final class AIService: ObservableObject {
/// Resolve absolute path for a command without login shell.
/// Searches known directories in priority order (system-managed first).
nonisolated private static func resolvePath(_ cmd: String) -> String? {
guard cmd == "claude" || cmd == "codex" else { return nil }
guard AIProvider.allCases.map(\.cliName).contains(cmd) else { return nil }

// 시스템 관리 경로만 허용 — 사용자 쓰기가능 경로(~/.local/bin 등)는 악성 바이너리 주입 위험
let searchPaths = [
Expand Down Expand Up @@ -1004,17 +1032,20 @@ final class AIService: ObservableObject {
let systemPrompt = buildSystemPrompt(calendarContext: calContext)

// CLI 경로가 없으면 한 번 재감지 시도
if (provider == .claude && claudePath == nil) || (provider == .codex && codexPath == nil) {
if !isAvailable(provider) {
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
Task.detached { [weak self] in
let claudeResolved = Self.resolvePath("claude")
let codexResolved = Self.resolvePath("codex")
let claudeResolved = Self.resolvePath(AIProvider.claude.cliName)
let codexResolved = Self.resolvePath(AIProvider.codex.cliName)
let hermesResolved = Self.resolvePath(AIProvider.hermes.cliName)
guard let self else { cont.resume(); return }
await MainActor.run { [self] in
self.claudePath = claudeResolved
self.claudeAvailable = claudeResolved != nil
self.codexPath = codexResolved
self.codexAvailable = codexResolved != nil
self.hermesPath = hermesResolved
self.hermesAvailable = hermesResolved != nil
cont.resume()
}
}
Expand Down Expand Up @@ -1078,6 +1109,12 @@ final class AIService: ObservableObject {
rawResponse = await sendCLI(executablePath: path, args: codexArgs,
system: systemPrompt, userMessage: augmentedMessage, history: history,
isCodex: true)
case .hermes:
guard hermesPath != nil else { return [ChatMessage(role: .assistant, content: "Hermes가 설치되지 않았습니다. Hermes 설치 후 `hermes gateway` 또는 `hermes --version`이 시스템 경로에서 실행되는지 확인해주세요.")] }
// TODO: Implement Hermes CLI integration after the command contract is finalized.
// Hermes is stateful, so future work should decide whether to persist a Planit
// conversation ID or force a fresh Hermes session per chat before invoking it.
rawResponse = "Hermes provider is prepared but not implemented yet. Hermes 설치 후 연동을 활성화할 예정입니다."
}

let (message, actions) = parseAIResponse(rawResponse)
Expand Down
55 changes: 55 additions & 0 deletions Planit/Services/README-ai-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# AI Provider Integration

Planit routes chat requests through `AIService`. The current provider boundary is:

- Input: `systemPrompt`, `userMessage`, recent chat `history`, calendar context, and safe attachment paths or extracted PDF text.
- Output: plain assistant text, or JSON matching `AIResponseWithActions` with `message` and `actions`.
- Action handling: providers only suggest actions. `AIService` parses, validates, queues, and executes calendar or todo changes.

## Providers

`AIProvider` declares the UI label, icon, default model, CLI name, and session behavior.

- Claude Code: stateless. Uses `--no-session-persistence`.
- Codex: stateless. Uses `--ephemeral`.
- Hermes: stateful by design. Planit currently detects the `hermes` CLI and exposes the provider in UI, but real execution is intentionally stubbed until the Hermes command contract is finalized.

## Adding a Provider

1. Add a case to `AIProvider`.
2. Fill in `icon`, `defaultModel`, `cliName`, and `isStateful`.
3. Extend CLI availability storage in `AIService` or replace it with a keyed registry.
4. Add a `sendMessage` branch that returns raw assistant output compatible with `parseAIResponse`.
5. Keep `resolvePath(_:)` restricted to system-managed directories.
6. Add setup/settings strings and provider-contract tests.

## Adapter Direction

The current implementation still uses provider branches inside `sendMessage`. The next structural step should be a small adapter registry:

```swift
struct AIProviderRequest {
let systemPrompt: String
let userMessage: String
let history: [ChatMessage]
let imagePaths: [String]
}

protocol AIProviderAdapter {
var provider: AIProvider { get }
func complete(_ request: AIProviderRequest) async -> String
}
```

Claude and Codex adapters can wrap the existing CLI argument construction and `sendCLI` execution. Hermes should start as an adapter stub, then move to real execution once Planit chooses a session policy.

## Hermes Session Policy

Hermes keeps memory between sessions, unlike the current Claude and Codex calls. Planit should keep app state authoritative:

- `chatMessages` remains the visible conversation history.
- Calendar context is rebuilt or reused by `AIService`, not recovered from Hermes memory.
- Pending calendar actions stay in Planit until the user confirms them.
- A future Hermes conversation ID should be scoped to one Planit chat and reset when external context consent changes.

Until that policy is implemented, Hermes should not receive calendar context or attachments through a real CLI call.
24 changes: 15 additions & 9 deletions Planit/Views/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ struct ChatView: View {
icon: "terminal.fill",
color: .purple,
title: String(localized: "setup.step1.title"),
done: aiService.claudeAvailable || aiService.codexAvailable
done: AIProvider.allCases.contains { aiService.isAvailable($0) }
) {
VStack(alignment: .leading, spacing: 8) {
// Claude Code
Expand Down Expand Up @@ -182,6 +182,18 @@ struct ChatView: View {
badgeColor: .green
)

// Hermes
installOption(
name: "Hermes",
description: String(localized: "setup.hermes.desc"),
commands: [
"# " + String(localized: "setup.hermes.install.after"),
"hermes --version"
],
isInstalled: aiService.hermesAvailable,
badgeColor: .orange
)

// 재감지 버튼
Button {
aiService.recheckCLI()
Expand Down Expand Up @@ -996,17 +1008,11 @@ struct ProviderRow: View {
let aiService: AIService

private var isAvailable: Bool {
switch provider {
case .claude: return aiService.claudeAvailable
case .codex: return aiService.codexAvailable
}
aiService.isAvailable(provider)
}

private var statusText: String {
switch provider {
case .claude: return aiService.claudeAvailable ? String(localized: "ai.provider.installed") : String(localized: "ai.provider.not.installed")
case .codex: return aiService.codexAvailable ? String(localized: "ai.provider.installed") : String(localized: "ai.provider.not.installed")
}
isAvailable ? String(localized: "ai.provider.installed") : String(localized: "ai.provider.not.installed")
}

var body: some View {
Expand Down
26 changes: 20 additions & 6 deletions Planit/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -376,19 +376,21 @@ struct SettingsView: View {

settingsCard(String(localized: "settings.ai.status.card")) {
VStack(spacing: 10) {
aiStatusRow("Claude CLI", available: aiService.claudeAvailable,
detail: aiService.claudeAvailable ? String(localized: "settings.ai.claude.installed.detail") : String(localized: "settings.ai.claude.install.hint"))
Divider()
aiStatusRow("Codex CLI", available: aiService.codexAvailable,
detail: aiService.codexAvailable ? String(localized: "settings.ai.codex.installed.detail") : String(localized: "settings.ai.codex.install.hint"))
ForEach(Array(AIProvider.allCases.enumerated()), id: \.element) { index, provider in
if index > 0 {
Divider()
}
aiStatusRow("\(provider.rawValue) CLI", available: aiService.isAvailable(provider),
detail: providerStatusDetail(provider))
}
}
}
}
}

private func aiProviderRow(_ provider: AIProvider) -> some View {
let isSelected = aiService.provider == provider
let isAvailable: Bool = provider == .claude ? aiService.claudeAvailable : aiService.codexAvailable
let isAvailable = aiService.isAvailable(provider)

return Button {
aiService.provider = provider
Expand Down Expand Up @@ -477,6 +479,18 @@ struct SettingsView: View {
switch provider {
case .claude: return String(localized: "settings.ai.claude.desc")
case .codex: return String(localized: "settings.ai.codex.desc")
case .hermes: return String(localized: "settings.ai.hermes.desc")
}
}

private func providerStatusDetail(_ provider: AIProvider) -> String {
switch provider {
case .claude:
return aiService.claudeAvailable ? String(localized: "settings.ai.claude.installed.detail") : String(localized: "settings.ai.claude.install.hint")
case .codex:
return aiService.codexAvailable ? String(localized: "settings.ai.codex.installed.detail") : String(localized: "settings.ai.codex.install.hint")
case .hermes:
return aiService.hermesAvailable ? String(localized: "settings.ai.hermes.installed.detail") : String(localized: "settings.ai.hermes.install.hint")
}
}

Expand Down
42 changes: 23 additions & 19 deletions Tests/CalenTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1142,28 +1142,32 @@ struct TestCachedEventFull: Codable, Identifiable {
// ============================================================================

@Test func aiProvider_claude_properties() {
let raw = "Claude Code"
#expect(raw == "Claude Code")
// icon / defaultModel은 enum이므로 테스트 가능
let icon = "c.circle.fill"
let model = "claude-sonnet-4-20250514"
#expect(!icon.isEmpty)
#expect(model.contains("claude"))
#expect(AIProvider.claude.rawValue == "Claude Code")
#expect(AIProvider.claude.icon == "c.circle.fill")
#expect(AIProvider.claude.defaultModel.contains("claude"))
#expect(AIProvider.claude.cliName == "claude")
#expect(AIProvider.claude.isStateful == false)
}

@Test func aiProvider_codex_properties() {
let raw = "Codex"
#expect(raw == "Codex")
let icon = "o.circle.fill"
let model = "gpt-5.4"
#expect(!icon.isEmpty)
#expect(model.contains("gpt"))
}

@Test func aiProvider_allCases_hasTwo() {
// CaseIterable → 2개 프로바이더
let providers = ["Claude Code", "Codex"]
#expect(providers.count == 2)
#expect(AIProvider.codex.rawValue == "Codex")
#expect(AIProvider.codex.icon == "o.circle.fill")
#expect(AIProvider.codex.defaultModel.contains("gpt"))
#expect(AIProvider.codex.cliName == "codex")
#expect(AIProvider.codex.isStateful == false)
}

@Test func aiProvider_hermes_properties() {
#expect(AIProvider.hermes.rawValue == "Hermes")
#expect(AIProvider.hermes.icon == "h.circle.fill")
#expect(AIProvider.hermes.defaultModel == "provider:model")
#expect(AIProvider.hermes.cliName == "hermes")
#expect(AIProvider.hermes.isStateful == true)
}

@Test func aiProvider_allCases_hasThree() {
let providers = AIProvider.allCases.map(\.rawValue)
#expect(providers == ["Claude Code", "Codex", "Hermes"])
}

@Test func aiTone_promptInstructions_areDistinct() {
Expand Down