5501e804ff8d41ce656061b91896c4ac8c681d78

Author
Nick Brassel <nick@tzarc.org>
Committer
GitHub <noreply@github.com>
Date

Message

QMK Userspace (#22222)

Co-authored-by: Duncan Sutherland <dunk2k_2000@hotmail.com>

Diff

This diff is truncated to protect this page.

   1diff --git a/Makefile b/Makefile
   2index 9ef406e420618b5fb0093da63a953580d7d20748..ab30a17f58afe356b76d25ce4c0e677110a2ee0b 100644
   3--- a/Makefile
   4+++ b/Makefile
   5@@ -38,6 +38,11 @@ $(info QMK Firmware $(QMK_VERSION))
   6 endif
   7 endif
   8 
   9+# Try to determine userspace from qmk config, if set.
  10+ifeq ($(QMK_USERSPACE),)
  11+    QMK_USERSPACE = $(shell qmk config -ro user.overlay_dir | cut -d= -f2 | sed -e 's@^None$$@@g')
  12+endif
  13+
  14 # Determine which qmk cli to use
  15 QMK_BIN := qmk
  16 
  17@@ -191,9 +196,20 @@ define PARSE_KEYBOARD
  18     KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(ROOT_DIR)/keyboards/$$(KEYBOARD_FOLDER_PATH_4)/keymaps/*/.)))
  19     KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(ROOT_DIR)/keyboards/$$(KEYBOARD_FOLDER_PATH_5)/keymaps/*/.)))
  20 
  21+    ifneq ($(QMK_USERSPACE),)
  22+        KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_1)/keymaps/*/.)))
  23+        KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_2)/keymaps/*/.)))
  24+        KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_3)/keymaps/*/.)))
  25+        KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_4)/keymaps/*/.)))
  26+        KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_5)/keymaps/*/.)))
  27+    endif
  28+
  29     KEYBOARD_LAYOUTS := $(shell $(QMK_BIN) list-layouts --keyboard $1)
  30     LAYOUT_KEYMAPS :=
  31     $$(foreach LAYOUT,$$(KEYBOARD_LAYOUTS),$$(eval LAYOUT_KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(ROOT_DIR)/layouts/*/$$(LAYOUT)/*/.)))))
  32+    ifneq ($(QMK_USERSPACE),)
  33+        $$(foreach LAYOUT,$$(KEYBOARD_LAYOUTS),$$(eval LAYOUT_KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/layouts/$$(LAYOUT)/*/.)))))
  34+    endif
  35 
  36     KEYMAPS := $$(sort $$(KEYMAPS) $$(LAYOUT_KEYMAPS))
  37 
  38@@ -431,8 +447,18 @@ clean:
  39 	rm -rf $(BUILD_DIR)
  40 	echo 'done.'
  41 
  42-.PHONY: distclean
  43-distclean: clean
  44+.PHONY: distclean distclean_qmk
  45+distclean: distclean_qmk
  46+distclean_qmk: clean
  47 	echo -n 'Deleting *.bin, *.hex, and *.uf2 ... '
  48 	rm -f *.bin *.hex *.uf2
  49 	echo 'done.'
  50+
  51+ifneq ($(QMK_USERSPACE),)
  52+.PHONY: distclean_userspace
  53+distclean: distclean_userspace
  54+distclean_userspace: clean
  55+	echo -n 'Deleting userspace *.bin, *.hex, and *.uf2 ... '
  56+	rm -f $(QMK_USERSPACE)/*.bin $(QMK_USERSPACE)/*.hex $(QMK_USERSPACE)/*.uf2
  57+	echo 'done.'
  58+endif
  59diff --git a/builddefs/build_json.mk b/builddefs/build_json.mk
  60index e29d678e48385ea62a3286a4caad634cb569059d..e9d1420f36391136f57f6f33f1101ac3ca5335ac 100644
  61--- a/builddefs/build_json.mk
  62+++ b/builddefs/build_json.mk
  63@@ -15,3 +15,22 @@ else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_1)/keymap.json)","")
  64     KEYMAP_JSON := $(MAIN_KEYMAP_PATH_1)/keymap.json
  65     KEYMAP_JSON_PATH := $(MAIN_KEYMAP_PATH_1)
  66 endif
  67+
  68+ifneq ($(QMK_USERSPACE),)
  69+    ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/keymap.json)","")
  70+        KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/keymap.json
  71+        KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)
  72+    else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/keymap.json)","")
  73+        KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/keymap.json
  74+        KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)
  75+    else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/keymap.json)","")
  76+        KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/keymap.json
  77+        KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)
  78+    else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/keymap.json)","")
  79+        KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/keymap.json
  80+        KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)
  81+    else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/keymap.json)","")
  82+        KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/keymap.json
  83+        KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)
  84+    endif
  85+endif
  86diff --git a/builddefs/build_keyboard.mk b/builddefs/build_keyboard.mk
  87index 12a8c5b67be3e3a608ed8bc4df61ddd893bcf82c..f17171fe209bf88ddfc54344ae5ccb0e22b48658 100644
  88--- a/builddefs/build_keyboard.mk
  89+++ b/builddefs/build_keyboard.mk
  90@@ -127,34 +127,60 @@ include $(INFO_RULES_MK)
  91 include $(BUILDDEFS_PATH)/build_json.mk
  92 
  93 # Pull in keymap level rules.mk
  94-# Look through the possible keymap folders until we find a matching keymap.c
  95-ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_1)/keymap.c)","")
  96-    -include $(MAIN_KEYMAP_PATH_1)/rules.mk
  97-    KEYMAP_C := $(MAIN_KEYMAP_PATH_1)/keymap.c
  98-    KEYMAP_PATH := $(MAIN_KEYMAP_PATH_1)
  99-else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_2)/keymap.c)","")
 100-    -include $(MAIN_KEYMAP_PATH_2)/rules.mk
 101-    KEYMAP_C := $(MAIN_KEYMAP_PATH_2)/keymap.c
 102-    KEYMAP_PATH := $(MAIN_KEYMAP_PATH_2)
 103-else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_3)/keymap.c)","")
 104-    -include $(MAIN_KEYMAP_PATH_3)/rules.mk
 105-    KEYMAP_C := $(MAIN_KEYMAP_PATH_3)/keymap.c
 106-    KEYMAP_PATH := $(MAIN_KEYMAP_PATH_3)
 107-else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_4)/keymap.c)","")
 108-    -include $(MAIN_KEYMAP_PATH_4)/rules.mk
 109-    KEYMAP_C := $(MAIN_KEYMAP_PATH_4)/keymap.c
 110-    KEYMAP_PATH := $(MAIN_KEYMAP_PATH_4)
 111-else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_5)/keymap.c)","")
 112-    -include $(MAIN_KEYMAP_PATH_5)/rules.mk
 113-    KEYMAP_C := $(MAIN_KEYMAP_PATH_5)/keymap.c
 114-    KEYMAP_PATH := $(MAIN_KEYMAP_PATH_5)
 115-else ifneq ($(LAYOUTS),)
 116-    # If we haven't found a keymap yet fall back to community layouts
 117-    include $(BUILDDEFS_PATH)/build_layout.mk
 118-# Not finding keymap.c is fine if we found a keymap.json
 119-else ifeq ("$(wildcard $(KEYMAP_JSON_PATH))", "")
 120-    $(call CATASTROPHIC_ERROR,Invalid keymap,Could not find keymap)
 121-    # this state should never be reached
 122+ifeq ("$(wildcard $(KEYMAP_PATH))", "")
 123+    # Look through the possible keymap folders until we find a matching keymap.c
 124+    ifneq ($(QMK_USERSPACE),)
 125+        ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/keymap.c)","")
 126+            -include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/rules.mk
 127+            KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/keymap.c
 128+            KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)
 129+        else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/keymap.c)","")
 130+            -include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/rules.mk
 131+            KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/keymap.c
 132+            KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)
 133+        else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/keymap.c)","")
 134+            -include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/rules.mk
 135+            KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/keymap.c
 136+            KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)
 137+        else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/keymap.c)","")
 138+            -include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/rules.mk
 139+            KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/keymap.c
 140+            KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)
 141+        else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/keymap.c)","")
 142+            -include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/rules.mk
 143+            KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/keymap.c
 144+            KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)
 145+        endif
 146+    endif
 147+    ifeq ($(KEYMAP_PATH),)
 148+        ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_1)/keymap.c)","")
 149+            -include $(MAIN_KEYMAP_PATH_1)/rules.mk
 150+            KEYMAP_C := $(MAIN_KEYMAP_PATH_1)/keymap.c
 151+            KEYMAP_PATH := $(MAIN_KEYMAP_PATH_1)
 152+        else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_2)/keymap.c)","")
 153+            -include $(MAIN_KEYMAP_PATH_2)/rules.mk
 154+            KEYMAP_C := $(MAIN_KEYMAP_PATH_2)/keymap.c
 155+            KEYMAP_PATH := $(MAIN_KEYMAP_PATH_2)
 156+        else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_3)/keymap.c)","")
 157+            -include $(MAIN_KEYMAP_PATH_3)/rules.mk
 158+            KEYMAP_C := $(MAIN_KEYMAP_PATH_3)/keymap.c
 159+            KEYMAP_PATH := $(MAIN_KEYMAP_PATH_3)
 160+        else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_4)/keymap.c)","")
 161+            -include $(MAIN_KEYMAP_PATH_4)/rules.mk
 162+            KEYMAP_C := $(MAIN_KEYMAP_PATH_4)/keymap.c
 163+            KEYMAP_PATH := $(MAIN_KEYMAP_PATH_4)
 164+        else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_5)/keymap.c)","")
 165+            -include $(MAIN_KEYMAP_PATH_5)/rules.mk
 166+            KEYMAP_C := $(MAIN_KEYMAP_PATH_5)/keymap.c
 167+            KEYMAP_PATH := $(MAIN_KEYMAP_PATH_5)
 168+        else ifneq ($(LAYOUTS),)
 169+            # If we haven't found a keymap yet fall back to community layouts
 170+            include $(BUILDDEFS_PATH)/build_layout.mk
 171+        else ifeq ("$(wildcard $(KEYMAP_JSON_PATH))", "") # Not finding keymap.c is fine if we found a keymap.json
 172+            $(call CATASTROPHIC_ERROR,Invalid keymap,Could not find keymap)
 173+            # this state should never be reached
 174+        endif
 175+    endif
 176 endif
 177 
 178 # Have we found a keymap.json?
 179@@ -364,6 +390,16 @@ ifeq ("$(USER_NAME)","")
 180 endif
 181 USER_PATH := users/$(USER_NAME)
 182 
 183+# If we have userspace, then add it to the lookup VPATH
 184+ifneq ($(wildcard $(QMK_USERSPACE)),)
 185+    VPATH += $(QMK_USERSPACE)
 186+endif
 187+
 188+# If the equivalent users directory exists in userspace, use that in preference to anything currently in the main repo
 189+ifneq ($(wildcard $(QMK_USERSPACE)/$(USER_PATH)),)
 190diff --git a/builddefs/build_layout.mk b/builddefs/build_layout.mk
 191index 6166bd847c5933c07c7eb0c47c05c1cb73195fad..9ff99cc2218efed58af9908c61421756024f8c76 100644
 192--- a/builddefs/build_layout.mk
 193+++ b/builddefs/build_layout.mk
 194@@ -1,6 +1,10 @@
 195 LAYOUTS_PATH := layouts
 196 LAYOUTS_REPOS := $(patsubst %/,%,$(sort $(dir $(wildcard $(LAYOUTS_PATH)/*/))))
 197 
 198+ifneq ($(QMK_USERSPACE),)
 199+    LAYOUTS_REPOS += $(patsubst %/,%,$(QMK_USERSPACE)/$(LAYOUTS_PATH))
 200+endif
 201+
 202 define SEARCH_LAYOUTS_REPO
 203     LAYOUT_KEYMAP_PATH := $$(LAYOUTS_REPO)/$$(LAYOUT)/$$(KEYMAP)
 204     LAYOUT_KEYMAP_JSON := $$(LAYOUT_KEYMAP_PATH)/keymap.json
 205diff --git a/builddefs/common_rules.mk b/builddefs/common_rules.mk
 206index 52dccbe475bc1e814ef5df54a2a39a62778c01e9..cfd261737c0618440078b2e481f20c3e5dc49c8d 100644
 207--- a/builddefs/common_rules.mk
 208+++ b/builddefs/common_rules.mk
 209@@ -191,7 +191,7 @@ DFU_SUFFIX_ARGS ?=
 210 elf: $(BUILD_DIR)/$(TARGET).elf
 211 hex: $(BUILD_DIR)/$(TARGET).hex
 212 uf2: $(BUILD_DIR)/$(TARGET).uf2
 213-cpfirmware: $(FIRMWARE_FORMAT)
 214+cpfirmware_qmk: $(FIRMWARE_FORMAT)
 215 	$(SILENT) || printf "Copying $(TARGET).$(FIRMWARE_FORMAT) to qmk_firmware folder" | $(AWK_CMD)
 216 	$(COPY) $(BUILD_DIR)/$(TARGET).$(FIRMWARE_FORMAT) $(TARGET).$(FIRMWARE_FORMAT) && $(PRINT_OK)
 217 eep: $(BUILD_DIR)/$(TARGET).eep
 218@@ -200,6 +200,15 @@ sym: $(BUILD_DIR)/$(TARGET).sym
 219 LIBNAME=lib$(TARGET).a
 220 lib: $(LIBNAME)
 221 
 222+cpfirmware: cpfirmware_qmk
 223+
 224+ifneq ($(QMK_USERSPACE),)
 225+cpfirmware: cpfirmware_userspace
 226+cpfirmware_userspace: cpfirmware_qmk
 227+	$(SILENT) || printf "Copying $(TARGET).$(FIRMWARE_FORMAT) to userspace folder" | $(AWK_CMD)
 228+	$(COPY) $(BUILD_DIR)/$(TARGET).$(FIRMWARE_FORMAT) $(QMK_USERSPACE)/$(TARGET).$(FIRMWARE_FORMAT) && $(PRINT_OK)
 229+endif
 230+
 231 # Display size of file, modifying the output so people don't mistakenly grab the hex output
 232 BINARY_SIZE = $(SIZE) --target=$(FORMAT) $(BUILD_DIR)/$(TARGET).hex | $(SED) -e 's/\.build\/.*$$/$(TARGET).$(FIRMWARE_FORMAT)/g'
 233 
 234diff --git a/data/schemas/definitions.jsonschema b/data/schemas/definitions.jsonschema
 235index 441e6395cf6ef69616660b200594ad97f6ccadb8..ea29343d0ac0fc0095b0d7c133364c7b2e712e26 100644
 236--- a/data/schemas/definitions.jsonschema
 237+++ b/data/schemas/definitions.jsonschema
 238@@ -177,5 +177,23 @@
 239         "type": "integer",
 240         "minimum": 0,
 241         "maximum": 1
 242+    },
 243+    "keyboard_keymap_tuple": {
 244+        "type": "array",
 245+        "prefixItems": [
 246+            { "$ref": "#/keyboard" },
 247+            { "$ref": "#/filename" }
 248+        ],
 249+        "unevaluatedItems": false
 250+    },
 251+    "json_file_path": {
 252+        "type": "string",
 253+        "pattern": "^[0-9a-z_/\\-]+\\.json$"
 254+    },
 255+    "build_target": {
 256+        "oneOf": [
 257+            { "$ref": "#/keyboard_keymap_tuple" },
 258+            { "$ref": "#/json_file_path" }
 259+        ]
 260     }
 261 }
 262diff --git a/data/schemas/user_repo_v0.jsonschema b/data/schemas/user_repo_v0.jsonschema
 263new file mode 100644
 264index 0000000000000000000000000000000000000000..b18ac504284bb35fa0d6d67f47c322f9c16f0037
 265--- /dev/null
 266+++ b/data/schemas/user_repo_v0.jsonschema
 267@@ -0,0 +1,14 @@
 268+{
 269+    "$schema": "https://json-schema.org/draft/2020-12/schema#",
 270+    "$id": "qmk.user_repo.v0",
 271+    "title": "User Repository Information",
 272+    "type": "object",
 273+    "required": [
 274+        "userspace_version"
 275+    ],
 276+    "properties": {
 277+        "userspace_version": {
 278+            "type": "string",
 279+        },
 280+    }
 281+}
 282diff --git a/data/schemas/user_repo_v1.jsonschema b/data/schemas/user_repo_v1.jsonschema
 283new file mode 100644
 284index 0000000000000000000000000000000000000000..6cdf758685c5a885918ba8e8b916eb1e054d5755
 285--- /dev/null
 286+++ b/data/schemas/user_repo_v1.jsonschema
 287@@ -0,0 +1,22 @@
 288+{
 289+    "$schema": "https://json-schema.org/draft/2020-12/schema#",
 290+    "$id": "qmk.user_repo.v1",
 291+    "title": "User Repository Information",
 292+    "type": "object",
 293+    "required": [
 294+        "userspace_version",
 295+        "build_targets"
 296+    ],
 297+    "properties": {
 298+        "userspace_version": {
 299+            "type": "string",
 300+            "enum": ["1.0"]
 301+        },
 302+        "build_targets": {
 303+            "type": "array",
 304+            "items": {
 305+                "$ref": "qmk.definitions.v1#/build_target"
 306+            }
 307+        }
 308+    }
 309+}
 310diff --git a/docs/_summary.md b/docs/_summary.md
 311index 722c5f9c5dd6a77eac285ff13fbf915d444f0902..36c90c5bb9f68c66141fb746bfcab0bef5a53c99 100644
 312--- a/docs/_summary.md
 313+++ b/docs/_summary.md
 314@@ -4,7 +4,7 @@
 315   * [Building Your First Firmware](newbs_building_firmware.md)
 316   * [Flashing Firmware](newbs_flashing.md)
 317   * [Getting Help/Support](support.md)
 318-  * [Building With GitHub Userspace](newbs_building_firmware_workflow.md)
 319+  * [External Userspace](newbs_external_userspace.md)
 320   * [Other Resources](newbs_learn_more_resources.md)
 321   * [Syllabus](syllabus.md)
 322 
 323diff --git a/docs/cli_commands.md b/docs/cli_commands.md
 324index 79fd9de575763a13f5c9108103f78a10d1760bd8..7b5ad5b13ac778db16cc4f0509f2101e978235a7 100644
 325--- a/docs/cli_commands.md
 326+++ b/docs/cli_commands.md
 327@@ -482,6 +482,131 @@ $ qmk import-kbfirmware ~/Downloads/gh62.json
 328 
 329 ---
 330 
 331+# External Userspace Commands
 332+
 333+## `qmk userspace-add`
 334+
 335+This command adds a keyboard/keymap to the External Userspace build targets.
 336+
 337+**Usage**:
 338+
 339+```
 340+qmk userspace-add [-h] [-km KEYMAP] [-kb KEYBOARD] [builds ...]
 341+
 342+positional arguments:
 343+  builds                List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.
 344+
 345+options:
 346+  -h, --help            show this help message and exit
 347+  -km KEYMAP, --keymap KEYMAP
 348+                        The keymap to build a firmware for. Ignored when a configurator export is supplied.
 349+  -kb KEYBOARD, --keyboard KEYBOARD
 350+                        The keyboard to build a firmware for. Ignored when a configurator export is supplied.
 351+```
 352+
 353+**Example**:
 354+
 355+```
 356+$ qmk userspace-add -kb planck/rev6 -km default
 357+Ψ Added planck/rev6:default to userspace build targets
 358+Ψ Saved userspace file to /home/you/qmk_userspace/qmk.json
 359+```
 360+
 361+## `qmk userspace-remove`
 362+
 363+This command removes a keyboard/keymap from the External Userspace build targets.
 364+
 365+**Usage**:
 366+
 367+```
 368+qmk userspace-remove [-h] [-km KEYMAP] [-kb KEYBOARD] [builds ...]
 369+
 370+positional arguments:
 371+  builds                List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.
 372+
 373+options:
 374+  -h, --help            show this help message and exit
 375+  -km KEYMAP, --keymap KEYMAP
 376+                        The keymap to build a firmware for. Ignored when a configurator export is supplied.
 377+  -kb KEYBOARD, --keyboard KEYBOARD
 378+                        The keyboard to build a firmware for. Ignored when a configurator export is supplied.
 379+```
 380+
 381+**Example**:
 382+
 383+```
 384+$ qmk userspace-remove -kb planck/rev6 -km default
 385+Ψ Removed planck/rev6:default from userspace build targets
 386+Ψ Saved userspace file to /home/you/qmk_userspace/qmk.json
 387+```
 388+
 389+## `qmk userspace-list`
 390+
 391+This command lists the External Userspace build targets.
 392+
 393+**Usage**:
 394+
 395+```
 396+qmk userspace-list [-h] [-e]
 397+
 398+options:
 399+  -h, --help    show this help message and exit
 400+  -e, --expand  Expands any use of `all` for either keyboard or keymap.
 401+```
 402+
 403+**Example**:
 404+
 405+```
 406+$ qmk userspace-list
 407+Ψ Current userspace build targets:
 408+Ψ Keyboard: planck/rev6, keymap: you
 409+Ψ Keyboard: clueboard/66/rev3, keymap: you
 410+```
 411+
 412+## `qmk userspace-compile`
 413+
 414+This command compiles all the External Userspace build targets.
 415+
 416+**Usage**:
 417+
 418+```
 419+qmk userspace-compile [-h] [-e ENV] [-n] [-c] [-j PARALLEL] [-t]
 420+
 421+options:
 422+  -h, --help            show this help message and exit
 423+  -e ENV, --env ENV     Set a variable to be passed to make. May be passed multiple times.
 424+  -n, --dry-run         Don't actually build, just show the commands to be run.
 425+  -c, --clean           Remove object files before compiling.
 426+  -j PARALLEL, --parallel PARALLEL
 427diff --git a/docs/newbs_external_userspace.md b/docs/newbs_external_userspace.md
 428new file mode 100644
 429index 0000000000000000000000000000000000000000..9bdf4b0b185c1273860fe5f96f887c9dac2f7d68
 430--- /dev/null
 431+++ b/docs/newbs_external_userspace.md
 432@@ -0,0 +1,96 @@
 433+# External QMK Userspace
 434+
 435+QMK Firmware now officially supports storing user keymaps outside of the normal QMK Firmware repository, allowing users to maintain their own keymaps without having to fork, modify, and maintain a copy of QMK Firmware themselves.
 436+
 437diff --git a/lib/python/qmk/build_targets.py b/lib/python/qmk/build_targets.py
 438index 16a7ef87a22dbedc993753771950837e8f0a8b56..1ab489cec3cc779adb955b073550ff74d4fc4076 100644
 439--- a/lib/python/qmk/build_targets.py
 440+++ b/lib/python/qmk/build_targets.py
 441@@ -10,6 +10,8 @@ from qmk.constants import QMK_FIRMWARE, INTERMEDIATE_OUTPUT_PREFIX
 442 from qmk.commands import find_make, get_make_parallel_args, parse_configurator_json
 443 from qmk.keyboard import keyboard_folder
 444 from qmk.info import keymap_json
 445+from qmk.keymap import locate_keymap
 446+from qmk.path import is_under_qmk_firmware, is_under_qmk_userspace
 447 
 448 
 449 class BuildTarget:
 450@@ -158,6 +160,20 @@ class KeyboardKeymapBuildTarget(BuildTarget):
 451         for key, value in env_vars.items():
 452             compile_args.append(f'{key}={value}')
 453 
 454+        # Need to override the keymap path if the keymap is a userspace directory.
 455+        # This also ensures keyboard aliases as per `keyboard_aliases.hjson` still work if the userspace has the keymap
 456+        # in an equivalent historical location.
 457+        keymap_location = locate_keymap(self.keyboard, self.keymap)
 458+        if is_under_qmk_userspace(keymap_location) and not is_under_qmk_firmware(keymap_location):
 459+            keymap_directory = keymap_location.parent
 460+            compile_args.extend([
 461+                f'MAIN_KEYMAP_PATH_1={keymap_directory}',
 462+                f'MAIN_KEYMAP_PATH_2={keymap_directory}',
 463+                f'MAIN_KEYMAP_PATH_3={keymap_directory}',
 464+                f'MAIN_KEYMAP_PATH_4={keymap_directory}',
 465+                f'MAIN_KEYMAP_PATH_5={keymap_directory}',
 466+            ])
 467+
 468         return compile_args
 469 
 470 
 471diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py
 472index 695a18006614ecb9653155b52fac0a5f61615512..cf6090368722d6810f5254b5cee47b19cc4e9f05 100644
 473--- a/lib/python/qmk/cli/__init__.py
 474+++ b/lib/python/qmk/cli/__init__.py
 475@@ -81,6 +81,11 @@ subcommands = [
 476     'qmk.cli.new.keymap',
 477     'qmk.cli.painter',
 478     'qmk.cli.pytest',
 479+    'qmk.cli.userspace.add',
 480+    'qmk.cli.userspace.compile',
 481+    'qmk.cli.userspace.doctor',
 482+    'qmk.cli.userspace.list',
 483+    'qmk.cli.userspace.remove',
 484     'qmk.cli.via2json',
 485 ]
 486 
 487diff --git a/lib/python/qmk/cli/compile.py b/lib/python/qmk/cli/compile.py
 488index 71c1dec1621fb6e773bb984cd63a49cabd281551..3c8f3664ea95119488853f13561d799d8a40004a 100755
 489--- a/lib/python/qmk/cli/compile.py
 490+++ b/lib/python/qmk/cli/compile.py
 491@@ -37,7 +37,9 @@ def compile(cli):
 492         from .mass_compile import mass_compile
 493         cli.args.builds = []
 494         cli.args.filter = []
 495-        cli.args.no_temp = False
 496+        cli.config.mass_compile.keymap = cli.config.compile.keymap
 497+        cli.config.mass_compile.parallel = cli.config.compile.parallel
 498+        cli.config.mass_compile.no_temp = False
 499         return mass_compile(cli)
 500 
 501     # Build the environment vars
 502diff --git a/lib/python/qmk/cli/doctor/main.py b/lib/python/qmk/cli/doctor/main.py
 503index 6a6feb87d10bda8c6f04f8a8ceb06d261aebe3f2..dd8b58b2c757ff4c0b0111cc62fb0c8075f1a8e6 100755
 504--- a/lib/python/qmk/cli/doctor/main.py
 505+++ b/lib/python/qmk/cli/doctor/main.py
 506@@ -9,10 +9,11 @@ from milc import cli
 507 from milc.questions import yesno
 508 
 509 from qmk import submodules
 510-from qmk.constants import QMK_FIRMWARE, QMK_FIRMWARE_UPSTREAM
 511+from qmk.constants import QMK_FIRMWARE, QMK_FIRMWARE_UPSTREAM, QMK_USERSPACE, HAS_QMK_USERSPACE
 512 from .check import CheckStatus, check_binaries, check_binary_versions, check_submodules
 513 from qmk.git import git_check_repo, git_get_branch, git_get_tag, git_get_last_log_entry, git_get_common_ancestor, git_is_dirty, git_get_remotes, git_check_deviation
 514 from qmk.commands import in_virtualenv
 515+from qmk.userspace import qmk_userspace_paths, qmk_userspace_validate, UserspaceValidationError
 516 
 517 
 518 def os_tests():
 519@@ -92,6 +93,25 @@ def output_submodule_status():
 520                 cli.log.error(f'- {sub_name}: <<< missing or unknown >>>')
 521 
 522 
 523+def userspace_tests(qmk_firmware):
 524+    if qmk_firmware:
 525+        cli.log.info(f'QMK home: {{fg_cyan}}{qmk_firmware}')
 526+
 527+    for path in qmk_userspace_paths():
 528+        try:
 529+            qmk_userspace_validate(path)
 530+            cli.log.info(f'Testing userspace candidate: {{fg_cyan}}{path}{{fg_reset}} -- {{fg_green}}Valid `qmk.json`')
 531+        except FileNotFoundError:
 532+            cli.log.warn(f'Testing userspace candidate: {{fg_cyan}}{path}{{fg_reset}} -- {{fg_red}}Missing `qmk.json`')
 533+        except UserspaceValidationError as err:
 534+            cli.log.warn(f'Testing userspace candidate: {{fg_cyan}}{path}{{fg_reset}} -- {{fg_red}}Invalid `qmk.json`')
 535+            cli.log.warn(f' -- {{fg_cyan}}{path}/qmk.json{{fg_reset}} validation error: {err}')
 536+
 537+    if QMK_USERSPACE is not None:
 538+        cli.log.info(f'QMK userspace: {{fg_cyan}}{QMK_USERSPACE}')
 539+    cli.log.info(f'Userspace enabled: {{fg_cyan}}{HAS_QMK_USERSPACE}')
 540+
 541+
 542 @cli.argument('-y', '--yes', action='store_true', arg_only=True, help='Answer yes to all questions.')
 543 @cli.argument('-n', '--no', action='store_true', arg_only=True, help='Answer no to all questions.')
 544 @cli.subcommand('Basic QMK environment checks')
 545@@ -108,6 +128,9 @@ def doctor(cli):
 546     cli.log.info('QMK home: {fg_cyan}%s', QMK_FIRMWARE)
 547 
 548     status = os_status = os_tests()
 549+
 550+    userspace_tests(None)
 551+
 552     git_status = git_tests()
 553 
 554     if git_status == CheckStatus.ERROR or (os_status == CheckStatus.OK and git_status == CheckStatus.WARNING):
 555diff --git a/lib/python/qmk/cli/format/json.py b/lib/python/qmk/cli/format/json.py
 556index 3299a0d80704d368a22d8585da9fa336af103790..283513254c5cdb29e88a3e9aaf2b517eb64f078d 100755
 557--- a/lib/python/qmk/cli/format/json.py
 558+++ b/lib/python/qmk/cli/format/json.py
 559@@ -9,48 +9,74 @@ from milc import cli
 560 
 561 from qmk.info import info_json
 562 from qmk.json_schema import json_load, validate
 563-from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder
 564+from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder, UserspaceJSONEncoder
 565 from qmk.path import normpath
 566 
 567 
 568-@cli.argument('json_file', arg_only=True, type=normpath, help='JSON file to format')
 569-@cli.argument('-f', '--format', choices=['auto', 'keyboard', 'keymap'], default='auto', arg_only=True, help='JSON formatter to use (Default: autodetect)')
 570-@cli.argument('-i', '--inplace', action='store_true', arg_only=True, help='If set, will operate in-place on the input file')
 571-@cli.argument('-p', '--print', action='store_true', arg_only=True, help='If set, will print the formatted json to stdout ')
 572-@cli.subcommand('Generate an info.json file for a keyboard.', hidden=False if cli.config.user.developer else True)
 573-def format_json(cli):
 574-    """Format a json file.
 575+def _detect_json_format(file, json_data):
 576+    """Detect the format of a json file.
 577     """
 578-    json_file = json_load(cli.args.json_file)
 579-
 580-    if cli.args.format == 'auto':
 581+    json_encoder = None
 582+    try:
 583+        validate(json_data, 'qmk.user_repo.v1')
 584+        json_encoder = UserspaceJSONEncoder
 585+    except ValidationError:
 586+        pass
 587+
 588+    if json_encoder is None:
 589         try:
 590-            validate(json_file, 'qmk.keyboard.v1')
 591+            validate(json_data, 'qmk.keyboard.v1')
 592             json_encoder = InfoJSONEncoder
 593-
 594         except ValidationError as e:
 595-            cli.log.warning('File %s did not validate as a keyboard:\n\t%s', cli.args.json_file, e)
 596-            cli.log.info('Treating %s as a keymap file.', cli.args.json_file)
 597+            cli.log.warning('File %s did not validate as a keyboard info.json or userspace qmk.json:\n\t%s', file, e)
 598+            cli.log.info('Treating %s as a keymap file.', file)
 599             json_encoder = KeymapJSONEncoder
 600+
 601+    return json_encoder
 602+
 603+
 604+def _get_json_encoder(file, json_data):
 605+    """Get the json encoder for a file.
 606+    """
 607+    json_encoder = None
 608+    if cli.args.format == 'auto':
 609+        json_encoder = _detect_json_format(file, json_data)
 610     elif cli.args.format == 'keyboard':
 611         json_encoder = InfoJSONEncoder
 612     elif cli.args.format == 'keymap':
 613         json_encoder = KeymapJSONEncoder
 614+    elif cli.args.format == 'userspace':
 615+        json_encoder = UserspaceJSONEncoder
 616     else:
 617         # This should be impossible
 618         cli.log.error('Unknown format: %s', cli.args.format)
 619+    return json_encoder
 620+
 621+
 622+@cli.argument('json_file', arg_only=True, type=normpath, help='JSON file to format')
 623+@cli.argument('-f', '--format', choices=['auto', 'keyboard', 'keymap', 'userspace'], default='auto', arg_only=True, help='JSON formatter to use (Default: autodetect)')
 624+@cli.argument('-i', '--inplace', action='store_true', arg_only=True, help='If set, will operate in-place on the input file')
 625+@cli.argument('-p', '--print', action='store_true', arg_only=True, help='If set, will print the formatted json to stdout ')
 626+@cli.subcommand('Generate an info.json file for a keyboard.', hidden=False if cli.config.user.developer else True)
 627+def format_json(cli):
 628+    """Format a json file.
 629+    """
 630+    json_data = json_load(cli.args.json_file)
 631+
 632+    json_encoder = _get_json_encoder(cli.args.json_file, json_data)
 633+    if json_encoder is None:
 634         return False
 635 
 636-    if json_encoder == KeymapJSONEncoder and 'layout' in json_file:
 637+    if json_encoder == KeymapJSONEncoder and 'layout' in json_data:
 638         # Attempt to format the keycodes.
 639-        layout = json_file['layout']
 640-        info_data = info_json(json_file['keyboard'])
 641+        layout = json_data['layout']
 642+        info_data = info_json(json_data['keyboard'])
 643 
 644         if layout in info_data.get('layout_aliases', {}):
 645-            layout = json_file['layout'] = info_data['layout_aliases'][layout]
 646+            layout = json_data['layout'] = info_data['layout_aliases'][layout]
 647 
 648         if layout in info_data.get('layouts'):
 649-            for layer_num, layer in enumerate(json_file['layers']):
 650+            for layer_num, layer in enumerate(json_data['layers']):
 651                 current_layer = []
 652                 last_row = 0
 653 
 654@@ -61,9 +87,9 @@ def format_json(cli):
 655 
 656                     current_layer.append(keymap_key)
 657 
 658-                json_file['layers'][layer_num] = current_layer
 659diff --git a/lib/python/qmk/cli/mass_compile.py b/lib/python/qmk/cli/mass_compile.py
 660index 7968de53e79aa39f67d3804796d07eda2cb3b475..b025f8570123327e275d37e3d92e22f4d1d41ca8 100755
 661--- a/lib/python/qmk/cli/mass_compile.py
 662+++ b/lib/python/qmk/cli/mass_compile.py
 663@@ -72,7 +72,7 @@ all: {keyboard_safe}_{keymap_name}_binary
 664                     # yapf: enable
 665                 f.write('\n')
 666 
 667-        cli.run([make_cmd, *get_make_parallel_args(parallel), '-f', makefile.as_posix(), 'all'], capture_output=False, stdin=DEVNULL)
 668+        cli.run([find_make(), *get_make_parallel_args(parallel), '-f', makefile.as_posix(), 'all'], capture_output=False, stdin=DEVNULL)
 669 
 670         # Check for failures
 671         failures = [f for f in builddir.glob(f'failed.log.{os.getpid()}.*')]
 672diff --git a/lib/python/qmk/cli/new/keymap.py b/lib/python/qmk/cli/new/keymap.py
 673index 9b0ac221a4ae129d6b3f72b3c28b810cc227d250..d4339bc9ef00a705c372278543dc26d04687441f 100755
 674--- a/lib/python/qmk/cli/new/keymap.py
 675+++ b/lib/python/qmk/cli/new/keymap.py
 676@@ -5,10 +5,12 @@ import shutil
 677 from milc import cli
 678 from milc.questions import question
 679 
 680+from qmk.constants import HAS_QMK_USERSPACE, QMK_USERSPACE
 681 from qmk.path import is_keyboard, keymaps, keymap
 682 from qmk.git import git_get_username
 683 from qmk.decorators import automagic_keyboard, automagic_keymap
 684 from qmk.keyboard import keyboard_completer, keyboard_folder
 685+from qmk.userspace import UserspaceDefs
 686 
 687 
 688 def prompt_keyboard():
 689@@ -68,3 +70,9 @@ def new_keymap(cli):
 690     # end message to user
 691     cli.log.info(f'{{fg_green}}Created a new keymap called {{fg_cyan}}{user_name}{{fg_green}} in: {{fg_cyan}}{keymap_path_new}.{{fg_reset}}')
 692     cli.log.info(f"Compile a firmware with your new keymap by typing: {{fg_yellow}}qmk compile -kb {kb_name} -km {user_name}{{fg_reset}}.")
 693+
 694+    # Add to userspace compile if we have userspace available
 695+    if HAS_QMK_USERSPACE:
 696+        userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
 697+        userspace.add_target(keyboard=kb_name, keymap=user_name, do_print=False)
 698+        return userspace.save()
 699diff --git a/lib/python/qmk/cli/userspace/__init__.py b/lib/python/qmk/cli/userspace/__init__.py
 700new file mode 100644
 701index 0000000000000000000000000000000000000000..5757d3a4c994e5e01896be2360a532c0473922bd
 702--- /dev/null
 703+++ b/lib/python/qmk/cli/userspace/__init__.py
 704@@ -0,0 +1,5 @@
 705+from . import doctor
 706+from . import add
 707+from . import remove
 708+from . import list
 709+from . import compile
 710diff --git a/lib/python/qmk/cli/userspace/add.py b/lib/python/qmk/cli/userspace/add.py
 711new file mode 100644
 712index 0000000000000000000000000000000000000000..8993d54dba5222158669714bb68de0a7959cd80c
 713--- /dev/null
 714+++ b/lib/python/qmk/cli/userspace/add.py
 715@@ -0,0 +1,51 @@
 716+# Copyright 2023 Nick Brassel (@tzarc)
 717+# SPDX-License-Identifier: GPL-2.0-or-later
 718+from pathlib import Path
 719+from milc import cli
 720+
 721+from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
 722+from qmk.keyboard import keyboard_completer, keyboard_folder_or_all
 723+from qmk.keymap import keymap_completer, is_keymap_target
 724+from qmk.userspace import UserspaceDefs
 725+
 726+
 727+@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.")
 728+@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
 729+@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
 730+@cli.subcommand('Adds a build target to userspace `qmk.json`.')
 731+def userspace_add(cli):
 732+    if not HAS_QMK_USERSPACE:
 733+        cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
 734+        return False
 735+
 736+    userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
 737+
 738+    if len(cli.args.builds) > 0:
 739+        json_like_targets = list([Path(p) for p in filter(lambda e: Path(e).exists() and Path(e).suffix == '.json', cli.args.builds)])
 740+        make_like_targets = list(filter(lambda e: Path(e) not in json_like_targets, cli.args.builds))
 741+
 742+        for e in json_like_targets:
 743+            userspace.add_target(json_path=e)
 744+
 745+        for e in make_like_targets:
 746+            s = e.split(':')
 747+            userspace.add_target(keyboard=s[0], keymap=s[1])
 748+
 749+    else:
 750+        failed = False
 751+        try:
 752+            if not is_keymap_target(cli.args.keyboard, cli.args.keymap):
 753+                failed = True
 754+        except KeyError:
 755+            failed = True
 756+
 757+        if failed:
 758+            from qmk.cli.new.keymap import new_keymap
 759+            cli.config.new_keymap.keyboard = cli.args.keyboard
 760+            cli.config.new_keymap.keymap = cli.args.keymap
 761+            if new_keymap(cli) is not False:
 762+                userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap)
 763+        else:
 764+            userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap)
 765+
 766+    return userspace.save()
 767diff --git a/lib/python/qmk/cli/userspace/compile.py b/lib/python/qmk/cli/userspace/compile.py
 768new file mode 100644
 769index 0000000000000000000000000000000000000000..0a42dd5bf5bfbebcb54b51575ff3e9e4a2128d50
 770--- /dev/null
 771+++ b/lib/python/qmk/cli/userspace/compile.py
 772@@ -0,0 +1,38 @@
 773+# Copyright 2023 Nick Brassel (@tzarc)
 774+# SPDX-License-Identifier: GPL-2.0-or-later
 775+from pathlib import Path
 776+from milc import cli
 777+
 778+from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
 779+from qmk.commands import build_environment
 780+from qmk.userspace import UserspaceDefs
 781+from qmk.build_targets import JsonKeymapBuildTarget
 782+from qmk.search import search_keymap_targets
 783+from qmk.cli.mass_compile import mass_compile_targets
 784+
 785+
 786+@cli.argument('-t', '--no-temp', arg_only=True, action='store_true', help="Remove temporary files during build.")
 787+@cli.argument('-j', '--parallel', type=int, default=1, help="Set the number of parallel make jobs; 0 means unlimited.")
 788+@cli.argument('-c', '--clean', arg_only=True, action='store_true', help="Remove object files before compiling.")
 789+@cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Don't actually build, just show the commands to be run.")
 790+@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Set a variable to be passed to make. May be passed multiple times.")
 791+@cli.subcommand('Compiles the build targets specified in userspace `qmk.json`.')
 792+def userspace_compile(cli):
 793+    if not HAS_QMK_USERSPACE:
 794+        cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
 795+        return False
 796+
 797+    userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
 798+
 799+    build_targets = []
 800+    keyboard_keymap_targets = []
 801+    for e in userspace.build_targets:
 802+        if isinstance(e, Path):
 803+            build_targets.append(JsonKeymapBuildTarget(e))
 804+        elif isinstance(e, dict):
 805+            keyboard_keymap_targets.append((e['keyboard'], e['keymap']))
 806+
 807+    if len(keyboard_keymap_targets) > 0:
 808+        build_targets.extend(search_keymap_targets(keyboard_keymap_targets))
 809+
 810+    mass_compile_targets(list(set(build_targets)), cli.args.clean, cli.args.dry_run, cli.config.userspace_compile.no_temp, cli.config.userspace_compile.parallel, **build_environment(cli.args.env))
 811diff --git a/lib/python/qmk/cli/userspace/doctor.py b/lib/python/qmk/cli/userspace/doctor.py
 812new file mode 100644
 813index 0000000000000000000000000000000000000000..2b7e29aa7eed3e4cb3200a8149d9beabe4cb4422
 814--- /dev/null
 815+++ b/lib/python/qmk/cli/userspace/doctor.py
 816@@ -0,0 +1,11 @@
 817+# Copyright 2023 Nick Brassel (@tzarc)
 818+# SPDX-License-Identifier: GPL-2.0-or-later
 819+from milc import cli
 820+
 821+from qmk.constants import QMK_FIRMWARE
 822+from qmk.cli.doctor.main import userspace_tests
 823+
 824+
 825+@cli.subcommand('Checks userspace configuration.')
 826+def userspace_doctor(cli):
 827+    userspace_tests(QMK_FIRMWARE)
 828diff --git a/lib/python/qmk/cli/userspace/list.py b/lib/python/qmk/cli/userspace/list.py
 829new file mode 100644
 830index 0000000000000000000000000000000000000000..a63f669dd7b85834de62ac4817018efa45a04c9e
 831--- /dev/null
 832+++ b/lib/python/qmk/cli/userspace/list.py
 833@@ -0,0 +1,51 @@
 834+# Copyright 2023 Nick Brassel (@tzarc)
 835+# SPDX-License-Identifier: GPL-2.0-or-later
 836+from pathlib import Path
 837+from dotty_dict import Dotty
 838+from milc import cli
 839+
 840+from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
 841+from qmk.userspace import UserspaceDefs
 842+from qmk.build_targets import BuildTarget
 843+from qmk.keyboard import is_all_keyboards, keyboard_folder
 844+from qmk.keymap import is_keymap_target
 845+from qmk.search import search_keymap_targets
 846+
 847+
 848+@cli.argument('-e', '--expand', arg_only=True, action='store_true', help="Expands any use of `all` for either keyboard or keymap.")
 849+@cli.subcommand('Lists the build targets specified in userspace `qmk.json`.')
 850+def userspace_list(cli):
 851+    if not HAS_QMK_USERSPACE:
 852+        cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
 853+        return False
 854+
 855+    userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
 856+
 857+    if cli.args.expand:
 858+        build_targets = []
 859+        for e in userspace.build_targets:
 860+            if isinstance(e, Path):
 861+                build_targets.append(e)
 862+            elif isinstance(e, dict) or isinstance(e, Dotty):
 863+                build_targets.extend(search_keymap_targets([(e['keyboard'], e['keymap'])]))
 864+    else:
 865+        build_targets = userspace.build_targets
 866+
 867+    for e in build_targets:
 868+        if isinstance(e, Path):
 869+            # JSON keymap from userspace
 870+            cli.log.info(f'JSON keymap: {{fg_cyan}}{e}{{fg_reset}}')
 871+            continue
 872+        elif isinstance(e, dict) or isinstance(e, Dotty):
 873+            # keyboard/keymap dict from userspace
 874+            keyboard = e['keyboard']
 875+            keymap = e['keymap']
 876+        elif isinstance(e, BuildTarget):
 877+            # BuildTarget from search_keymap_targets()
 878+            keyboard = e.keyboard
 879+            keymap = e.keymap
 880+
 881+        if is_all_keyboards(keyboard) or is_keymap_target(keyboard_folder(keyboard), keymap):
 882+            cli.log.info(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}}')
 883+        else:
 884+            cli.log.warn(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}} -- not found!')
 885diff --git a/lib/python/qmk/cli/userspace/remove.py b/lib/python/qmk/cli/userspace/remove.py
 886new file mode 100644
 887index 0000000000000000000000000000000000000000..c7d180bfd1234735993723bb2ba713e929f304d4
 888--- /dev/null
 889+++ b/lib/python/qmk/cli/userspace/remove.py
 890@@ -0,0 +1,37 @@
 891+# Copyright 2023 Nick Brassel (@tzarc)
 892+# SPDX-License-Identifier: GPL-2.0-or-later
 893+from pathlib import Path
 894+from milc import cli
 895+
 896+from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
 897+from qmk.keyboard import keyboard_completer, keyboard_folder_or_all
 898+from qmk.keymap import keymap_completer
 899+from qmk.userspace import UserspaceDefs
 900+
 901+
 902+@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.")
 903+@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
 904+@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
 905+@cli.subcommand('Removes a build target from userspace `qmk.json`.')
 906+def userspace_remove(cli):
 907+    if not HAS_QMK_USERSPACE:
 908+        cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
 909+        return False
 910+
 911+    userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
 912+
 913+    if len(cli.args.builds) > 0:
 914+        json_like_targets = list([Path(p) for p in filter(lambda e: Path(e).exists() and Path(e).suffix == '.json', cli.args.builds)])
 915+        make_like_targets = list(filter(lambda e: Path(e) not in json_like_targets, cli.args.builds))
 916+
 917+        for e in json_like_targets:
 918+            userspace.remove_target(json_path=e)
 919+
 920+        for e in make_like_targets:
 921+            s = e.split(':')
 922+            userspace.remove_target(keyboard=s[0], keymap=s[1])
 923+
 924+    else:
 925+        userspace.remove_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap)
 926+
 927+    return userspace.save()
 928diff --git a/lib/python/qmk/commands.py b/lib/python/qmk/commands.py
 929index 519cb4c708ef82afc3dd82e46293f2606aee7c17..d95ff5f923ee67aece803ee363a83a88cfc12707 100644
 930--- a/lib/python/qmk/commands.py
 931+++ b/lib/python/qmk/commands.py
 932@@ -3,10 +3,12 @@
 933 import os
 934 import sys
 935 import shutil
 936+from pathlib import Path
 937 
 938 from milc import cli
 939 import jsonschema
 940 
 941+from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
 942 from qmk.json_schema import json_load, validate
 943 from qmk.keyboard import keyboard_alias_definitions
 944 
 945@@ -75,6 +77,10 @@ def build_environment(args):
 946             envs[key] = value
 947         else:
 948             cli.log.warning('Invalid environment variable: %s', env)
 949+
 950+    if HAS_QMK_USERSPACE:
 951+        envs['QMK_USERSPACE'] = Path(QMK_USERSPACE).resolve()
 952+
 953     return envs
 954 
 955 
 956diff --git a/lib/python/qmk/constants.py b/lib/python/qmk/constants.py
 957index 1967441fc89a961f30fc645e4a60d6e9ec7831f2..90e4452f2b9941986014af4cb4be2c31702b6342 100644
 958--- a/lib/python/qmk/constants.py
 959+++ b/lib/python/qmk/constants.py
 960@@ -4,9 +4,17 @@ from os import environ
 961 from datetime import date
 962 from pathlib import Path
 963 
 964+from qmk.userspace import detect_qmk_userspace
 965+
 966 # The root of the qmk_firmware tree.
 967 QMK_FIRMWARE = Path.cwd()
 968 
 969+# The detected userspace tree
 970+QMK_USERSPACE = detect_qmk_userspace()
 971+
 972+# Whether or not we have a separate userspace directory
 973+HAS_QMK_USERSPACE = True if QMK_USERSPACE is not None else False
 974+
 975 # Upstream repo url
 976 QMK_FIRMWARE_UPSTREAM = 'qmk/qmk_firmware'
 977 
 978diff --git a/lib/python/qmk/json_encoders.py b/lib/python/qmk/json_encoders.py
 979index 1e90f6a2880877897fd1dc8795a14ef1e3d2da1c..0e4ad1d220280fe6818ff2a5ff6a79508244540f 100755
 980--- a/lib/python/qmk/json_encoders.py
 981+++ b/lib/python/qmk/json_encoders.py
 982@@ -217,3 +217,21 @@ class KeymapJSONEncoder(QMKJSONEncoder):
 983                 return '50' + str(key)
 984 
 985         return key
 986+
 987+
 988+class UserspaceJSONEncoder(QMKJSONEncoder):
 989+    """Custom encoder to make userspace qmk.json's a little nicer to work with.
 990+    """
 991+    def sort_dict(self, item):
 992+        """Sorts the hashes in a nice way.
 993+        """
 994+        key = item[0]
 995+
 996+        if self.indentation_level == 1:
 997+            if key == 'userspace_version':
 998+                return '00userspace_version'
 999+
1000+            if key == 'build_targets':
1001+                return '01build_targets'
1002+
1003+        return key
1004diff --git a/lib/python/qmk/keyboard.py b/lib/python/qmk/keyboard.py
1005index 34257bee8d6fe875af4b74d28abd129fa0cc685e..b56505d649ca6c130ce3b8b834d791a193588713 100644
1006--- a/lib/python/qmk/keyboard.py
1007+++ b/lib/python/qmk/keyboard.py
1008@@ -78,13 +78,17 @@ def keyboard_alias_definitions():
1009 def is_all_keyboards(keyboard):
1010     """Returns True if the keyboard is an AllKeyboards object.
1011     """
1012+    if isinstance(keyboard, str):
1013+        return (keyboard == 'all')
1014     return isinstance(keyboard, AllKeyboards)
1015 
1016 
1017 def find_keyboard_from_dir():
1018     """Returns a keyboard name based on the user's current directory.
1019     """
1020-    relative_cwd = qmk.path.under_qmk_firmware()
1021+    relative_cwd = qmk.path.under_qmk_userspace()
1022+    if not relative_cwd:
1023+        relative_cwd = qmk.path.under_qmk_firmware()
1024 
1025     if relative_cwd and len(relative_cwd.parts) > 1 and relative_cwd.parts[0] == 'keyboards':
1026         # Attempt to extract the keyboard name from the current directory
1027@@ -133,6 +137,22 @@ def keyboard_folder(keyboard):
1028     return keyboard
1029 
1030 
1031+def keyboard_aliases(keyboard):
1032+    """Returns the list of aliases for the supplied keyboard.
1033+
1034+    Includes the keyboard itself.
1035+    """
1036+    aliases = json_load(Path('data/mappings/keyboard_aliases.hjson'))
1037+
1038+    if keyboard in aliases:
1039+        keyboard = aliases[keyboard].get('target', keyboard)
1040+
1041+    keyboards = set(filter(lambda k: aliases[k].get('target', '') == keyboard, aliases.keys()))
1042+    keyboards.add(keyboard)
1043+    keyboards = list(sorted(keyboards))
1044+    return keyboards
1045+
1046+
1047 def keyboard_folder_or_all(keyboard):
1048     """Returns the actual keyboard folder.
1049 
1050diff --git a/lib/python/qmk/keymap.py b/lib/python/qmk/keymap.py
1051index 281c53cfda296fc9f2a7e0728f66f6b718e7f35d..b7bf897377c501439d00e483116ade144ba5ad28 100644
1052--- a/lib/python/qmk/keymap.py
1053+++ b/lib/python/qmk/keymap.py
1054@@ -12,7 +12,8 @@ from pygments.token import Token
1055 from pygments import lex
1056 
1057 import qmk.path
1058-from qmk.keyboard import find_keyboard_from_dir, keyboard_folder
1059+from qmk.constants import QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE
1060+from qmk.keyboard import find_keyboard_from_dir, keyboard_folder, keyboard_aliases
1061 from qmk.errors import CppError
1062 from qmk.info import info_json
1063 
1064@@ -194,29 +195,38 @@ def _strip_any(keycode):
1065 def find_keymap_from_dir(*args):
1066     """Returns `(keymap_name, source)` for the directory provided (or cwd if not specified).
1067     """
1068-    relative_path = qmk.path.under_qmk_firmware(*args)
1069+    def _impl_find_keymap_from_dir(relative_path):
1070+        if relative_path and len(relative_path.parts) > 1:
1071+            # If we're in `qmk_firmware/keyboards` and `keymaps` is in our path, try to find the keyboard name.
1072+            if relative_path.parts[0] == 'keyboards' and 'keymaps' in relative_path.parts:
1073+                current_path = Path('/'.join(relative_path.parts[1:]))  # Strip 'keyboards' from the front
1074 
1075-    if relative_path and len(relative_path.parts) > 1:
1076-        # If we're in `qmk_firmware/keyboards` and `keymaps` is in our path, try to find the keyboard name.
1077-        if relative_path.parts[0] == 'keyboards' and 'keymaps' in relative_path.parts:
1078-            current_path = Path('/'.join(relative_path.parts[1:]))  # Strip 'keyboards' from the front
1079+                if 'keymaps' in current_path.parts and current_path.name != 'keymaps':
1080+                    while current_path.parent.name != 'keymaps':
1081+                        current_path = current_path.parent
1082 
1083-            if 'keymaps' in current_path.parts and current_path.name != 'keymaps':
1084-                while current_path.parent.name != 'keymaps':
1085-                    current_path = current_path.parent
1086+                    return current_path.name, 'keymap_directory'
1087 
1088-                return current_path.name, 'keymap_directory'
1089+            # If we're in `qmk_firmware/layouts` guess the name from the community keymap they're in
1090+            elif relative_path.parts[0] == 'layouts' and is_keymap_dir(relative_path):
1091+                return relative_path.name, 'layouts_directory'
1092 
1093-        # If we're in `qmk_firmware/layouts` guess the name from the community keymap they're in
1094-        elif relative_path.parts[0] == 'layouts' and is_keymap_dir(relative_path):
1095-            return relative_path.name, 'layouts_directory'
1096+            # If we're in `qmk_firmware/users` guess the name from the userspace they're in
1097+            elif relative_path.parts[0] == 'users':
1098+                # Guess the keymap name based on which userspace they're in
1099+                return relative_path.parts[1], 'users_directory'
1100+        return None, None
1101 
1102-        # If we're in `qmk_firmware/users` guess the name from the userspace they're in
1103-        elif relative_path.parts[0] == 'users':
1104-            # Guess the keymap name based on which userspace they're in
1105-            return relative_path.parts[1], 'users_directory'
1106+    if HAS_QMK_USERSPACE:
1107+        name, source = _impl_find_keymap_from_dir(qmk.path.under_qmk_userspace(*args))
1108+        if name and source:
1109+            return name, source
1110 
1111-    return None, None
1112+    name, source = _impl_find_keymap_from_dir(qmk.path.under_qmk_firmware(*args))
1113+    if name and source:
1114+        return name, source
1115+
1116+    return (None, None)
1117 
1118 
1119 def keymap_completer(prefix, action, parser, parsed_args):
1120@@ -417,29 +427,45 @@ def locate_keymap(keyboard, keymap):
1121         raise KeyError('Invalid keyboard: ' + repr(keyboard))
1122 
1123     # Check the keyboard folder first, last match wins
1124-    checked_dirs = ''
1125     keymap_path = ''
1126 
1127-    for dir in keyboard_folder(keyboard).split('/'):
1128-        if checked_dirs:
1129-            checked_dirs = '/'.join((checked_dirs, dir))
1130-        else:
1131-            checked_dirs = dir
1132+    search_dirs = [QMK_FIRMWARE]
1133+    keyboard_dirs = [keyboard_folder(keyboard)]
1134+    if HAS_QMK_USERSPACE:
1135+        # When we've got userspace, check there _last_ as we want them to override anything in the main repo.
1136+        search_dirs.append(QMK_USERSPACE)
1137+        # We also want to search for any aliases as QMK's folder structure may have changed, with an alias, but the user
1138+        # hasn't updated their keymap location yet.
1139+        keyboard_dirs.extend(keyboard_aliases(keyboard))
1140+        keyboard_dirs = list(set(keyboard_dirs))
1141+
1142+    for search_dir in search_dirs:
1143+        for keyboard_dir in keyboard_dirs:
1144+            checked_dirs = ''
1145+            for dir in keyboard_dir.split('/'):
1146+                if checked_dirs:
1147+                    checked_dirs = '/'.join((checked_dirs, dir))
1148+                else:
1149+                    checked_dirs = dir
1150 
1151-        keymap_dir = Path('keyboards') / checked_dirs / 'keymaps'
1152+                keymap_dir = Path(search_dir) / Path('keyboards') / checked_dirs / 'keymaps'
1153 
1154diff --git a/lib/python/qmk/path.py b/lib/python/qmk/path.py
1155index 94582a05e080936ebeb611b908ec89bad558f632..74364ee04b02440450887269e21dae1db89cd611 100644
1156--- a/lib/python/qmk/path.py
1157+++ b/lib/python/qmk/path.py
1158@@ -5,7 +5,7 @@ import os
1159 import argparse
1160 from pathlib import Path
1161 
1162-from qmk.constants import MAX_KEYBOARD_SUBFOLDERS, QMK_FIRMWARE
1163+from qmk.constants import MAX_KEYBOARD_SUBFOLDERS, QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE
1164 from qmk.errors import NoSuchKeyboardError
1165 
1166 
1167@@ -28,6 +28,40 @@ def under_qmk_firmware(path=Path(os.environ['ORIG_CWD'])):
1168         return None
1169 
1170 
1171+def under_qmk_userspace(path=Path(os.environ['ORIG_CWD'])):
1172+    """Returns a Path object representing the relative path under $QMK_USERSPACE, or None.
1173+    """
1174+    try:
1175+        if HAS_QMK_USERSPACE:
1176+            return path.relative_to(QMK_USERSPACE)
1177+    except ValueError:
1178+        pass
1179+    return None
1180+
1181+
1182+def is_under_qmk_firmware(path=Path(os.environ['ORIG_CWD'])):
1183+    """Returns a boolean if the input path is a child under qmk_firmware.
1184+    """
1185+    if path is None:
1186+        return False
1187+    try:
1188+        return Path(os.path.commonpath([Path(path), QMK_FIRMWARE])) == QMK_FIRMWARE
1189+    except ValueError:
1190+        return False
1191+
1192+
1193+def is_under_qmk_userspace(path=Path(os.environ['ORIG_CWD'])):
1194+    """Returns a boolean if the input path is a child under $QMK_USERSPACE.
1195+    """
1196+    if path is None:
1197+        return False
1198+    try:
1199+        if HAS_QMK_USERSPACE:
1200+            return Path(os.path.commonpath([Path(path), QMK_USERSPACE])) == QMK_USERSPACE
1201+    except ValueError:
1202+        return False
1203+
1204+
1205 def keyboard(keyboard_name):
1206     """Returns the path to a keyboard's directory relative to the qmk root.
1207     """
1208@@ -45,11 +79,28 @@ def keymaps(keyboard_name):
1209     keyboard_folder = keyboard(keyboard_name)
1210     found_dirs = []
1211 
1212+    if HAS_QMK_USERSPACE:
1213+        this_keyboard_folder = Path(QMK_USERSPACE) / keyboard_folder
1214+        for _ in range(MAX_KEYBOARD_SUBFOLDERS):
1215+            if (this_keyboard_folder / 'keymaps').exists():
1216+                found_dirs.append((this_keyboard_folder / 'keymaps').resolve())
1217+
1218+            this_keyboard_folder = this_keyboard_folder.parent
1219+            if this_keyboard_folder.resolve() == QMK_USERSPACE.resolve():
1220+                break
1221+
1222+        # We don't have any relevant keymap directories in userspace, so we'll use the fully-qualified path instead.
1223+        if len(found_dirs) == 0:
1224+            found_dirs.append((QMK_USERSPACE / keyboard_folder / 'keymaps').resolve())
1225+
1226+    this_keyboard_folder = QMK_FIRMWARE / keyboard_folder
1227     for _ in range(MAX_KEYBOARD_SUBFOLDERS):
1228-        if (keyboard_folder / 'keymaps').exists():
1229-            found_dirs.append((keyboard_folder / 'keymaps').resolve())
1230+        if (this_keyboard_folder / 'keymaps').exists():
1231+            found_dirs.append((this_keyboard_folder / 'keymaps').resolve())
1232 
1233-        keyboard_folder = keyboard_folder.parent
1234+        this_keyboard_folder = this_keyboard_folder.parent
1235+        if this_keyboard_folder.resolve() == QMK_FIRMWARE.resolve():
1236+            break
1237 
1238     if len(found_dirs) > 0:
1239         return found_dirs
1240diff --git a/lib/python/qmk/userspace.py b/lib/python/qmk/userspace.py
1241new file mode 100644
1242index 0000000000000000000000000000000000000000..3783568006993c7f4381d33b8f876e9a0d39b5bb
1243--- /dev/null
1244+++ b/lib/python/qmk/userspace.py
1245@@ -0,0 +1,185 @@
1246+# Copyright 2023 Nick Brassel (@tzarc)
1247+# SPDX-License-Identifier: GPL-2.0-or-later
1248+from os import environ
1249+from pathlib import Path
1250+import json
1251+import jsonschema
1252+
1253+from milc import cli
1254+
1255+from qmk.json_schema import validate, json_load
1256+from qmk.json_encoders import UserspaceJSONEncoder
1257+
1258+
1259+def qmk_userspace_paths():
1260+    test_dirs = []
1261+
1262+    # If we're already in a directory with a qmk.json and a keyboards or layouts directory, interpret it as userspace
1263+    current_dir = Path(environ['ORIG_CWD'])
1264+    while len(current_dir.parts) > 1:
1265+        if (current_dir / 'qmk.json').is_file():
1266+            test_dirs.append(current_dir)
1267+        current_dir = current_dir.parent
1268+
1269+    # If we have a QMK_USERSPACE environment variable, use that
1270+    if environ.get('QMK_USERSPACE') is not None:
1271+        current_dir = Path(environ.get('QMK_USERSPACE'))
1272+        if current_dir.is_dir():
1273+            test_dirs.append(current_dir)
1274+
1275+    # If someone has configured a directory, use that
1276+    if cli.config.user.overlay_dir is not None:
1277+        current_dir = Path(cli.config.user.overlay_dir)
1278+        if current_dir.is_dir():
1279+            test_dirs.append(current_dir)
1280+
1281+    return test_dirs
1282+
1283+
1284+def qmk_userspace_validate(path):
1285+    # Construct a UserspaceDefs object to ensure it validates correctly
1286+    if (path / 'qmk.json').is_file():
1287+        UserspaceDefs(path / 'qmk.json')
1288+        return
1289+
1290+    # No qmk.json file found
1291+    raise FileNotFoundError('No qmk.json file found.')
1292+
1293+
1294+def detect_qmk_userspace():
1295+    # Iterate through all the detected userspace paths and return the first one that validates correctly
1296+    test_dirs = qmk_userspace_paths()
1297+    for test_dir in test_dirs:
1298+        try:
1299+            qmk_userspace_validate(test_dir)
1300+            return test_dir
1301+        except FileNotFoundError:
1302+            continue
1303+        except UserspaceValidationError:
1304+            continue
1305+    return None
1306+
1307+
1308+class UserspaceDefs:
1309+    def __init__(self, userspace_json: Path):
1310+        self.path = userspace_json
1311+        self.build_targets = []
1312+        json = json_load(userspace_json)
1313+
1314+        exception = UserspaceValidationError()
1315+        success = False
1316+
1317+        try:
1318+            validate(json, 'qmk.user_repo.v0')  # `qmk.json` must have a userspace_version at minimum
1319+        except jsonschema.ValidationError as err:
1320+            exception.add('qmk.user_repo.v0', err)
1321+            raise exception
1322+
1323+        # Iterate through each version of the schema, starting with the latest and decreasing to v1
1324+        try:
1325+            validate(json, 'qmk.user_repo.v1')
1326+            self.__load_v1(json)
1327+            success = True
1328+        except jsonschema.ValidationError as err:
1329+            exception.add('qmk.user_repo.v1', err)
1330+
1331+        if not success:
1332+            raise exception
1333+
1334+    def save(self):
1335+        target_json = {
1336+            "userspace_version": "1.0",  # Needs to match latest version
1337+            "build_targets": []
1338+        }
1339+
1340+        for e in self.build_targets:
1341+            if isinstance(e, dict):
1342+                target_json['build_targets'].append([e['keyboard'], e['keymap']])
1343+            elif isinstance(e, Path):
1344+                target_json['build_targets'].append(str(e.relative_to(self.path.parent)))