5501e804ff8d41ce656061b91896c4ac8c681d78
- Author
- Nick Brassel <nick@tzarc.org>
- Committer
- GitHub <noreply@github.com>
- Date
Message
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)))