diff --git a/README.md b/README.md index 21f8d438..2f928080 100644 --- a/README.md +++ b/README.md @@ -1114,6 +1114,7 @@ basectl history --format json basectl history --report basectl history --report --format json basectl history --include-internal +basectl history --local-time ``` `basectl history` reads the local history index at @@ -1123,7 +1124,9 @@ as internal records and can be shown with `--include-internal`. History records point to raw logs instead of replacing them, and malformed history lines are ignored while listing recent runs. `--report` prints a privacy-conscious local activity summary with recent commands, failure counts, common failing command families, -and log file locations. Reports do not include raw log contents, compact home +and log file locations. Text and Markdown timestamps use UTC by default; +`--local-time` renders those views in the host's local timezone. JSON retains +canonical UTC timestamps. Reports do not include raw log contents, compact home paths to `~`, and redact secret-looking arguments and URL credentials. The broader local diagnostic report model is described in [docs/observability.md](docs/observability.md). diff --git a/cli/bash/commands/basectl/basectl.sh b/cli/bash/commands/basectl/basectl.sh index ded68144..b017baf8 100644 --- a/cli/bash/commands/basectl/basectl.sh +++ b/cli/bash/commands/basectl/basectl.sh @@ -509,9 +509,10 @@ basectl_history_recordable_command() { basectl_history_record() { local command="$1" local exit_code="$2" + local scope="$3" local wrapper="$BASE_HOME/bin/base-wrapper" local args=() - shift 2 + shift 3 basectl_history_recordable_command "$command" || return 0 [[ -x "$wrapper" ]] || return 0 @@ -519,6 +520,7 @@ basectl_history_record() { args+=(--command "$command") args+=(--run-id "${BASE_CLI_HISTORY_PARENT_RUN_ID:-}") args+=(--exit-code "$exit_code") + args+=(--scope "$scope") if [[ -n "${BASE_CLI_HISTORY_STARTED_AT:-}" ]]; then args+=(--started-at "$BASE_CLI_HISTORY_STARTED_AT") fi @@ -538,7 +540,7 @@ basectl_history_record() { basectl_main() { local base_debug=0 command="" command_status - local history_args=() + local history_args=() history_scope="${BASE_CLI_HISTORY_SCOPE:-primary}" local opt if [[ "${1:-}" == "help" ]]; then @@ -673,7 +675,7 @@ basectl_main() { ;; esac - basectl_history_record "$command" "$command_status" "${history_args[@]}" + basectl_history_record "$command" "$command_status" "$history_scope" "${history_args[@]}" return "$command_status" } diff --git a/cli/bash/commands/basectl/subcommands/history.sh b/cli/bash/commands/basectl/subcommands/history.sh index 639b53fe..83caff7c 100644 --- a/cli/bash/commands/basectl/subcommands/history.sh +++ b/cli/bash/commands/basectl/subcommands/history.sh @@ -18,6 +18,7 @@ Options: Output format. Defaults to text, or Markdown with --report. --report Print a privacy-conscious Markdown or JSON activity report. --include-internal Include delegated internal steps in the output. + --local-time Render text and Markdown timestamps in the local timezone. Defaults to UTC. -v Enable DEBUG logging for this subcommand. -h, --help Show this help text. @@ -47,6 +48,10 @@ base_history_subcommand_main() { args+=("$1") shift ;; + --local-time) + args+=("$1") + shift + ;; --project|--command|--status|--limit|--format) [[ -n "${2:-}" ]] || { base_history_subcommand_usage >&2 diff --git a/cli/bash/commands/basectl/subcommands/onboard.sh b/cli/bash/commands/basectl/subcommands/onboard.sh index 25ff1dec..6e897dfe 100644 --- a/cli/bash/commands/basectl/subcommands/onboard.sh +++ b/cli/bash/commands/basectl/subcommands/onboard.sh @@ -120,6 +120,29 @@ base_onboard_execute() { base_onboard_run_command "$@" } +base_onboard_set_history_context() { + local project="$1" + local manifest_path python_bin resolved_name resolved_root + + export BASE_CLI_HISTORY_PROJECT="$project" + unset BASE_CLI_HISTORY_PROJECT_ROOT BASE_CLI_HISTORY_MANIFEST + if [[ "$project" == base ]]; then + export BASE_CLI_HISTORY_PROJECT_ROOT="$BASE_HOME" + export BASE_CLI_HISTORY_MANIFEST="$BASE_HOME/base_manifest.yaml" + return 0 + fi + + # Onboarding can run before the Base venv exists, so context resolution is + # best effort. The delegated setup/check commands will resolve it later. + setup_ensure_cached_paths + python_bin="$(setup_base_venv_python_bin "$_BASE_SETUP_VENV_DIR_CACHE")" || return 0 + if setup_resolve_project_manifest "$project" "$python_bin" resolved_name resolved_root manifest_path; then + export BASE_CLI_HISTORY_PROJECT="$resolved_name" + export BASE_CLI_HISTORY_PROJECT_ROOT="$resolved_root" + export BASE_CLI_HISTORY_MANIFEST="$manifest_path" + fi +} + base_onboard_subcommand_main() { local dry_run=0 local no_profile=0 @@ -188,6 +211,10 @@ base_onboard_subcommand_main() { setup_args=(setup "$project") doctor_args=(doctor "$project") + # Keep the onboarding record associated with the selected project even + # though its checklist delegates to several other basectl commands. + base_onboard_set_history_context "$project" + if setup_profiles_enabled; then check_args+=(--profile "$(setup_profiles_csv)") setup_args+=(--profile "$(setup_profiles_csv)") diff --git a/cli/bash/commands/basectl/tests/completions.bats b/cli/bash/commands/basectl/tests/completions.bats index 20e30ee9..41482dea 100644 --- a/cli/bash/commands/basectl/tests/completions.bats +++ b/cli/bash/commands/basectl/tests/completions.bats @@ -331,7 +331,7 @@ EOF [[ "$output" == *"logs_options=--command --limit --path --tail --open --lines"* ]] [[ "$output" == *"logs_last_options=--command --lines --format"* ]] [[ "$output" == *"logs_commands=last"* ]] - [[ "$output" == *"history_options=--project --command --status --limit --format --report --include-internal"* ]] + [[ "$output" == *"history_options=--project --command --status --limit --format --report --include-internal --local-time"* ]] [[ "$output" == *"trust_commands=status allow revoke"* ]] [[ "$output" == *"trust_projects=base demo"* ]] [[ "$output" == *"trust_status_options=--workspace --format"* ]] diff --git a/cli/bash/commands/basectl/tests/history.bats b/cli/bash/commands/basectl/tests/history.bats index 177d8ddc..19c66c68 100644 --- a/cli/bash/commands/basectl/tests/history.bats +++ b/cli/bash/commands/basectl/tests/history.bats @@ -19,11 +19,11 @@ exit 1 EOF chmod +x "$python_bin" - run_basectl history --project demo --command check --status error --limit 3 --format json --include-internal + run_basectl history --project demo --command check --status error --limit 3 --format json --include-internal --local-time [ "$status" -eq 0 ] [[ "$output" == *"BASE_PROJECT=base"* ]] - [[ "$output" == *"ARGS=--project demo --command check --status error --limit 3 --format json --include-internal"* ]] + [[ "$output" == *"ARGS=--project demo --command check --status error --limit 3 --format json --include-internal --local-time"* ]] } @test "basectl history forwards report mode to the Python history layer" { @@ -77,6 +77,7 @@ EOF [[ "$output" == *"--project "* ]] [[ "$output" == *"--report"* ]] [[ "$output" == *"--include-internal"* ]] + [[ "$output" == *"--local-time"* ]] [[ "$output" == *"--format "* ]] } diff --git a/cli/bash/commands/basectl/tests/onboard.bats b/cli/bash/commands/basectl/tests/onboard.bats index fd0d3fe6..e4c2ab44 100644 --- a/cli/bash/commands/basectl/tests/onboard.bats +++ b/cli/bash/commands/basectl/tests/onboard.bats @@ -81,6 +81,23 @@ load ./basectl_helpers.bash [[ "$output" != *"unexpected run"* ]] } +@test "basectl onboard preserves the selected project for history" { + run env \ + HOME="$TEST_HOME" \ + BASE_HOME="$BASE_REPO_ROOT" \ + bash -c ' + source "$BASE_HOME/base_init.sh" + source "$BASE_HOME/cli/bash/commands/basectl/subcommands/onboard.sh" + base_onboard_run_command() { return 0; } + base_onboard_subcommand_main bankbuddy --dry-run --no-profile + printf "history_project=%s history_root=%s history_manifest=%s\\n" \ + "${BASE_CLI_HISTORY_PROJECT:-}" "${BASE_CLI_HISTORY_PROJECT_ROOT:-}" "${BASE_CLI_HISTORY_MANIFEST:-}" + ' + + [ "$status" -eq 0 ] + [[ "$output" == *"history_project=bankbuddy history_root= history_manifest="* ]] +} + @test "basectl onboard rejects multiple projects" { run env \ HOME="$TEST_HOME" \ diff --git a/cli/python/base_history/engine.py b/cli/python/base_history/engine.py index 7002e814..2a90f672 100644 --- a/cli/python/base_history/engine.py +++ b/cli/python/base_history/engine.py @@ -58,6 +58,7 @@ class HistoryOptions: output_format: str report: bool include_internal: bool + local_time: bool @dataclass(frozen=True) @@ -89,6 +90,11 @@ def main(argv: list[str] | None = None) -> int: @base_cli.option("--format", "output_format", default="text", help="Output format: text, markdown, or json.") @base_cli.option("--report", is_flag=True, help="Print a privacy-conscious local activity report.") @base_cli.option("--include-internal", is_flag=True, help="Include delegated internal steps in the output.") +@base_cli.option( + "--local-time", + is_flag=True, + help="Render text and Markdown timestamps in local time; defaults to UTC.", +) # pylint: disable=too-many-arguments,too-many-positional-arguments def run( ctx: base_cli.Context, @@ -99,6 +105,7 @@ def run( output_format: str, report: bool, include_internal: bool, + local_time: bool, ) -> int: try: options = HistoryOptions( @@ -109,6 +116,7 @@ def run( output_format=normalize_report_format(output_format) if report else normalize_format(output_format), report=report, include_internal=include_internal, + local_time=local_time, ) except ValueError as exc: ctx.log.error(str(exc)) @@ -121,7 +129,7 @@ def run( if options.output_format == "json": print(json.dumps(history_report_to_json(history_report), indent=2, sort_keys=True)) else: - print_history_report_markdown(history_report) + print_history_report_markdown(history_report, local_time=options.local_time) return base_cli.ExitCode.SUCCESS if options.output_format == "json": @@ -130,7 +138,7 @@ def run( if not records: print(f"No Base command history found under {cache_root / HISTORY_PATH}.") return base_cli.ExitCode.SUCCESS - print_history_table(records) + print_history_table(records, local_time=options.local_time) return base_cli.ExitCode.SUCCESS @@ -235,11 +243,12 @@ def filter_history(records: list[HistoryRecord], options: HistoryOptions) -> lis return filtered -def print_history_table(records: list[HistoryRecord]) -> None: - print(f"{'TIME':<19} {'COMMAND':<12} {'PROJECT':<12} {'STATUS':<6} {'EXIT':<4} LOG") +def print_history_table(records: list[HistoryRecord], *, local_time: bool = False) -> None: + time_label = "TIME (LOCAL)" if local_time else "TIME (UTC)" + print(f"{time_label:<19} {'COMMAND':<12} {'PROJECT':<12} {'STATUS':<6} {'EXIT':<4} LOG") for record in records: print( - f"{display_time(record):<19} " + f"{display_time(record, local_time=local_time):<19} " f"{record.command:<12} " f"{display_project(record):<12} " f"{record.status:<6} " @@ -248,10 +257,11 @@ def print_history_table(records: list[HistoryRecord]) -> None: ) -def display_time(record: HistoryRecord) -> str: +def display_time(record: HistoryRecord, *, local_time: bool = False) -> str: if record.sort_time == datetime.min.replace(tzinfo=timezone.utc): return "?" - return f"{record.sort_time:%Y-%m-%d %H:%M:%S}" + rendered = record.sort_time.astimezone() if local_time else record.sort_time.astimezone(timezone.utc) + return f"{rendered:%Y-%m-%d %H:%M:%S}" def display_project(record: HistoryRecord) -> str: @@ -389,7 +399,7 @@ def compact_report_home_text(value: str) -> str: return value -def print_history_report_markdown(report: HistoryReport) -> None: +def print_history_report_markdown(report: HistoryReport, *, local_time: bool = False) -> None: print("# Base Local Activity Report") print() print(f"- History index: `{sanitize_report_path(str(report.history_path))}`") @@ -423,12 +433,13 @@ def print_history_report_markdown(report: HistoryReport) -> None: print("## Recent Commands") print() - print("| Time | Command | Project | Status | Exit | Log |") + time_label = "Time (LOCAL)" if local_time else "Time (UTC)" + print(f"| {time_label} | Command | Project | Status | Exit | Log |") print("| --- | --- | --- | --- | --- | --- |") for record in report.records: print( "| " - f"{markdown_cell(display_time(record))} | " + f"{markdown_cell(display_time(record, local_time=local_time))} | " f"`{markdown_cell(record.command)}` | " f"{markdown_cell(display_project(record))} | " f"{markdown_cell(record.status)} | " @@ -441,7 +452,7 @@ def print_history_report_markdown(report: HistoryReport) -> None: print("## Failure Details") print() for record in report.failures: - print(f"- `{sanitize_report_text(record.command)}` at {display_time(record)}") + print(f"- `{sanitize_report_text(record.command)}` at {display_time(record, local_time=local_time)}") print(f" - status: {sanitize_report_text(record.status)}") print(f" - exit: {display_exit_code(record)}") print(f" - log: {display_report_log_path(record)}") diff --git a/cli/python/base_history/record.py b/cli/python/base_history/record.py index 257b15c5..f7105e72 100644 --- a/cli/python/base_history/record.py +++ b/cli/python/base_history/record.py @@ -1,4 +1,4 @@ -"""Append a primary history record for a Bash-dispatched command.""" +"""Append a history record for a Bash-dispatched command.""" from __future__ import annotations @@ -20,6 +20,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--command", required=True) parser.add_argument("--run-id", required=True) parser.add_argument("--exit-code", required=True, type=int) + parser.add_argument("--scope", choices=("primary", "internal"), default="primary") parser.add_argument("--started-at") parser.add_argument("--project") parser.add_argument("--project-root") @@ -37,6 +38,7 @@ def main(argv: list[str] | None = None) -> int: started_at=started_at, exit_code=options.exit_code, run_id=options.run_id, + scope=options.scope, project=options.project, project_root=options.project_root, manifest=options.manifest, diff --git a/cli/python/base_history/tests/test_engine.py b/cli/python/base_history/tests/test_engine.py index ee74678f..1cab57b3 100644 --- a/cli/python/base_history/tests/test_engine.py +++ b/cli/python/base_history/tests/test_engine.py @@ -107,13 +107,33 @@ def test_text_output_lists_recent_history_and_missing_log_marker(self) -> None: self.assertEqual(status, 0) self.assertEqual(stderr, "") - self.assertIn("TIME", stdout) + self.assertIn("TIME (UTC)", stdout) self.assertIn("COMMAND", stdout) self.assertIn("PROJECT", stdout) self.assertIn("check", stdout) self.assertIn("error", stdout) self.assertIn("missing", stdout) + def test_local_time_changes_text_label_but_json_remains_canonical(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache_root = Path(tmpdir) + write_history_line( + cache_root, + history_record( + "run-1", + "check", + ended_at="2026-06-10T10:15:00Z", + ), + ) + + status, stdout, stderr = invoke(["--local-time"], cache_root) + json_status, json_stdout, json_stderr = invoke(["--local-time", "--format", "json"], cache_root) + + self.assertEqual((status, stderr), (0, "")) + self.assertIn("TIME (LOCAL)", stdout) + self.assertEqual((json_status, json_stderr), (0, "")) + self.assertEqual(json.loads(json_stdout)[0]["ended_at"], "2026-06-10T10:15:00Z") + def test_json_output_filters_history_records(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: cache_root = Path(tmpdir) @@ -190,6 +210,16 @@ def test_markdown_report_handles_empty_history(self) -> None: self.assertIn("No command history records found.", stdout) self.assertIn(str(cache_root / "history" / "runs.jsonl"), stdout) + def test_markdown_report_labels_local_time_when_requested(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache_root = Path(tmpdir) + write_history_line(cache_root, history_record("run-1", "check")) + + status, stdout, stderr = invoke(["--report", "--local-time"], cache_root) + + self.assertEqual((status, stderr), (0, "")) + self.assertIn("| Time (LOCAL) | Command |", stdout) + def test_report_json_summarizes_successful_history(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: cache_root = Path(tmpdir) diff --git a/docs/command-reference.md b/docs/command-reference.md index 7c159a4e..28e2f8ce 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -109,8 +109,8 @@ manifest trust. | `basectl logs` | List recent Base CLI runtime logs. | `--command `, `--limit ` | | `basectl logs last` | Print the latest failed command metadata plus a bounded redacted log tail. | `--command `, `--lines `, `--format ` | | `basectl logs --path` | Print the newest matching log path only. | `--command ` | -| `basectl history` | List recent user-facing Base command history records. | `--project `, `--command `, `--status `, `--format `, `--include-internal` | -| `basectl history --report` | Print a local Markdown or JSON activity report from history and log metadata. | `--limit `, `--format ` | +| `basectl history` | List recent user-facing Base command history records. | `--project `, `--command `, `--status `, `--format `, `--include-internal`, `--local-time` | +| `basectl history --report` | Print a local Markdown or JSON activity report from history and log metadata. | `--limit `, `--format `, `--local-time` | | `basectl logs --open` | Open the newest matching log in `PAGER` or `EDITOR`. | `--command ` | | `basectl logs --tail` | Tail and follow the newest matching log. | `--command `, `--lines ` | | `basectl clean` | Remove old Base runtime logs, temp files, and cache entries. | `--older-than `, `--keep-last `, `--dry-run` | diff --git a/docs/observability.md b/docs/observability.md index b730f42a..c5547bb1 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -180,6 +180,9 @@ Expected options: - `--report --format json` prints the same report as deterministic JSON. - `--include-internal` includes delegated resolver, routing, bootstrap, and trust-gate records linked to each primary command. +- Text and Markdown timestamps are labeled `TIME (UTC)`/`Time (UTC)` by default. + `--local-time` renders those views in the host's local timezone; JSON keeps + canonical UTC timestamps for stable automation. `basectl logs` should remain the command for opening or tailing raw log files. `basectl history` should point to logs, not replace them. diff --git a/lib/python/base_cli/history.py b/lib/python/base_cli/history.py index 7a0260d8..945bced5 100644 --- a/lib/python/base_cli/history.py +++ b/lib/python/base_cli/history.py @@ -88,6 +88,7 @@ def write_primary_record( started_at: datetime, exit_code: int, run_id: str, + scope: str = HISTORY_SCOPE_PRIMARY, project: str | None = None, project_root: str | None = None, manifest: str | None = None, @@ -108,7 +109,7 @@ def write_primary_record( "exit_code": exit_code, "status": "ok" if exit_code == 0 else "error", "os": normalized_os(), - "scope": HISTORY_SCOPE_PRIMARY, + "scope": scope, } optional_fields = { "project": project, diff --git a/lib/python/base_cli/tests/test_history.py b/lib/python/base_cli/tests/test_history.py index 2e609d8b..c7e63ee6 100644 --- a/lib/python/base_cli/tests/test_history.py +++ b/lib/python/base_cli/tests/test_history.py @@ -121,6 +121,7 @@ def test_write_primary_record_preserves_user_command_and_project_metadata(self) started_at=history_helpers.utc_now(), exit_code=1, run_id="parent-1", + scope=history_helpers.HISTORY_SCOPE_INTERNAL, project="demo", project_root=str(project_root), manifest=str(manifest), @@ -129,7 +130,7 @@ def test_write_primary_record_preserves_user_command_and_project_metadata(self) self.assertEqual(record["command"], "test") self.assertEqual(record["raw_command"], "basectl") - self.assertEqual(record["scope"], "primary") + self.assertEqual(record["scope"], "internal") self.assertEqual(record["run_id"], "parent-1") self.assertEqual(record["project"], "demo") self.assertEqual(record["project_root"], str(project_root.resolve())) diff --git a/lib/shell/completions/basectl_completion.sh b/lib/shell/completions/basectl_completion.sh index bc364808..f67f1185 100644 --- a/lib/shell/completions/basectl_completion.sh +++ b/lib/shell/completions/basectl_completion.sh @@ -766,7 +766,7 @@ _base_basectl_completion() { fi ;; history) - _base_basectl_completion_compgen "--project --command --status --limit --format --report --include-internal -v -h --help" "$cur" + _base_basectl_completion_compgen "--project --command --status --limit --format --report --include-internal --local-time -v -h --help" "$cur" ;; config) if ((COMP_CWORD == 2)); then diff --git a/lib/shell/completions/basectl_completion.zsh b/lib/shell/completions/basectl_completion.zsh index 222781d4..d9c13c75 100644 --- a/lib/shell/completions/basectl_completion.zsh +++ b/lib/shell/completions/basectl_completion.zsh @@ -916,6 +916,7 @@ _base_basectl_completion() { '--format[Output format]:format:(text json)' \ '--report[Print a privacy-conscious Markdown or JSON activity report]' \ '--include-internal[Include delegated internal steps in the output]' \ + '--local-time[Render text and Markdown timestamps in local time; defaults to UTC]' \ '-v[Enable DEBUG logging]' '(-h --help)'{-h,--help}'[Show help text]' ;; config) diff --git a/lib/shell/completions/tests/completions.bats b/lib/shell/completions/tests/completions.bats index 30670ecb..f3216c63 100644 --- a/lib/shell/completions/tests/completions.bats +++ b/lib/shell/completions/tests/completions.bats @@ -587,7 +587,7 @@ run_zsh_positional_completion() { [[ "$block" == *"--config"* ]] } -@test "Zsh history completion includes report option" { +@test "Zsh history completion includes report and local-time options" { local block block="$( @@ -600,6 +600,7 @@ run_zsh_positional_completion() { [[ "$block" == *"--report"* ]] [[ "$block" == *"--include-internal"* ]] + [[ "$block" == *"--local-time"* ]] } @test "Zsh release completion scopes inspection and publish options" {