diff --git a/README.md b/README.md index 66b18a4..cf96111 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,20 @@ apple-docs types view URLSession.AsyncBytes --technology Foundation apple-docs types view URLSession/AsyncBytes --technology Foundation ``` +### Agent output + +Use `--agent` on documentation commands for Markdown with explicit technology, paths, links, and safely quoted follow-up commands: + +```bash +apple-docs types view String --technology Swift --agent +apple-docs types search Button --technology SwiftUI --agent +apple-docs technologies list --agent +``` + +Agent output uses the same normalized documentation content as human text. Follow-up commands are included only for destinations the CLI can represent safely. All commands remain one-shot, even when run in a terminal. + +`--agent` no longer aliases `--json`. Existing scripts that require JSON should use `--json`, which takes precedence if both flags are supplied. + ### JSON output The documentation commands accept `--json`, but their output contracts differ: diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift index dd0ba81..df0c1e9 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift @@ -2,28 +2,23 @@ import ArgumentParser struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions + @OptionGroup var output: OutputOptions static let configuration = CommandConfiguration( commandName: "list", abstract: "List Apple documentation technologies." ) - @Flag( - name: [.long, .customLong("agent")], - help: "Output a JSON array of technologies. --agent currently aliases --json." - ) - var json = false - mutating func run() async throws { try await run(telemetry: Dependencies.telemetry) } func run(telemetry: Telemetry) async throws { - let context = TelemetryCommandContext.technologiesList(json: json) + let context = TelemetryCommandContext.technologiesList(json: output.json) telemetry.startCommand(context) let result = try await TechnologiesListCommandRunner( client: Dependencies.documentationClient, - renderer: Dependencies.technologyListRenderer(json: json) + renderer: Dependencies.technologyListRenderer(output: output) ).run() telemetry.record(.technologyCatalog(count: result.technologyCount), context: context) print(result.output) diff --git a/Sources/CLI/cmd/types/TypesListCommand.swift b/Sources/CLI/cmd/types/TypesListCommand.swift index 4c83f60..8cf3b5a 100644 --- a/Sources/CLI/cmd/types/TypesListCommand.swift +++ b/Sources/CLI/cmd/types/TypesListCommand.swift @@ -2,6 +2,7 @@ import ArgumentParser struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions + @OptionGroup var output: OutputOptions static let configuration = CommandConfiguration( commandName: "list", @@ -11,22 +12,16 @@ struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { @Option(help: "The framework or technology whose types to list.") var technology: String - @Flag( - name: [.long, .customLong("agent")], - help: "Output a JSON array of types. --agent currently aliases --json." - ) - var json = false - mutating func run() async throws { try await run(telemetry: Dependencies.telemetry) } func run(telemetry: Telemetry) async throws { - let context = TelemetryCommandContext.typesList(technology: technology, json: json) + let context = TelemetryCommandContext.typesList(technology: technology, json: output.json) telemetry.startCommand(context) let result = try await TypesListCommandRunner( client: Dependencies.documentationClient, - renderer: Dependencies.documentationTypeListRenderer(json: json) + renderer: Dependencies.documentationTypeListRenderer(output: output, technology: technology) ).run(technology: technology) telemetry.record(.typeCatalog(count: result.typeCount), context: context) print(result.output) diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index 386f04e..c9a9de1 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -3,6 +3,7 @@ import Foundation struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions + @OptionGroup var output: OutputOptions static let configuration = CommandConfiguration( commandName: "search", @@ -15,23 +16,17 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { @Option(help: "The framework or technology whose types to search.") var technology: String - @Flag( - name: [.long, .customLong("agent")], - help: "Output a JSON array of matching types. --agent currently aliases --json." - ) - var json = false - mutating func run() async throws { try await run(telemetry: Dependencies.telemetry) } func run(telemetry: Telemetry) async throws { // Search text can be user-authored, so it is deliberately excluded from telemetry context. - let context = TelemetryCommandContext.typesSearch(technology: technology, json: json) + let context = TelemetryCommandContext.typesSearch(technology: technology, json: output.json) telemetry.startCommand(context) let result = try await TypesSearchCommandRunner( client: Dependencies.documentationClient, - renderer: Dependencies.documentationTypeListRenderer(json: json) + renderer: Dependencies.documentationTypeListRenderer(output: output, technology: technology) ).run(query: query, technology: technology) telemetry.record(.typeSearch(matches: result.matchCount), context: context) if result.unavailableCollectionCount > 0 { diff --git a/Sources/CLI/cmd/types/TypesViewCommand.swift b/Sources/CLI/cmd/types/TypesViewCommand.swift index f07f004..1cd49e1 100644 --- a/Sources/CLI/cmd/types/TypesViewCommand.swift +++ b/Sources/CLI/cmd/types/TypesViewCommand.swift @@ -2,6 +2,7 @@ import ArgumentParser struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions + @OptionGroup var output: OutputOptions static let configuration = CommandConfiguration( commandName: "view", @@ -14,22 +15,16 @@ struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { @Option(help: "The framework or technology containing the type.") var technology: String - @Flag( - name: [.long, .customLong("agent")], - help: "Output the raw Apple DocC JSON document. --agent currently aliases --json." - ) - var json = false - mutating func run() async throws { try await run(telemetry: Dependencies.telemetry) } func run(telemetry: Telemetry) async throws { - let context = TelemetryCommandContext.typesView(name: name, technology: technology, json: json) + let context = TelemetryCommandContext.typesView(name: name, technology: technology, json: output.json) telemetry.startCommand(context) let result = try await TypesViewCommandRunner( client: Dependencies.documentationClient, - renderer: Dependencies.documentationRenderer(json: json) + renderer: Dependencies.documentationRenderer(output: output) ).run(name: name, technology: technology) telemetry.record(.typeView(responseBytes: result.responseByteCount), context: context) print(result.output) diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index e6f7e03..a689cc7 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -61,27 +61,18 @@ enum Dependencies { AgentSkillInstaller(logger: Logger(label: "com.techprimate.apple-docs.skills.installer")) } - static func documentationRenderer( - json: Bool - ) -> DefaultTypeDocumentationRenderer { - DefaultTypeDocumentationRenderer( - output: json ? .json : .text - ) + static func documentationRenderer(output: OutputOptions) -> DefaultTypeDocumentationRenderer { + DefaultTypeDocumentationRenderer(output: output.format, audience: output.audience) } static func documentationTypeListRenderer( - json: Bool + output: OutputOptions, technology: String ) -> DefaultDocumentationTypeListRenderer { DefaultDocumentationTypeListRenderer( - output: json ? .json : .table - ) + output: output.json ? .json : .table, audience: output.audience, technology: technology) } - static func technologyListRenderer( - json: Bool - ) -> DefaultTechnologyListRenderer { - DefaultTechnologyListRenderer( - output: json ? .json : .table - ) + static func technologyListRenderer(output: OutputOptions) -> DefaultTechnologyListRenderer { + DefaultTechnologyListRenderer(output: output.json ? .json : .table, audience: output.audience) } } diff --git a/Sources/CLI/main/OutputOptions.swift b/Sources/CLI/main/OutputOptions.swift new file mode 100644 index 0000000..218a734 --- /dev/null +++ b/Sources/CLI/main/OutputOptions.swift @@ -0,0 +1,20 @@ +import ArgumentParser + +enum OutputAudience: String, Codable, Equatable, Sendable { + case human, agent +} + +enum OutputFormat: Equatable, Sendable { + case text, json +} + +struct OutputOptions: ParsableArguments { + @Flag(help: "Print JSON. Page output preserves Apple's raw DocC document, even with --agent.") + var json = false + + @Flag(help: "Print agent-oriented Markdown with follow-up commands. --json takes precedence.") + var agent = false + + var audience: OutputAudience { agent && !json ? .agent : .human } + var format: OutputFormat { json ? .json : .text } +} diff --git a/Sources/CLI/renderer/AgentDocumentationRenderer.swift b/Sources/CLI/renderer/AgentDocumentationRenderer.swift new file mode 100644 index 0000000..919960b --- /dev/null +++ b/Sources/CLI/renderer/AgentDocumentationRenderer.swift @@ -0,0 +1,84 @@ +import Foundation + +struct AgentDocumentationRenderer: Sendable { + private let content = DocumentationContentRenderer(audience: .agent) + + func render(_ presentation: PagePresentation) -> String { + let page = presentation.document + var sections = [ + "# " + content.markdown(page.title), + "Technology: " + content.inlineCode(page.destination.technology) + + "\nPath: " + content.inlineCode(page.destination.path) + + "\nKind: " + content.markdown(page.kind) + + "\nURL: " + page.url.absoluteString, + ] + if !page.modules.isEmpty { + sections.append("Modules: " + page.modules.map(content.markdown).joined(separator: ", ")) + } + if let navigation = presentation.navigation { sections.append(command(navigation)) } + append("Summary", content.inline(page.abstract), to: §ions) + append("Deprecated", content.blocks(page.deprecation), to: §ions) + append( + "Declaration", + page.declarations.map { + content.code($0.text.components(separatedBy: "\n"), language: $0.languages.first) + }.joined(separator: "\n\n"), to: §ions) + append( + "Availability", + page.availability.map { + "- " + content.markdown($0.name) + ": " + content.availability($0) + }.joined(separator: "\n"), to: §ions) + append("Content", content.blocks(page.content), to: §ions) + append("Relationships", groups(presentation.relationships), to: §ions) + append("Topics", groups(presentation.topics), to: §ions) + append("See Also", groups(presentation.seeAlso), to: §ions) + return terminalSafeText(sections.joined(separator: "\n\n")) + } + + func render(_ symbols: [SymbolPresentation], technology: String) -> String { + let header = "# Symbols\n\nTechnology: " + content.inlineCode(technology) + let entries = symbols.map { symbol in + var parts = [ + "## " + content.markdown(symbol.name), "Kind: " + content.markdown(symbol.kind), + "Path: " + content.inlineCode(symbol.path), "URL: " + symbol.url, + ] + if let navigation = symbol.navigation { parts.append(command(navigation)) } + return parts.joined(separator: "\n\n") + } + return terminalSafeText( + ([header] + (entries.isEmpty ? ["No symbols found."] : entries)).joined(separator: "\n\n")) + } + + func render(_ technologies: [TechnologyPresentation]) -> String { + let entries = technologies.map { technology in + var parts = [ + "## " + content.markdown(technology.name), "Identifier: " + content.inlineCode(technology.identifier), + ] + if let navigation = technology.navigation { parts.append(command(navigation)) } + return parts.joined(separator: "\n\n") + } + return terminalSafeText((["# Technologies"] + entries).joined(separator: "\n\n")) + } + + private func append(_ title: String, _ body: String, to sections: inout [String]) { + if !body.isEmpty { sections.append("## " + title + "\n\n" + body) } + } + + private func groups(_ groups: [GroupPresentation]) -> String { + groups.map { group in + let entries = group.references.map { item in + let reference = item.reference + var parts = [content.link(content.markdown(reference.title), target: reference.target)] + let abstract = content.inline(reference.abstract) + if !abstract.isEmpty { parts.append(abstract) } + if let navigation = item.navigation { parts.append(command(navigation)) } + return parts.joined(separator: "\n\n") + } + return (["### " + content.markdown(group.title)] + entries).joined(separator: "\n\n") + }.joined(separator: "\n\n") + } + + private func command(_ navigation: AgentNavigation) -> String { + content.code([navigation.command], language: "sh") + } +} diff --git a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift index 242a0a0..a37a006 100644 --- a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift +++ b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift @@ -7,15 +7,23 @@ struct DefaultDocumentationTypeListRenderer: Sendable { } private let output: Output + private let audience: OutputAudience + private let technology: String - init(output: Output) { + init(output: Output, audience: OutputAudience = .human, technology: String = "") { self.output = output + self.audience = audience + self.technology = technology } func render(_ types: [DocumentationType]) throws -> String { switch output { case .table: - return renderTable(types) + if audience == .agent { + let presentation = DocumentationPresenter().symbols(types, technology: technology, audience: .agent) + return AgentDocumentationRenderer().render(presentation, technology: technology) + } + return terminalSafeText(renderTable(types)) case .json: let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] diff --git a/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift b/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift index d64a3a2..60ed978 100644 --- a/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift +++ b/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift @@ -7,15 +7,21 @@ struct DefaultTechnologyListRenderer: Sendable { } private let output: Output + private let audience: OutputAudience - init(output: Output) { + init(output: Output, audience: OutputAudience = .human) { self.output = output + self.audience = audience } func render(_ technologies: [Technology]) throws -> String { switch output { case .table: - return renderTable(technologies) + if audience == .agent { + let presentation = DocumentationPresenter().technologies(technologies, audience: .agent) + return AgentDocumentationRenderer().render(presentation) + } + return terminalSafeText(renderTable(technologies)) case .json: let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] diff --git a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift index 98412e5..1b67f52 100644 --- a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift @@ -1,22 +1,17 @@ struct DefaultTypeDocumentationRenderer: Sendable { - enum Output: Sendable { - case text - case json - } - - private let output: Output + typealias Output = OutputFormat - init(output: Output) { - self.output = output - } + let output: Output + var audience: OutputAudience = .human func render(_ document: TypeDocumentationDocument) throws -> String { - switch output { - case .text: - let page = try DocumentationPageDecoder().decode(document.data, destination: document.destination) - return TextTypeDocumentationRenderer().render(page) - case .json: + if output == .json { return RawJSONTypeDocumentationRenderer().render(document) } + let page = try DocumentationPageDecoder().decode(document.data, destination: document.destination) + let presentation = DocumentationPresenter().page(page, audience: audience) + return audience == .agent + ? AgentDocumentationRenderer().render(presentation) + : TextTypeDocumentationRenderer().render(page) } } diff --git a/Sources/CLI/renderer/DocumentationContentRenderer.swift b/Sources/CLI/renderer/DocumentationContentRenderer.swift index b90d556..24b7e3e 100644 --- a/Sources/CLI/renderer/DocumentationContentRenderer.swift +++ b/Sources/CLI/renderer/DocumentationContentRenderer.swift @@ -1,14 +1,16 @@ import Foundation struct DocumentationContentRenderer { + let audience: OutputAudience private let layout = DocumentationTextLayout() func inline(_ content: [DocumentationInline]) -> String { content.map { item in switch item { - case .text(let text): return text - case .code(let code): return "`\(code)`" - case .link(let label, _): return inline(label) + case .text(let text): return audience == .agent ? markdown(text) : text + case .code(let code): return audience == .agent ? inlineCode(code) : "`\(code)`" + case .link(let label, let target): + return audience == .agent ? link(inline(label), target: target) : inline(label) } }.joined() } @@ -18,7 +20,17 @@ struct DocumentationContentRenderer { } func code(_ lines: [String], language: String?, indent: String = "") -> String { - layout.codeBlock(lines, language: language, indent: indent) + if audience == .human { return layout.codeBlock(lines, language: language, indent: indent) } + let text = lines.joined(separator: "\n") + let fence = String(repeating: "`", count: max(3, longestBacktickRun(text) + 1)) + // A language hint is a single Markdown info-string token, not arbitrary source text. + let syntax = (language ?? "").filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "+" } + return "\(fence)\(syntax)\n\(text)\n\(fence)" + } + + func link(_ label: String, target: DocumentationLinkTarget) -> String { + guard let url = url(for: target) else { return label } + return "[\(label)](<\(url)>)" } func url(for target: DocumentationLinkTarget) -> String? { @@ -29,13 +41,31 @@ struct DocumentationContentRenderer { } } + func markdown(_ text: String) -> String { + text.reduce(into: "") { result, character in + if "\\`*_[]<>#".contains(character) { result.append("\\") } + result.append(character) + } + } + + func inlineCode(_ text: String) -> String { + let fence = String(repeating: "`", count: longestBacktickRun(text) + 1) + let padding = text.hasPrefix("`") || text.hasSuffix("`") ? " " : "" + return fence + padding + text + padding + fence + } + func availability(_ platform: DocumentationAvailability) -> String { var parts: [String] = [] - switch (platform.introducedAt, platform.deprecatedAt) { - case (let introduced?, let deprecated?): parts.append("\(introduced)–\(deprecated)") - case (let introduced?, nil): parts.append("\(introduced)+") - case (nil, let deprecated?): parts.append("Until \(deprecated)") - case (nil, nil): break + if audience == .human { + switch (platform.introducedAt, platform.deprecatedAt) { + case (let introduced?, let deprecated?): parts.append("\(introduced)–\(deprecated)") + case (let introduced?, nil): parts.append("\(introduced)+") + case (nil, let deprecated?): parts.append("Until \(deprecated)") + case (nil, nil): break + } + } else { + if let version = platform.introducedAt { parts.append("introduced \(version)") } + if let version = platform.deprecatedAt { parts.append("deprecated \(version)") } } if let version = platform.obsoletedAt { parts.append("obsoleted \(version)") } if platform.isBeta { parts.append("beta") } @@ -45,25 +75,49 @@ struct DocumentationContentRenderer { private func block(_ block: DocumentationBlock, indent: String) -> String { switch block { - case .paragraph(let content): return layout.paragraph(inline(content), indent: indent) - case .heading(let text): return layout.heading(text) + case .paragraph(let content): + let text = inline(content) + return audience == .human ? layout.paragraph(text, indent: indent) : text + case .heading(let text): return audience == .human ? layout.heading(text) : "### " + markdown(text) case .codeListing(let lines, let language): return code(lines, language: language, indent: indent) case .orderedList(let items, let start): return list(items, start: start, indent: indent) case .unorderedList(let items): return list(items, start: nil, indent: indent) case .aside(let content, let style, let name): - return indent + (name ?? style.capitalized) + "\n" + blocks(content, indent: indent + "│ ") + if audience == .human { + return indent + (name ?? style.capitalized) + "\n" + blocks(content, indent: indent + "│ ") + } + let body = ([markdown(name ?? style.capitalized)] + [blocks(content)]) + .joined(separator: "\n").components(separatedBy: "\n") + return body.map { "> " + $0 }.joined(separator: "\n") } } private func list(_ items: [[DocumentationBlock]], start: Int?, indent: String) -> String { items.enumerated().map { index, content in - let marker = start.map { "\($0 + index). " } ?? "• " + let marker = start.map { "\($0 + index). " } ?? (audience == .human ? "• " : "- ") let continuation = indent + String(repeating: " ", count: marker.count) - let body = blocks(content, indent: continuation) - if body.hasPrefix(continuation) { return indent + marker + body.dropFirst(continuation.count) } - return indent + marker + "\n" + body + if audience == .human { + let body = blocks(content, indent: continuation) + if body.hasPrefix(continuation) { return indent + marker + body.dropFirst(continuation.count) } + return indent + marker + "\n" + body + } + let body = blocks(content).components(separatedBy: "\n") + return marker + (body.first ?? "") + + body.dropFirst().map { + $0.isEmpty ? "\n" : "\n" + String(repeating: " ", count: marker.count) + $0 + }.joined() }.joined(separator: "\n") } + + private func longestBacktickRun(_ text: String) -> Int { + var longest = 0 + var current = 0 + for character in text { + current = character == "`" ? current + 1 : 0 + longest = max(longest, current) + } + return longest + } } func terminalSafeText(_ text: String) -> String { diff --git a/Sources/CLI/renderer/DocumentationPresentation.swift b/Sources/CLI/renderer/DocumentationPresentation.swift new file mode 100644 index 0000000..a8c571e --- /dev/null +++ b/Sources/CLI/renderer/DocumentationPresentation.swift @@ -0,0 +1,39 @@ +struct AgentNavigation: Equatable, Sendable { + let technology: String + let path: String? + let command: String +} + +struct PagePresentation: Sendable { + let document: DocumentationPage + let audience: OutputAudience + let navigation: AgentNavigation? + let relationships: [GroupPresentation] + let topics: [GroupPresentation] + let seeAlso: [GroupPresentation] +} + +struct GroupPresentation: Sendable { + let id: String + let title: String + let references: [ReferencePresentation] +} + +struct ReferencePresentation: Sendable { + let reference: DocumentationReference + let navigation: AgentNavigation? +} + +struct SymbolPresentation: Sendable { + let name: String + let kind: String + let path: String + let url: String + let navigation: AgentNavigation? +} + +struct TechnologyPresentation: Sendable { + let name: String + let identifier: String + let navigation: AgentNavigation? +} diff --git a/Sources/CLI/renderer/DocumentationPresenter.swift b/Sources/CLI/renderer/DocumentationPresenter.swift new file mode 100644 index 0000000..e599235 --- /dev/null +++ b/Sources/CLI/renderer/DocumentationPresenter.swift @@ -0,0 +1,93 @@ +import Foundation + +struct DocumentationPresenter: Sendable { + func page(_ page: DocumentationPage, audience: OutputAudience) -> PagePresentation { + PagePresentation( + document: page, audience: audience, + navigation: audience == .agent ? navigation(for: page.destination) : nil, + relationships: groups(page.relationships, audience: audience), + topics: groups(page.topics, audience: audience), + seeAlso: groups(page.seeAlso, audience: audience) + ) + } + + func symbols( + _ symbols: [DocumentationType], technology: String, audience: OutputAudience + ) -> [SymbolPresentation] { + let root = DocumentationDestination( + technology: technology.lowercased(), path: "/documentation/\(technology.lowercased())" + ) + return symbols.map { symbol in + let target = try? DocumentationDestination.resolve(symbol.url, relativeTo: root) + return SymbolPresentation( + name: symbol.name, kind: symbol.kind, path: symbol.path, url: symbol.url, + navigation: audience == .agent ? target.flatMap(navigation(for:)) : nil + ) + } + } + + func technologies(_ technologies: [Technology], audience: OutputAudience) -> [TechnologyPresentation] { + technologies.map { technology in + TechnologyPresentation( + name: technology.name, identifier: technology.identifier, + navigation: audience == .agent ? technologyNavigation(technology.identifier) : nil + ) + } + } + + private func groups(_ groups: [DocumentationGroup], audience: OutputAudience) -> [GroupPresentation] { + groups.map { group in + GroupPresentation( + id: group.id, title: group.title, + references: group.references.map { + ReferencePresentation( + reference: $0, navigation: audience == .agent ? navigation(for: $0.target) : nil + ) + } + ) + } + } + + private func technologyNavigation(_ identifier: String) -> AgentNavigation? { + guard let components = URLComponents(string: identifier) else { return nil } + let raw = components.scheme == "doc" ? components.path : identifier + let base = DocumentationDestination(technology: "", path: "/documentation") + guard let target = try? DocumentationDestination.resolve(raw, relativeTo: base), + case .documentation(let destination) = target, + destination.path.split(separator: "/").count == 2 + else { return nil } + return navigation(for: destination) + } + + private func navigation(for target: DocumentationLinkTarget) -> AgentNavigation? { + guard case .documentation(let destination) = target else { return nil } + return navigation(for: destination) + } + + private func navigation(for destination: DocumentationDestination) -> AgentNavigation? { + let components = destination.path.split(separator: "/") + guard components.count >= 2, components[0] == "documentation", + components[1].lowercased() == destination.technology + else { return nil } + let technology = shellQuote(destination.technology) + if components.count == 2 { + return AgentNavigation( + technology: destination.technology, path: nil, + command: "apple-docs types list --technology \(technology) --agent" + ) + } + let path = components.dropFirst(2).joined(separator: "/") + // Named CLI input interprets dots as Swift hierarchy separators and lowercases paths. + // Do not advertise an exact-link command that that input path cannot round-trip. + guard !path.contains("."), path == path.lowercased() else { return nil } + let command = + path.hasPrefix("-") + ? "apple-docs types view --technology \(technology) --agent -- \(shellQuote(path))" + : "apple-docs types view \(shellQuote(path)) --technology \(technology) --agent" + return AgentNavigation(technology: destination.technology, path: path, command: command) + } +} + +func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" +} diff --git a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift index becdbe2..06c22aa 100644 --- a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift @@ -2,7 +2,7 @@ import Foundation struct TextTypeDocumentationRenderer: Sendable { private let layout = DocumentationTextLayout() - private let content = DocumentationContentRenderer() + private let content = DocumentationContentRenderer(audience: .human) func render(_ page: DocumentationPage) -> String { let metadata = ([page.roleHeading ?? page.kind.capitalized] + page.modules) diff --git a/Sources/CLI/skills/BundledAgentSkills.swift b/Sources/CLI/skills/BundledAgentSkills.swift index 2e71245..55f6f3c 100644 --- a/Sources/CLI/skills/BundledAgentSkills.swift +++ b/Sources/CLI/skills/BundledAgentSkills.swift @@ -47,7 +47,7 @@ enum BundledAgentSkills { 1. Identify the framework, symbol or behavior, target OS, deployment version, and Swift language constraints. Use project context when available. Ask only when a missing constraint changes the answer. - 2. If the framework is unknown, run `apple-docs technologies list`. If the symbol is unknown, use the + 2. If the framework is unknown, run `apple-docs technologies list --agent`. If the symbol is unknown, use the `apple-docs-discover-api` skill or the discovery commands below. 3. Retrieve the relevant type and, when necessary, its specific member pages. Read the declaration, overview, availability, and caveats before recommending code. @@ -59,11 +59,11 @@ enum BundledAgentSkills { Commands are stateless. Always pass `--technology` to `types` commands, even after an earlier lookup. ```bash - apple-docs technologies list - apple-docs types list --technology Foundation - apple-docs types search URLSession --technology Foundation - apple-docs types view URLSession --technology Foundation - apple-docs types view URLSession.AsyncBytes --technology Foundation + apple-docs technologies list --agent + apple-docs types list --technology Foundation --agent + apple-docs types search URLSession --technology Foundation --agent + apple-docs types view URLSession --technology Foundation --agent + apple-docs types view URLSession.AsyncBytes --technology Foundation --agent ``` `types list` returns symbols referenced directly by a curated technology root. `types search` matches symbol @@ -75,14 +75,14 @@ enum BundledAgentSkills { From a returned `/documentation/foundation/...` URL, pass only the part after `/documentation/foundation/`. Quote paths containing parentheses or other shell metacharacters. Do not pass a full URL as the type argument. - Text output includes available summaries, Swift declarations, availability, relationships, topics, and links. + Text output includes available summaries, declarations, availability, relationships, topics, and links. Follow Topics and See Also links to inspect member behavior, rather than extrapolating from a type. If a linked API belongs to another technology, change `--technology` accordingly. ## Structured evidence - On JSON-capable commands, `--agent` currently aliases `--json`. It is not a global flag or auto-detected. - Agent output may evolve. Keep `types view --json` for raw upstream bytes. + Use `--agent` for complete normalized Markdown and safely quoted commands for supported destinations. + `--json` takes precedence and retains raw DocC. Neither flag is global or auto-detected. ```bash apple-docs technologies list --agent @@ -91,7 +91,7 @@ enum BundledAgentSkills { ``` Technology, list, and search JSON are CLI-produced arrays. `types view --json` preserves Apple's raw DocC - response bytes. Inspect it when the text view omits detail or a non-Swift declaration is needed. Useful sections + response bytes. Inspect it when the normalized text view omits upstream detail. Useful sections include `metadata`, `primaryContentSections`, `topicSections`, `references`, and `variants`. Fields vary. Resolve topic identifiers through `references` to find member URLs. A missing field is not a guarantee. @@ -149,7 +149,7 @@ enum BundledAgentSkills { ## Discover the technology ```bash - apple-docs technologies list + apple-docs technologies list --agent apple-docs technologies list --json ``` @@ -160,8 +160,8 @@ enum BundledAgentSkills { ## Find candidate symbols ```bash - apple-docs types list --technology SwiftUI - apple-docs types search Button --technology SwiftUI + apple-docs types list --technology SwiftUI --agent + apple-docs types search Button --technology SwiftUI --agent apple-docs types search Button --technology SwiftUI --json ``` @@ -177,8 +177,8 @@ enum BundledAgentSkills { ## Inspect candidates before choosing ```bash - apple-docs types view Button --technology SwiftUI - apple-docs types view URLSession.AsyncBytes --technology Foundation + apple-docs types view Button --technology SwiftUI --agent + apple-docs types view URLSession.AsyncBytes --technology Foundation --agent ``` Prefer the exact `path` from a search result. For nested members, inspect the parent type's Topics or raw @@ -219,7 +219,7 @@ enum BundledAgentSkills { ## Retrieve the exact API ```bash - apple-docs types view URLSession.AsyncBytes --technology Foundation + apple-docs types view URLSession.AsyncBytes --technology Foundation --agent apple-docs types view URLSession.AsyncBytes --technology Foundation --json ``` diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift index 8c40544..de677d2 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift @@ -10,10 +10,10 @@ private let integrationTestsEnabled = .serialized ) struct AppleDocsCommandIntegrationTests { - @Test("returns Swift String documentation as JSON", arguments: ["--json", "--agent"]) - func returnsSwiftStringJSON(flag: String) throws { + @Test("returns Swift String documentation as JSON", arguments: [["--json"], ["--agent", "--json"]]) + func returnsSwiftStringJSON(flags: [String]) throws { // -- Arrange -- - let arguments = ["types", "view", "String", "--technology", "Swift", flag] + let arguments = ["types", "view", "String", "--technology", "Swift"] + flags // -- Act -- let output = try runAppleDocs(arguments) @@ -25,6 +25,27 @@ struct AppleDocsCommandIntegrationTests { #expect(document.metadata.symbolKind == "struct") } + @Test( + "agent output is Markdown with supported follow-up commands", + arguments: [ + (["types", "view", "String", "--technology", "Swift"], "# String"), + (["types", "list", "--technology", "MetricKit"], "# Symbols"), + (["types", "search", "Button", "--technology", "SwiftUI"], "# Symbols"), + (["technologies", "list"], "# Technologies"), + ]) + func rendersAgentMarkdown(arguments: [String], heading: String) throws { + // -- Arrange -- + let arguments = arguments + ["--agent"] + + // -- Act -- + let output = try runAppleDocs(arguments) + + // -- Assert -- + #expect(output.hasPrefix(heading + "\n")) + #expect(output.contains("--agent")) + #expect(!output.contains("\u{1B}")) + } + @Test("returns Foundation URL documentation as JSON") func returnsFoundationURLJSON() throws { // -- Arrange -- @@ -55,10 +76,10 @@ struct AppleDocsCommandIntegrationTests { #expect(output.contains("Overview\n────────")) } - @Test("lists MetricKit root types as JSON", arguments: ["--json", "--agent"]) - func listsMetricKitTypes(flag: String) throws { + @Test("lists MetricKit root types as JSON", arguments: [["--json"], ["--agent", "--json"]]) + func listsMetricKitTypes(flags: [String]) throws { // -- Arrange -- - let arguments = ["types", "list", "--technology", "MetricKit", flag] + let arguments = ["types", "list", "--technology", "MetricKit"] + flags // -- Act -- let output = try runAppleDocs(arguments) @@ -77,14 +98,14 @@ struct AppleDocsCommandIntegrationTests { ) } - @Test("searches SwiftUI collection groups as JSON", arguments: ["--json", "--agent"]) - func searchesSwiftUITypes(flag: String) throws { + @Test("searches SwiftUI collection groups as JSON", arguments: [["--json"], ["--agent", "--json"]]) + func searchesSwiftUITypes(flags: [String]) throws { // -- Arrange -- - let arguments = [ - "types", "search", "Button", - "--technology", "SwiftUI", - flag, - ] + let arguments = + [ + "types", "search", "Button", + "--technology", "SwiftUI", + ] + flags // -- Act -- let output = try runAppleDocs(arguments) @@ -120,10 +141,10 @@ struct AppleDocsCommandIntegrationTests { #expect(document.metadata.title == "URLSession.AsyncBytes") } - @Test("lists stable technologies as JSON", arguments: ["--json", "--agent"]) - func listsStableTechnologies(flag: String) throws { + @Test("lists stable technologies as JSON", arguments: [["--json"], ["--agent", "--json"]]) + func listsStableTechnologies(flags: [String]) throws { // -- Arrange -- - let arguments = ["technologies", "list", flag] + let arguments = ["technologies", "list"] + flags // -- Act -- let output = try runAppleDocs(arguments) diff --git a/Tests/CLITests/cmd/types/TypesViewCommandTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandTests.swift index f47cda7..cad2d8c 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandTests.swift @@ -23,7 +23,8 @@ struct TypesViewCommandTests { // -- Assert -- let listCommand = try #require(command as? TypesListCommand) #expect(listCommand.technology == "MetricKit") - #expect(listCommand.json) + #expect(listCommand.output.json == flags.contains("--json")) + #expect(listCommand.output.agent == flags.contains("--agent")) } @Test( @@ -46,7 +47,8 @@ struct TypesViewCommandTests { let searchCommand = try #require(command as? TypesSearchCommand) #expect(searchCommand.query == "Button") #expect(searchCommand.technology == "SwiftUI") - #expect(searchCommand.json) + #expect(searchCommand.output.json == flags.contains("--json")) + #expect(searchCommand.output.agent == flags.contains("--agent")) } @Test("accepts a type name and required technology option") @@ -64,7 +66,7 @@ struct TypesViewCommandTests { let viewCommand = try #require(command as? TypesViewCommand) #expect(viewCommand.name == "MXHangDiagnostic") #expect(viewCommand.technology == "MetricKit") - #expect(viewCommand.json == false) + #expect(viewCommand.output.json == false) } @Test( @@ -85,6 +87,7 @@ struct TypesViewCommandTests { // -- Assert -- let viewCommand = try #require(command as? TypesViewCommand) - #expect(viewCommand.json) + #expect(viewCommand.output.json == flags.contains("--json")) + #expect(viewCommand.output.agent == flags.contains("--agent")) } } diff --git a/Tests/CLITests/main/CLITests.swift b/Tests/CLITests/main/CLITests.swift index 8fab04d..4862001 100644 --- a/Tests/CLITests/main/CLITests.swift +++ b/Tests/CLITests/main/CLITests.swift @@ -111,7 +111,8 @@ struct CLITests { // -- Assert -- let listCommand = try #require(command as? TechnologiesListCommand) - #expect(listCommand.json) + #expect(listCommand.output.json == flags.contains("--json")) + #expect(listCommand.output.agent == flags.contains("--agent")) } @Test("reports complete build metadata") diff --git a/Tests/CLITests/main/OutputOptionsTests.swift b/Tests/CLITests/main/OutputOptionsTests.swift new file mode 100644 index 0000000..3f3ec48 --- /dev/null +++ b/Tests/CLITests/main/OutputOptionsTests.swift @@ -0,0 +1,28 @@ +import Testing + +@testable import CLI + +@Suite("Noninteractive output options") +struct OutputOptionsTests { + @Test( + "selects human text by default and separates audience from format", + arguments: [ + ([], OutputAudience.human, OutputFormat.text), + (["--agent"], .agent, .text), + (["--json"], .human, .json), + (["--agent", "--json"], .human, .json), + (["--json", "--agent"], .human, .json), + ]) + func selectsOutput(flags: [String], audience: OutputAudience, format: OutputFormat) throws { + // -- Arrange -- + let arguments = ["types", "view", "String", "--technology", "Swift"] + flags + + // -- Act -- + let command = try #require(CLI.parseAsRoot(arguments) as? TypesViewCommand) + + // -- Assert -- + #expect(command.output.audience == audience) + #expect(command.output.format == format) + } + +} diff --git a/Tests/CLITests/renderer/AgentDocumentationRendererTests.swift b/Tests/CLITests/renderer/AgentDocumentationRendererTests.swift new file mode 100644 index 0000000..468b77a --- /dev/null +++ b/Tests/CLITests/renderer/AgentDocumentationRendererTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Agent documentation text") +struct AgentDocumentationRendererTests { + @Test("renders full normalized Markdown with explicit paths and executable follow-up commands") + func rendersCompletePage() throws { + // -- Arrange -- + let destination = DocumentationDestination( + technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic/member(_:)" + ) + let page = try DocumentationPageDecoder().decode(DocumentationFixtures.member, destination: destination) + let presentation = DocumentationPresenter().page(page, audience: .agent) + + // -- Act -- + let output = AgentDocumentationRenderer().render(presentation) + + // -- Assert -- + #expect(output.contains("# member(\\_:)")) + #expect(output.contains("Technology: `metrickit`")) + #expect(output.contains("Path: `/documentation/metrickit/mxhangdiagnostic/member(_:)`")) + #expect(output.contains("## Summary\n\nRead `value` with [a string]")) + #expect(output.contains("```swift\nfunc member(_ value: String)\n```")) + #expect(output.contains("```occ\n- (void)member;\n```")) + #expect(output.contains("## Availability")) + #expect(output.contains("macOS: introduced 15.0, deprecated 26.0, obsoleted 27.0, beta, unavailable")) + #expect(output.contains("## Deprecated\n\nUse the replacement.")) + #expect(output.contains("3. First\n\n - Nested")) + #expect(output.contains("> Important\n> Take care.")) + #expect(output.contains("## Relationships\n\n### Conforms To")) + #expect(output.contains("## Topics\n\n### Children")) + #expect(output.contains("Text storage.")) + #expect(output.contains("apple-docs types view 'string' --technology 'swift' --agent")) + #expect(output.contains("## See Also\n\n### Guides")) + #expect(output.contains("https://example.com/guide")) + #expect(!output.contains("javascript:")) + } + + @Test("code fences cannot be closed by embedded backticks and remote controls are inert") + func rendersUntrustedCode() { + // -- Arrange -- + let page = DocumentationPage( + destination: DocumentationDestination(technology: "swift", path: "/documentation/swift/string"), + title: "String\u{001B}[2J", kind: "struct", + content: [.codeListing(code: ["```", "[value]", "\u{009B}31m"], syntax: "swift")] + ) + let presentation = DocumentationPresenter().page(page, audience: .agent) + + // -- Act -- + let output = AgentDocumentationRenderer().render(presentation) + + // -- Assert -- + #expect(output.contains("````swift\n```\n[value]\n31m\n````")) + #expect(!output.contains("\u{001B}")) + #expect(!output.contains("\u{009B}")) + } + + @Test("symbol and technology Markdown retain navigation and discovery fields") + func rendersDiscovery() { + // -- Arrange -- + let presenter = DocumentationPresenter() + let symbols = presenter.symbols( + [ + DocumentationType( + name: "String", kind: "struct", path: "string", + url: "https://developer.apple.com/documentation/swift/string") + ], technology: "Swift", audience: .agent) + let technologies = presenter.technologies( + [ + Technology(name: "Swift", identifier: "doc://swift/documentation/Swift") + ], audience: .agent) + + // -- Act -- + let symbolOutput = AgentDocumentationRenderer().render(symbols, technology: "Swift") + let technologyOutput = AgentDocumentationRenderer().render(technologies) + + // -- Assert -- + #expect(symbolOutput.contains("Technology: `Swift`")) + #expect(symbolOutput.contains("Kind: struct")) + #expect(symbolOutput.contains("Path: `string`")) + #expect(symbolOutput.contains("apple-docs types view 'string' --technology 'swift' --agent")) + #expect(technologyOutput.contains("doc://swift/documentation/Swift")) + #expect(technologyOutput.contains("apple-docs types list --technology 'swift' --agent")) + } +} diff --git a/Tests/CLITests/renderer/DocumentationPresentationTests.swift b/Tests/CLITests/renderer/DocumentationPresentationTests.swift new file mode 100644 index 0000000..f4b4091 --- /dev/null +++ b/Tests/CLITests/renderer/DocumentationPresentationTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Documentation audience presentations") +struct DocumentationPresentationTests { + @Test("audiences retain identical normalized content while agent references add navigation") + func preservesContentParity() throws { + // -- Arrange -- + let destination = DocumentationDestination( + technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic/member(_:)" + ) + let page = try DocumentationPageDecoder().decode(DocumentationFixtures.member, destination: destination) + let presenter = DocumentationPresenter() + + // -- Act -- + let human = presenter.page(page, audience: .human) + let agent = presenter.page(page, audience: .agent) + + // -- Assert -- + #expect(human.document == page) + #expect(agent.document == page) + #expect(human.navigation == nil) + #expect( + agent.navigation?.command + == "apple-docs types view 'mxhangdiagnostic/member(_:)' --technology 'metrickit' --agent") + #expect(human.topics[0].references[0].navigation == nil) + #expect( + agent.topics[0].references[0].navigation?.command + == "apple-docs types view 'string' --technology 'swift' --agent") + #expect(agent.seeAlso[0].references[0].navigation == nil) + } + + @Test("technology roots use the supported list command") + func presentsRootNavigation() { + // -- Arrange -- + let page = DocumentationPage( + destination: DocumentationDestination(technology: "swiftui", path: "/documentation/swiftui"), + title: "SwiftUI", kind: "collection" + ) + + // -- Act -- + let presentation = DocumentationPresenter().page(page, audience: .agent) + + // -- Assert -- + #expect(presentation.navigation?.path == nil) + #expect(presentation.navigation?.command == "apple-docs types list --technology 'swiftui' --agent") + } + + @Test("shell arguments preserve apostrophes, spaces, substitutions, and overload punctuation") + func quotesArguments() { + // -- Arrange -- + let input = "O'Brien `cmd` $(cmd) value(_:)" + + // -- Act -- + let quoted = shellQuote(input) + + // -- Assert -- + #expect(quoted == "'O'\\''Brien `cmd` $(cmd) value(_:)'") + } + + @Test("does not fabricate commands for external or unrepresentable symbol destinations") + func omitsUnsupportedCommands() { + // -- Arrange -- + let symbols = [ + DocumentationType(name: "External", kind: "symbol", path: "external", url: "https://example.com"), + DocumentationType( + name: "Exact dots", kind: "symbol", path: "member.with.dots", + url: "https://developer.apple.com/documentation/swift/member.with.dots"), + ] + + // -- Act -- + let presentations = DocumentationPresenter().symbols(symbols, technology: "Swift", audience: .agent) + + // -- Assert -- + #expect(presentations.allSatisfy { $0.navigation == nil }) + #expect(presentations.map(\.name) == ["External", "Exact dots"]) + } + + @Test("operator paths beginning with a dash follow the option terminator") + func quotesOperatorPath() { + // -- Arrange -- + let page = DocumentationPage( + destination: DocumentationDestination(technology: "swift", path: "/documentation/swift/-(_:_:)"), + title: "-(_:_:)", kind: "func" + ) + + // -- Act -- + let presentation = DocumentationPresenter().page(page, audience: .agent) + + // -- Assert -- + #expect( + presentation.navigation?.command == "apple-docs types view --technology 'swift' --agent -- '-(_:_:)'" + ) + } + + @Test("technology presentation retains identifiers and only adds supported root navigation") + func presentsTechnologies() { + // -- Arrange -- + let technologies = [ + Technology(name: "SwiftUI", identifier: "doc://com.apple.documentation/documentation/SwiftUI"), + Technology(name: "External", identifier: "https://example.com"), + ] + + // -- Act -- + let presentations = DocumentationPresenter().technologies(technologies, audience: .agent) + + // -- Assert -- + #expect(presentations.map(\.identifier) == technologies.map(\.identifier)) + #expect(presentations[0].navigation?.command == "apple-docs types list --technology 'swiftui' --agent") + #expect(presentations[1].navigation == nil) + } +}