Skip to content

Commit 2bad3e7

Browse files
authored
Merge pull request #50 from zmanian/tall-bag
Add Claude Code version check and update button
2 parents 457ddb4 + cd4b686 commit 2bad3e7

File tree

2 files changed

+183
-2
lines changed

2 files changed

+183
-2
lines changed

Wisp/ViewModels/SpriteOverviewViewModel.swift

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ enum SpritesCLIAuth {
88
case unknown, checking, authenticated, notAuthenticated
99
}
1010

11+
enum ClaudeCodeVersionStatus: Equatable {
12+
case unknown
13+
case checking
14+
case upToDate(version: String)
15+
case updateAvailable(current: String, latest: String)
16+
case updating
17+
case updateFailed(error: String)
18+
case failed
19+
}
20+
1121
@Observable
1222
@MainActor
1323
final class SpriteOverviewViewModel {
@@ -19,6 +29,7 @@ final class SpriteOverviewViewModel {
1929
var isAuthenticatingGitHub = false
2030
var spritesCLIAuthStatus: SpritesCLIAuth = .unknown
2131
var isAuthenticatingSprites = false
32+
var claudeCodeVersionStatus: ClaudeCodeVersionStatus = .unknown
2233
var errorMessage: String?
2334
var isUploading = false
2435
var uploadResult: SpritesAPIClient.FileUploadResponse?
@@ -195,6 +206,74 @@ final class SpriteOverviewViewModel {
195206
}
196207
}
197208

209+
func checkClaudeCodeVersion(apiClient: SpritesAPIClient) async {
210+
claudeCodeVersionStatus = .checking
211+
212+
async let execResult = apiClient.runExec(
213+
spriteName: sprite.name,
214+
command: "claude --version 2>/dev/null || echo CLAUDE_NOT_FOUND"
215+
)
216+
async let latestVersion = fetchNpmLatestVersion()
217+
218+
let (output, success) = await execResult
219+
let npmVersion = await latestVersion
220+
221+
if !success || output.contains("CLAUDE_NOT_FOUND") || output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
222+
claudeCodeVersionStatus = .failed
223+
return
224+
}
225+
226+
let installed = output.trimmingCharacters(in: .whitespacesAndNewlines)
227+
228+
if let npm = npmVersion, let installedSemver = extractSemver(from: installed) {
229+
if installedSemver == npm {
230+
claudeCodeVersionStatus = .upToDate(version: installed)
231+
} else {
232+
claudeCodeVersionStatus = .updateAvailable(current: installed, latest: npm)
233+
}
234+
} else {
235+
claudeCodeVersionStatus = .upToDate(version: installed)
236+
}
237+
}
238+
239+
func updateClaudeCode(apiClient: SpritesAPIClient) async {
240+
claudeCodeVersionStatus = .updating
241+
let (output, success) = await apiClient.runExec(
242+
spriteName: sprite.name,
243+
command: "claude update 2>&1 && claude --version",
244+
timeout: 120
245+
)
246+
if success {
247+
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: "\n")
248+
if let lastLine = lines.last {
249+
let newInstalled = String(lastLine).trimmingCharacters(in: .whitespacesAndNewlines)
250+
let npmVersion = await fetchNpmLatestVersion()
251+
if let npm = npmVersion, let semver = extractSemver(from: newInstalled), semver == npm {
252+
claudeCodeVersionStatus = .upToDate(version: newInstalled)
253+
} else {
254+
claudeCodeVersionStatus = .upToDate(version: newInstalled)
255+
}
256+
} else {
257+
claudeCodeVersionStatus = .updateFailed(error: "Update succeeded but could not read version")
258+
}
259+
} else {
260+
claudeCodeVersionStatus = .updateFailed(error: "Update failed")
261+
}
262+
}
263+
264+
private func fetchNpmLatestVersion() async -> String? {
265+
guard let url = URL(string: "https://registry.npmjs.org/@anthropic-ai/claude-code/latest") else { return nil }
266+
guard let (data, _) = try? await URLSession.shared.data(from: url) else { return nil }
267+
struct NpmResponse: Decodable { let version: String }
268+
return try? JSONDecoder().decode(NpmResponse.self, from: data).version
269+
}
270+
271+
private func extractSemver(from string: String) -> String? {
272+
let pattern = #"\d+\.\d+\.\d+"#
273+
return string.range(of: pattern, options: .regularExpression)
274+
.map { String(string[$0]) }
275+
}
276+
198277
func authenticateSprites(apiClient: SpritesAPIClient) async {
199278
guard let token = apiClient.spritesToken else { return }
200279
isAuthenticatingSprites = true

Wisp/Views/SpriteDetail/Overview/SpriteOverviewView.swift

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,106 @@ struct SpriteOverviewView: View {
145145
}
146146
}
147147

148+
Section("Claude Code") {
149+
switch viewModel.claudeCodeVersionStatus {
150+
case .unknown, .checking:
151+
HStack(spacing: 8) {
152+
Text("Version")
153+
Spacer()
154+
ProgressView()
155+
Text("Checking...")
156+
.foregroundStyle(.secondary)
157+
}
158+
case .upToDate(let version):
159+
HStack {
160+
Text("Version")
161+
Spacer()
162+
Image(systemName: "checkmark.circle.fill")
163+
.foregroundStyle(.green)
164+
Text(version)
165+
.foregroundStyle(.secondary)
166+
}
167+
.contextMenu {
168+
Button {
169+
UIPasteboard.general.string = version
170+
} label: {
171+
Label("Copy Version", systemImage: "doc.on.doc")
172+
}
173+
}
174+
case .updateAvailable(let current, _):
175+
HStack {
176+
Text("Version")
177+
Spacer()
178+
Image(systemName: "arrow.triangle.2.circlepath")
179+
.foregroundStyle(.orange)
180+
Text(current)
181+
.foregroundStyle(.secondary)
182+
}
183+
.contextMenu {
184+
Button {
185+
UIPasteboard.general.string = current
186+
} label: {
187+
Label("Copy Version", systemImage: "doc.on.doc")
188+
}
189+
}
190+
case .updating:
191+
HStack(spacing: 8) {
192+
Text("Version")
193+
Spacer()
194+
ProgressView()
195+
Text("Updating...")
196+
.foregroundStyle(.secondary)
197+
}
198+
case .updateFailed(let error):
199+
HStack {
200+
Text("Version")
201+
Spacer()
202+
Text(error)
203+
.foregroundStyle(.red)
204+
.font(.caption)
205+
}
206+
case .failed:
207+
HStack {
208+
Text("Version")
209+
Spacer()
210+
Text("Not installed")
211+
.foregroundStyle(.secondary)
212+
}
213+
}
214+
215+
Button {
216+
Task { await viewModel.updateClaudeCode(apiClient: apiClient) }
217+
} label: {
218+
HStack {
219+
if case .updateAvailable(_, let latest) = viewModel.claudeCodeVersionStatus {
220+
Text("Update to \(latest)")
221+
.foregroundStyle(.primary)
222+
} else {
223+
Text("Update Claude Code")
224+
.foregroundStyle(.primary)
225+
}
226+
Spacer()
227+
if case .updating = viewModel.claudeCodeVersionStatus {
228+
ProgressView()
229+
} else if case .checking = viewModel.claudeCodeVersionStatus {
230+
EmptyView()
231+
} else if case .upToDate = viewModel.claudeCodeVersionStatus {
232+
Text("Latest version")
233+
.foregroundStyle(.secondary)
234+
} else {
235+
Image(systemName: "arrow.triangle.2.circlepath")
236+
.foregroundStyle(Color.accentColor)
237+
}
238+
}
239+
}
240+
.disabled({
241+
if case .updating = viewModel.claudeCodeVersionStatus { return true }
242+
if case .checking = viewModel.claudeCodeVersionStatus { return true }
243+
if case .upToDate = viewModel.claudeCodeVersionStatus { return true }
244+
return false
245+
}())
246+
}
247+
148248
Section("Sprites CLI") {
149249
switch viewModel.spritesCLIAuthStatus {
150250
case .unknown, .checking:
@@ -238,8 +338,10 @@ struct SpriteOverviewView: View {
238338
.task {
239339
loadWorkingDirectory()
240340
await viewModel.refresh(apiClient: apiClient)
241-
await viewModel.checkSpritesAuth(apiClient: apiClient)
242-
await viewModel.checkGitHubAuth(apiClient: apiClient)
341+
async let claude: Void = viewModel.checkClaudeCodeVersion(apiClient: apiClient)
342+
async let sprites: Void = viewModel.checkSpritesAuth(apiClient: apiClient)
343+
async let github: Void = viewModel.checkGitHubAuth(apiClient: apiClient)
344+
_ = await (claude, sprites, github)
243345
}
244346
.task {
245347
await viewModel.pollStatus(apiClient: apiClient)

0 commit comments

Comments
 (0)