Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
8 changes: 5 additions & 3 deletions cli/bash/commands/basectl/basectl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -509,16 +509,18 @@ 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

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
Expand All @@ -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
Expand Down Expand Up @@ -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"
}

Expand Down
5 changes: 5 additions & 0 deletions cli/bash/commands/basectl/subcommands/history.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions cli/bash/commands/basectl/subcommands/onboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)")
Expand Down
2 changes: 1 addition & 1 deletion cli/bash/commands/basectl/tests/completions.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"* ]]
Expand Down
5 changes: 3 additions & 2 deletions cli/bash/commands/basectl/tests/history.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -77,6 +77,7 @@ EOF
[[ "$output" == *"--project <name>"* ]]
[[ "$output" == *"--report"* ]]
[[ "$output" == *"--include-internal"* ]]
[[ "$output" == *"--local-time"* ]]
[[ "$output" == *"--format <text|markdown|json>"* ]]
}

Expand Down
17 changes: 17 additions & 0 deletions cli/bash/commands/basectl/tests/onboard.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
33 changes: 22 additions & 11 deletions cli/python/base_history/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class HistoryOptions:
output_format: str
report: bool
include_internal: bool
local_time: bool


@dataclass(frozen=True)
Expand Down Expand Up @@ -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,
Expand All @@ -99,6 +105,7 @@ def run(
output_format: str,
report: bool,
include_internal: bool,
local_time: bool,
) -> int:
try:
options = HistoryOptions(
Expand All @@ -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))
Expand All @@ -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":
Expand All @@ -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


Expand Down Expand Up @@ -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} "
Expand All @@ -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:
Expand Down Expand Up @@ -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))}`")
Expand Down Expand Up @@ -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)} | "
Expand All @@ -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)}")
Expand Down
4 changes: 3 additions & 1 deletion cli/python/base_history/record.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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")
Expand All @@ -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,
Expand Down
32 changes: 31 additions & 1 deletion cli/python/base_history/tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ manifest trust.
| `basectl logs` | List recent Base CLI runtime logs. | `--command <name>`, `--limit <count>` |
| `basectl logs last` | Print the latest failed command metadata plus a bounded redacted log tail. | `--command <name>`, `--lines <count>`, `--format <text\|json>` |
| `basectl logs --path` | Print the newest matching log path only. | `--command <name>` |
| `basectl history` | List recent user-facing Base command history records. | `--project <name>`, `--command <name>`, `--status <ok\|warn\|error>`, `--format <text\|json>`, `--include-internal` |
| `basectl history --report` | Print a local Markdown or JSON activity report from history and log metadata. | `--limit <count>`, `--format <markdown\|json>` |
| `basectl history` | List recent user-facing Base command history records. | `--project <name>`, `--command <name>`, `--status <ok\|warn\|error>`, `--format <text\|json>`, `--include-internal`, `--local-time` |
| `basectl history --report` | Print a local Markdown or JSON activity report from history and log metadata. | `--limit <count>`, `--format <markdown\|json>`, `--local-time` |
| `basectl logs --open` | Open the newest matching log in `PAGER` or `EDITOR`. | `--command <name>` |
| `basectl logs --tail` | Tail and follow the newest matching log. | `--command <name>`, `--lines <count>` |
| `basectl clean` | Remove old Base runtime logs, temp files, and cache entries. | `--older-than <age>`, `--keep-last <count>`, `--dry-run` |
Expand Down
3 changes: 3 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading