Skip to content

Commit c83efa5

Browse files
committed
Add native console redirection and dotted variable namespace aliases
1 parent bd40d2a commit c83efa5

8 files changed

Lines changed: 193 additions & 9 deletions

File tree

docs/changelog.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
# Changelog
22

3+
## 2026-03-09
4+
5+
### CLI - Native Output Redirection (`>` / `>>`)
6+
- Added console-level post-command redirection in `modules/console.py` (no per-command changes required).
7+
- Supports:
8+
- file targets: `today > temp/today.txt`, `today >> temp/today.txt`
9+
- variable targets: `today > @out`, `today >> @out`
10+
- Redirection is parsed in the console after command parsing and routes captured command stdout to the selected destination.
11+
12+
### CLI - Persistent Variable Write-Through
13+
- `set var status_<indicator>:<value>` now writes through to `user/current_status.yml` and re-syncs mirrored status vars.
14+
- Added `@location` alias for `@status_place` (read/write alias to a single canonical source).
15+
- Added dotted namespace aliases for readability:
16+
- `@status.energy` <-> `status_energy`
17+
- `@profile.nickname` <-> `nickname`
18+
- `@timer.profile` <-> `timer_profile`
19+
- `set var nickname:<value>` now writes through to `user/profile/profile.yml`.
20+
- `set var timer_profile:<name>` now writes through to `user/settings/timer_settings.yml` (`default_profile`) with profile validation against `timer_profiles.yml` when available.
21+
22+
### Tray + Timer UX
23+
- Tray app now shows a timer check-in popup when a schedule block reaches pending confirmation (Done / Skip Today / Later / Start Over / Stretch actions).
24+
- Mini panel and tray timer popup windows now apply the Chronos icon where available.
25+
- Added Windows timer sound fallback (`winsound`) in timer notifications when pygame mixer playback is unavailable.
26+
327
## 2026-03-02
428

529
### Dashboard - Milestones Filtering and Deep-Linking

docs/dev/chs_scripting.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Chronos executes `.chs` scripts with one command per line. Lines support quoted
88
- Use in any command: `echo Hello @name` or `echo Hello @{name}`
99
- Current status is mirrored to vars like `@status_energy`, `@status_focus`, `@status_health`
1010
- `@location` is an alias of `@status_place` (for example, `set var location:home`)
11+
- Dotted namespace aliases are supported (for readability): `@status.energy`, `@profile.nickname`, `@timer.profile`
1112
- Setting `status_*` vars writes through to status YAML: `set var status_energy:high`
1213
- `@timer_profile` mirrors timer default profile; set with `set var timer_profile:classic_pomodoro`
1314
- Escape a literal `@`: use `@@`
@@ -23,6 +24,19 @@ Chronos executes `.chs` scripts with one command per line. Lines support quoted
2324
- Quote values with spaces: `category:"deep work"`.
2425
- Detection rule: a token is treated as a property only if the key starts with a letter and the key contains letters, digits, or underscores. This avoids mis-parsing Windows paths like `C:\Work\file.txt` as properties.
2526

27+
## Output Redirection
28+
29+
Chronos console supports post-command output redirection:
30+
31+
- `>` overwrite target
32+
- `>>` append target
33+
34+
Targets:
35+
- file path (`today > temp/today.txt`)
36+
- variable (`today > @out`, `today >> @out`)
37+
38+
This is implemented at the console routing layer, so it works for commands without requiring per-command changes.
39+
2640
## Conditionals: `if`
2741

2842
Two forms are supported:

docs/documentation_overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ today reschedule
124124

125125
**Features**:
126126
- Variables: `@nickname`, `@status_energy`, `@status_focus`, `@location` (alias `@status_place`), `@timer_profile`, `@var`, `@{var}`
127+
- Dotted aliases are supported: `@status.energy`, `@profile.nickname`, `@timer.profile`
127128
- Conditionals: `if/elseif/else/end` (block and single-line)
128129
- Loops: `repeat`, `for`, `while` (bounded)
129130
- Operators: `== != > < >= <= matches` (regex)

docs/readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ Variables
130130
- Current status values are mirrored as runtime vars like `@status_energy`, `@status_focus`, `@status_health`.
131131
- `@location` is an alias of `@status_place` (same source, no duplicate storage).
132132
- Timer default profile is mirrored as `@timer_profile` (from `user/settings/timer_settings.yml`).
133+
- Dotted aliases are supported for readability: `@status.energy`, `@profile.nickname`, `@timer.profile`.
133134
- Set/read variables programmatically via `modules/variables.py` or CLI patterns.
134135

135136
## Dashboard

docs/reference/cli_commands.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Sets properties of an item or defines a script variable.
3434
**Notes:**
3535
- `set var status_<indicator>:<value>` also updates `user/current_status.yml` (same source used by `status`).
3636
- `set var location:<value>` is an alias for `set var status_place:<value>`.
37+
- Dotted aliases are supported for readability, for example: `status.energy`, `profile.nickname`, `timer.profile`.
3738
- `set var timer_profile:<name>` updates `user/settings/timer_settings.yml` (`default_profile`).
3839

3940
### `get`
@@ -97,6 +98,21 @@ Displays the contents of an item in the terminal.
9798
Displays help information for a command.
9899
**Usage:** `help <command>`
99100

101+
### Console Redirection
102+
Chronos supports console-level output redirection for any command.
103+
104+
**Usage:**
105+
- `<command> > <file_path>` (overwrite file)
106+
- `<command> >> <file_path>` (append file)
107+
- `<command> > @<varname>` (overwrite variable)
108+
- `<command> >> @<varname>` (append variable)
109+
110+
**Examples:**
111+
- `today > temp/today.txt`
112+
- `today >> temp/today.txt`
113+
- `today > @out`
114+
- `today >> @out`
115+
100116
## Scheduling & Tracking
101117

102118
### `today`

modules/console.py

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import shlex
77
import json
88
import re
9+
import io
10+
from contextlib import redirect_stdout
911

1012
try:
1113
import yaml # type: ignore
@@ -945,6 +947,81 @@ def _coerce_value(val: str):
945947
return val
946948

947949

950+
_REDIR_VAR_SIMPLE_RE = re.compile(r"^@([A-Za-z_][A-Za-z0-9_]*)$")
951+
_REDIR_VAR_BRACED_RE = re.compile(r"^@\{([A-Za-z_][A-Za-z0-9_]*)\}$")
952+
953+
954+
def _parse_redirection_target(raw_target: str):
955+
"""
956+
Parse redirection target token.
957+
- @name / @{name} => variable target
958+
- otherwise => file path target (after variable expansion)
959+
Returns: (kind, target)
960+
"""
961+
token = str(raw_target or "").strip()
962+
if not token:
963+
return None, None
964+
m = _REDIR_VAR_SIMPLE_RE.match(token) or _REDIR_VAR_BRACED_RE.match(token)
965+
if m:
966+
return "var", m.group(1)
967+
# For file targets we allow variable expansion in token text.
968+
return "file", Variables.expand_token(token)
969+
970+
971+
def _extract_redirection_tokens(command: str, raw_tokens: list):
972+
"""
973+
Extract a trailing redirection from raw tokens.
974+
Supported: ... > target, ... >> target
975+
Returns: (tokens_without_redirection, op, target_kind, target_value)
976+
"""
977+
if not raw_tokens:
978+
return raw_tokens, None, None, None
979+
op_idx = None
980+
op_val = None
981+
# Take the last operator so regular '>' text in args remains usable.
982+
for i, tok in enumerate(raw_tokens):
983+
t = str(tok).strip()
984+
if t in (">", ">>"):
985+
op_idx = i
986+
op_val = t
987+
if op_idx is None:
988+
return raw_tokens, None, None, None
989+
if op_idx + 1 >= len(raw_tokens):
990+
print("❌ Redirection target missing after > or >>.")
991+
return raw_tokens[:op_idx], None, None, None
992+
target_raw = str(raw_tokens[op_idx + 1] or "").strip()
993+
kind, target = _parse_redirection_target(target_raw)
994+
if not kind or not target:
995+
print("❌ Invalid redirection target.")
996+
return raw_tokens[:op_idx], None, None, None
997+
# Ignore any extra tokens after target to keep behavior deterministic.
998+
return raw_tokens[:op_idx], op_val, kind, target
999+
1000+
1001+
def _route_command_output(op: str, target_kind: str, target_value: str, output_text: str):
1002+
"""
1003+
Route captured command stdout according to redirection.
1004+
"""
1005+
text = str(output_text or "")
1006+
if target_kind == "var":
1007+
var_name = str(target_value).strip()
1008+
if op == ">>":
1009+
prev = Variables.get_var(var_name, "")
1010+
Variables.set_var(var_name, f"{prev}{text}")
1011+
else:
1012+
Variables.set_var(var_name, text)
1013+
return
1014+
1015+
# File target
1016+
path = str(target_value).strip()
1017+
if not os.path.isabs(path):
1018+
path = os.path.join(ROOT_DIR, path)
1019+
os.makedirs(os.path.dirname(path) or ROOT_DIR, exist_ok=True)
1020+
mode = "a" if op == ">>" else "w"
1021+
with open(path, mode, encoding="utf-8") as fh:
1022+
fh.write(text)
1023+
1024+
9481025
def parse_input(input_parts):
9491026
command = None
9501027
args = []
@@ -961,11 +1038,19 @@ def parse_input(input_parts):
9611038
args = raw
9621039
return command, args, properties
9631040

1041+
# Parse redirection before variable expansion so `@var` targets are kept
1042+
# as variable references instead of being expanded to values.
1043+
raw, redir_op, redir_kind, redir_target = _extract_redirection_tokens(command, raw)
1044+
9641045
# Expand variables in all tokens first
965-
parts = Variables.expand_list(input_parts)
1046+
parts = Variables.expand_list([command] + raw)
9661047

9671048
command = parts[0]
9681049
raw = parts[1:]
1050+
if redir_op:
1051+
properties["__redir_op"] = redir_op
1052+
properties["__redir_kind"] = redir_kind
1053+
properties["__redir_target"] = redir_target
9691054

9701055
# Special-case: keep raw args to allow colon syntax
9711056
if command.lower() == 'set' and raw and str(raw[0]).lower() == 'var':
@@ -993,35 +1078,52 @@ def parse_input(input_parts):
9931078
def run_command(command_name, args, properties):
9941079
command_name = resolve_command_alias(command_name)
9951080
try:
1081+
props_local = dict(properties or {})
1082+
redir_op = props_local.pop("__redir_op", None)
1083+
redir_kind = props_local.pop("__redir_kind", None)
1084+
redir_target = props_local.pop("__redir_target", None)
9961085
suppress = False
9971086
try:
9981087
if os.environ.get("CHRONOS_SUPPRESS_MACROS"):
9991088
suppress = True
1000-
if str((properties or {}).get("no_macros")).lower() in ("1", "true", "yes"):
1089+
if str((props_local or {}).get("no_macros")).lower() in ("1", "true", "yes"):
10011090
suppress = True
10021091
except Exception:
10031092
pass
10041093
if not suppress:
10051094
try:
10061095
from modules import macro_engine
1007-
MacroEngine.run_before(command_name, args, properties)
1096+
MacroEngine.run_before(command_name, args, props_local)
10081097
except Exception:
10091098
pass
1010-
run_command_core(command_name, args, properties)
1099+
if redir_op in (">", ">>") and redir_kind in ("var", "file") and redir_target:
1100+
capture = io.StringIO()
1101+
with redirect_stdout(capture):
1102+
run_command_core(command_name, args, props_local)
1103+
try:
1104+
_route_command_output(redir_op, redir_kind, redir_target, capture.getvalue())
1105+
except Exception as e:
1106+
print(f"❌ Redirection failed: {e}")
1107+
# Fallback: print captured output so it isn't lost.
1108+
out = capture.getvalue()
1109+
if out:
1110+
print(out, end="" if out.endswith("\n") else "\n")
1111+
else:
1112+
run_command_core(command_name, args, props_local)
10111113
try:
10121114
from modules.achievement import evaluator as AchievementEvaluator # type: ignore
10131115
AchievementEvaluator.emit_event("command_executed", {
10141116
"command": str(command_name or "").lower(),
10151117
"args": list(args or []),
1016-
"properties": dict(properties or {}),
1118+
"properties": dict(props_local or {}),
10171119
})
10181120
except Exception:
10191121
pass
10201122
finally:
10211123
try:
10221124
if not suppress:
10231125
from modules import macro_engine
1024-
MacroEngine.run_after(command_name, args, properties, {"ok": True})
1126+
MacroEngine.run_after(command_name, args, props_local, {"ok": True})
10251127
except Exception:
10261128
pass
10271129

modules/variables.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,21 @@
55
_STATUS_MANAGED = set()
66
_ALIASES = {
77
"location": "status_place",
8+
"profile.nickname": "nickname",
9+
"timer.profile": "timer_profile",
810
}
911

1012

1113
def canonical_var_name(name: str) -> str:
1214
raw = str(name or "").strip()
1315
if not raw:
1416
return raw
15-
mapped = _ALIASES.get(raw.lower())
17+
low = raw.lower()
18+
# Namespace illusion: map dotted status keys to canonical flat keys.
19+
if low.startswith("status.") and len(raw) > len("status."):
20+
tail = raw[len("status."):]
21+
return f"status_{_status_slug(tail)}"
22+
mapped = _ALIASES.get(low)
1623
return mapped if mapped else raw
1724

1825

@@ -61,9 +68,10 @@ def sync_status_vars(status_map):
6168
_STATUS_MANAGED = next_managed
6269

6370

64-
_re_braced = re.compile(r"@\{([A-Za-z_][A-Za-z0-9_]*)\}")
71+
_VAR_TOKEN = r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
72+
_re_braced = re.compile(rf"@\{{({_VAR_TOKEN})\}}")
6573
# Only expand @var when not preceded by a word char to avoid emails/usernames
66-
_re_simple = re.compile(r"(?<![A-Za-z0-9_])@([A-Za-z_][A-Za-z0-9_]*)")
74+
_re_simple = re.compile(rf"(?<![A-Za-z0-9_])@({_VAR_TOKEN})")
6775

6876

6977
def _replace_match(match):

tests/test_variables.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,23 @@ def test_location_alias_maps_to_status_place(self):
5555
self.assertEqual(Variables.get_var("status_place"), "office")
5656
self.assertEqual(Variables.get_var("location"), "office")
5757

58+
def test_dotted_status_namespace_alias_maps_to_flat(self):
59+
Variables.set_var("status.energy", "high")
60+
self.assertEqual(Variables.get_var("status_energy"), "high")
61+
self.assertEqual(Variables.get_var("status.energy"), "high")
62+
63+
def test_dotted_profile_and_timer_aliases(self):
64+
Variables.set_var("profile.nickname", "Alice")
65+
Variables.set_var("timer.profile", "classic_pomodoro")
66+
self.assertEqual(Variables.get_var("nickname"), "Alice")
67+
self.assertEqual(Variables.get_var("timer_profile"), "classic_pomodoro")
68+
self.assertEqual(Variables.get_var("profile.nickname"), "Alice")
69+
self.assertEqual(Variables.get_var("timer.profile"), "classic_pomodoro")
70+
71+
def test_expansion_dotted_tokens(self):
72+
Variables.set_var("status.energy", "low")
73+
text = "Energy is @status.energy and braced @{status.energy}"
74+
self.assertEqual(Variables.expand_token(text), "Energy is low and braced low")
75+
5876
if __name__ == '__main__':
5977
unittest.main()

0 commit comments

Comments
 (0)