t3code: add module
Adds programs.t3code so T3 Code can be installed and configured from Home Manager, including optional merge semantics when the app mutates its config files. Includes a news entry and tests for empty, merged, and immutable setups.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
time = "2026-05-10T20:18:04+00:00";
|
||||
condition = true;
|
||||
message = ''
|
||||
A new module is available: 'programs.t3code'.
|
||||
|
||||
T3 Code is a minimal web GUI for coding agents; the module installs
|
||||
the application and can manage its JSON settings in '~/.t3/userdata/'.
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (lib)
|
||||
literalExpression
|
||||
mkIf
|
||||
mkMerge
|
||||
mkOption
|
||||
types
|
||||
;
|
||||
|
||||
cfg = config.programs.t3code;
|
||||
jsonFormat = pkgs.formats.json { };
|
||||
json5 = pkgs.python3Packages.toPythonApplication pkgs.python3Packages.json5;
|
||||
|
||||
impureConfigMerger = empty: jqOperation: path: staticSettings: ''
|
||||
mkdir -p "$(dirname -- ${lib.escapeShellArg path})"
|
||||
if [ ! -e ${lib.escapeShellArg path} ]; then
|
||||
echo ${lib.escapeShellArg empty} > ${lib.escapeShellArg path}
|
||||
fi
|
||||
dynamic="$(${lib.getExe json5} --as-json ${lib.escapeShellArg path})"
|
||||
static="$(cat ${lib.escapeShellArg staticSettings})"
|
||||
config="$(${lib.getExe pkgs.jq} -n ${lib.escapeShellArg jqOperation} --argjson dynamic "$dynamic" --argjson static "$static")"
|
||||
printf '%s\n' "$config" > ${lib.escapeShellArg path}
|
||||
unset config
|
||||
'';
|
||||
|
||||
userDataDir = "${config.home.homeDirectory}/.t3/userdata";
|
||||
in
|
||||
{
|
||||
meta.maintainers = [
|
||||
lib.maintainers.iamanaws
|
||||
];
|
||||
|
||||
options.programs.t3code = {
|
||||
enable = lib.mkEnableOption "T3 Code, a minimal web GUI for coding agents";
|
||||
|
||||
package = mkOption {
|
||||
type = types.nullOr types.package;
|
||||
default = pkgs.t3code or null;
|
||||
defaultText = literalExpression "pkgs.t3code";
|
||||
description = ''
|
||||
The t3code package to install.
|
||||
'';
|
||||
};
|
||||
|
||||
mutableUserSettings = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
example = false;
|
||||
description = ''
|
||||
Whether user settings ({file}`settings.json`) can be updated by t3code.
|
||||
'';
|
||||
};
|
||||
|
||||
mutableKeybindings = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
example = false;
|
||||
description = ''
|
||||
Whether user keybindings ({file}`keybindings.json`) can be updated by t3code.
|
||||
'';
|
||||
};
|
||||
|
||||
mutableClientSettings = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
example = false;
|
||||
description = ''
|
||||
Whether client settings ({file}`client-settings.json`) can be updated by t3code.
|
||||
'';
|
||||
};
|
||||
|
||||
userSettings = mkOption {
|
||||
inherit (jsonFormat) type;
|
||||
default = { };
|
||||
example = literalExpression ''
|
||||
{
|
||||
enableAssistantStreaming = true;
|
||||
providerInstances = {
|
||||
codex = {
|
||||
driver = "codex";
|
||||
enabled = true;
|
||||
config = {
|
||||
enabled = true;
|
||||
binaryPath = "codex";
|
||||
homePath = "";
|
||||
shadowHomePath = "";
|
||||
customModels = [ ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Configuration written to t3code's {file}`settings.json`.
|
||||
'';
|
||||
};
|
||||
|
||||
keybindings = mkOption {
|
||||
inherit (jsonFormat) type;
|
||||
default = [ ];
|
||||
example = literalExpression ''
|
||||
[
|
||||
{
|
||||
key = "mod+j";
|
||||
command = "terminal.toggle";
|
||||
}
|
||||
{
|
||||
key = "mod+d";
|
||||
command = "terminal.split";
|
||||
when = "terminalFocus";
|
||||
}
|
||||
{
|
||||
key = "mod+d";
|
||||
command = "diff.toggle";
|
||||
when = "!terminalFocus";
|
||||
}
|
||||
]
|
||||
'';
|
||||
description = ''
|
||||
Configuration written to t3code's {file}`keybindings.json`.
|
||||
'';
|
||||
};
|
||||
|
||||
clientSettings = mkOption {
|
||||
inherit (jsonFormat) type;
|
||||
default = { };
|
||||
example = literalExpression ''
|
||||
{
|
||||
settings = {
|
||||
favorites = [
|
||||
{
|
||||
provider = "codex";
|
||||
model = "gpt-5.5";
|
||||
}
|
||||
];
|
||||
sidebarProjectGroupingMode = "repository";
|
||||
timestampFormat = "locale";
|
||||
};
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Configuration written to t3code's {file}`client-settings.json`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
home.packages = mkIf (cfg.package != null) [ cfg.package ];
|
||||
|
||||
home.activation = mkMerge [
|
||||
(mkIf (cfg.mutableUserSettings && cfg.userSettings != { }) {
|
||||
t3codeSettingsActivation = lib.hm.dag.entryAfter [ "linkGeneration" ] (
|
||||
impureConfigMerger "{}" "$dynamic * $static" "${userDataDir}/settings.json" (
|
||||
jsonFormat.generate "t3code-user-settings" cfg.userSettings
|
||||
)
|
||||
);
|
||||
})
|
||||
(mkIf (cfg.mutableKeybindings && cfg.keybindings != [ ]) {
|
||||
t3codeKeybindingsActivation = lib.hm.dag.entryAfter [ "linkGeneration" ] (
|
||||
impureConfigMerger "[]"
|
||||
"$dynamic + $static | group_by([.key, .when]) | map(reduce .[] as $item ({}; . * $item))"
|
||||
"${userDataDir}/keybindings.json"
|
||||
(jsonFormat.generate "t3code-user-keybindings" cfg.keybindings)
|
||||
);
|
||||
})
|
||||
(mkIf (cfg.mutableClientSettings && cfg.clientSettings != { }) {
|
||||
t3codeClientSettingsActivation = lib.hm.dag.entryAfter [ "linkGeneration" ] (
|
||||
impureConfigMerger "{}" "$dynamic * $static" "${userDataDir}/client-settings.json" (
|
||||
jsonFormat.generate "t3code-client-settings" cfg.clientSettings
|
||||
)
|
||||
);
|
||||
})
|
||||
];
|
||||
|
||||
home.file = mkMerge [
|
||||
(mkIf (!cfg.mutableUserSettings && cfg.userSettings != { }) {
|
||||
".t3/userdata/settings.json".source = jsonFormat.generate "t3code-user-settings" cfg.userSettings;
|
||||
})
|
||||
(mkIf (!cfg.mutableKeybindings && cfg.keybindings != [ ]) {
|
||||
".t3/userdata/keybindings.json".source =
|
||||
jsonFormat.generate "t3code-user-keybindings" cfg.keybindings;
|
||||
})
|
||||
(mkIf (!cfg.mutableClientSettings && cfg.clientSettings != { }) {
|
||||
".t3/userdata/client-settings.json".source =
|
||||
jsonFormat.generate "t3code-client-settings" cfg.clientSettings;
|
||||
})
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
t3code-settings = ./settings.nix;
|
||||
t3code-settings-immutable = ./settings-immutable.nix;
|
||||
t3code-settings-empty = ./settings-empty.nix;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{ config, ... }:
|
||||
|
||||
{
|
||||
programs.t3code = {
|
||||
enable = true;
|
||||
package = config.lib.test.mkStubPackage { };
|
||||
};
|
||||
|
||||
nmt.script = ''
|
||||
assertPathNotExists "home-files/.t3/userdata/settings.json"
|
||||
assertPathNotExists "home-files/.t3/userdata/keybindings.json"
|
||||
assertPathNotExists "home-files/.t3/userdata/client-settings.json"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{ config, ... }:
|
||||
|
||||
{
|
||||
programs.t3code = {
|
||||
enable = true;
|
||||
package = config.lib.test.mkStubPackage { };
|
||||
mutableUserSettings = false;
|
||||
mutableKeybindings = false;
|
||||
mutableClientSettings = false;
|
||||
userSettings = {
|
||||
enableAssistantStreaming = true;
|
||||
providerInstances.codex = {
|
||||
driver = "codex";
|
||||
enabled = true;
|
||||
config = {
|
||||
enabled = true;
|
||||
binaryPath = "codex";
|
||||
homePath = "";
|
||||
shadowHomePath = "";
|
||||
customModels = [ ];
|
||||
};
|
||||
};
|
||||
};
|
||||
keybindings = [
|
||||
{
|
||||
key = "mod+d";
|
||||
command = "terminal.split";
|
||||
when = "terminalFocus";
|
||||
}
|
||||
{
|
||||
key = "mod+d";
|
||||
command = "diff.toggle";
|
||||
when = "!terminalFocus";
|
||||
}
|
||||
];
|
||||
clientSettings = {
|
||||
settings = {
|
||||
favorites = [
|
||||
{
|
||||
provider = "codex";
|
||||
model = "gpt-5.5";
|
||||
}
|
||||
];
|
||||
sidebarProjectGroupingMode = "repository";
|
||||
timestampFormat = "locale";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nmt.script =
|
||||
let
|
||||
expectedSettings = builtins.toFile "expected-settings.json" ''
|
||||
{
|
||||
"enableAssistantStreaming": true,
|
||||
"providerInstances": {
|
||||
"codex": {
|
||||
"config": {
|
||||
"binaryPath": "codex",
|
||||
"customModels": [],
|
||||
"enabled": true,
|
||||
"homePath": "",
|
||||
"shadowHomePath": ""
|
||||
},
|
||||
"driver": "codex",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
'';
|
||||
|
||||
expectedKeybindings = builtins.toFile "expected-keybindings.json" ''
|
||||
[
|
||||
{
|
||||
"command": "terminal.split",
|
||||
"key": "mod+d",
|
||||
"when": "terminalFocus"
|
||||
},
|
||||
{
|
||||
"command": "diff.toggle",
|
||||
"key": "mod+d",
|
||||
"when": "!terminalFocus"
|
||||
}
|
||||
]
|
||||
'';
|
||||
|
||||
expectedClientSettings = builtins.toFile "expected-client-settings.json" ''
|
||||
{
|
||||
"settings": {
|
||||
"favorites": [
|
||||
{
|
||||
"model": "gpt-5.5",
|
||||
"provider": "codex"
|
||||
}
|
||||
],
|
||||
"sidebarProjectGroupingMode": "repository",
|
||||
"timestampFormat": "locale"
|
||||
}
|
||||
}
|
||||
'';
|
||||
in
|
||||
''
|
||||
assertFileExists "home-files/.t3/userdata/settings.json"
|
||||
assertFileContent "home-files/.t3/userdata/settings.json" "${expectedSettings}"
|
||||
|
||||
assertFileExists "home-files/.t3/userdata/keybindings.json"
|
||||
assertFileContent "home-files/.t3/userdata/keybindings.json" "${expectedKeybindings}"
|
||||
|
||||
assertFileExists "home-files/.t3/userdata/client-settings.json"
|
||||
assertFileContent "home-files/.t3/userdata/client-settings.json" "${expectedClientSettings}"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
{
|
||||
programs.t3code = {
|
||||
enable = true;
|
||||
package = config.lib.test.mkStubPackage { };
|
||||
userSettings = {
|
||||
enableAssistantStreaming = true;
|
||||
providerInstances.codex = {
|
||||
driver = "codex";
|
||||
enabled = true;
|
||||
config = {
|
||||
enabled = true;
|
||||
binaryPath = "codex";
|
||||
homePath = "";
|
||||
shadowHomePath = "";
|
||||
customModels = [ ];
|
||||
};
|
||||
};
|
||||
};
|
||||
keybindings = [
|
||||
{
|
||||
key = "mod+d";
|
||||
command = "terminal.split";
|
||||
when = "terminalFocus";
|
||||
}
|
||||
{
|
||||
key = "mod+d";
|
||||
command = "diff.toggle";
|
||||
when = "!terminalFocus";
|
||||
}
|
||||
];
|
||||
clientSettings = {
|
||||
settings = {
|
||||
favorites = [
|
||||
{
|
||||
provider = "codex";
|
||||
model = "gpt-5.5";
|
||||
}
|
||||
];
|
||||
sidebarProjectGroupingMode = "repository";
|
||||
timestampFormat = "locale";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
home.homeDirectory = lib.mkForce "/@TMPDIR@/hm-user";
|
||||
|
||||
nmt.script =
|
||||
let
|
||||
preexistingSettings = builtins.toFile "preexisting-settings.json" ''
|
||||
{
|
||||
"enableAssistantStreaming": false,
|
||||
"providerInstances": {
|
||||
"codex": {
|
||||
"driver": "codex",
|
||||
"enabled": false,
|
||||
"config": {
|
||||
"enabled": false,
|
||||
"binaryPath": "old-codex",
|
||||
"homePath": "",
|
||||
"shadowHomePath": "",
|
||||
"customModels": []
|
||||
}
|
||||
},
|
||||
"opencode": {
|
||||
"driver": "opencode",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"binaryPath": "opencode",
|
||||
"serverUrl": "",
|
||||
"serverPassword": "",
|
||||
"customModels": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'';
|
||||
|
||||
preexistingKeybindings = builtins.toFile "preexisting-keybindings.json" ''
|
||||
[
|
||||
{
|
||||
"key": "mod+d",
|
||||
"command": "old.terminalSplit",
|
||||
"when": "terminalFocus"
|
||||
},
|
||||
{
|
||||
"key": "mod+d",
|
||||
"command": "old.diffToggle",
|
||||
"when": "!terminalFocus"
|
||||
},
|
||||
{
|
||||
"key": "mod+j",
|
||||
"command": "terminal.toggle"
|
||||
}
|
||||
]
|
||||
'';
|
||||
|
||||
preexistingClientSettings = builtins.toFile "preexisting-client-settings.json" ''
|
||||
{
|
||||
"settings": {
|
||||
"favorites": [],
|
||||
"sidebarProjectGroupingMode": "workspace",
|
||||
"timestampFormat": "relative",
|
||||
"unmanagedSetting": true
|
||||
}
|
||||
}
|
||||
'';
|
||||
|
||||
expectedSettings = builtins.toFile "expected-settings.json" ''
|
||||
{
|
||||
"enableAssistantStreaming": true,
|
||||
"providerInstances": {
|
||||
"codex": {
|
||||
"driver": "codex",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"binaryPath": "codex",
|
||||
"homePath": "",
|
||||
"shadowHomePath": "",
|
||||
"customModels": []
|
||||
}
|
||||
},
|
||||
"opencode": {
|
||||
"driver": "opencode",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"binaryPath": "opencode",
|
||||
"serverUrl": "",
|
||||
"serverPassword": "",
|
||||
"customModels": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'';
|
||||
|
||||
expectedKeybindings = builtins.toFile "expected-keybindings.json" ''
|
||||
[
|
||||
{
|
||||
"key": "mod+d",
|
||||
"command": "diff.toggle",
|
||||
"when": "!terminalFocus"
|
||||
},
|
||||
{
|
||||
"key": "mod+d",
|
||||
"command": "terminal.split",
|
||||
"when": "terminalFocus"
|
||||
},
|
||||
{
|
||||
"key": "mod+j",
|
||||
"command": "terminal.toggle"
|
||||
}
|
||||
]
|
||||
'';
|
||||
|
||||
expectedClientSettings = builtins.toFile "expected-client-settings.json" ''
|
||||
{
|
||||
"settings": {
|
||||
"favorites": [
|
||||
{
|
||||
"model": "gpt-5.5",
|
||||
"provider": "codex"
|
||||
}
|
||||
],
|
||||
"sidebarProjectGroupingMode": "repository",
|
||||
"timestampFormat": "locale",
|
||||
"unmanagedSetting": true
|
||||
}
|
||||
}
|
||||
'';
|
||||
|
||||
settingsPath = ".t3/userdata/settings.json";
|
||||
keybindingsPath = ".t3/userdata/keybindings.json";
|
||||
clientSettingsPath = ".t3/userdata/client-settings.json";
|
||||
settingsActivation = pkgs.writeScript "settings-activation" config.home.activation.t3codeSettingsActivation.data;
|
||||
keybindingsActivation = pkgs.writeScript "keybindings-activation" config.home.activation.t3codeKeybindingsActivation.data;
|
||||
clientSettingsActivation = pkgs.writeScript "client-settings-activation" config.home.activation.t3codeClientSettingsActivation.data;
|
||||
in
|
||||
''
|
||||
export HOME=$TMPDIR/hm-user
|
||||
|
||||
mkdir -p $HOME/.t3/userdata
|
||||
cat ${preexistingSettings} > $HOME/${settingsPath}
|
||||
cat ${preexistingKeybindings} > $HOME/${keybindingsPath}
|
||||
cat ${preexistingClientSettings} > $HOME/${clientSettingsPath}
|
||||
|
||||
substitute ${settingsActivation} $TMPDIR/settings-activate --subst-var TMPDIR
|
||||
chmod +x $TMPDIR/settings-activate
|
||||
$TMPDIR/settings-activate
|
||||
|
||||
substitute ${keybindingsActivation} $TMPDIR/keybindings-activate --subst-var TMPDIR
|
||||
chmod +x $TMPDIR/keybindings-activate
|
||||
$TMPDIR/keybindings-activate
|
||||
|
||||
substitute ${clientSettingsActivation} $TMPDIR/client-settings-activate --subst-var TMPDIR
|
||||
chmod +x $TMPDIR/client-settings-activate
|
||||
$TMPDIR/client-settings-activate
|
||||
|
||||
assertFileContent "$HOME/${settingsPath}" "${expectedSettings}"
|
||||
assertFileContent "$HOME/${keybindingsPath}" "${expectedKeybindings}"
|
||||
assertFileContent "$HOME/${clientSettingsPath}" "${expectedClientSettings}"
|
||||
|
||||
$TMPDIR/settings-activate
|
||||
$TMPDIR/keybindings-activate
|
||||
$TMPDIR/client-settings-activate
|
||||
|
||||
assertFileContent "$HOME/${settingsPath}" "${expectedSettings}"
|
||||
assertFileContent "$HOME/${keybindingsPath}" "${expectedKeybindings}"
|
||||
assertFileContent "$HOME/${clientSettingsPath}" "${expectedClientSettings}"
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user