browser-automation-skill 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/SECURITY.md +39 -0
  4. package/SKILL.md +206 -0
  5. package/bin/cli.mjs +55 -0
  6. package/install.sh +143 -0
  7. package/package.json +54 -0
  8. package/references/adapter-candidates.md +40 -0
  9. package/references/browser-mcp-cheatsheet.md +132 -0
  10. package/references/browser-stats-cheatsheet.md +155 -0
  11. package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
  12. package/references/midscene-integration.md +359 -0
  13. package/references/obscura-cheatsheet.md +103 -0
  14. package/references/playwright-cli-cheatsheet.md +64 -0
  15. package/references/playwright-lib-cheatsheet.md +90 -0
  16. package/references/recipes/add-a-tool-adapter.md +134 -0
  17. package/references/recipes/agent-workflows/README.md +37 -0
  18. package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
  19. package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
  20. package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
  21. package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
  22. package/references/recipes/anti-patterns-tool-extension.md +182 -0
  23. package/references/recipes/body-bytes-not-body.md +139 -0
  24. package/references/recipes/cache-write-security.md +210 -0
  25. package/references/recipes/fingerprint-rescue.md +154 -0
  26. package/references/recipes/model-routing.md +143 -0
  27. package/references/recipes/path-security.md +138 -0
  28. package/references/recipes/privacy-canary.md +96 -0
  29. package/references/recipes/visual-rescue-hook.md +182 -0
  30. package/references/stats-prices.json +42 -0
  31. package/references/stats-schema.json +77 -0
  32. package/references/tool-versions.md +8 -0
  33. package/scripts/browser-add-site.sh +113 -0
  34. package/scripts/browser-assert.sh +106 -0
  35. package/scripts/browser-audit.sh +68 -0
  36. package/scripts/browser-baseline.sh +135 -0
  37. package/scripts/browser-click.sh +100 -0
  38. package/scripts/browser-creds-add.sh +254 -0
  39. package/scripts/browser-creds-list.sh +67 -0
  40. package/scripts/browser-creds-migrate.sh +122 -0
  41. package/scripts/browser-creds-remove.sh +69 -0
  42. package/scripts/browser-creds-rotate-totp.sh +109 -0
  43. package/scripts/browser-creds-show.sh +82 -0
  44. package/scripts/browser-creds-totp.sh +94 -0
  45. package/scripts/browser-do.sh +630 -0
  46. package/scripts/browser-doctor.sh +365 -0
  47. package/scripts/browser-drag.sh +90 -0
  48. package/scripts/browser-extract.sh +192 -0
  49. package/scripts/browser-fill.sh +142 -0
  50. package/scripts/browser-flow.sh +316 -0
  51. package/scripts/browser-history.sh +187 -0
  52. package/scripts/browser-hover.sh +92 -0
  53. package/scripts/browser-inspect.sh +188 -0
  54. package/scripts/browser-list-sessions.sh +78 -0
  55. package/scripts/browser-list-sites.sh +42 -0
  56. package/scripts/browser-login.sh +279 -0
  57. package/scripts/browser-mcp.sh +65 -0
  58. package/scripts/browser-migrate.sh +195 -0
  59. package/scripts/browser-open.sh +134 -0
  60. package/scripts/browser-press.sh +80 -0
  61. package/scripts/browser-remove-session.sh +72 -0
  62. package/scripts/browser-remove-site.sh +68 -0
  63. package/scripts/browser-replay.sh +206 -0
  64. package/scripts/browser-route.sh +174 -0
  65. package/scripts/browser-select.sh +122 -0
  66. package/scripts/browser-show-session.sh +57 -0
  67. package/scripts/browser-show-site.sh +37 -0
  68. package/scripts/browser-snapshot.sh +176 -0
  69. package/scripts/browser-stats.sh +522 -0
  70. package/scripts/browser-tab-close.sh +112 -0
  71. package/scripts/browser-tab-list.sh +70 -0
  72. package/scripts/browser-tab-switch.sh +111 -0
  73. package/scripts/browser-upload.sh +132 -0
  74. package/scripts/browser-use.sh +60 -0
  75. package/scripts/browser-vlm.sh +707 -0
  76. package/scripts/browser-wait.sh +97 -0
  77. package/scripts/install-git-hooks.sh +16 -0
  78. package/scripts/lib/capture.sh +356 -0
  79. package/scripts/lib/common.sh +262 -0
  80. package/scripts/lib/credential.sh +237 -0
  81. package/scripts/lib/fingerprint-rescue.js +123 -0
  82. package/scripts/lib/flow.sh +448 -0
  83. package/scripts/lib/flow_record.sh +210 -0
  84. package/scripts/lib/mask.sh +49 -0
  85. package/scripts/lib/memory.sh +427 -0
  86. package/scripts/lib/migrate.sh +390 -0
  87. package/scripts/lib/migrators/README.md +23 -0
  88. package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
  89. package/scripts/lib/migrators/recent_urls/README.md +13 -0
  90. package/scripts/lib/migrators/stats/README.md +24 -0
  91. package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
  92. package/scripts/lib/node/mcp-server.mjs +531 -0
  93. package/scripts/lib/node/mcp-tools.json +68 -0
  94. package/scripts/lib/node/playwright-driver.mjs +1104 -0
  95. package/scripts/lib/node/totp-core.mjs +52 -0
  96. package/scripts/lib/node/totp.mjs +52 -0
  97. package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
  98. package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
  99. package/scripts/lib/output.sh +79 -0
  100. package/scripts/lib/router.sh +342 -0
  101. package/scripts/lib/sanitize.sh +107 -0
  102. package/scripts/lib/secret/keychain.sh +91 -0
  103. package/scripts/lib/secret/libsecret.sh +74 -0
  104. package/scripts/lib/secret/plaintext.sh +75 -0
  105. package/scripts/lib/secret_backend_select.sh +57 -0
  106. package/scripts/lib/session.sh +153 -0
  107. package/scripts/lib/site.sh +126 -0
  108. package/scripts/lib/stats.sh +419 -0
  109. package/scripts/lib/tool/.gitkeep +0 -0
  110. package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
  111. package/scripts/lib/tool/obscura.sh +249 -0
  112. package/scripts/lib/tool/playwright-cli.sh +155 -0
  113. package/scripts/lib/tool/playwright-lib.sh +106 -0
  114. package/scripts/lib/verb_helpers.sh +222 -0
  115. package/scripts/lib/visual-rescue-default.sh +145 -0
  116. package/scripts/regenerate-docs.sh +99 -0
  117. package/uninstall.sh +51 -0
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/browser-fill.sh — fill an input by --ref eN or --selector CSS with --text or --secret-stdin.
3
+ # Usage: bash scripts/browser-fill.sh [--site NAME] [--tool NAME] [--dry-run]
4
+ # [--raw] (--ref eN | --selector CSS)
5
+ # (--text VALUE | --secret-stdin)
6
+ #
7
+ # CRITICAL: --secret-stdin reads the secret from this script's stdin and pipes
8
+ # it to the adapter; the secret never appears on argv (anti-pattern AP-7).
9
+ # Test: tests/browser-fill.bats::secret-not-in-argv.
10
+ #
11
+ # --selector path enables Phase 11 cache dispatch (cache stores selectors,
12
+ # not snapshot-relative refs). Mirrors browser-click.sh's --ref/--selector
13
+ # precedent.
14
+
15
+ set -euo pipefail
16
+ IFS=$'\n\t'
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ # shellcheck source=lib/common.sh
20
+ # shellcheck disable=SC1091
21
+ source "${SCRIPT_DIR}/lib/common.sh"
22
+ # shellcheck source=lib/output.sh
23
+ # shellcheck disable=SC1091
24
+ source "${SCRIPT_DIR}/lib/output.sh"
25
+ # shellcheck source=lib/router.sh
26
+ # shellcheck disable=SC1091
27
+ source "${SCRIPT_DIR}/lib/router.sh"
28
+ # shellcheck source=lib/verb_helpers.sh
29
+ # shellcheck disable=SC1091
30
+ source "${SCRIPT_DIR}/lib/verb_helpers.sh"
31
+ # shellcheck source=lib/stats.sh
32
+ # shellcheck disable=SC1091
33
+ source "${SCRIPT_DIR}/lib/stats.sh"
34
+
35
+ init_paths
36
+
37
+ SUMMARY_T0="$(now_ms)"; export SUMMARY_T0
38
+
39
+ parse_verb_globals "$@"
40
+
41
+ # Resolve site/session → BROWSER_SKILL_STORAGE_STATE (no-op if neither set).
42
+ # Router's rule_session_required reads the env var to prefer playwright-lib
43
+ # (which natively supports --secret-stdin via stdin-pipe to driver).
44
+ resolve_session_storage_state
45
+
46
+ ref="" selector="" text="" use_stdin=0
47
+ verb_argv=()
48
+ i=0
49
+ while [ "${i}" -lt "${#REMAINING_ARGV[@]}" ]; do
50
+ case "${REMAINING_ARGV[i]}" in
51
+ --ref)
52
+ ref="${REMAINING_ARGV[i+1]:-}"
53
+ [ -n "${ref}" ] || die "${EXIT_USAGE_ERROR}" "--ref requires a value"
54
+ verb_argv+=(--ref "${ref}")
55
+ i=$((i + 2))
56
+ ;;
57
+ --selector)
58
+ selector="${REMAINING_ARGV[i+1]:-}"
59
+ [ -n "${selector}" ] || die "${EXIT_USAGE_ERROR}" "--selector requires a value"
60
+ verb_argv+=(--selector "${selector}")
61
+ i=$((i + 2))
62
+ ;;
63
+ --text)
64
+ text="${REMAINING_ARGV[i+1]:-}"
65
+ [ -n "${text}" ] || die "${EXIT_USAGE_ERROR}" "--text requires a value"
66
+ verb_argv+=(--text "${text}")
67
+ i=$((i + 2))
68
+ ;;
69
+ --secret-stdin)
70
+ use_stdin=1
71
+ verb_argv+=(--secret-stdin)
72
+ i=$((i + 1))
73
+ ;;
74
+ *)
75
+ verb_argv+=("${REMAINING_ARGV[i]}")
76
+ i=$((i + 1))
77
+ ;;
78
+ esac
79
+ done
80
+
81
+ if [ -n "${ref}" ] && [ -n "${selector}" ]; then
82
+ die "${EXIT_USAGE_ERROR}" "--ref and --selector are mutually exclusive"
83
+ fi
84
+ if [ -z "${ref}" ] && [ -z "${selector}" ]; then
85
+ die "${EXIT_USAGE_ERROR}" "fill requires --ref eN or --selector CSS"
86
+ fi
87
+ if [ -n "${text}" ] && [ "${use_stdin}" = "1" ]; then
88
+ die "${EXIT_USAGE_ERROR}" "--text and --secret-stdin are mutually exclusive"
89
+ fi
90
+ if [ -z "${text}" ] && [ "${use_stdin}" = "0" ]; then
91
+ die "${EXIT_USAGE_ERROR}" "fill requires --text VALUE or --secret-stdin"
92
+ fi
93
+
94
+ if [ "${ARG_DRY_RUN:-0}" = "1" ]; then
95
+ ok "dry-run: would fill ${ref:-${selector}}"
96
+ emit_summary verb=fill tool=none why=dry-run status=ok ref="${ref}" selector="${selector}" dry_run=true
97
+ exit 0
98
+ fi
99
+
100
+ picked="$(pick_tool fill "${verb_argv[@]}")"
101
+ tool_name="${picked%%$'\t'*}"
102
+ why="${picked#*$'\t'}"
103
+
104
+ source_picked_adapter "${tool_name}"
105
+
106
+ # stdin (if --secret-stdin) flows through to tool_fill -> adapter binary.
107
+ # Capture stdout in subshell; stdin inherits naturally.
108
+ stats_t0="$(now_ms)"
109
+ set +e
110
+ adapter_out="$(invoke_with_retry fill "${verb_argv[@]}")"
111
+ adapter_rc=$?
112
+ set -e
113
+
114
+ # Phase 12 part 1 + Phase 14 (Bundle #2): per-action telemetry. CRITICAL — when
115
+ # --secret-stdin was used, NEVER auto-derive EXPECT_VALUE from the secret
116
+ # (would leak the secret into stats.jsonl per AP-7). Auto-derive only fires when
117
+ # (a) --text was used (no secrets) AND (b) BROWSER_SKILL_STRICT_POSTCOND=1
118
+ # opts in. Opt-in default: many fill adapters don't echo the typed value back,
119
+ # so a blanket auto-check would generate false oblivious_success events.
120
+ # Future: compose with a follow-up snapshot to read the actual element value.
121
+ if [ "${BROWSER_SKILL_STRICT_POSTCOND:-0}" = "1" ] \
122
+ && [ "${use_stdin}" = "0" ] && [ -n "${text}" ] \
123
+ && [ "${adapter_rc}" -eq 0 ]; then
124
+ : "${BROWSER_STATS_EXPECT_TYPE:=element_value}"
125
+ : "${BROWSER_STATS_EXPECT_MATCH:=include}"
126
+ : "${BROWSER_STATS_EXPECT_VALUE:=${text}}"
127
+ fi
128
+ : "${BROWSER_STATS_OBSERVED:=${adapter_out}}"
129
+ export BROWSER_STATS_EXPECT_TYPE BROWSER_STATS_EXPECT_MATCH BROWSER_STATS_EXPECT_VALUE BROWSER_STATS_OBSERVED
130
+
131
+ stats_run_adapter_emit \
132
+ "fill" "${tool_name}" "${stats_t0}" "${adapter_rc}" "${adapter_out}" "" \
133
+ -- "${verb_argv[@]}" || true
134
+
135
+ [ -n "${adapter_out}" ] && printf '%s\n' "${adapter_out}"
136
+
137
+ if [ "${adapter_rc}" -eq 0 ]; then
138
+ emit_summary verb=fill tool="${tool_name}" why="${why}" status=ok ref="${ref}" selector="${selector}"
139
+ exit 0
140
+ fi
141
+ emit_summary verb=fill tool="${tool_name}" why="${why}" status=error ref="${ref}" selector="${selector}"
142
+ exit "${adapter_rc}"
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/browser-flow.sh — flow runner verb (Phase 9 part 1-i).
3
+ #
4
+ # Usage:
5
+ # bash scripts/browser-flow.sh run <flow-file> [--var key=val ...] [--dry-run]
6
+ #
7
+ # Sub-modes (current):
8
+ # run — execute a .flow.yaml file end-to-end (this PR)
9
+ # Sub-modes (planned):
10
+ # record — wrap `playwright codegen` (9-1-iii)
11
+ #
12
+ # Capture composition (per design doc 2026-05-10-phase-09-flow-runner-design §3 F4):
13
+ # one capture per flow run; per-step events streamed to ${CAPTURE_DIR}/steps.jsonl;
14
+ # meta.json carries verb=flow + flow_name + step_count + successful_steps +
15
+ # failed_steps + status (ok / partial / error).
16
+
17
+ set -euo pipefail
18
+ IFS=$'\n\t'
19
+
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ SCRIPTS_DIR="${SCRIPT_DIR}"
22
+ export SCRIPTS_DIR
23
+
24
+ # shellcheck source=lib/common.sh
25
+ # shellcheck disable=SC1091
26
+ source "${SCRIPT_DIR}/lib/common.sh"
27
+ # shellcheck source=lib/output.sh
28
+ # shellcheck disable=SC1091
29
+ source "${SCRIPT_DIR}/lib/output.sh"
30
+ # shellcheck source=lib/capture.sh
31
+ # shellcheck disable=SC1091
32
+ source "${SCRIPT_DIR}/lib/capture.sh"
33
+ # shellcheck source=lib/flow.sh
34
+ # shellcheck disable=SC1091
35
+ source "${SCRIPT_DIR}/lib/flow.sh"
36
+
37
+ init_paths
38
+
39
+ SUMMARY_T0="$(now_ms)"; export SUMMARY_T0
40
+
41
+ sub_mode="${1:-}"
42
+ [ -n "${sub_mode}" ] || die "${EXIT_USAGE_ERROR}" "browser-flow: missing sub-mode (use 'run')"
43
+ shift
44
+
45
+ case "${sub_mode}" in
46
+ run) ;;
47
+ record)
48
+ # Phase 9 part 1-iii: wraps `playwright codegen <url>`; transforms emitted
49
+ # JS → flow YAML; writes ${OUT} mode 0600. Privacy canary on recorder
50
+ # write side: passwords detected via /password/i name match, replaced
51
+ # with ${secrets.password} placeholder.
52
+ # shellcheck source=lib/flow_record.sh
53
+ # shellcheck disable=SC1091
54
+ source "${SCRIPT_DIR}/lib/flow_record.sh"
55
+
56
+ record_url=""
57
+ record_out=""
58
+ record_name=""
59
+ record_tool=""
60
+ while [ "$#" -gt 0 ]; do
61
+ case "$1" in
62
+ --url) record_url="$2"; shift 2 ;;
63
+ --out) record_out="$2"; shift 2 ;;
64
+ --name) record_name="$2"; shift 2 ;;
65
+ --tool) record_tool="$2"; shift 2 ;;
66
+ --site) shift 2 ;; # accepted; site resolution deferred
67
+ *) die "${EXIT_USAGE_ERROR}" "browser-flow record: unknown flag '$1'" ;;
68
+ esac
69
+ done
70
+
71
+ # Per locked decision W1: codegen targets Playwright/Chrome; obscura's
72
+ # stateless one-shot model has no interactive recording surface.
73
+ if [ "${record_tool}" = "obscura" ]; then
74
+ die "${EXIT_USAGE_ERROR}" "browser-flow record: recorder does not support obscura (codegen targets Playwright; obscura is stateless one-shot — no interactive recording surface)"
75
+ fi
76
+
77
+ # Per locked decision O1: --out is REQUIRED.
78
+ [ -n "${record_out}" ] || die "${EXIT_USAGE_ERROR}" "browser-flow record: --out FILE is required"
79
+ [ -n "${record_url}" ] || die "${EXIT_USAGE_ERROR}" "browser-flow record: --url URL is required (or --site NAME — deferred to follow-up)"
80
+
81
+ # Path security: realpath canonicalize + sensitive-pattern reject. Mirror
82
+ # references/recipes/path-security.md.
83
+ record_out_dir="$(dirname "${record_out}")"
84
+ [ -d "${record_out_dir}" ] || mkdir -p "${record_out_dir}"
85
+ record_out_abs="$(cd "${record_out_dir}" && pwd)/$(basename "${record_out}")"
86
+ case "${record_out_abs}" in
87
+ */.ssh/*|*/.aws/*|*/.gnupg/*|*/.netrc*|*/private_key*|*/id_rsa*|*/id_ed25519*)
88
+ die "${EXIT_USAGE_ERROR}" "browser-flow record: --out path matches sensitive pattern (refusing): ${record_out_abs}"
89
+ ;;
90
+ esac
91
+
92
+ # Default flow name = basename of --out (sans .flow.yaml).
93
+ [ -z "${record_name}" ] && record_name="$(basename "${record_out}" .flow.yaml)"
94
+
95
+ # Spawn codegen (or mock via env-var override).
96
+ codegen_bin="${PLAYWRIGHT_CODEGEN_BIN:-}"
97
+ if [ -z "${codegen_bin}" ]; then
98
+ codegen_bin="$(command -v playwright || true)"
99
+ [ -z "${codegen_bin}" ] && die "${EXIT_PREFLIGHT_FAILED}" "playwright not found on PATH (set PLAYWRIGHT_CODEGEN_BIN to override)"
100
+ codegen_args=(codegen --target javascript "${record_url}")
101
+ else
102
+ codegen_args=()
103
+ fi
104
+
105
+ # Capture codegen stdout. Real codegen blocks until user closes the headed
106
+ # window; mock exits immediately.
107
+ set +e
108
+ codegen_js="$("${codegen_bin}" "${codegen_args[@]}" 2>/dev/null)"
109
+ codegen_rc=$?
110
+ set -e
111
+ if [ "${codegen_rc}" -ne 0 ]; then
112
+ die "${EXIT_TOOL_CRASHED}" "playwright codegen failed (rc=${codegen_rc})"
113
+ fi
114
+
115
+ # Transform JS → YAML. flow_record_transform sets globals
116
+ # FLOW_RECORD_PASSWORD_REDACTIONS + FLOW_RECORD_STEP_COUNT.
117
+ yaml_out="$(printf '%s' "${codegen_js}" | flow_record_transform "${record_name}" 2>/tmp/flow-record-stderr-$$.log)"
118
+ redaction_msgs="$(cat /tmp/flow-record-stderr-$$.log 2>/dev/null || true)"
119
+ rm -f /tmp/flow-record-stderr-$$.log
120
+ [ -n "${redaction_msgs}" ] && printf '%s\n' "${redaction_msgs}" >&2
121
+
122
+ # Write to --out, mode 0600.
123
+ tmp="${record_out_abs}.tmp.$$"
124
+ printf '%s' "${yaml_out}" > "${tmp}"
125
+ chmod 600 "${tmp}"
126
+ mv "${tmp}" "${record_out_abs}"
127
+
128
+ emit_summary verb=flow tool=playwright-cli why=record status=ok mode=record \
129
+ flow_name="${record_name}" out_file="${record_out_abs}" \
130
+ step_count="${FLOW_RECORD_STEP_COUNT}" \
131
+ password_redactions="${FLOW_RECORD_PASSWORD_REDACTIONS}"
132
+ exit 0
133
+ ;;
134
+ *) die "${EXIT_USAGE_ERROR}" "browser-flow: unknown sub-mode '${sub_mode}'" ;;
135
+ esac
136
+
137
+ flow_file="${1:-}"
138
+ [ -n "${flow_file}" ] || die "${EXIT_USAGE_ERROR}" "browser-flow run: missing <flow-file>"
139
+ shift
140
+
141
+ # Path security: realpath canonicalize. Reject sensitive patterns. Per recipe
142
+ # references/recipes/path-security.md.
143
+ if [ ! -f "${flow_file}" ]; then
144
+ alt="${BROWSER_SKILL_HOME}/flows/${flow_file}"
145
+ if [ -f "${alt}" ]; then
146
+ flow_file="${alt}"
147
+ else
148
+ die "${EXIT_USAGE_ERROR}" "flow file not found: ${flow_file}"
149
+ fi
150
+ fi
151
+ flow_file_abs="$(cd "$(dirname "${flow_file}")" && pwd)/$(basename "${flow_file}")"
152
+ case "${flow_file_abs}" in
153
+ */.ssh/*|*/.aws/*|*/.gnupg/*|*/.netrc*|*/private_key*|*/id_rsa*|*/id_ed25519*)
154
+ die "${EXIT_USAGE_ERROR}" "flow file path matches sensitive pattern (refusing): ${flow_file_abs}"
155
+ ;;
156
+ esac
157
+
158
+ cli_var_overrides=()
159
+ dry_run=0
160
+ while [ "$#" -gt 0 ]; do
161
+ case "$1" in
162
+ --var)
163
+ [ -n "${2:-}" ] || die "${EXIT_USAGE_ERROR}" "--var requires key=val"
164
+ cli_var_overrides+=("$2")
165
+ shift 2
166
+ ;;
167
+ --dry-run)
168
+ dry_run=1
169
+ shift
170
+ ;;
171
+ *)
172
+ die "${EXIT_USAGE_ERROR}" "browser-flow run: unknown flag '$1'"
173
+ ;;
174
+ esac
175
+ done
176
+
177
+ # Reset flow state in this shell.
178
+ declare -gA FLOW_VARS=()
179
+ declare -gA FLOW_REFS=()
180
+ FLOW_NAME=""
181
+ FLOW_SESSION=""
182
+
183
+ # Parse the flow file → captures _meta line + per-step lines on stdout.
184
+ parsed="$(flow_parse "${flow_file_abs}")"
185
+
186
+ # Extract _meta line (first one with _kind=="meta").
187
+ meta_line="$(printf '%s\n' "${parsed}" | jq -c -s 'map(select(._kind=="meta")) | .[0]' 2>/dev/null || printf 'null')"
188
+ [ "${meta_line}" = "null" ] && die "${EXIT_GENERIC_ERROR}" "flow_parse: missing _meta line in output"
189
+ FLOW_NAME="$(printf '%s' "${meta_line}" | jq -r '.name')"
190
+ FLOW_SESSION="$(printf '%s' "${meta_line}" | jq -r '.session // ""')"
191
+
192
+ # Hydrate FLOW_VARS from _meta.vars (file-defined defaults).
193
+ while IFS=$'\t' read -r k v; do
194
+ [ -z "${k}" ] && continue
195
+ FLOW_VARS["${k}"]="${v}"
196
+ done <<< "$(printf '%s' "${meta_line}" | jq -r '.vars | to_entries[]? | "\(.key)\t\(.value)"')"
197
+
198
+ # Apply CLI --var overrides (after parse, so they win over file vars:).
199
+ for ov in "${cli_var_overrides[@]}"; do
200
+ case "${ov}" in
201
+ *=*) FLOW_VARS["${ov%%=*}"]="${ov#*=}" ;;
202
+ *) die "${EXIT_USAGE_ERROR}" "--var requires key=val (got: ${ov})" ;;
203
+ esac
204
+ done
205
+
206
+ # Extract step lines.
207
+ steps_jsonl="$(printf '%s\n' "${parsed}" | jq -c 'select(._kind=="step")')"
208
+ step_count=$(printf '%s\n' "${steps_jsonl}" | grep -c '^.' || printf '0')
209
+
210
+ if [ "${dry_run}" = "1" ]; then
211
+ # Dry-run pre-pass: substitute vars (with refs-mode=skip since no snapshot
212
+ # has actually run); print the planned step list. Per Phase 9 part 1-ii:
213
+ # ${refs.NAME} stays literal in dry-run output (FLOW_REFS would be empty
214
+ # anyway).
215
+ while IFS= read -r step_line; do
216
+ [ -z "${step_line}" ] && continue
217
+ flow_apply_vars "${step_line}" skip
218
+ done <<< "${steps_jsonl}"
219
+ emit_summary verb=flow tool=none why=dry-run status=ok mode=run \
220
+ flow_name="${FLOW_NAME}" step_count="${step_count}" dry_run=true
221
+ exit 0
222
+ fi
223
+
224
+ # Real run: capture pipeline + per-step dispatch with mid-flow ref resolution.
225
+ capture_start "flow"
226
+ # Append flow_name into meta.json (additive; no schema bump per design F4).
227
+ meta="${CAPTURE_DIR}/meta.json"
228
+ tmp="${meta}.tmp.$$"
229
+ jq --arg n "${FLOW_NAME}" '.flow_name = $n' "${meta}" > "${tmp}"
230
+ chmod 600 "${tmp}"
231
+ mv "${tmp}" "${meta}"
232
+
233
+ steps_log="${CAPTURE_DIR}/steps.jsonl"
234
+ : > "${steps_log}"
235
+ chmod 600 "${steps_log}"
236
+
237
+ successful_steps=0
238
+ failed_steps=0
239
+ last_exit=0
240
+ while IFS= read -r step_line; do
241
+ [ -z "${step_line}" ] && continue
242
+ # Per-step substitution AT EXECUTION TIME — FLOW_REFS may have just been
243
+ # populated by the prior snapshot step. flow_apply_vars defaults to
244
+ # refs-mode=strict (fail loud on missing ref).
245
+ set +e
246
+ substituted_step="$(flow_apply_vars "${step_line}")"
247
+ apply_rc=$?
248
+ set -e
249
+ if [ "${apply_rc}" -ne 0 ]; then
250
+ # flow_apply_vars already emitted the error message via die. Surface
251
+ # the failure as a step-event + abort the flow.
252
+ evt="$(jq -nc \
253
+ --argjson step_index "$(printf '%s' "${step_line}" | jq '.step_index')" \
254
+ --arg verb "$(printf '%s' "${step_line}" | jq -r '.verb')" \
255
+ --argjson exit_code "${apply_rc}" \
256
+ --arg status "error" \
257
+ --arg error "var/ref substitution failed" \
258
+ '{step_index: $step_index, verb: $verb, status: $status, exit_code: $exit_code, error: $error}')"
259
+ printf '%s\n' "${evt}" >> "${steps_log}"
260
+ failed_steps=$((failed_steps + 1))
261
+ last_exit="${apply_rc}"
262
+ break
263
+ fi
264
+
265
+ evt="$(flow_dispatch "${substituted_step}")"
266
+ printf '%s\n' "${evt}" >> "${steps_log}"
267
+ status="$(printf '%s' "${evt}" | jq -r '.status')"
268
+ if [ "${status}" = "ok" ]; then
269
+ successful_steps=$((successful_steps + 1))
270
+ else
271
+ failed_steps=$((failed_steps + 1))
272
+ last_exit="$(printf '%s' "${evt}" | jq -r '.exit_code')"
273
+ fi
274
+
275
+ # Phase 9 part 1-ii: harvest step.refs into FLOW_REFS (latest-wins).
276
+ refs_for_step="$(printf '%s' "${evt}" | jq -c '.refs // null')"
277
+ if [ "${refs_for_step}" != "null" ]; then
278
+ # Reset FLOW_REFS wholesale (latest-snapshot-wins).
279
+ FLOW_REFS=()
280
+ while IFS=$'\t' read -r ref_text ref_id; do
281
+ [ -z "${ref_text}" ] && continue
282
+ FLOW_REFS["${ref_text}"]="${ref_id}"
283
+ done <<< "$(printf '%s' "${refs_for_step}" | jq -r '.[] | "\(.text)\t\(.ref)"')"
284
+ fi
285
+ done <<< "${steps_jsonl}"
286
+
287
+ # Determine overall flow status.
288
+ if [ "${failed_steps}" = "0" ]; then
289
+ flow_status="ok"
290
+ elif [ "${successful_steps}" = "0" ]; then
291
+ flow_status="error"
292
+ else
293
+ flow_status="partial"
294
+ fi
295
+
296
+ # Append per-flow counts to meta.json.
297
+ tmp="${meta}.tmp.$$"
298
+ jq \
299
+ --argjson sc "${step_count}" \
300
+ --argjson ss "${successful_steps}" \
301
+ --argjson fs "${failed_steps}" \
302
+ '. + {step_count: $sc, successful_steps: $ss, failed_steps: $fs}' \
303
+ "${meta}" > "${tmp}"
304
+ chmod 600 "${tmp}"
305
+ mv "${tmp}" "${meta}"
306
+
307
+ capture_finish "${flow_status}" true
308
+
309
+ emit_summary verb=flow tool=none why=run status="${flow_status}" mode=run \
310
+ flow_name="${FLOW_NAME}" capture_id="${CAPTURE_ID}" \
311
+ step_count="${step_count}" successful_steps="${successful_steps}" failed_steps="${failed_steps}"
312
+
313
+ if [ "${flow_status}" = "ok" ]; then
314
+ exit 0
315
+ fi
316
+ exit "${last_exit}"
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/browser-history.sh — read-side ops over the captures pipeline +
3
+ # manual-trigger prune. Phase 9 part 1-v (CLOSES Phase 9).
4
+ #
5
+ # Sub-modes:
6
+ # list [--limit N] — enumerate captures (newest first)
7
+ # show <capture-id> — print meta.json + steps.jsonl
8
+ # diff <id1> <id2> — per-step replay_diff via flow_diff_steps
9
+ # clear [--keep N] [--days D] — manual prune (composes Phase 7's
10
+ # [--not-baseline] capture_prune; respects is_baseline
11
+ # skip-rule by default)
12
+
13
+ set -euo pipefail
14
+ IFS=$'\n\t'
15
+
16
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ SCRIPTS_DIR="${SCRIPT_DIR}"
18
+ export SCRIPTS_DIR
19
+
20
+ # shellcheck source=lib/common.sh
21
+ # shellcheck disable=SC1091
22
+ source "${SCRIPT_DIR}/lib/common.sh"
23
+ # shellcheck source=lib/output.sh
24
+ # shellcheck disable=SC1091
25
+ source "${SCRIPT_DIR}/lib/output.sh"
26
+ # shellcheck source=lib/capture.sh
27
+ # shellcheck disable=SC1091
28
+ source "${SCRIPT_DIR}/lib/capture.sh"
29
+ # shellcheck source=lib/flow.sh
30
+ # shellcheck disable=SC1091
31
+ source "${SCRIPT_DIR}/lib/flow.sh"
32
+
33
+ init_paths
34
+
35
+ SUMMARY_T0="$(now_ms)"; export SUMMARY_T0
36
+
37
+ sub_mode="${1:-}"
38
+ [ -n "${sub_mode}" ] || die "${EXIT_USAGE_ERROR}" "browser-history: missing sub-mode (list / show / diff / clear)"
39
+ shift
40
+
41
+ case "${sub_mode}" in
42
+ list)
43
+ limit=""
44
+ while [ "$#" -gt 0 ]; do
45
+ case "$1" in
46
+ --limit) limit="$2"; shift 2 ;;
47
+ --since) shift 2 ;; # accepted; deferred (out-of-scope filter)
48
+ *) die "${EXIT_USAGE_ERROR}" "history list: unknown flag '$1'" ;;
49
+ esac
50
+ done
51
+ total=0
52
+ if [ -d "${CAPTURES_DIR}" ]; then
53
+ shopt -s nullglob
54
+ # Sort by capture_id (which is monotonic per Phase 7); newest first.
55
+ ids=()
56
+ for d in "${CAPTURES_DIR}"/[0-9]*/; do
57
+ ids+=("$(basename "${d}")")
58
+ done
59
+ shopt -u nullglob
60
+ # Reverse-sort to put newest first.
61
+ mapfile -t sorted < <(printf '%s\n' "${ids[@]}" | sort -r)
62
+ for id in "${sorted[@]}"; do
63
+ meta="${CAPTURES_DIR}/${id}/meta.json"
64
+ [ -f "${meta}" ] || continue
65
+ if [ -n "${limit}" ] && [ "${total}" -ge "${limit}" ]; then
66
+ break
67
+ fi
68
+ # Emit per-capture row event.
69
+ jq -c --arg event "history_row" \
70
+ '. + {event: $event}' "${meta}"
71
+ total=$((total + 1))
72
+ done
73
+ fi
74
+ emit_summary verb=history tool=none why=list status=ok mode=list total="${total}"
75
+ exit 0
76
+ ;;
77
+
78
+ show)
79
+ show_id="${1:-}"
80
+ [ -n "${show_id}" ] || die "${EXIT_USAGE_ERROR}" "history show: missing <capture-id>"
81
+ meta="${CAPTURES_DIR}/${show_id}/meta.json"
82
+ [ -f "${meta}" ] || die "${EXIT_USAGE_ERROR}" "history show: no such capture '${show_id}'"
83
+ # Compact meta.json onto a single line so callers can parse the first
84
+ # line as a complete JSON object (capture_start writes pretty-printed
85
+ # multi-line; jq -c re-flattens).
86
+ jq -c . "${meta}"
87
+ steps_log="${CAPTURES_DIR}/${show_id}/steps.jsonl"
88
+ [ -f "${steps_log}" ] && cat "${steps_log}"
89
+ emit_summary verb=history tool=none why=show status=ok mode=show capture_id="${show_id}"
90
+ exit 0
91
+ ;;
92
+
93
+ diff)
94
+ id1="${1:-}"
95
+ id2="${2:-}"
96
+ [ -n "${id1}" ] && [ -n "${id2}" ] || die "${EXIT_USAGE_ERROR}" "history diff: requires <id1> <id2>"
97
+ log1="${CAPTURES_DIR}/${id1}/steps.jsonl"
98
+ log2="${CAPTURES_DIR}/${id2}/steps.jsonl"
99
+ [ -f "${log1}" ] || die "${EXIT_USAGE_ERROR}" "history diff: no steps.jsonl at captures/${id1}/"
100
+ [ -f "${log2}" ] || die "${EXIT_USAGE_ERROR}" "history diff: no steps.jsonl at captures/${id2}/"
101
+ # Iterate paired step events; emit replay_diff per pair.
102
+ matched=0
103
+ diverged=0
104
+ paste -d $'\t' "${log1}" "${log2}" | while IFS=$'\t' read -r old new; do
105
+ [ -z "${old}" ] || [ -z "${new}" ] && continue
106
+ flow_diff_steps "${old}" "${new}" || true
107
+ done
108
+ # Aggregate counts via separate read pass.
109
+ total_steps="$(wc -l < "${log1}" | tr -d ' ')"
110
+ emit_summary verb=history tool=none why=diff status=ok mode=diff \
111
+ capture_id_old="${id1}" capture_id_new="${id2}" total_steps="${total_steps}"
112
+ exit 0
113
+ ;;
114
+
115
+ clear)
116
+ keep=""
117
+ days=""
118
+ not_baseline=0
119
+ while [ "$#" -gt 0 ]; do
120
+ case "$1" in
121
+ --keep) keep="$2"; shift 2 ;;
122
+ --days) days="$2"; shift 2 ;;
123
+ --not-baseline) not_baseline=1; shift ;;
124
+ *) die "${EXIT_USAGE_ERROR}" "history clear: unknown flag '$1'" ;;
125
+ esac
126
+ done
127
+
128
+ pruned=0
129
+ if [ -d "${CAPTURES_DIR}" ]; then
130
+ shopt -s nullglob
131
+ ids=()
132
+ for d in "${CAPTURES_DIR}"/[0-9]*/; do
133
+ ids+=("$(basename "${d}")")
134
+ done
135
+ shopt -u nullglob
136
+ # Newest-first sort; keep first N if --keep.
137
+ mapfile -t sorted < <(printf '%s\n' "${ids[@]}" | sort -r)
138
+ idx=0
139
+ now_epoch="$(date -u +%s)"
140
+ for id in "${sorted[@]}"; do
141
+ meta="${CAPTURES_DIR}/${id}/meta.json"
142
+ [ -f "${meta}" ] || continue
143
+ is_baseline="$(jq -r '.is_baseline // false' "${meta}" 2>/dev/null || printf 'false')"
144
+ # is_baseline:true is ALWAYS skipped (per Phase 7 prune contract +
145
+ # locked decision H3).
146
+ if [ "${is_baseline}" = "true" ]; then
147
+ idx=$((idx + 1))
148
+ continue
149
+ fi
150
+ # --keep N: keep the newest N (skip pruning the first N).
151
+ if [ -n "${keep}" ] && [ "${idx}" -lt "${keep}" ]; then
152
+ idx=$((idx + 1))
153
+ continue
154
+ fi
155
+ # --days D: keep captures younger than D days.
156
+ if [ -n "${days}" ]; then
157
+ finished_at="$(jq -r '.finished_at // .started_at // ""' "${meta}" 2>/dev/null || printf '')"
158
+ if [ -n "${finished_at}" ]; then
159
+ cap_epoch="$(date -d "${finished_at}" +%s 2>/dev/null || date -j -f '%Y-%m-%dT%H:%M:%SZ' "${finished_at}" +%s 2>/dev/null || printf '0')"
160
+ age_days=$(( (now_epoch - cap_epoch) / 86400 ))
161
+ if [ "${age_days}" -lt "${days}" ]; then
162
+ idx=$((idx + 1))
163
+ continue
164
+ fi
165
+ fi
166
+ fi
167
+ # If --not-baseline alone (no --keep / --days), prune everything
168
+ # non-baseline. If --not-baseline + --keep / --days, the above
169
+ # checks already excluded baselines + applied limits.
170
+ if [ -z "${keep}" ] && [ -z "${days}" ] && [ "${not_baseline}" = "0" ]; then
171
+ # No flags at all → no-op (use config defaults via auto-prune).
172
+ idx=$((idx + 1))
173
+ continue
174
+ fi
175
+ rm -rf "${CAPTURES_DIR}/${id}"
176
+ pruned=$((pruned + 1))
177
+ idx=$((idx + 1))
178
+ done
179
+ fi
180
+ emit_summary verb=history tool=none why=clear status=ok mode=clear pruned="${pruned}"
181
+ exit 0
182
+ ;;
183
+
184
+ *)
185
+ die "${EXIT_USAGE_ERROR}" "browser-history: unknown sub-mode '${sub_mode}' (use list / show / diff / clear)"
186
+ ;;
187
+ esac