Skip to content

Commit f1f85a7

Browse files
committed
Support optional JSON output for config, list, lookup/info, outdated & search.
Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com>
1 parent e7af470 commit f1f85a7

39 files changed

Lines changed: 2131 additions & 517 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
/.idea/
33
/.swiftpm/
44
/.vscode/
5+
/libexec/bin/mas
56
.DS_Store
67
*~

Package.resolved

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ private let swiftSettings = [
1010
.enableUpcomingFeature("InternalImportsByDefault"),
1111
.enableUpcomingFeature("MemberImportVisibility"),
1212
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
13+
.strictMemorySafety(),
1314
.treatAllWarnings(as: .error),
1415
]
1516

@@ -23,6 +24,7 @@ _ = Package(
2324
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.3.0"),
2425
.package(url: "https://github.com/apple/swift-collections.git", from: "1.4.1"),
2526
.package(url: "https://github.com/attaswift/BigInt.git", from: "5.7.0"),
27+
.package(url: "https://github.com/mas-cli/swift-json.git", from: "3.3.0"),
2628
.package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.13.4"),
2729
],
2830
targets: [
@@ -33,6 +35,7 @@ _ = Package(
3335
dependencies: [
3436
.product(name: "ArgumentParser", package: "swift-argument-parser"),
3537
.product(name: "Atomics", package: "swift-atomics"),
38+
.product(name: "JSON", package: "swift-json"),
3639
.product(name: "OrderedCollections", package: "swift-collections"),
3740
"BigInt",
3841
"PrivateFrameworks",

README.md

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ Detailed documentation is available via `man mas` & `mas --help`.
3838

3939
<!--markdownlint-disable line-length-->
4040
<!--editorconfig-checker-disable-->
41-
| Command | Functionality | Requirements | Aliases |
41+
| Command | Functionality | Notes | Aliases |
4242
|:------------------------------|:----------------------------------------------|:------------------------------------------------------------------------------------------------------------|:-----------|
43-
| `search <term>…` | Search for App Store apps | | |
44-
| `lookup <id>…` | Output App Store app details | | `info` |
45-
| `list [<id>…]` | Output installed apps | [spotlight](#spotlight) | |
46-
| `outdated [<id>…]` | Output outdated apps | [spotlight](#spotlight) | |
47-
| `outdated --accurate [<id>…]` | Output outdated apps | [spotlight](#spotlight), [account](#app-store-apple-account-requirements) | |
43+
| `search <term>…` | Search for App Store apps | [json](#json-app-output) | |
44+
| `lookup <id>…` | Output App Store app details | [json](#json-app-output) | `info` |
45+
| `list [<id>…]` | Output installed apps | [spotlight](#spotlight), [json](#json-app-output) | |
46+
| `outdated [<id>…]` | Output outdated apps | [spotlight](#spotlight), [json](#json-app-output) | |
47+
| `outdated --accurate [<id>…]` | Output outdated apps | [spotlight](#spotlight), [account](#app-store-apple-account-requirements), [json](#json-app-output) | |
4848
| `get <id>…` | [Get free apps](#paid-apps), install any apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements-for-get) | `purchase` |
4949
| `install <id>…` | Install gotten or purchased apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | |
5050
| `lucky <term>…` | Install first matching app | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | |
@@ -56,7 +56,7 @@ Detailed documentation is available via `man mas` & `mas --help`.
5656
| `home <id>…` | Open app web pages | | |
5757
| `seller <id>…` | Open seller app web pages | | `vendor` |
5858
| `reset` | Reset App Store processes | | |
59-
| `config` | Output config | | |
59+
| `config` | Output config | [json](#json-config-output) | |
6060
| `version` | Output version | | |
6161
<!--editorconfig-checker-enable-->
6262
<!--markdownlint-enable line-length-->
@@ -96,6 +96,8 @@ Detailed documentation is available via `man mas` & `mas --help`.
9696
| Action | Command |
9797
|:------------------------------------------------------------------------|:-----------------------------|
9898
| Build | `Scripts/build` or Xcode 26+ |
99+
| Set up zsh wrapper | `Scripts/setup_libexec` |
100+
| Run zsh wrapper | `Scripts/mas` |
99101
| Test ([Swift Testing](https://developer.apple.com/xcode/swift-testing)) | `Scripts/test` |
100102
<!--editorconfig-checker-enable-->
101103
<!--markdownlint-enable line-length-->
@@ -128,6 +130,41 @@ ADAM IDs can be found via:
128130
- e.g., `497799835` from
129131
<https://apps.apple.com/us/app/xcode/id497799835?mt=12>
130132

133+
## JSON App Output
134+
135+
`list`, `outdated` & `search` normally output tabular data, with a few fields
136+
for each app on its own row.
137+
138+
`lookup` normally outputs fields as key-value pairs—one per line—in a contiguous
139+
block for each app, with a blank line between apps.
140+
141+
If `--json` is supplied, these commands output a stream of JSON objects—one per
142+
app—each containing all fields provided by Apple for that app.
143+
144+
Many of the JSON keys provided by Apple are poorly named, so they are mapped to
145+
better names by an algorithm.
146+
147+
<!--editorconfig-checker-disable-->
148+
Mapped JSON keys are [sorted](
149+
https://developer.apple.com/documentation/foundation/nsstring/compareoptions/numeric
150+
).
151+
<!--editorconfig-checker-enable-->
152+
153+
Each JSON key should be unique within an object; if duplicate keys exist in an
154+
object, their relative ordering in the input is preserved in the output.
155+
156+
If Apple renames or adds JSON keys, suboptimal JSON keys might be output until
157+
the mapping is updated.
158+
159+
## JSON Config Output
160+
161+
`config` normally outputs settings as key-value pairs, one per line.
162+
163+
If `--json` is supplied, `config` outputs all settings in a single JSON object.
164+
165+
Since the JSON keys are defined by mas, they are guaranteed to be unique &
166+
correct.
167+
131168
## Spotlight
132169

133170
`list`, `outdated`, `get`, `install`, `lucky`, `update` & `uninstall` obtain

Scripts/mas

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/bin/zsh -Ndefgku
2+
3+
# Copyright © 2026 mas-cli. All rights reserved.
4+
5+
builtin unalias -as
6+
setopt pipefail
7+
8+
path_or_simple_command() {
9+
# shellcheck disable=SC2139
10+
[[ -x "${1}" ]] && printf %s "${1}" || printf %s "${1:t}"
11+
}
12+
13+
# shellcheck disable=SC2311
14+
mas="$(path_or_simple_command "${0:A:h}/../libexec/bin/mas")"
15+
readonly mas
16+
# shellcheck disable=SC2311
17+
column="$(path_or_simple_command /usr/bin/column)"
18+
readonly column
19+
# shellcheck disable=SC2311
20+
jq="$(path_or_simple_command /usr/bin/jq)"
21+
readonly jq
22+
23+
[[ -t 2 ]] && readonly error_prefix=$'\u001B[4;31mError:\u001B[0m' || readonly error_prefix=Error:
24+
25+
case "${1:-}" in
26+
outdated)
27+
# shellcheck disable=SC1056,SC1072,SC1073
28+
{
29+
# shellcheck disable=SC1083
30+
{ exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | "${jq}" -sr '
31+
try (
32+
(map(.adamID | tostring | length) | max) as $max_adam_id_length |
33+
(map(.version // "" | length) | max) as $max_version_length |
34+
.[] |
35+
(.adamID | tostring) as $adam_id |
36+
(.version // "") as $version |
37+
[
38+
" " * ($max_adam_id_length - ($adam_id | length)) + $adam_id,
39+
.name,
40+
"(" + $version + " " * ($max_version_length - ($version | length)) + " -> " + .newVersion + ")"
41+
] |
42+
join("\u001f")
43+
) catch ("'"${error_prefix}"' Invalid data from mas: \(.)\n" | halt_error(1))
44+
' | "${column}" -ts $'\u001f'
45+
} 4>&1 5>&2
46+
;;
47+
search)
48+
[[ -n ${argv[(r)--price]-} ]] && readonly output_price=' + [.formattedPrice // .price // "?"]'
49+
;&
50+
list)
51+
{
52+
{ exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } |
53+
"${jq}" -sr '
54+
try (
55+
(map(.adamID | tostring | length) | max) as $max_adam_id_length |
56+
.[] |
57+
(.adamID | tostring) as $adam_id |
58+
[
59+
" " * ($max_adam_id_length - ($adam_id | length)) + $adam_id,
60+
.name,
61+
"(" + (.version // "") + ")"
62+
] '"${output_price-}"'|
63+
join("\u001f")
64+
) catch ("'"${error_prefix}"' Invalid data from mas: \(.)\n" | halt_error(1))
65+
' | "${column}" -ts $'\u001f'
66+
} 4>&1 5>&2
67+
;;
68+
lookup|info)
69+
{
70+
{ exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | "${jq}" -sr '
71+
def numberCommas($n):
72+
($n | abs | round | tostring) as $s |
73+
if $n < 0 then "-" else "" end + ([$s | while(length > 0; .[:-3]) | .[-3:]] | reverse | join(","))
74+
;
75+
try (
76+
{
77+
"name": "App",
78+
"version": "Version",
79+
"formattedPrice": "Price",
80+
"sellerName": "By",
81+
"currentVersionReleaseDate": "Released",
82+
"minimumOSVersion": "Minimum OS",
83+
"fileSizeBytes": "Size",
84+
"appStorePageURL": "From"
85+
} as $key_map |
86+
($key_map | values | map(length) | max) as $max_key_length |
87+
[
88+
.[]? as $in |
89+
[
90+
($key_map | keys_unsorted)[] |
91+
select($in[.] != null) as $k |
92+
"\($key_map[$k]) \("▁" * ($max_key_length - ($key_map[$k] | length) + 1)) \(
93+
$in[$k] |
94+
if $k == "fileSizeBytes" then
95+
numberCommas(tonumber? / 1e6 // 0) + " MB"
96+
elif $k == "currentVersionReleaseDate" then
97+
(fromdateiso8601? | strflocaltime("%Y-%m-%d")) // .
98+
else
99+
.
100+
end
101+
)"
102+
] |
103+
join("\n")
104+
] |
105+
map(select(length > 0)) | if length > 0 then join("\n\n") else empty end
106+
) catch ("'"${error_prefix}"' Invalid data from mas: \(.)\n" | halt_error(1))
107+
'
108+
} 4>&1 5>&2
109+
;;
110+
config)
111+
{
112+
{ exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | "${jq}" -r '
113+
try (
114+
(keys_unsorted | map(length) | max) as $max_key_length |
115+
to_entries[] |
116+
"\(.key) \("▁" * ($max_key_length - (.key | length) + 1)) \(.value)"
117+
) catch ("'"${error_prefix}"' Invalid data from mas: \(.)\n" | halt_error(1))
118+
'
119+
} 4>&1 5>&2
120+
;;
121+
*)
122+
exec "${mas}" "${@}"
123+
;;
124+
esac

Scripts/package

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,21 @@ swift package generate-manual
2828

2929
mkdir -p "${installation_staging_folder}/bin"
3030
mkdir -p "${installation_staging_folder}/etc/bash_completion.d"
31+
mkdir -p "${installation_staging_folder}/libexec/bin"
3132
mkdir -p "${installation_staging_folder}/share/fish/vendor_completions.d"
3233
mkdir -p "${installation_staging_folder}/share/man/man1"
3334
mkdir -p "${usr_local_bin_staging_folder}"
3435

35-
cp -c "$(swift build -c release --show-bin-path "${@:2}")/mas" "${installation_staging_folder}/bin/mas"
36+
cp -c "$(swift build -c release --show-bin-path "${@:2}")/mas" "${installation_staging_folder}/libexec/bin/mas"
37+
cp -c Scripts/mas "${installation_staging_folder}/bin/mas"
3638
cp -c contrib/completion/mas.bash "${installation_staging_folder}/etc/bash_completion.d/mas"
3739
cp -c contrib/completion/mas.fish "${installation_staging_folder}/share/fish/vendor_completions.d/mas.fish"
3840
cp -c LICENSE README.md "${installation_staging_folder}"
3941
cp -c .build/plugins/GenerateManual/outputs/mas/mas.1 "${installation_staging_folder}/share/man/man1/mas.1"
4042

4143
ln -fs "${installation_folder}/bin/mas" "${usr_local_bin_staging_folder}/mas"
4244

43-
archs=("${(s: :n)$(lipo -archs "${installation_staging_folder}/bin/mas")}")
45+
archs=("${(s: :n)$(lipo -archs "${installation_staging_folder}/libexec/bin/mas")}")
4446
# shellcheck disable=SC2034
4547
readonly -a archs
4648

Scripts/setup_libexec

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/zsh -Ndefgku
2+
#
3+
# Scripts/setup_libexec
4+
# mas
5+
#
6+
# Copyright © 2026 mas-cli. All rights reserved.
7+
#
8+
# Copies executable to libexec/bin/mas.
9+
#
10+
11+
. "${0:A:h}/_setup_script"
12+
13+
mkdir -p libexec/bin
14+
# shellcheck disable=SC1036,SC2086,SC2225
15+
cp -c .build/${1:-(debug|release)}/mas(om[1]) libexec/bin/mas

Sources/mas/Commands/Config.swift

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
internal import ArgumentParser
99
private import Darwin
1010
private import Foundation
11+
private import JSONAST
1112

1213
extension MAS {
1314
/// Outputs mas config & related system info.
@@ -16,24 +17,28 @@ extension MAS {
1617
abstract: "Output mas config & related system info",
1718
)
1819

20+
@OptionGroup
21+
private var outputFormatOptionGroup: OutputFormatOptionGroup
22+
1923
func run() {
20-
printer.info(
21-
"""
22-
mas ▁▁▁▁ \(version)
23-
slice ▁▁ \(runningSliceArchitecture)
24-
slices ▁ \(supportedSliceArchitectures.joined(separator: " "))
25-
dist ▁▁▁ \(distribution)
26-
origin ▁ \(gitOrigin)
27-
rev ▁▁▁▁ \(gitRevision)
28-
swift ▁▁ \(swiftVersion)
29-
driver ▁ \(swiftDriverVersion)
30-
store ▁▁ \(appStoreRegion)
31-
region ▁ \(macRegion)
32-
macos ▁▁ \(macOSVersion)
33-
mac ▁▁▁▁ \(configStringValue("hw.product"))
34-
cpu ▁▁▁▁ \(configStringValue("machdep.cpu.brand_string"))
35-
arch ▁▁▁ \(configStringValue("hw.machine"))
36-
""",
24+
outputFormatOptionGroup.info(
25+
JSON.Object( // swiftformat:disable:this wrap wrapArguments
26+
dictionaryLiteral: // swiftformat:disable indent
27+
("mas", .string(version)), // swiftlint:disable vertical_parameter_alignment_on_call
28+
("slice", .string(runningSliceArchitecture)),
29+
("slices", .string(supportedSliceArchitectures.joined(separator: " "))),
30+
("dist", .string(distribution)),
31+
("origin", .string(gitOrigin)),
32+
("rev", .string(gitRevision)),
33+
("swift", .string(swiftVersion)),
34+
("driver", .string(swiftDriverVersion)),
35+
("store", .string(appStoreRegion)),
36+
("region", .string(macRegion)),
37+
("macos", .string(macOSVersion)),
38+
("mac", .string(configStringValue("hw.product"))),
39+
("cpu", .string(configStringValue("machdep.cpu.brand_string"))),
40+
("arch", .string(configStringValue("hw.machine"))), // swiftlint:enable vertical_parameter_alignment_on_call
41+
), // swiftformat:enable indent
3742
)
3843
}
3944
}
@@ -78,8 +83,10 @@ private var supportedSliceArchitectures: [String] {
7883
?? .init() // swiftformat:disable:this indent
7984
}
8085

81-
private var macOSVersion: Substring {
82-
ProcessInfo.processInfo.operatingSystemVersionString.dropFirst(8).replacing("Build ", with: "", maxReplacements: 1)
86+
private var macOSVersion: String {
87+
.init(
88+
ProcessInfo.processInfo.operatingSystemVersionString.dropFirst(8).replacing("Build ", with: "", maxReplacements: 1),
89+
)
8390
}
8491

8592
private func configStringValue(_ name: String) -> String {

Sources/mas/Commands/Get.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ extension MAS {
2525
try await AppStore.get.apps(
2626
withAppIDs: catalogAppIDsOptionGroup.appIDs,
2727
force: forceOptionGroup.force,
28-
installedApps: try await installedApps,
28+
installedApps: try await installedApps(),
2929
)
3030
}
3131
}

Sources/mas/Commands/Install.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ extension MAS {
2424
try await AppStore.install.apps(
2525
withAppIDs: catalogAppIDsOptionGroup.appIDs,
2626
force: forceOptionGroup.force,
27-
installedApps: try await installedApps,
27+
installedApps: try await installedApps(),
2828
)
2929
}
3030
}

0 commit comments

Comments
 (0)