From a925e609f969487105bd3e66be817d5b28743ace Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 04:05:13 +0000 Subject: [PATCH 1/3] =?UTF-8?q?slides:=20format-text=20=E2=80=94=20set=20b?= =?UTF-8?q?old/italic/underline/size/color=20on=20element=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `slides format-text ` (updateTextStyle): style a shape's text (or a table cell via --row/--col) — bold/italic/underline as tri-state flags (--bold / --no-bold), --font-size (points), --foreground (hex RGB). Styles all of the element's text by default or a --start/--end range; the update field mask is built from whichever options are given. Self-contained: objectId comes from read-slide, no document read. Validates at least one style, positive font size, 6-digit hex, and the paired range / cell args. Completes the slides authoring arc (create -> insert -> format -> position -> read). 5 wiring tests (verb/mask/objectId, requires-a-style, hex->rgb decode, bad hex, table-cell cellLocation). SKILL.md documents it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JGWAxXq3PZatbdBR4P5RrA --- .agents/skills/gog/SKILL.md | 1 + Sources/GogCommands/GogCommand.swift | 130 ++++++++++++++++++++++- Tests/GogShellTests/GogWiringTests.swift | 96 +++++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) diff --git a/.agents/skills/gog/SKILL.md b/.agents/skills/gog/SKILL.md index 722d3bc..0e22924 100644 --- a/.agents/skills/gog/SKILL.md +++ b/.agents/skills/gog/SKILL.md @@ -204,6 +204,7 @@ gog slides list-slides # read (slide gog slides delete-slide # full gog slides read-slide # read (element objectIds + text) gog slides insert-text --text 'Hi' # edit (shape; add --row/--col for a table cell) +gog slides format-text --bold --font-size 18 # edit (style text: bold/italic/underline/--foreground hex; all text or --start/--end; --row/--col for a cell) gog slides move --x 100 --y 100 # edit (reposition, preserving scale/rotation; --scale-x/--scale-y to rescale) gog slides reorder --to front # edit (z-order: front/back/forward/backward) ``` diff --git a/Sources/GogCommands/GogCommand.swift b/Sources/GogCommands/GogCommand.swift index 81c8e8b..2f8856e 100644 --- a/Sources/GogCommands/GogCommand.swift +++ b/Sources/GogCommands/GogCommand.swift @@ -3596,7 +3596,8 @@ struct GogSlides: AsyncParsableCommand { SlidesDeleteSlide.self, SlidesReadSlide.self, SlidesInsertText.self, SlidesCreateTable.self, SlidesCreateTextbox.self, SlidesCreateImage.self, - SlidesMove.self, SlidesReorder.self], + SlidesMove.self, SlidesReorder.self, + SlidesFormatText.self], aliases: ["slide"]) } @@ -4398,6 +4399,133 @@ struct SlidesReorder: AsyncParsableCommand { } } +/// `gog slides format-text ` — set text style (bold / +/// italic / underline / size / color) on a shape, or a table cell with `--row`/`--col`, +/// via `updateTextStyle`. Styles all of the element's text by default, or a +/// `--start`/`--end` range. Find object IDs with `slides read-slide`. +struct SlidesFormatText: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "format-text", + abstract: "Set bold/italic/underline/size/color on a slide element's text (--dry-run).") + + @Argument(help: "Presentation ID.") var presentationId: String + @Argument(help: "Page element object ID (from `slides read-slide`).") + var objectId: String + @Flag(inversion: .prefixedNo, help: "Bold (--no-bold to unset).") var bold: Bool? + @Flag(inversion: .prefixedNo, help: "Italic (--no-italic to unset).") var italic: Bool? + @Flag(inversion: .prefixedNo, help: "Underline (--no-underline to unset).") var underline: Bool? + @Option(name: .long, help: "Font size in points.") var fontSize: Double? + @Option(name: .long, help: "Text color as hex RGB, e.g. FF0000.") var foreground: String? + @Option(name: .long, help: "Range start index within the element (with --end).") var start: Int? + @Option(name: .long, help: "Range end index within the element (with --start).") var end: Int? + @Option(name: .long, help: "Table cell row (0-based); requires --col.") var row: Int? + @Option(name: .long, help: "Table cell column (0-based); requires --row.") var col: Int? + @Flag(name: .long, help: "Build the request but do not apply.") var dryRun: Bool = false + @Flag(name: [.customShort("j"), .long], help: "Emit raw JSON.") var json: Bool = false + + func run() async throws { + try requireWriteTier(.edit) + // Build the field mask from whichever style options were given. + var fields: [String] = [] + if bold != nil { fields.append("bold") } + if italic != nil { fields.append("italic") } + if underline != nil { fields.append("underline") } + if fontSize != nil { fields.append("fontSize") } + if foreground != nil { fields.append("foregroundColor") } + guard !fields.isEmpty else { + Shell.bashCurrent.stderr( + "gog: give at least one of --bold/--italic/--underline/--font-size/--foreground\n") + throw ExitCode(2) + } + if let fontSize, !(fontSize.isFinite && fontSize > 0) { + Shell.bashCurrent.stderr("gog: --font-size must be a positive number\n") + throw ExitCode(2) + } + // Parse "RRGGBB" (or "#RRGGBB") to 0-1 RGB components. + var rgb: (r: Double, g: Double, b: Double)? + if let foreground { + let hex = foreground.hasPrefix("#") ? String(foreground.dropFirst()) : foreground + guard hex.count == 6, let v = Int(hex, radix: 16) else { + Shell.bashCurrent.stderr("gog: --foreground must be a 6-digit hex RGB, e.g. FF0000\n") + throw ExitCode(2) + } + rgb = (Double((v >> 16) & 0xFF) / 255, Double((v >> 8) & 0xFF) / 255, Double(v & 0xFF) / 255) + } + guard (start == nil) == (end == nil) else { + Shell.bashCurrent.stderr("gog: --start and --end must be given together (a range)\n") + throw ExitCode(2) + } + if let start, let end, !(start >= 0 && end > start) { + Shell.bashCurrent.stderr("gog: --start must be >= 0 and --end greater than --start\n") + throw ExitCode(2) + } + guard (row == nil) == (col == nil) else { + Shell.bashCurrent.stderr("gog: --row and --col must be given together (table cell)\n") + throw ExitCode(2) + } + struct Batch: Encodable { + struct Request: Encodable { + struct UpdateTextStyle: Encodable { + struct CellLocation: Encodable { let rowIndex: Int; let columnIndex: Int } + struct TextRange: Encodable { + let type: String + let startIndex: Int? + let endIndex: Int? + } + struct Style: Encodable { + struct FontSize: Encodable { let magnitude: Double; let unit: String } + struct Color: Encodable { + struct Opaque: Encodable { + struct Rgb: Encodable { let red: Double; let green: Double; let blue: Double } + let rgbColor: Rgb + } + let opaqueColor: Opaque + } + let bold: Bool? + let italic: Bool? + let underline: Bool? + let fontSize: FontSize? + let foregroundColor: Color? + } + let objectId: String + let cellLocation: CellLocation? + let textRange: TextRange + let style: Style + let fields: String + } + let updateTextStyle: UpdateTextStyle + } + let requests: [Request] + } + let cell = row.flatMap { r in + col.map { Batch.Request.UpdateTextStyle.CellLocation(rowIndex: r, columnIndex: $0) } } + let textRange = start.flatMap { s in end.map { e in + Batch.Request.UpdateTextStyle.TextRange(type: "FIXED_RANGE", startIndex: s, endIndex: e) } } + ?? .init(type: "ALL", startIndex: nil, endIndex: nil) + let style = Batch.Request.UpdateTextStyle.Style( + bold: bold, italic: italic, underline: underline, + fontSize: fontSize.map { .init(magnitude: $0, unit: "PT") }, + foregroundColor: rgb.map { + .init(opaqueColor: .init(rgbColor: .init(red: $0.r, green: $0.g, blue: $0.b))) }) + let payload = try JSONEncoder().encode(Batch(requests: [.init(updateTextStyle: .init( + objectId: objectId, cellLocation: cell, textRange: textRange, style: style, + fields: fields.joined(separator: ",")))])) + if dryRun { + Shell.bashCurrent.stderr("dry-run: not formatting\n") + Shell.bashCurrent.stdout(String(decoding: payload, as: UTF8.self) + "\n") + return + } + let url = try googleURL( + "https://slides.googleapis.com/v1/presentations/\(pathSegment(presentationId)):batchUpdate") + let result = try await GoogleHTTPClient().post(url, jsonBody: payload) + if json { + Shell.bashCurrent.stdout(String(decoding: result, as: UTF8.self) + "\n") + return + } + Shell.bashCurrent.stdout("formatted text in: \(objectId)\n") + } +} + // MARK: - Chat /// `gog chat …` — Google Chat. diff --git a/Tests/GogShellTests/GogWiringTests.swift b/Tests/GogShellTests/GogWiringTests.swift index d6d2bbd..d5e7b7c 100644 --- a/Tests/GogShellTests/GogWiringTests.swift +++ b/Tests/GogShellTests/GogWiringTests.swift @@ -3311,6 +3311,102 @@ extension Trait where Self == WriteTierTrait { #expect(run.stderr.contains("--to")) } + @Test func slidesFormatTextPostsUpdateTextStyle() async throws { + let shell = Shell() + shell.registerGogCommands() + let transport = RecordingTransport( + response: HTTPResponse(status: 200, body: Data("{}".utf8))) + let run = try await GogTransportProvider.$current.withValue(transport) { + try await GogCredentials.$current.withValue( + StubProvider(token: "t", accountHint: nil) + ) { + try await shell.runCapturing("gog slides format-text P1 sh1 --bold --font-size 18") + } + } + #expect(run.exitStatus == .success) + #expect(transport.lastMethod == "POST") + #expect(transport.lastURL?.absoluteString.contains("/presentations/P1:batchUpdate") == true) + let body = String(decoding: transport.lastBody ?? Data(), as: UTF8.self) + #expect(body.contains("updateTextStyle") && body.contains(#""objectId":"sh1""#)) + #expect(body.contains(#""type":"ALL""#) && body.contains(#""bold":true"#)) + #expect(body.contains(#""fields":"bold,fontSize""#)) // mask lists only set props, in order + } + + @Test func slidesFormatTextRequiresAStyle() async throws { + let shell = Shell() + shell.registerGogCommands() + let run = try await shell.runCapturing("gog slides format-text P1 sh1") + #expect(run.exitStatus == ExitStatus(2)) + #expect(run.stderr.contains("at least one")) + } + + @Test func slidesFormatTextParsesForegroundHex() async throws { + let shell = Shell() + shell.registerGogCommands() + let transport = RecordingTransport( + response: HTTPResponse(status: 200, body: Data("{}".utf8))) + let run = try await GogTransportProvider.$current.withValue(transport) { + try await GogCredentials.$current.withValue( + StubProvider(token: "t", accountHint: nil) + ) { + try await shell.runCapturing("gog slides format-text P1 sh1 --foreground FF0000") + } + } + #expect(run.exitStatus == .success) + // Decode so numeric checks don't depend on float formatting. + struct Body: Decodable { + struct R: Decodable { + struct U: Decodable { + struct S: Decodable { + struct C: Decodable { + struct O: Decodable { + struct Rgb: Decodable { let red: Double; let green: Double; let blue: Double } + let rgbColor: Rgb + } + let opaqueColor: O + } + let foregroundColor: C + } + let style: S + let fields: String + } + let updateTextStyle: U + } + let requests: [R] + } + let u = try JSONDecoder().decode(Body.self, from: transport.lastBody ?? Data()) + .requests.first!.updateTextStyle + let rgb = u.style.foregroundColor.opaqueColor.rgbColor + #expect(rgb.red == 1 && rgb.green == 0 && rgb.blue == 0) // FF0000 + #expect(u.fields == "foregroundColor") + } + + @Test func slidesFormatTextRejectsBadHex() async throws { + let shell = Shell() + shell.registerGogCommands() + let run = try await shell.runCapturing("gog slides format-text P1 sh1 --foreground ZZ") + #expect(run.exitStatus == ExitStatus(2)) + #expect(run.stderr.contains("hex")) + } + + @Test func slidesFormatTextIntoCellEncodesCellLocation() async throws { + let shell = Shell() + shell.registerGogCommands() + let transport = RecordingTransport( + response: HTTPResponse(status: 200, body: Data("{}".utf8))) + let run = try await GogTransportProvider.$current.withValue(transport) { + try await GogCredentials.$current.withValue( + StubProvider(token: "t", accountHint: nil) + ) { + try await shell.runCapturing("gog slides format-text P1 tbl1 --bold --row 1 --col 2") + } + } + #expect(run.exitStatus == .success) + let body = String(decoding: transport.lastBody ?? Data(), as: UTF8.self) + #expect(body.contains("cellLocation")) + #expect(body.contains(#""rowIndex":1"#) && body.contains(#""columnIndex":2"#)) + } + @Test func docsInsertImagePostsInsertInlineImage() async throws { let shell = Shell() shell.registerGogCommands() From 9c52744c6d611bd5b61b5bcf9f527c257d570dce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 04:15:02 +0000 Subject: [PATCH 2/3] slides format-text: reject signed hex and negative cell indices (Gemini on #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --foreground now requires all hex digits (allSatisfy(\.isHexDigit)) so a signed value like "-F0000" — which Int(_:radix:) would accept and turn into a wrong color — is rejected. - --row/--col reject negative indices up front instead of sending an invalid cellLocation to the API. Adds a test for each. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JGWAxXq3PZatbdBR4P5RrA --- Sources/GogCommands/GogCommand.swift | 9 ++++++++- Tests/GogShellTests/GogWiringTests.swift | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Sources/GogCommands/GogCommand.swift b/Sources/GogCommands/GogCommand.swift index 2f8856e..adee216 100644 --- a/Sources/GogCommands/GogCommand.swift +++ b/Sources/GogCommands/GogCommand.swift @@ -4445,7 +4445,10 @@ struct SlidesFormatText: AsyncParsableCommand { var rgb: (r: Double, g: Double, b: Double)? if let foreground { let hex = foreground.hasPrefix("#") ? String(foreground.dropFirst()) : foreground - guard hex.count == 6, let v = Int(hex, radix: 16) else { + // allSatisfy(isHexDigit) rejects a leading sign — Int(_:radix:) would + // otherwise accept "-F0000"/"+F0000" and yield a wrong color. + guard hex.count == 6, hex.allSatisfy(\.isHexDigit), + let v = Int(hex, radix: 16) else { Shell.bashCurrent.stderr("gog: --foreground must be a 6-digit hex RGB, e.g. FF0000\n") throw ExitCode(2) } @@ -4463,6 +4466,10 @@ struct SlidesFormatText: AsyncParsableCommand { Shell.bashCurrent.stderr("gog: --row and --col must be given together (table cell)\n") throw ExitCode(2) } + if let row, let col, !(row >= 0 && col >= 0) { + Shell.bashCurrent.stderr("gog: --row and --col must be non-negative\n") + throw ExitCode(2) + } struct Batch: Encodable { struct Request: Encodable { struct UpdateTextStyle: Encodable { diff --git a/Tests/GogShellTests/GogWiringTests.swift b/Tests/GogShellTests/GogWiringTests.swift index d5e7b7c..3da25ac 100644 --- a/Tests/GogShellTests/GogWiringTests.swift +++ b/Tests/GogShellTests/GogWiringTests.swift @@ -3407,6 +3407,24 @@ extension Trait where Self == WriteTierTrait { #expect(body.contains(#""rowIndex":1"#) && body.contains(#""columnIndex":2"#)) } + @Test func slidesFormatTextRejectsSignedHex() async throws { + let shell = Shell() + shell.registerGogCommands() + // A signed 6-char value parses as an Int but isn't a valid color; + // --foreground=-F0000 (equals form) so the value isn't parsed as a flag. + let run = try await shell.runCapturing("gog slides format-text P1 sh1 --foreground=-F0000") + #expect(run.exitStatus == ExitStatus(2)) + #expect(run.stderr.contains("hex")) + } + + @Test func slidesFormatTextRejectsNegativeCell() async throws { + let shell = Shell() + shell.registerGogCommands() + let run = try await shell.runCapturing("gog slides format-text P1 tbl1 --bold --row=-1 --col 0") + #expect(run.exitStatus == ExitStatus(2)) + #expect(run.stderr.contains("non-negative")) + } + @Test func docsInsertImagePostsInsertInlineImage() async throws { let shell = Shell() shell.registerGogCommands() From cacf7ca720d8c0ad8e4ab9b4c5129357e11f85a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 04:15:02 +0000 Subject: [PATCH 3/3] ci: resolve libswiftCompatibilitySpan.dylib for `swift test` on Xcode 26.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macos-latest bumped to Xcode 26.0.1, whose test runner dlopens the xctest bundle without the toolchain lib dir on its dyld path, so it can't load the Span back- deployment shim (libswiftCompatibilitySpan.dylib, linked for our macOS 13 target) and the suite aborts before running. The earlier --no-parallel change did NOT fix this — the parallel helper isn't the cause. Locate the shim in the active toolchain and add its dir to DYLD_FALLBACK_LIBRARY_PATH (with an echo that says so if it's not found). Sibling repos' green CI predates the 26.0.1 runner bump, so there was no config difference to copy — they'd hit the same failure if re-run. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JGWAxXq3PZatbdBR4P5RrA --- .github/workflows/ci.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c144a53..41f9a2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,12 +84,20 @@ jobs: working-directory: SwiftGog run: swift build --build-tests -v - # Run tests serially. SwiftPM's parallel runner (swiftpm_testing_helper) - # dlopens the .xctest bundle from a process whose dyld search path omits the - # toolchain's swift lib dir, so on the Xcode 26 / Swift 6.2 runner it fails to - # load libswiftCompatibilitySpan.dylib (a Span back-deployment shim) and the - # whole suite aborts before running. --no-parallel uses the normal xctest path, - # which resolves the dylib. Mirrors SwiftBash's own macOS CI. + # macos-latest now ships Xcode 26.0.1, whose `swift test` runner dlopens the + # xctest bundle without the toolchain lib dir on its dyld search path, so it + # can't load libswiftCompatibilitySpan.dylib — a Span back-deployment shim + # linked for our macOS 13 target — and the suite aborts before any test runs. + # Locate that shim in the active toolchain and put its directory on + # DYLD_FALLBACK_LIBRARY_PATH so the bundle resolves it. The echo is a + # self-diagnostic: if the shim isn't found, the log says so. (--no-parallel + # keeps the task-local-based tests serial.) - name: Test working-directory: SwiftGog - run: swift test --skip-build --no-parallel + run: | + span="$(find "$(xcode-select -p)/Toolchains" -name libswiftCompatibilitySpan.dylib 2>/dev/null | head -1)" + echo "libswiftCompatibilitySpan.dylib: ${span:-}" + if [ -n "$span" ]; then + export DYLD_FALLBACK_LIBRARY_PATH="$(dirname "$span"):${DYLD_FALLBACK_LIBRARY_PATH:-}" + fi + swift test --skip-build --no-parallel