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,135 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/browser-baseline.sh — named-blessed-capture management. Phase 9
3
+ # part 1-v (CLOSES Phase 9).
4
+ #
5
+ # Sub-modes:
6
+ # save <capture-id> --as NAME — set is_baseline:true; write baselines.json entry
7
+ # list — emit one baseline_row per entry
8
+ # remove <NAME> — clear is_baseline; splice baselines.json
9
+ # (does NOT delete the capture dir; use
10
+ # history clear for that)
11
+ #
12
+ # Per locked decision B1: thin wrapper over Phase 7's meta.is_baseline:true.
13
+ # capture_prune already honors the skip-rule (landed in 7-1-v as forward-compat
14
+ # for Phase 9). NO new prune logic here.
15
+
16
+ set -euo pipefail
17
+ IFS=$'\n\t'
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ SCRIPTS_DIR="${SCRIPT_DIR}"
21
+ export SCRIPTS_DIR
22
+
23
+ # shellcheck source=lib/common.sh
24
+ # shellcheck disable=SC1091
25
+ source "${SCRIPT_DIR}/lib/common.sh"
26
+ # shellcheck source=lib/output.sh
27
+ # shellcheck disable=SC1091
28
+ source "${SCRIPT_DIR}/lib/output.sh"
29
+
30
+ init_paths
31
+
32
+ SUMMARY_T0="$(now_ms)"; export SUMMARY_T0
33
+
34
+ readonly BASELINES_FILE="${BROWSER_SKILL_HOME}/baselines.json"
35
+
36
+ # Lazy-create baselines.json on first save (mode 0600).
37
+ _baseline_init_file() {
38
+ if [ ! -f "${BASELINES_FILE}" ]; then
39
+ jq -nc '{schema_version: 1, baselines: []}' > "${BASELINES_FILE}"
40
+ chmod 600 "${BASELINES_FILE}"
41
+ fi
42
+ }
43
+
44
+ sub_mode="${1:-}"
45
+ [ -n "${sub_mode}" ] || die "${EXIT_USAGE_ERROR}" "browser-baseline: missing sub-mode (save / list / remove)"
46
+ shift
47
+
48
+ case "${sub_mode}" in
49
+ save)
50
+ capture_id="${1:-}"
51
+ [ -n "${capture_id}" ] || die "${EXIT_USAGE_ERROR}" "baseline save: missing <capture-id>"
52
+ shift
53
+ name=""
54
+ while [ "$#" -gt 0 ]; do
55
+ case "$1" in
56
+ --as) name="$2"; shift 2 ;;
57
+ *) die "${EXIT_USAGE_ERROR}" "baseline save: unknown flag '$1'" ;;
58
+ esac
59
+ done
60
+ [ -n "${name}" ] || die "${EXIT_USAGE_ERROR}" "baseline save: --as NAME is required"
61
+
62
+ meta="${CAPTURES_DIR}/${capture_id}/meta.json"
63
+ [ -f "${meta}" ] || die "${EXIT_USAGE_ERROR}" "baseline save: no such capture '${capture_id}'"
64
+
65
+ # Set is_baseline:true on meta.json (Phase 7's prune skip-rule honors this).
66
+ tmp="${meta}.tmp.$$"
67
+ jq '.is_baseline = true' "${meta}" > "${tmp}"
68
+ chmod 600 "${tmp}"
69
+ mv "${tmp}" "${meta}"
70
+
71
+ # Append entry to baselines.json.
72
+ _baseline_init_file
73
+ saved_at="$(now_iso)"
74
+ summary_obj="$(jq -c '{verb, flow_name, step_count}' "${meta}")"
75
+ tmp="${BASELINES_FILE}.tmp.$$"
76
+ jq -c \
77
+ --arg name "${name}" \
78
+ --arg cid "${capture_id}" \
79
+ --arg ts "${saved_at}" \
80
+ --argjson sum "${summary_obj}" \
81
+ '.baselines += [{name: $name, capture_id: $cid, saved_at: $ts, summary: $sum}]' \
82
+ "${BASELINES_FILE}" > "${tmp}"
83
+ chmod 600 "${tmp}"
84
+ mv "${tmp}" "${BASELINES_FILE}"
85
+
86
+ emit_summary verb=baseline tool=none why=save status=ok mode=save \
87
+ capture_id="${capture_id}" name="${name}"
88
+ exit 0
89
+ ;;
90
+
91
+ list)
92
+ _baseline_init_file
93
+ total=0
94
+ while IFS= read -r row; do
95
+ [ -z "${row}" ] && continue
96
+ printf '%s' "${row}" | jq -c '. + {event: "baseline_row"}'
97
+ total=$((total + 1))
98
+ done <<< "$(jq -c '.baselines[]?' "${BASELINES_FILE}")"
99
+ emit_summary verb=baseline tool=none why=list status=ok mode=list total="${total}"
100
+ exit 0
101
+ ;;
102
+
103
+ remove)
104
+ name="${1:-}"
105
+ [ -n "${name}" ] || die "${EXIT_USAGE_ERROR}" "baseline remove: missing <name>"
106
+ _baseline_init_file
107
+ # Find the capture-id for the given name.
108
+ capture_id="$(jq -r --arg n "${name}" '.baselines[] | select(.name == $n) | .capture_id' "${BASELINES_FILE}")"
109
+ [ -n "${capture_id}" ] || die "${EXIT_USAGE_ERROR}" "baseline remove: no such baseline '${name}'"
110
+
111
+ # Clear is_baseline on meta.json (if capture still exists; fail-soft).
112
+ meta="${CAPTURES_DIR}/${capture_id}/meta.json"
113
+ if [ -f "${meta}" ]; then
114
+ tmp="${meta}.tmp.$$"
115
+ jq '.is_baseline = false' "${meta}" > "${tmp}"
116
+ chmod 600 "${tmp}"
117
+ mv "${tmp}" "${meta}"
118
+ fi
119
+
120
+ # Splice baselines.json.
121
+ tmp="${BASELINES_FILE}.tmp.$$"
122
+ jq -c --arg n "${name}" '.baselines |= map(select(.name != $n))' \
123
+ "${BASELINES_FILE}" > "${tmp}"
124
+ chmod 600 "${tmp}"
125
+ mv "${tmp}" "${BASELINES_FILE}"
126
+
127
+ emit_summary verb=baseline tool=none why=remove status=ok mode=remove \
128
+ capture_id="${capture_id}" name="${name}"
129
+ exit 0
130
+ ;;
131
+
132
+ *)
133
+ die "${EXIT_USAGE_ERROR}" "browser-baseline: unknown sub-mode '${sub_mode}' (use save / list / remove)"
134
+ ;;
135
+ esac
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/browser-click.sh — click an element by --ref eN or --selector CSS.
3
+ # Usage: bash scripts/browser-click.sh [--site NAME] [--tool NAME] [--dry-run]
4
+ # [--raw] (--ref eN | --selector CSS)
5
+
6
+ set -euo pipefail
7
+ IFS=$'\n\t'
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ # shellcheck source=lib/common.sh
11
+ # shellcheck disable=SC1091
12
+ source "${SCRIPT_DIR}/lib/common.sh"
13
+ # shellcheck source=lib/output.sh
14
+ # shellcheck disable=SC1091
15
+ source "${SCRIPT_DIR}/lib/output.sh"
16
+ # shellcheck source=lib/router.sh
17
+ # shellcheck disable=SC1091
18
+ source "${SCRIPT_DIR}/lib/router.sh"
19
+ # shellcheck source=lib/verb_helpers.sh
20
+ # shellcheck disable=SC1091
21
+ source "${SCRIPT_DIR}/lib/verb_helpers.sh"
22
+ # shellcheck source=lib/stats.sh
23
+ # shellcheck disable=SC1091
24
+ source "${SCRIPT_DIR}/lib/stats.sh"
25
+
26
+ init_paths
27
+
28
+ SUMMARY_T0="$(now_ms)"; export SUMMARY_T0
29
+
30
+ parse_verb_globals "$@"
31
+
32
+ # Resolve site/session → BROWSER_SKILL_STORAGE_STATE (no-op if neither set).
33
+ # Router's rule_session_required reads the env var to prefer playwright-lib.
34
+ resolve_session_storage_state
35
+
36
+ ref="" selector=""
37
+ verb_argv=()
38
+ i=0
39
+ while [ "${i}" -lt "${#REMAINING_ARGV[@]}" ]; do
40
+ case "${REMAINING_ARGV[i]}" in
41
+ --ref)
42
+ ref="${REMAINING_ARGV[i+1]:-}"
43
+ [ -n "${ref}" ] || die "${EXIT_USAGE_ERROR}" "--ref requires a value"
44
+ verb_argv+=(--ref "${ref}")
45
+ i=$((i + 2))
46
+ ;;
47
+ --selector)
48
+ selector="${REMAINING_ARGV[i+1]:-}"
49
+ [ -n "${selector}" ] || die "${EXIT_USAGE_ERROR}" "--selector requires a value"
50
+ verb_argv+=(--selector "${selector}")
51
+ i=$((i + 2))
52
+ ;;
53
+ *)
54
+ verb_argv+=("${REMAINING_ARGV[i]}")
55
+ i=$((i + 1))
56
+ ;;
57
+ esac
58
+ done
59
+
60
+ if [ -n "${ref}" ] && [ -n "${selector}" ]; then
61
+ die "${EXIT_USAGE_ERROR}" "--ref and --selector are mutually exclusive"
62
+ fi
63
+ if [ -z "${ref}" ] && [ -z "${selector}" ]; then
64
+ die "${EXIT_USAGE_ERROR}" "click requires --ref eN or --selector CSS"
65
+ fi
66
+
67
+ if [ "${ARG_DRY_RUN:-0}" = "1" ]; then
68
+ ok "dry-run: would click ${ref:-${selector}}"
69
+ emit_summary verb=click tool=none why=dry-run status=ok ref="${ref}" selector="${selector}" dry_run=true
70
+ exit 0
71
+ fi
72
+
73
+ picked="$(pick_tool click "${verb_argv[@]}")"
74
+ tool_name="${picked%%$'\t'*}"
75
+ why="${picked#*$'\t'}"
76
+
77
+ source_picked_adapter "${tool_name}"
78
+
79
+ stats_t0="$(now_ms)"
80
+ set +e
81
+ adapter_out="$(invoke_with_retry click "${verb_argv[@]}")"
82
+ adapter_rc=$?
83
+ set -e
84
+
85
+ # Phase 12 part 1: per-action telemetry. No natural observed value for click;
86
+ # observed=adapter_out so element_value post-conditions can match against any
87
+ # response payload the adapter emits.
88
+ BROWSER_STATS_OBSERVED="${adapter_out}" \
89
+ stats_run_adapter_emit \
90
+ "click" "${tool_name}" "${stats_t0}" "${adapter_rc}" "${adapter_out}" "" \
91
+ -- "${verb_argv[@]}" || true
92
+
93
+ [ -n "${adapter_out}" ] && printf '%s\n' "${adapter_out}"
94
+
95
+ if [ "${adapter_rc}" -eq 0 ]; then
96
+ emit_summary verb=click tool="${tool_name}" why="${why}" status=ok ref="${ref}" selector="${selector}"
97
+ exit 0
98
+ fi
99
+ emit_summary verb=click tool="${tool_name}" why="${why}" status=error ref="${ref}" selector="${selector}"
100
+ exit "${adapter_rc}"
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env bash
2
+ # creds-add — register a credential. Smart per-OS backend select; AP-7 strict
3
+ # (password ALWAYS via stdin, never argv). Metadata in ${CREDENTIALS_DIR}/
4
+ # <name>.json mode 0600; secret payload via backend (plaintext: same dir,
5
+ # <name>.secret mode 0600; keychain/libsecret: OS vault).
6
+ set -euo pipefail
7
+ IFS=$'\n\t'
8
+ umask 077
9
+
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ # shellcheck source=lib/common.sh
12
+ # shellcheck disable=SC1091
13
+ source "${SCRIPT_DIR}/lib/common.sh"
14
+ # shellcheck source=lib/site.sh
15
+ # shellcheck disable=SC1091
16
+ source "${SCRIPT_DIR}/lib/site.sh"
17
+ # shellcheck source=lib/credential.sh
18
+ # shellcheck disable=SC1091
19
+ source "${SCRIPT_DIR}/lib/credential.sh"
20
+ # shellcheck source=lib/secret_backend_select.sh
21
+ # shellcheck disable=SC1091
22
+ source "${SCRIPT_DIR}/lib/secret_backend_select.sh"
23
+ init_paths
24
+
25
+ site=""
26
+ as=""
27
+ account=""
28
+ backend=""
29
+ auto_relogin="true"
30
+ auth_flow="single-step-username-password"
31
+ enable_totp=0
32
+ yes_totp=0
33
+ totp_secret_stdin=0
34
+ read_stdin=0
35
+ dry_run=0
36
+ yes_plaintext=0
37
+
38
+ usage() {
39
+ cat <<'USAGE'
40
+ Usage: creds-add --site SITE --as CRED_NAME --password-stdin [options]
41
+
42
+ --site SITE site profile name (must exist)
43
+ --as CRED_NAME credential name (filename, must be safe)
44
+ --account ACCOUNT account/email value (default: "<site>@example.com")
45
+ --backend BACKEND keychain | libsecret | plaintext
46
+ (default: smart auto-detect per OS — keychain on
47
+ Darwin, libsecret on Linux when secret-tool is
48
+ reachable, plaintext fallback otherwise)
49
+ --auto-relogin BOOL true | false (default: true; relogin via login --auto
50
+ requires auth_flow=single-step-username-password)
51
+ --auth-flow FLOW single-step-username-password | multi-step-username-
52
+ password | username-only | custom (default:
53
+ single-step-username-password). Only single-step is
54
+ supported by login --auto today; others persist
55
+ metadata for documentation but require --interactive
56
+ for relogin.
57
+ --enable-totp mark this credential as TOTP-enabled (phase-5 part
58
+ 4-i: plumbing only — codegen/replay/rotation land
59
+ in parts 4-ii/iii/iv). Requires --yes-i-know-totp
60
+ (typed-phrase ack) and forbids --backend plaintext
61
+ (TOTP shared secrets MUST go through OS keychain /
62
+ libsecret per parent spec §1).
63
+ --yes-i-know-totp acknowledgment for --enable-totp.
64
+ --totp-secret-stdin read base32 TOTP shared secret from stdin AFTER
65
+ the password (separated by NUL byte). Stored at
66
+ the <name>:totp backend slot. Requires
67
+ --enable-totp. Phase-5 part 4-ii.
68
+ --password-stdin REQUIRED — read password from stdin (one line);
69
+ this is the ONLY password-input path. AP-7
70
+ forbids accepting the password as an argv arg.
71
+ --yes-i-know-plaintext acknowledge that the plaintext backend stores
72
+ the secret on disk. Required on the FIRST
73
+ plaintext credential add; subsequent adds skip
74
+ (a marker file at ${CREDENTIALS_DIR}/.plaintext-
75
+ acknowledged tracks acknowledgment).
76
+ --dry-run print planned action; write nothing
77
+ -h, --help this message
78
+
79
+ Examples:
80
+ printf 'mypass' | creds-add --site prod --as prod--admin --password-stdin
81
+ printf 'mypass' | creds-add --site prod --as prod--admin --backend keychain --password-stdin
82
+ USAGE
83
+ }
84
+
85
+ while [ $# -gt 0 ]; do
86
+ case "$1" in
87
+ --site) site="$2"; shift 2 ;;
88
+ --as) as="$2"; shift 2 ;;
89
+ --account) account="$2"; shift 2 ;;
90
+ --backend) backend="$2"; shift 2 ;;
91
+ --auto-relogin) auto_relogin="$2"; shift 2 ;;
92
+ --auth-flow) auth_flow="$2"; shift 2 ;;
93
+ --enable-totp) enable_totp=1; shift ;;
94
+ --yes-i-know-totp) yes_totp=1; shift ;;
95
+ --totp-secret-stdin) totp_secret_stdin=1; shift ;;
96
+ --password-stdin) read_stdin=1; shift ;;
97
+ --yes-i-know-plaintext) yes_plaintext=1; shift ;;
98
+ --dry-run) dry_run=1; shift ;;
99
+ -h|--help) usage; exit 0 ;;
100
+ *) die "${EXIT_USAGE_ERROR}" "unknown flag: $1" ;;
101
+ esac
102
+ done
103
+
104
+ [ -n "${site}" ] || { usage; die "${EXIT_USAGE_ERROR}" "--site is required"; }
105
+ [ -n "${as}" ] || { usage; die "${EXIT_USAGE_ERROR}" "--as is required"; }
106
+ [ "${read_stdin}" = "1" ] || { usage; die "${EXIT_USAGE_ERROR}" "--password-stdin is required (passwords MUST come via stdin per AP-7)"; }
107
+
108
+ case "${auto_relogin}" in
109
+ true|false) ;;
110
+ *) die "${EXIT_USAGE_ERROR}" "--auto-relogin must be 'true' or 'false' (got: ${auto_relogin})" ;;
111
+ esac
112
+
113
+ case "${auth_flow}" in
114
+ single-step-username-password|multi-step-username-password|username-only|custom) ;;
115
+ *) die "${EXIT_USAGE_ERROR}" "--auth-flow must be one of {single-step-username-password, multi-step-username-password, username-only, custom} (got: ${auth_flow})" ;;
116
+ esac
117
+
118
+ # Phase 5 part 4-i: --enable-totp requires explicit ack + forbids plaintext.
119
+ # Per parent spec §1, TOTP shared secrets are even more sensitive than
120
+ # passwords (they generate codes for the lifetime of the secret).
121
+ if [ "${enable_totp}" = "1" ] && [ "${yes_totp}" = "0" ]; then
122
+ die "${EXIT_USAGE_ERROR}" "--enable-totp requires --yes-i-know-totp (TOTP shared secrets are highly sensitive)"
123
+ fi
124
+ if [ "${totp_secret_stdin}" = "1" ] && [ "${enable_totp}" = "0" ]; then
125
+ die "${EXIT_USAGE_ERROR}" "--totp-secret-stdin requires --enable-totp"
126
+ fi
127
+
128
+ assert_safe_name "${as}" "credential-name"
129
+
130
+ # Phase 5 part 4-ii: prevent collisions with internal TOTP slot names.
131
+ # Internal slots use `<as>__totp` suffix; if a user picks a cred name ending
132
+ # in `__totp`, that user's password slot would alias another cred's TOTP slot.
133
+ case "${as}" in
134
+ *__totp) die "${EXIT_USAGE_ERROR}" "credential name '${as}' reserved suffix '__totp' (collides with TOTP slot of cred '${as%__totp}')" ;;
135
+ esac
136
+ [ -z "${account}" ] && account="${site}@example.com"
137
+
138
+ if ! site_exists "${site}"; then
139
+ die "${EXIT_SITE_NOT_FOUND}" "site '${site}' not registered (try: add-site --name ${site} --url ...)"
140
+ fi
141
+
142
+ if credential_exists "${as}"; then
143
+ die "${EXIT_USAGE_ERROR}" "credential '${as}' already exists (run: creds-remove --as ${as} first)"
144
+ fi
145
+
146
+ # Resolve backend.
147
+ if [ -z "${backend}" ]; then
148
+ backend="$(detect_backend)"
149
+ fi
150
+ case "${backend}" in
151
+ keychain|libsecret|plaintext) ;;
152
+ *) die "${EXIT_USAGE_ERROR}" "--backend must be one of {keychain, libsecret, plaintext} (got: ${backend})" ;;
153
+ esac
154
+
155
+ # Phase 5 part 4-i: TOTP-enabled creds MUST go through OS keychain / libsecret.
156
+ # plaintext on-disk storage of a TOTP shared secret means anyone with read
157
+ # access to the file can generate auth codes forever — that's worse than
158
+ # plaintext password (passwords expire/rotate; TOTP secrets typically don't).
159
+ if [ "${enable_totp}" = "1" ] && [ "${backend}" = "plaintext" ]; then
160
+ die "${EXIT_USAGE_ERROR}" "--enable-totp forbids --backend plaintext (TOTP secrets must go through keychain or libsecret)"
161
+ fi
162
+
163
+ # First-use plaintext gate (per parent spec §1: plaintext is paper security
164
+ # without disk encryption — gate the first add behind an explicit ack).
165
+ # Marker file at ${CREDENTIALS_DIR}/.plaintext-acknowledged (mode 0600)
166
+ # tracks the user's acknowledgment; subsequent adds skip the gate silently.
167
+ if [ "${backend}" = "plaintext" ]; then
168
+ plaintext_marker="${CREDENTIALS_DIR}/.plaintext-acknowledged"
169
+ if [ ! -f "${plaintext_marker}" ]; then
170
+ if [ "${yes_plaintext}" -ne 1 ]; then
171
+ die "${EXIT_USAGE_ERROR}" \
172
+ "first plaintext credential requires --yes-i-know-plaintext (or pre-create ${plaintext_marker}); plaintext stores the secret on disk and is paper security without disk encryption — see 'doctor' for FileVault/LUKS status"
173
+ fi
174
+ mkdir -p "${CREDENTIALS_DIR}"
175
+ chmod 700 "${CREDENTIALS_DIR}"
176
+ ( umask 077; : > "${plaintext_marker}" )
177
+ chmod 600 "${plaintext_marker}"
178
+ fi
179
+ fi
180
+
181
+ # Read stdin. When --totp-secret-stdin is set, stdin is `password\0totp_secret`
182
+ # (NUL-separated, AP-7: secrets never on argv); otherwise it's just the password.
183
+ # Bash `$(cat)` strips embedded NULs ("warning: ignored null byte"), so use
184
+ # `read -r -d ''` which reads up to a NUL delimiter without losing bytes.
185
+ totp_secret=""
186
+ if [ "${totp_secret_stdin}" = "1" ]; then
187
+ IFS= read -r -d '' password || \
188
+ die "${EXIT_USAGE_ERROR}" "--totp-secret-stdin: stdin must be 'password\\0totp_secret' (no NUL found)"
189
+ # Second chunk: EOF-terminated (no trailing NUL required); `read` returns
190
+ # non-zero on EOF-before-delim but still populates the variable.
191
+ IFS= read -r -d '' totp_secret || true
192
+ if [ -z "${totp_secret}" ]; then
193
+ die "${EXIT_USAGE_ERROR}" "--totp-secret-stdin: stdin must be 'password\\0totp_secret' (got only one chunk)"
194
+ fi
195
+ else
196
+ password="$(cat)"
197
+ fi
198
+
199
+ started_at_ms="$(now_ms)"
200
+
201
+ if [ "${dry_run}" -eq 1 ]; then
202
+ ok "dry-run: would write ${CREDENTIALS_DIR}/${as}.{json,secret} via backend=${backend}"
203
+ duration_ms=$(( $(now_ms) - started_at_ms ))
204
+ summary_json verb=creds-add tool=none why=dry-run status=ok would_run=true \
205
+ credential="${as}" site="${site}" backend="${backend}" \
206
+ duration_ms="${duration_ms}"
207
+ exit "${EXIT_OK}"
208
+ fi
209
+
210
+ now_ts="$(now_iso)"
211
+ totp_json="$([ "${enable_totp}" = "1" ] && printf 'true' || printf 'false')"
212
+ meta_json="$(jq -nc \
213
+ --arg n "${as}" \
214
+ --arg s "${site}" \
215
+ --arg a "${account}" \
216
+ --arg b "${backend}" \
217
+ --argjson ar "${auto_relogin}" \
218
+ --arg af "${auth_flow}" \
219
+ --argjson tt "${totp_json}" \
220
+ --arg now "${now_ts}" \
221
+ '{
222
+ schema_version: 1,
223
+ name: $n,
224
+ site: $s,
225
+ account: $a,
226
+ backend: $b,
227
+ auth_flow: $af,
228
+ auto_relogin: $ar,
229
+ totp_enabled: $tt,
230
+ created_at: $now
231
+ }')"
232
+
233
+ credential_save "${as}" "${meta_json}"
234
+
235
+ # Pipe the password into the backend via stdin (AP-7 — never argv).
236
+ printf '%s' "${password}" | credential_set_secret "${as}"
237
+
238
+ # Phase 5 part 4-ii: store TOTP shared secret in the same backend at the
239
+ # `<as>:totp` slot when --totp-secret-stdin was provided.
240
+ if [ -n "${totp_secret}" ]; then
241
+ printf '%s' "${totp_secret}" | credential_set_totp_secret "${as}"
242
+ fi
243
+
244
+ if [ "${backend}" = "plaintext" ]; then
245
+ warn "credential '${as}' stored via plaintext backend at ${CREDENTIALS_DIR}/${as}.secret (mode 0600)"
246
+ warn " ensure disk encryption is enabled (FileVault/LUKS) — see 'doctor'"
247
+ fi
248
+
249
+ ok "credential added: ${as} (site=${site}, backend=${backend})"
250
+
251
+ duration_ms=$(( $(now_ms) - started_at_ms ))
252
+ summary_json verb=creds-add tool=none why=register-credential status=ok \
253
+ credential="${as}" site="${site}" backend="${backend}" \
254
+ duration_ms="${duration_ms}"
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # creds-list — list registered credentials. Optional --site filter mirrors
3
+ # list-sessions. Emits ONLY metadata (NEVER secret values; backend payloads
4
+ # stay in their respective vaults).
5
+
6
+ set -euo pipefail
7
+ IFS=$'\n\t'
8
+ umask 077
9
+
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ # shellcheck source=lib/common.sh
12
+ # shellcheck disable=SC1091
13
+ source "${SCRIPT_DIR}/lib/common.sh"
14
+ # shellcheck source=lib/credential.sh
15
+ # shellcheck disable=SC1091
16
+ source "${SCRIPT_DIR}/lib/credential.sh"
17
+ init_paths
18
+
19
+ site_filter=""
20
+ while [ $# -gt 0 ]; do
21
+ case "$1" in
22
+ --site)
23
+ site_filter="$2"; shift 2
24
+ ;;
25
+ -h|--help)
26
+ cat <<'USAGE'
27
+ Usage: creds-list [--site NAME]
28
+
29
+ --site NAME show only credentials bound to this site
30
+ USAGE
31
+ exit 0
32
+ ;;
33
+ *) die "${EXIT_USAGE_ERROR}" "unknown flag: $1" ;;
34
+ esac
35
+ done
36
+
37
+ started_at_ms="$(now_ms)"
38
+
39
+ rows='[]'
40
+ count=0
41
+ for name in $(credential_list_names); do
42
+ meta="$(credential_load "${name}" 2>/dev/null)" || continue
43
+ cred_site="$(printf '%s' "${meta}" | jq -r '.site // ""')"
44
+ if [ -n "${site_filter}" ] && [ "${cred_site}" != "${site_filter}" ]; then
45
+ continue
46
+ fi
47
+ rows="$(jq --arg n "${name}" --argjson m "${meta}" '
48
+ . + [{
49
+ credential: $n,
50
+ site: ($m.site // null),
51
+ account: ($m.account // null),
52
+ backend: ($m.backend // null),
53
+ auto_relogin: ($m.auto_relogin // null),
54
+ totp_enabled: ($m.totp_enabled // null),
55
+ created_at: ($m.created_at // null)
56
+ }]' <<< "${rows}")"
57
+ count=$((count + 1))
58
+ done
59
+
60
+ duration_ms=$(( $(now_ms) - started_at_ms ))
61
+ jq -cn --argjson r "${rows}" --argjson c "${count}" --argjson d "${duration_ms}" \
62
+ --arg sf "${site_filter}" \
63
+ '{verb: "creds-list", tool: "none",
64
+ why: (if $sf == "" then "list-all" else "list-by-site" end),
65
+ status: (if $c == 0 then "empty" else "ok" end),
66
+ site_filter: ($sf | if . == "" then null else . end),
67
+ count: $c, credentials: $r, duration_ms: $d}'
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bash
2
+ # creds-migrate — move a credential from one backend to another. Fail-safe
3
+ # ordering: writes to new backend BEFORE deleting from old, so a failed
4
+ # new-backend write leaves the original credential intact.
5
+ #
6
+ # Inherits the first-use plaintext gate from creds-add: migrating TO plaintext
7
+ # requires --yes-i-know-plaintext (or a pre-existing acknowledgment marker)
8
+ # so users can't bypass the gate by going via creds-migrate.
9
+ #
10
+ # Privacy invariant: summary JSON NEVER contains the secret value.
11
+ set -euo pipefail
12
+ IFS=$'\n\t'
13
+ umask 077
14
+
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ # shellcheck source=lib/common.sh
17
+ # shellcheck disable=SC1091
18
+ source "${SCRIPT_DIR}/lib/common.sh"
19
+ # shellcheck source=lib/credential.sh
20
+ # shellcheck disable=SC1091
21
+ source "${SCRIPT_DIR}/lib/credential.sh"
22
+ init_paths
23
+
24
+ name=""; to_backend=""; yes=0; yes_plaintext=0; dry_run=0
25
+
26
+ usage() {
27
+ cat <<'USAGE'
28
+ Usage: creds-migrate --as CRED_NAME --to BACKEND [options]
29
+
30
+ --as CRED_NAME credential to migrate (required)
31
+ --to BACKEND target: keychain | libsecret | plaintext (required)
32
+ --yes-i-know skip the typed-name confirmation
33
+ --yes-i-know-plaintext acknowledge plaintext storage; required when
34
+ --to plaintext on a fresh box without the
35
+ ${CREDENTIALS_DIR}/.plaintext-acknowledged marker
36
+ --dry-run print planned action; migrate nothing
37
+ -h, --help this message
38
+
39
+ Fail-safe: if the new-backend write fails (e.g. keychain unavailable), the
40
+ original credential is left intact. If the old-backend delete fails AFTER a
41
+ successful new-backend write, both backends transiently hold the secret —
42
+ verb logs a warning, doesn't crash; you can manually clean via creds-remove
43
+ on the old backend OR re-run creds-migrate to consolidate.
44
+ USAGE
45
+ }
46
+
47
+ while [ $# -gt 0 ]; do
48
+ case "$1" in
49
+ --as) name="$2"; shift 2 ;;
50
+ --to) to_backend="$2"; shift 2 ;;
51
+ --yes-i-know) yes=1; shift ;;
52
+ --yes-i-know-plaintext) yes_plaintext=1; shift ;;
53
+ --dry-run) dry_run=1; shift ;;
54
+ -h|--help) usage; exit 0 ;;
55
+ *) die "${EXIT_USAGE_ERROR}" "unknown flag: $1" ;;
56
+ esac
57
+ done
58
+
59
+ [ -n "${name}" ] || { usage; die "${EXIT_USAGE_ERROR}" "--as is required"; }
60
+ [ -n "${to_backend}" ] || { usage; die "${EXIT_USAGE_ERROR}" "--to is required"; }
61
+ assert_safe_name "${name}" "credential-name"
62
+
63
+ case "${to_backend}" in
64
+ keychain|libsecret|plaintext) ;;
65
+ *) die "${EXIT_USAGE_ERROR}" "--to must be one of {keychain, libsecret, plaintext} (got: ${to_backend})" ;;
66
+ esac
67
+
68
+ if ! credential_exists "${name}"; then
69
+ die "${EXIT_SITE_NOT_FOUND}" "credential not found: ${name}"
70
+ fi
71
+
72
+ old_meta="$(credential_load "${name}")"
73
+ old_backend="$(printf '%s' "${old_meta}" | jq -r '.backend')"
74
+
75
+ if [ "${old_backend}" = "${to_backend}" ]; then
76
+ die "${EXIT_USAGE_ERROR}" "credential ${name}: already on backend '${to_backend}' (no-op refused)"
77
+ fi
78
+
79
+ # First-use plaintext gate inherited from creds-add — migrating TO plaintext
80
+ # must respect the same acknowledgment requirement.
81
+ if [ "${to_backend}" = "plaintext" ]; then
82
+ plaintext_marker="${CREDENTIALS_DIR}/.plaintext-acknowledged"
83
+ if [ ! -f "${plaintext_marker}" ] && [ "${yes_plaintext}" -ne 1 ]; then
84
+ die "${EXIT_USAGE_ERROR}" \
85
+ "migrate-to-plaintext requires --yes-i-know-plaintext (or pre-create ${plaintext_marker}); plaintext stores the secret on disk and is paper security without disk encryption"
86
+ fi
87
+ # Touch the marker if not present (so subsequent plaintext ops skip the gate).
88
+ if [ ! -f "${plaintext_marker}" ]; then
89
+ mkdir -p "${CREDENTIALS_DIR}"
90
+ chmod 700 "${CREDENTIALS_DIR}"
91
+ ( umask 077; : > "${plaintext_marker}" )
92
+ chmod 600 "${plaintext_marker}"
93
+ fi
94
+ fi
95
+
96
+ started_at_ms="$(now_ms)"
97
+
98
+ if [ "${dry_run}" -eq 1 ]; then
99
+ ok "dry-run: would migrate credential ${name}: ${old_backend} → ${to_backend}"
100
+ duration_ms=$(( $(now_ms) - started_at_ms ))
101
+ summary_json verb=creds-migrate tool=none why=dry-run status=ok would_run=true \
102
+ credential="${name}" from="${old_backend}" to="${to_backend}" \
103
+ duration_ms="${duration_ms}"
104
+ exit "${EXIT_OK}"
105
+ fi
106
+
107
+ if [ "${yes}" -ne 1 ]; then
108
+ printf 'Type the credential name (%s) to confirm migration: ' "${name}" >&2
109
+ answer=""
110
+ IFS= read -r answer || true
111
+ if [ "${answer}" != "${name}" ]; then
112
+ die "${EXIT_USAGE_ERROR}" "migration aborted (confirmation mismatch)"
113
+ fi
114
+ fi
115
+
116
+ credential_migrate_to "${name}" "${to_backend}"
117
+ ok "credential migrated: ${name} (${old_backend} → ${to_backend})"
118
+
119
+ duration_ms=$(( $(now_ms) - started_at_ms ))
120
+ summary_json verb=creds-migrate tool=none why=migrate status=ok \
121
+ credential="${name}" from="${old_backend}" to="${to_backend}" \
122
+ duration_ms="${duration_ms}"