Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLI/Tests/TritonKitCLITests/DeviceCrossPlatformTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ struct DeviceCrossPlatformTests {
#expect(appOptionNames.contains("--runtime"))
#expect(app.examples.contains("triton app list --device iphone15 --user-only --json"))
#expect(app.examples.contains("triton app install --device harmony-a --hap /tmp/Demo.hap --json"))
#expect(app.examples.contains(#"triton app go "example://debug""#))
#expect(app.examples.contains(#"triton app go "example://debug" --device iphone15"#))
#expect(app.examples.contains("triton app prefs get DEBUG-mock --device iphone15 --bundle-id com.example.app --json"))

#expect(smokeOptionNames.contains("--device"))
Expand Down
81 changes: 78 additions & 3 deletions CLI/Tests/TritonKitCLITests/SchemaFactSourceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,9 @@ struct SchemaFactSourceTests {
#expect(openURL.primaryNextActionSource == "next-step-step")
#expect(openURL.steps.map(\.id) == ["target-resolve", "app-open-url", "wait-text", "assert-text", "evidence"])
#expect(openURL.steps.first(where: { $0.id == "app-open-url" })?.workflowCategories == ["app", "assert", "evidence", "target"])
#expect(openURL.steps.first(where: { $0.id == "app-open-url" })?.command.contains("--wait-ready") == true)
#expect(openURL.steps.first(where: { $0.id == "app-open-url" })?.command.contains("triton app go") == true)
#expect(openURL.steps.first(where: { $0.id == "app-open-url" })?.command.contains("--wait-ready") == false)
#expect(openURL.steps.first(where: { $0.id == "app-open-url" })?.command.contains("--json") == false)

let webview = buildWorkflowPlan(
capabilities: capabilities,
Expand Down Expand Up @@ -1631,6 +1633,7 @@ struct SchemaFactSourceTests {
@Test("capability groups keep machine-readable next-action output flags")
func capabilityGroupsKeepMachineReadableNextActionOutputFlags() {
let fixtures = capabilityStateFixtures()
let schemas = commandSchemaMap()

var missingMachineReadableFlags: [String] = []
var screenshotObserveMismatches: [String] = []
Expand All @@ -1651,7 +1654,10 @@ struct SchemaFactSourceTests {
let hasJSONFlag = argSet.contains("--json") || argSet.contains("--jsonl")
let hasPlanFormatJSON = args.contains("--format") && args.contains("json")
let hasScreenshotMetadata = argSet.contains("--metadata")
if !(hasJSONFlag || hasPlanFormatJSON || hasScreenshotMetadata) {
let defaultsToJSON = schemas[nextAction.command]?.options.contains {
$0.name == "--format" && $0.defaultValue == "json"
} == true
if !(hasJSONFlag || hasPlanFormatJSON || hasScreenshotMetadata || defaultsToJSON) {
missingMachineReadableFlags.append(context)
}

Expand Down Expand Up @@ -1886,6 +1892,75 @@ struct SchemaFactSourceTests {
#expect(assertTextExistsPlaceholderMismatches == [])
}

@Test("capability next-action url placeholders stay canonical")
func capabilityNextActionURLPlaceholdersStayCanonical() {
let fixtures = capabilityStateFixtures()

var urlFlagPlaceholderMismatches: [String] = []
var openURLFlagPlaceholderMismatches: [String] = []
var appOpenURLArgumentMismatches: [String] = []
var routeAssertCurrentURLArgumentMismatches: [String] = []

for fixture in fixtures {
for capability in fixture.capabilities {
guard let nextAction = capability.nextAction else {
continue
}
let args = nextAction.args
let context = "\(fixture.name):\(capability.name):command=\(nextAction.command):args=\(args)"

for (index, arg) in args.enumerated() where arg == "--url" {
guard args.indices.contains(index + 1) else {
urlFlagPlaceholderMismatches.append("\(context):missing-url-value")
continue
}
if args[index + 1] != "<url>" {
urlFlagPlaceholderMismatches.append("\(context):expected=<url>:actual=\(args[index + 1])")
}
}

for (index, arg) in args.enumerated() where arg == "--open-url" {
guard args.indices.contains(index + 1) else {
openURLFlagPlaceholderMismatches.append("\(context):missing-open-url-value")
continue
}
if args[index + 1] != "<url>" {
openURLFlagPlaceholderMismatches.append("\(context):expected=<url>:actual=\(args[index + 1])")
}
}

if nextAction.command == "app",
args.first == "open-url" || args.first == "go" {
guard args.indices.contains(1) else {
appOpenURLArgumentMismatches.append("\(context):missing-open-url-argument")
continue
}
if args[1] != "<url>" {
appOpenURLArgumentMismatches.append("\(context):expected=<url>:actual=\(args[1])")
}
}

if nextAction.command == "route",
args.first == "assert-current-url" {
guard args.indices.contains(1) else {
routeAssertCurrentURLArgumentMismatches.append("\(context):missing-expected-url-argument")
continue
}
if args[1] != "<expected-url>" {
routeAssertCurrentURLArgumentMismatches.append(
"\(context):expected=<expected-url>:actual=\(args[1])"
)
}
}
}
}

#expect(urlFlagPlaceholderMismatches == [])
#expect(openURLFlagPlaceholderMismatches == [])
#expect(appOpenURLArgumentMismatches == [])
#expect(routeAssertCurrentURLArgumentMismatches == [])
}

@Test("capability names remain unique for agent indexing")
func capabilityNamesRemainUniqueForAgentIndexing() {
var duplicateSchemaCapabilities: [String] = []
Expand Down Expand Up @@ -3742,7 +3817,7 @@ struct SchemaFactSourceTests {

#expect(app.failureCodes.contains("app_launch_failed"))
#expect(app.failureCodes.contains("host_open_url_failed"))
#expect(app.nextCommands.contains("triton app open-url <url> --wait-ready --snapshot --json"))
#expect(app.nextCommands.contains("triton app go <url>"))
expectContract(app, selector: "host.app-action", fields: [
"ok", "action", "runtimeScope", "target", "selection", "tool", "exitCode",
"riskLevel", "sourceCommand", "stdoutTruncated", "stderrTruncated", "artifacts", "note",
Expand Down
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@ Before filing a public issue, redact private project and personal information. D
| Build, test, run, and diagnose an unknown Apple repo | [CLI Integration Guide](#cli-integration-guide) | Use `triton xcode`, `triton xcresult`, and artifact commands before raw `xcodebuild`. |
| Prepare a HarmonyOS / DevEco Emulator | [Harmony App Integration Guide](#harmony-app-integration-guide) | Host-side HDC adapter works without embedded runtime. |
| Validate a Harmony embedded runtime | [Harmony App Integration Guide](#harmony-app-integration-guide) | Use package id / import path `tritonkit` and `--runtime-base-url` direct checks while the SDK is standalone. |
| Add optional Codex / agent workflows | [Optional Agent Skills](#optional-agent-skills) | Install only the public skills; internal skills are for TritonKit repository maintenance. |

## Optional Agent Skills

TritonKit ships optional Codex / agent skills for AI-assisted adoption, feedback, and local emulator regression work. They are not required to use the iOS runtime, Harmony runtime notes, or macOS `triton` CLI.

External users and adopting projects should install only the public skills from `.agents/tritonkit-skills/public/` or from the release asset `tritonkit-skills.tar.gz`. Current public skills are:

- `tritonkit-dev-feedback`: collect adoption feedback, missing capabilities, confusing behavior, and documentation gaps as actionable TritonKit issues.
- `tritonkit-emulator-cli-takeover`: guide local CLI takeover of iOS Simulator, Android Emulator, and HarmonyOS / DevEco Emulator workflows.
- `tritonkit-real-project-regression`: validate TritonKit against real app projects while isolating external repo changes and preserving machine-readable evidence.

Do not install `.agents/tritonkit-skills/internal/` into adopting projects by default. Internal skills are repo-maintenance, governance, planning, supervision, and implementation workflows for TritonKit maintainers, and release packaging excludes them.

If you install from a source checkout, copy only the public skill directories into your Codex / agent skills directory:

```sh
# Replace AGENT_SKILLS_DIR with your Codex / agent's configured skills directory.
mkdir -p "$AGENT_SKILLS_DIR"
cp -R .agents/tritonkit-skills/public/* "$AGENT_SKILLS_DIR"/
```

If you install from a release asset, extract the public skill bundle into that same configured skills directory:

```sh
tar -xzf tritonkit-skills.tar.gz -C "$AGENT_SKILLS_DIR"
```

Restart the Codex / agent session after installation so the new skills are discovered.

## iOS Embedded Runtime Integration Guide

Expand Down Expand Up @@ -363,8 +392,9 @@ triton app install --device booted --app /tmp/Demo.app --json
triton app uninstall --device booted --bundle-id com.example.app --confirm --json
triton app launch --device booted --bundle-id com.example.app --json
triton app terminate --device booted --bundle-id com.example.app --json
triton app go "example://debug"
triton app go "example://debug" --device booted
triton app open-url "example://debug" --device booted --json
triton app open-url "example://debug" --device booted --wait-ready --snapshot --json
triton webview current-url --platform ios --json
triton route assert-current-url "https://example.invalid/path" --platform ios --json
triton app container --device booted --bundle-id com.example.app --kind data --json
Expand All @@ -375,8 +405,9 @@ triton app prefs dump --device booted --bundle-id com.example.app --json

Destructive commands require `--confirm` by default, and `runtime delete` supports `--dry-run` first so agents can inspect the selected runtimes before deleting anything.

`app open-url` only proves the URL was submitted to Simulator. Continue with `triton wait`, `triton find`, `triton assert`, `triton webview current-url`, `triton route assert-current-url`, or `triton app prefs get` to verify the business state.
When an embedded runtime is expected to be connected, add `--wait-ready --snapshot` to make the one-shot result include runtime readiness and an app/route/AX snapshot summary.
`app go <url>` is the short iOS deep-link smoke entry: it opens the URL, waits for embedded runtime readiness, returns an app/route/AX snapshot summary, and defaults to JSON output. Use `--device <selector>` only when the current/default target is ambiguous.
For iOS simulator selectors, `sim:` is optional. `--device 60667794-96F8-40E6-8664-85538EC4663E` and `--device sim:60667794-96F8-40E6-8664-85538EC4663E` both resolve to the same simulator; keep `sim:` only when you want explicit platform disambiguation.
Comment on lines +408 to +409
`app open-url` is the lower-level host action and only proves the URL was submitted to Simulator. Continue with `triton wait`, `triton find`, `triton assert`, `triton webview current-url`, `triton route assert-current-url`, or `triton app prefs get` to verify the business state.

Xcode project discovery and `xcodebuild` execution are also exposed through Triton CLI. Use this path before falling back to XcodeBuildMCP or raw `xcodebuild` so the agent sees stable JSON/JSONL contracts:

Expand Down
119 changes: 119 additions & 0 deletions Sources/TritonKitCLI/CLIHostCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,59 @@ private func hostDeviceSelectionRequest(
)
}

private func resolveDefaultIOSHostDeviceSelection(
device: String?,
simulator: String?,
name: String?,
runtime: String?,
state: String?,
ready: Bool
) throws -> HostDeviceSelectionResult {
let explicitDevice = device ?? simulator
if explicitDevice != nil {
return try resolveHostDeviceSelection(
request: hostDeviceSelectionRequest(
device: explicitDevice,
platform: .ios,
defaultPlatform: .ios,
name: name,
runtime: runtime,
state: state,
ready: ready
),
hdc: "hdc"
)
}

do {
return try resolveHostDeviceSelection(
request: hostDeviceSelectionRequest(
device: "current",
platform: .ios,
defaultPlatform: .ios,
name: name,
runtime: runtime,
state: state,
ready: ready
),
hdc: "hdc"
)
} catch HostDeviceSelectionError.targetNotFound(let target) where target == "current" {
return try resolveHostDeviceSelection(
request: hostDeviceSelectionRequest(
device: nil,
platform: .ios,
defaultPlatform: .ios,
name: name,
runtime: runtime,
state: state,
ready: ready
),
hdc: "hdc"
)
}
}

func ensureHostDeviceSelectorCompatibility(device: String?, simulator: String?, target: String?) throws {
if device != nil && (simulator != nil || target != nil) {
throw HostDeviceSelectionError.parameterConflict("--device cannot be combined with --simulator or Harmony --target.")
Expand Down Expand Up @@ -978,6 +1031,7 @@ struct HostApp: AsyncParsableCommand {
HostAppUninstall.self,
HostAppLaunch.self,
HostAppTerminate.self,
HostAppGo.self,
HostAppOpenURL.self,
HostAppContainer.self,
HostAppPrefs.self,
Expand Down Expand Up @@ -1400,6 +1454,71 @@ struct HostAppTerminate: AsyncParsableCommand {
}
}

struct HostAppGo: AsyncParsableCommand {
static let configuration = CommandConfiguration(commandName: "go", abstract: "Open a URL and return runtime readiness plus snapshot")

@Argument(help: "URL to open") var url: String
@Option(help: "Unified host device selector: alias, sim:<udid>, raw id, booted, or current") var device: String?
@Option(help: "Explicit iOS simulator selector: UDID or booted") var simulator: String?
@Option(help: "Device name filter, for example iPhone 15") var name: String?
@Option(help: "Runtime filter, for example iOS 26.5") var runtime: String?
@Option(help: "Target state filter, for example booted") var state: String?
@Flag(help: "Only match ready targets") var ready = false
@Option(name: .customLong("runtime-target"), help: "iOS embedded runtime target id from `triton list`") var runtimeTarget: String = TKLocalTargetID
@Option(name: .customLong("snapshot-include"), help: "Comma-separated snapshot sections") var snapshotInclude: String = "app,scene,route,ax,geometry"
@Option(name: .customLong("max-ax-nodes"), help: "Maximum AX nodes in the runtime snapshot") var maxAXNodes: Int?
@Option(help: "Server host") var host: String = "127.0.0.1"
@Option(help: "Server port") var port: Int = 19421
@Option(help: "Runtime wait timeout in seconds") var timeout: Double = 20
@Option(help: "Runtime wait polling interval in seconds") var interval: Double = 0.5
@Flag(help: "Alias for --format json") var json = false
@Option(help: "Output format: text or json") var format: ClientOutputFormat = .json

func run() async throws {
let outputFormat = effectiveFormat(format, json: json)
do {
try ensureHostDeviceSelectorCompatibility(device: device, simulator: simulator, target: nil)
let selection = try resolveDefaultIOSHostDeviceSelection(
device: device,
simulator: simulator,
name: name,
runtime: runtime,
state: state,
ready: ready
)
let include = snapshotInclude.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
let summary = try await runIOSAppOpenURLFlow(options: IOSAppOpenURLFlowOptions(
simulator: selection.target.target,
runtimeTarget: runtimeTarget,
url: url,
waitReady: true,
snapshot: true,
snapshotInclude: include,
maxAXNodes: maxAXNodes,
host: host,
port: port,
timeout: timeout,
interval: interval
))
switch outputFormat {
case .json:
print(try encodeJSON(summary))
case .text:
print("status: \(summary.status.rawValue)")
print("source: \(summary.hostAction.sourceCommand)")
if let ready = summary.ready {
print("runtime: connected=\(ready.connected) hierarchy=\(ready.hierarchyCacheState ?? "-")")
}
if let snapshot = summary.snapshot {
print("snapshot: app=\(snapshot.appName ?? "-") axNodes=\(snapshot.axNodeCount ?? 0)")
}
}
} catch {
try failCommand(error, outputFormat: outputFormat, endpoint: "/request", host: host, port: port)
}
}
}

struct HostAppOpenURL: AsyncParsableCommand {
static let configuration = CommandConfiguration(commandName: "open-url", abstract: "Open a URL in a simulator or Harmony app")

Expand Down
9 changes: 3 additions & 6 deletions Sources/TritonKitCLI/CLIRuntimeTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -561,9 +561,9 @@ func runtimeCapabilityNextAction(
case "host-app":
return TKCLINextAction(command: "app", args: ["list", "--device", "<selector>", "--json"])
case "host-app-open-url-ready":
return TKCLINextAction(command: "app", args: ["open-url", "<url>", "--device", "<selector>", "--wait-ready", "--json"])
return TKCLINextAction(command: "app", args: ["go", "<url>", "--device", "<selector>"])
case "host-app-open-url-snapshot":
return TKCLINextAction(command: "app", args: ["open-url", "<url>", "--device", "<selector>", "--wait-ready", "--snapshot", "--json"])
return TKCLINextAction(command: "app", args: ["go", "<url>", "--device", "<selector>"])
Comment on lines 563 to +566
case "host-preferences":
return TKCLINextAction(command: "app", args: ["prefs", "get", "<key>", "--device", "<selector>", "--bundle-id", "<bundle-id>", "--json"])
case "xcode-discovery", "xcode-build", "xcode-test", "xcode-run":
Expand Down Expand Up @@ -1289,12 +1289,9 @@ func buildTaskWorkflowPlan(
id: "app-open-url",
title: "Open app URL and capture runtime readiness",
command: [
"triton", "app", "open-url",
"triton", "app", "go",
planValue(request.url, "<url>"),
"--device", planValue(request.device, "<device>"),
"--wait-ready",
"--snapshot",
"--json",
].map(shellEscaped).joined(separator: " "),
requiresServer: true,
requiresTarget: true,
Expand Down
Loading
Loading