Skip to content

Commit 210425a

Browse files
committed
Support optional JSON output for config, list, lookup/info, outdated & search.
Update `README.md`. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com>
1 parent 72eb6ce commit 210425a

36 files changed

Lines changed: 2163 additions & 538 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
*~

.swiftlint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ type_contents_order:
115115
- type_method
116116
- view_life_cycle_method
117117
- ib_action
118-
- other_method
119118
- subscript
119+
- other_method
120120
unneeded_override:
121121
affect_initializers: true
122122
unused_import:

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: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,28 +38,26 @@ Detailed documentation is available via `man mas` & `mas --help`.
3838

3939
<!--markdownlint-disable line-length-->
4040
<!--editorconfig-checker-disable-->
41-
| Command | Functionality | Requires |
42-
|:---------------------------|:----------------------------------------------|:----------------------------------------------------------------------------------------------------------------------|
43-
| `search <term>…` | Search for App Store apps by name | |
44-
| `lookup <id>…` | Output App Store app details | |
45-
| `info <id>…` | `lookup` alias | |
46-
| `list [<id>…]` | Output installed apps | [spotlight](#spotlight) |
47-
| `outdated [<id>…]` | Output outdated apps | [spotlight](#spotlight), [account](#app-store-apple-account-requirements) for `--accurate` |
48-
| `get <id>…` | [Get free apps](#paid-apps), install any apps | [spotlight](#spotlight), [root](#root-privileges), [account for `get`](#app-store-apple-account-requirements-for-get) |
49-
| `purchase <id>…` | `get` alias | [spotlight](#spotlight), [root](#root-privileges), [account for `get`](#app-store-apple-account-requirements-for-get) |
50-
| `install <id>…` | Install already gotten or purchased apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) |
51-
| `lucky <term>…` | Install first app from `search <term>…` | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) |
52-
| `update [<id>…]` | Update outdated apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) |
53-
| `upgrade [<id>…]` | `update` alias | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) |
54-
| `uninstall (<id>…\|--all)` | Uninstall apps | [spotlight](#spotlight), [root](#root-privileges) |
55-
| `signout` | Sign out Apple Account from App Store | |
56-
| `open [<id>]` | Open app App Store page | |
57-
| `home <id>…` | Open app web pages | |
58-
| `seller <id>…` | Open seller app web pages | |
59-
| `vendor <id>…` | `seller` alias | |
60-
| `reset` | Reset App Store processes | |
61-
| `config` | Output config | |
62-
| `version` | Output version | |
41+
| Command | Functionality | Notes | Aliases |
42+
|:------------------------------|:----------------------------------------------|:------------------------------------------------------------------------------------------------------------|:-----------|
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) | |
48+
| `get <id>…` | [Get free apps](#paid-apps), install any apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements-for-get) | `purchase` |
49+
| `install <id>…` | Install gotten or purchased apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | |
50+
| `lucky <term>…` | Install first matching app | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | |
51+
| `update [<id>…]` | Update outdated apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | `upgrade` |
52+
| `update --accurate [<id>…]` | Update outdated apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | `upgrade` |
53+
| `uninstall (<id>…\|--all)` | Uninstall apps | [spotlight](#spotlight), [root](#root-privileges) | |
54+
| `signout` | Sign out from App Store | | |
55+
| `open [<id>]` | Open app App Store page | | |
56+
| `home <id>…` | Open app web pages | | |
57+
| `seller <id>…` | Open seller app web pages | | `vendor` |
58+
| `reset` | Reset App Store processes | | |
59+
| `config` | Output config | [json](#json-config-output) | |
60+
| `version` | Output version | | |
6361
<!--editorconfig-checker-enable-->
6462
<!--markdownlint-enable line-length-->
6563

@@ -98,6 +96,8 @@ Detailed documentation is available via `man mas` & `mas --help`.
9896
| Action | Command |
9997
|:------------------------------------------------------------------------|:-----------------------------|
10098
| Build | `Scripts/build` or Xcode 26+ |
99+
| Set up zsh wrapper | `Scripts/setup_libexec` |
100+
| Run zsh wrapper | `Scripts/mas` |
101101
| Test ([Swift Testing](https://developer.apple.com/xcode/swift-testing)) | `Scripts/test` |
102102
<!--editorconfig-checker-enable-->
103103
<!--markdownlint-enable line-length-->
@@ -130,6 +130,41 @@ ADAM IDs can be found via:
130130
- e.g., `497799835` from
131131
<https://apps.apple.com/us/app/xcode/id497799835?mt=12>
132132

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+
133168
## Spotlight
134169

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

Scripts/mas

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

0 commit comments

Comments
 (0)