@forhuman/flowmcp 0.1.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.
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: flowmcp rename <old-org> <new-org> [--dry-run] [--json]
3
+ # Renames an org: moves its profile and saved credentials without any
4
+ # re-authentication, and updates the entry name in any client config where
5
+ # it was already installed (server names are derived from the org name, so
6
+ # a rename has to touch those too, not just our own storage).
7
+
8
+ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
9
+
10
+ old="${1:?Usage: flowmcp rename <old-org> <new-org> [--dry-run]}"
11
+ new="${2:?Usage: flowmcp rename <old-org> <new-org> [--dry-run]}"
12
+ shift 2 || true
13
+ dry_run="" json_flag=""
14
+ while [[ $# -gt 0 ]]; do
15
+ case "$1" in
16
+ --dry-run) dry_run="1"; shift ;;
17
+ --json) json_flag="1"; shift ;;
18
+ *) echo "error: unknown option '$1'" >&2; exit 1 ;;
19
+ esac
20
+ done
21
+
22
+ wfw_valid_org "$new" || {
23
+ wfw_say_err "$(wfw_t msg_invalid_org "$new")"
24
+ exit 1
25
+ }
26
+
27
+ wfw_profile_exists "$old" || {
28
+ wfw_say_err "$(wfw_t msg_org_not_found "$old")"
29
+ exit 1
30
+ }
31
+
32
+ if wfw_profile_exists "$new"; then
33
+ wfw_say_err "$(wfw_t msg_rename_new_exists "$new")"
34
+ exit 1
35
+ fi
36
+
37
+ auth_method="$(jq -r '.auth_method // "pat"' <<<"$(wfw_profile_read "$old")")"
38
+
39
+ # find which already-installed client configs would need updating, whether
40
+ # this is a real run or a dry-run — the scan itself never writes anything.
41
+ would_update=()
42
+ for spec in "claude-code:user" "claude-code:project" "claude-desktop:user" "cursor:user" "cursor:project"; do
43
+ client="${spec%%:*}"; scope="${spec#*:}"
44
+ path="$(wfw_client_config_path "$client" "$scope" 2>/dev/null || true)"
45
+ [[ -z "$path" || ! -f "$path" ]] && continue
46
+ jq empty "$path" >/dev/null 2>&1 || continue
47
+ has_old="$(jq -r --arg n "webflow-$old" '.mcpServers[$n] // empty' "$path")"
48
+ [[ -z "$has_old" ]] && continue
49
+ would_update+=("$path")
50
+ done
51
+
52
+ if [[ -n "$dry_run" ]]; then
53
+ if wfw_json_mode "$json_flag"; then
54
+ jq -nc --arg old "$old" --arg new "$new" \
55
+ --argjson updated "$(printf '%s\n' "${would_update[@]:-}" | jq -R . | jq -sc 'map(select(length>0))')" \
56
+ '{ok: true, dry_run: true, old: $old, new: $new, would_update: $updated}'
57
+ else
58
+ echo "${WFW_C_DIM}dry-run — would rename '$old' to '$new' (no re-login needed)${WFW_C_RESET}"
59
+ for p in "${would_update[@]:-}"; do
60
+ echo "${WFW_C_DIM}would update: $p (webflow-$old -> webflow-$new)${WFW_C_RESET}"
61
+ done
62
+ fi
63
+ exit 0
64
+ fi
65
+
66
+ jq --arg org "$new" '.org = $org' "$(wfw_profile_path "$old")" > "$(wfw_profile_path "$new")"
67
+ rm -f "$(wfw_profile_path "$old")"
68
+
69
+ if [[ "$auth_method" == "mcp-remote" ]]; then
70
+ old_dir="$(wfw_mcp_remote_dir "$old")"
71
+ new_dir="$(wfw_mcp_remote_dir "$new")"
72
+ [[ -d "$old_dir" ]] && mv "$old_dir" "$new_dir"
73
+ else
74
+ wfw_old_token="$(wfw_secret_get "$old" || true)"
75
+ if [[ -n "$wfw_old_token" ]]; then
76
+ wfw_secret_set "$new" wfw_old_token
77
+ wfw_secret_delete "$old"
78
+ fi
79
+ fi
80
+
81
+ updated=()
82
+ for path in "${would_update[@]:-}"; do
83
+ server_json="$(wfw_build_server_json "$new" "$auth_method")"
84
+ wfw_client_remove_server "$path" "webflow-$old"
85
+ wfw_client_merge_server "$path" "webflow-$new" "$server_json" "--force"
86
+ updated+=("$path")
87
+ done
88
+
89
+ wfw_audit_log "rename" "$old" "ok" "to=$new"
90
+
91
+ if wfw_json_mode "$json_flag"; then
92
+ jq -nc --arg old "$old" --arg new "$new" \
93
+ --argjson updated "$(printf '%s\n' "${updated[@]:-}" | jq -R . | jq -sc 'map(select(length>0))')" \
94
+ '{ok: true, dry_run: false, old: $old, new: $new, updated: $updated,
95
+ next_steps: (if ($updated | length) > 0 then ["restart any client whose config was just updated"] else [] end)}'
96
+ else
97
+ wfw_say_ok "$(wfw_t msg_rename_ok "$old" "$new")"
98
+ if [[ ${#updated[@]} -gt 0 ]]; then
99
+ for p in "${updated[@]}"; do
100
+ echo "${WFW_C_DIM}updated: $p (webflow-$old -> webflow-$new)${WFW_C_RESET}"
101
+ done
102
+ wfw_say_hint "$(wfw_t msg_rename_hint)"
103
+ fi
104
+ fi
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: flowmcp rotate <org>
3
+ # Same interactive, human-only flow as secret-set — overwrites the existing
4
+ # token for an org that's already registered.
5
+
6
+ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
7
+
8
+ org="${1:?Usage: flowmcp rotate <org>}"
9
+
10
+ wfw_profile_exists "$org" || {
11
+ echo "error: no org '$org' registered" >&2
12
+ exit 1
13
+ }
14
+
15
+ if [[ ! -t 0 ]]; then
16
+ wfw_t msg_need_tty_rotate >&2
17
+ wfw_t msg_run_yourself_short >&2
18
+ exit 1
19
+ fi
20
+
21
+ wfw_t msg_rotate_enter "$org"
22
+ read -rs wfw_token_input
23
+ echo
24
+ if [[ -z "$wfw_token_input" ]]; then
25
+ echo "error: empty token, aborting" >&2
26
+ exit 1
27
+ fi
28
+
29
+ wfw_secret_set "$org" wfw_token_input
30
+ wfw_audit_log "rotate" "$org" "ok"
31
+
32
+ wfw_t msg_rotate_done "$org" "$(wfw_secret_backend)"
33
+ wfw_t msg_test_hint_new "$org"
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: run-mcp.sh <org>
3
+ #
4
+ # NOT meant to be run by a human or an agent directly. This is what the
5
+ # `command` field in the generated mcpServers entry points to — the MCP
6
+ # client (Claude Code / Claude Desktop / Cursor) launches this at server
7
+ # start time. It looks up the token for <org> from the secret backend,
8
+ # exports it, and execs webflow-mcp-server. The token therefore never
9
+ # needs to appear in any client's config JSON file on disk.
10
+
11
+ set -euo pipefail
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ # shellcheck source=../lib/common.sh
14
+ source "$SCRIPT_DIR/../lib/common.sh"
15
+ # shellcheck source=../lib/secrets.sh
16
+ source "$SCRIPT_DIR/../lib/secrets.sh"
17
+
18
+ org="${1:?Usage: run-mcp.sh <org>}"
19
+
20
+ WEBFLOW_TOKEN="$(wfw_secret_get "$org" || true)"
21
+ if [[ -z "$WEBFLOW_TOKEN" ]]; then
22
+ echo "flowmcp: no token stored for org '$org'. Run 'flowmcp secret-set $org'." >&2
23
+ exit 1
24
+ fi
25
+ export WEBFLOW_TOKEN
26
+
27
+ exec npx -y webflow-mcp-server@latest
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: flowmcp schema
3
+ # Machine-readable description of every command: args, flags, and the JSON
4
+ # shape it emits in --json mode. Always JSON, regardless of TTY — an agent
5
+ # should be able to `flowmcp schema` once at the start of a
6
+ # session and know the whole surface without parsing --help text.
7
+
8
+ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
9
+
10
+ jq -nc --arg version "$(wfw_version)" '
11
+ {
12
+ schema_version: 1,
13
+ cli_version: $version,
14
+ commands: [
15
+ {
16
+ name: "connect",
17
+ usage: "connect <org> [--label NAME]",
18
+ mutates: true,
19
+ requires_tty: true,
20
+ description: "Add/reconnect an org via browser OAuth (mcp-remote, no setup needed).",
21
+ output: null
22
+ },
23
+ {
24
+ name: "add",
25
+ usage: "add <org> [--label NAME]",
26
+ mutates: true,
27
+ requires_tty: false,
28
+ description: "Register org metadata only, no secret.",
29
+ output: null
30
+ },
31
+ {
32
+ name: "secret-set",
33
+ usage: "secret-set <org>",
34
+ mutates: true,
35
+ requires_tty: true,
36
+ description: "Interactively paste a token. Refuses to run without a real TTY.",
37
+ output: null
38
+ },
39
+ {
40
+ name: "rotate",
41
+ usage: "rotate <org>",
42
+ mutates: true,
43
+ requires_tty: true,
44
+ description: "Replace a stored token. Refuses to run without a real TTY.",
45
+ output: null
46
+ },
47
+ {
48
+ name: "list",
49
+ usage: "list [--json]",
50
+ mutates: false,
51
+ requires_tty: false,
52
+ description: "List registered orgs and last test status.",
53
+ output: {shape: "array", item: "profile"}
54
+ },
55
+ {
56
+ name: "inspect",
57
+ usage: "inspect <org> [--live] [--json]",
58
+ mutates: false,
59
+ requires_tty: false,
60
+ description: "Show one profile in full, plus a connected boolean. --live re-runs test first.",
61
+ output: {shape: "object", item: "profile + connected"}
62
+ },
63
+ {
64
+ name: "test",
65
+ usage: "test <org> [--json]",
66
+ mutates: false,
67
+ requires_tty: false,
68
+ description: "Validate stored credentials (network call for pat orgs, file check for mcp-remote orgs).",
69
+ output: {shape: "object", fields: ["org","auth_method","status","sites_count","scopes","error"]}
70
+ },
71
+ {
72
+ name: "install",
73
+ usage: "install <org> <client> [--scope user|project] [--force] [--dry-run] [--json]",
74
+ mutates: true,
75
+ requires_tty: false,
76
+ destructive: false,
77
+ description: "Merge an mcpServers entry into a client config. --dry-run prints the entry without writing.",
78
+ clients: ["claude-code","claude-desktop","cursor"],
79
+ output: {shape: "object", fields: ["ok","org","client","scope","path","dry_run"]}
80
+ },
81
+ {
82
+ name: "remove",
83
+ usage: "remove <org> --yes [--from client:scope]... [--dry-run] [--json]",
84
+ mutates: true,
85
+ requires_tty: false,
86
+ destructive: true,
87
+ description: "Delete profile + credentials, optionally strip client entries. Requires --yes, no prompt.",
88
+ output: {shape: "object", fields: ["ok","org","removed_from","dry_run"]}
89
+ },
90
+ {
91
+ name: "rename",
92
+ usage: "rename <old-org> <new-org> [--dry-run] [--json]",
93
+ mutates: true,
94
+ requires_tty: false,
95
+ destructive: false,
96
+ description: "Move a profile + saved session to a new org name, no re-login, updates installed client configs.",
97
+ output: {shape: "object", fields: ["ok","old","new","updated"]}
98
+ },
99
+ {
100
+ name: "debug",
101
+ usage: "debug <org> [--json]",
102
+ mutates: false,
103
+ requires_tty: false,
104
+ description: "Diagnose profile/credential/network/config issues.",
105
+ output: {shape: "object", fields: ["org","checks"]}
106
+ },
107
+ {
108
+ name: "schema",
109
+ usage: "schema",
110
+ mutates: false,
111
+ requires_tty: false,
112
+ description: "This output.",
113
+ output: {shape: "object", fields: ["schema_version","cli_version","commands"]}
114
+ },
115
+ {
116
+ name: "lang",
117
+ usage: "lang [en|es] [--json]",
118
+ mutates: true,
119
+ requires_tty: false,
120
+ description: "View or change the saved human-readable output language. Only affects --help/banner text, never JSON.",
121
+ output: {shape: "object", fields: ["lang"]}
122
+ }
123
+ ],
124
+ conventions: {
125
+ json_auto: "list/inspect/test/debug/install/remove/rename print JSON automatically when stdout is not a TTY, no --json flag required",
126
+ next_steps_field: "JSON output includes next_steps: [string] where applicable — suggested commands to run next",
127
+ exit_codes: {"0": "success", "1": "user error or failed check (org not found, gate refused, test failed)"},
128
+ secrets: "no command ever accepts a token as an argument or prints one; secret-set/rotate require a real TTY"
129
+ }
130
+ }
131
+ '
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: flowmcp secret-set <org>
3
+ #
4
+ # INTERACTIVE ONLY. This is the one command in this tool that must be run
5
+ # by a human, in their own terminal, never through an agent's tool-call
6
+ # interface — the token is read with a hidden prompt and never touches
7
+ # argv, stdout, or any log. If stdin isn't a real TTY, this refuses to run.
8
+
9
+ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
10
+
11
+ org="${1:?Usage: flowmcp secret-set <org>}"
12
+
13
+ wfw_profile_exists "$org" || {
14
+ echo "error: no org '$org' — run 'flowmcp add $org' first" >&2
15
+ exit 1
16
+ }
17
+
18
+ if [[ ! -t 0 ]]; then
19
+ wfw_t msg_need_tty_secret >&2
20
+ wfw_t msg_run_yourself1 >&2
21
+ wfw_t msg_run_yourself2 >&2
22
+ exit 1
23
+ fi
24
+
25
+ wfw_t msg_secretset_enter "$org"
26
+ read -rs wfw_token_input
27
+ echo
28
+ if [[ -z "$wfw_token_input" ]]; then
29
+ echo "error: empty token, aborting" >&2
30
+ exit 1
31
+ fi
32
+
33
+ wfw_secret_set "$org" wfw_token_input
34
+ wfw_audit_log "secret-set" "$org" "ok"
35
+
36
+ wfw_t msg_secretset_stored "$org" "$(wfw_secret_backend)"
37
+ wfw_t msg_test_hint "$org"
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: flowmcp test <org> [--json]
3
+ # Validates the stored credentials. For "pat" orgs, the token is read into
4
+ # a local variable and used only in an Authorization header — it is never
5
+ # echoed, logged, or included in any error message. For "mcp-remote" orgs
6
+ # we deliberately don't make a live call here (it could trigger mcp-remote
7
+ # to silently pop open a browser if the session needs re-auth) — we just
8
+ # check that a completed session exists on disk.
9
+ # JSON automatically when stdout isn't a real TTY (e.g. an agent's tool call).
10
+
11
+ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
12
+
13
+ org="${1:?Usage: flowmcp test <org> [--json]}"
14
+ shift || true
15
+ json_flag=""
16
+ [[ "${1:-}" == "--json" ]] && json_flag="1"
17
+
18
+ wfw_profile_exists "$org" || {
19
+ if wfw_json_mode "$json_flag"; then
20
+ jq -nc --arg org "$org" '{org: $org, status: "fail", error: "org not registered"}'
21
+ else
22
+ wfw_say_err "$(wfw_t msg_org_not_found "$org")"
23
+ fi
24
+ exit 1
25
+ }
26
+
27
+ auth_method="$(jq -r '.auth_method // "pat"' <<<"$(wfw_profile_read "$org")")"
28
+
29
+ # wfw_test_emit <status> <hint> — final output in whichever mode was requested.
30
+ wfw_test_emit() {
31
+ local status="$1" hint="${2:-}"
32
+ if wfw_json_mode "$json_flag"; then
33
+ local next_hint="$hint"
34
+ [[ "$status" == "ok" ]] && next_hint="flowmcp install $org <client>"
35
+ jq -nc \
36
+ --arg org "$org" --arg auth "$auth_method" --arg status "$status" \
37
+ --arg error "$err_msg" --argjson scopes "${scopes_json:-[]}" \
38
+ --argjson sites "${sites_count:-null}" --arg next_hint "$next_hint" \
39
+ '{org: $org, auth_method: $auth, status: $status,
40
+ sites_count: $sites, scopes: $scopes,
41
+ error: (if $error == "" then null else $error end),
42
+ next_steps: (if $next_hint == "" then [] else [$next_hint] end)}'
43
+ else
44
+ if [[ "$status" == "ok" ]]; then
45
+ if [[ "$auth_method" == "mcp-remote" ]]; then
46
+ wfw_say_ok "$(wfw_t msg_test_mcpremote_ok "$org")"
47
+ echo "${WFW_C_DIM}$(wfw_t msg_test_note1)"
48
+ echo "$(wfw_t msg_test_note2)"
49
+ echo "$(wfw_t msg_test_note3)${WFW_C_RESET}"
50
+ else
51
+ wfw_say_ok "$(wfw_t msg_test_pat_ok "$org" "$sites_count")"
52
+ [[ "$scopes_json" != "[]" ]] && echo "${WFW_C_DIM}$(wfw_t msg_test_scopes "$(jq -r 'join(", ")' <<<"$scopes_json")")${WFW_C_RESET}"
53
+ fi
54
+ echo
55
+ wfw_say_next "$(wfw_t msg_test_next_intro)"
56
+ echo " - claude-code: flowmcp install $org claude-code"
57
+ echo " - claude-desktop: flowmcp install $org claude-desktop"
58
+ echo " - cursor: flowmcp install $org cursor"
59
+ else
60
+ wfw_say_err "$err_msg"
61
+ [[ -n "$hint" ]] && wfw_say_hint "$hint"
62
+ fi
63
+ fi
64
+ }
65
+
66
+ err_msg="" scopes_json="[]" sites_count="null"
67
+
68
+ if [[ "$auth_method" == "mcp-remote" ]]; then
69
+ if wfw_mcp_remote_connected "$org"; then
70
+ wfw_profile_update_last_test "$org" "ok" "[]" "null" ""
71
+ wfw_audit_log "test" "$org" "ok" "mcp-remote session present"
72
+ wfw_test_emit "ok"
73
+ exit 0
74
+ else
75
+ err_msg="no mcp-remote session found"
76
+ wfw_profile_update_last_test "$org" "fail" "[]" "null" "$err_msg"
77
+ wfw_audit_log "test" "$org" "fail" "$err_msg"
78
+ wfw_test_emit "fail" "run 'flowmcp connect $org' to log in"
79
+ exit 1
80
+ fi
81
+ fi
82
+
83
+ wfw_require_curl
84
+
85
+ wfw_token="$(wfw_secret_get "$org" || true)"
86
+ if [[ -z "$wfw_token" ]]; then
87
+ err_msg="no token stored"
88
+ wfw_profile_update_last_test "$org" "fail" "[]" "null" "$err_msg"
89
+ wfw_test_emit "fail" "run 'flowmcp secret-set $org'"
90
+ exit 1
91
+ fi
92
+
93
+ body_file="$(mktemp)"
94
+ trap 'rm -f "$body_file"; unset wfw_token' EXIT
95
+
96
+ http_code="$(curl -s -o "$body_file" -w "%{http_code}" \
97
+ -H "Authorization: Bearer $wfw_token" \
98
+ -H "accept: application/json" \
99
+ "https://api.webflow.com/v2/sites")"
100
+
101
+ if [[ "$http_code" == "200" ]]; then
102
+ sites_count="$(jq '.sites | length' "$body_file" 2>/dev/null || echo 0)"
103
+
104
+ auth_body_file="$(mktemp)"
105
+ auth_code="$(curl -s -o "$auth_body_file" -w "%{http_code}" \
106
+ -H "Authorization: Bearer $wfw_token" \
107
+ -H "accept: application/json" \
108
+ "https://api.webflow.com/v2/token/authorized_by")"
109
+ if [[ "$auth_code" == "200" ]] && jq -e '.scope' "$auth_body_file" >/dev/null 2>&1; then
110
+ scopes_json="$(jq -c '.scope | split(" ")' "$auth_body_file")"
111
+ fi
112
+ rm -f "$auth_body_file"
113
+
114
+ wfw_profile_update_last_test "$org" "ok" "$scopes_json" "$sites_count" ""
115
+ wfw_audit_log "test" "$org" "ok" "sites=$sites_count"
116
+ wfw_test_emit "ok"
117
+ else
118
+ err_msg="HTTP $http_code"
119
+ detail="$(jq -r '.message // empty' "$body_file" 2>/dev/null || true)"
120
+ [[ -n "$detail" ]] && err_msg="$err_msg: $detail"
121
+ wfw_profile_update_last_test "$org" "fail" "[]" "null" "$err_msg"
122
+ wfw_audit_log "test" "$org" "fail" "$err_msg"
123
+ hint=""
124
+ case "$http_code" in
125
+ 401) hint="token is invalid, revoked, or expired — try 'flowmcp rotate $org'" ;;
126
+ 403) hint="token is valid but lacks required scopes for this site/workspace" ;;
127
+ 000) hint="no response — check network connectivity / DNS / proxy" ;;
128
+ esac
129
+ wfw_test_emit "fail" "$hint"
130
+ exit 1
131
+ fi
package/friction.md ADDED
@@ -0,0 +1,67 @@
1
+ # friction.md
2
+
3
+ Written retrospectively (this file should have been opened on day one — see
4
+ the case entry for that admission). Kept going forward for anything that
5
+ turns out wrong for this domain.
6
+
7
+ ## Contract origin
8
+
9
+ Mixed. `lib/profiles.sh` / `lib/secrets.sh` / `lib/clients.sh` are a defined
10
+ contract — we own the shape of a "profile" and a "client config entry".
11
+ The OAuth surface (`mcp.webflow.com`'s `.well-known/oauth-authorization-server`,
12
+ DCR at `/oauth/register`, PKCE support) was discovered, not assumed — verified
13
+ live with a real registration + full token exchange before writing `connect`
14
+ against it, rather than guessing at Webflow's OAuth behavior from docs.
15
+
16
+ ## Rejected
17
+
18
+ - **Hand-rolled OAuth (PKCE + refresh + local callback server).** Built once,
19
+ worked, then thrown away in favor of shelling out to `mcp-remote`
20
+ (github.com/punkpeye/mcp-remote). Reason: it already solves this exact
21
+ problem (stdio↔remote-HTTP-MCP OAuth proxy) and is what the MCP ecosystem
22
+ actually uses — re-implementing token refresh and SSE proxying ourselves
23
+ was surface area with no payoff. The one thing we still own is per-org
24
+ isolation, via `MCP_REMOTE_CONFIG_DIR`.
25
+ - **Custom OAuth App setup step (client_id/secret the user creates by hand).**
26
+ Assumed necessary at first because that's how Webflow's classic Data API
27
+ OAuth (`api.webflow.com/oauth`) works — no PKCE alternative there. Turned
28
+ out false for the hosted MCP server specifically: `mcp.webflow.com`
29
+ supports Dynamic Client Registration, so `connect` needed zero setup.
30
+ Confirmed by reading the metadata endpoint, not by assumption.
31
+ - **`flowmcp` as a Claude-Code-only tool.** Considered scoping
32
+ install targets to just `claude-code`. Rejected once it became clear the
33
+ actual pain point (Claude Desktop's native Connector supporting only one
34
+ Webflow account) lives in Claude Desktop, not Claude Code — `install`
35
+ needed `claude-desktop` and `cursor` from the start.
36
+
37
+ ## What turned out wrong
38
+
39
+ - **Test-org naming.** Used a throwaway name (`aktis-juanpa`) during manual
40
+ testing of `rename` that collided with a real, already-connected client
41
+ org of the same name. `rename`'s client-config scan resolves through the
42
+ real `$HOME` (by design — `install`/`remove`/`rename` are meant to touch
43
+ real, shared, global client config files), so it silently repointed a real
44
+ entry in the user's real Claude Desktop config at a fake session directory.
45
+ Recovered via `wfw_client_merge_server`'s own `.bak` backup, no data lost.
46
+ Fix going forward: test org names must be obviously fake (`zzz-test-*`)
47
+ and functional tests must run under an isolated `WFW_HOME`/`HOME`, not the
48
+ real ones — see the test blocks now embedded in the build for `schema`,
49
+ `--dry-run`, and `next_steps`.
50
+ - **`set -e` swallowing errors on command substitution.** Recurred four
51
+ separate times (`wfw_secret_get` in `test.sh`, `run-mcp.sh`, and the
52
+ `wfw_wait_with_logo`/`wait` pattern in `lib/ui.sh`): assigning the output
53
+ of a command that can fail, without wrapping it in `if`/`|| true`, kills
54
+ the whole script silently before the intended error handling runs. Now a
55
+ standing check whenever a new command script reads a keychain value.
56
+
57
+ ## Conventions this build confirms
58
+
59
+ - `--json` automatically on non-TTY stdout, no flag required — implemented
60
+ across every read command (`list`/`inspect`/`test`/`debug`) plus every
61
+ write command that now also supports `--json` (`install`/`remove`/`rename`).
62
+ - `next_steps` in structured output, so an agent isn't left to guess the
63
+ follow-up command from prose it may not even be shown.
64
+ - `schema` command, versioned (`schema_version`), listing every command's
65
+ usage, JSON output shape, and whether it mutates/requires a TTY/is
66
+ destructive — added specifically because the skill flagged it as the
67
+ highest-value, least-adopted convention in its corpus.
package/lib/audit.sh ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ # Append-only audit log. Never write secret values here.
3
+
4
+ wfw_audit_log() {
5
+ local action="$1" org="$2" result="$3" detail="${4:-}"
6
+ local month file
7
+ month="$(date -u +%Y-%m)"
8
+ file="$WFW_AUDIT_DIR/$month.jsonl"
9
+ jq -nc \
10
+ --arg ts "$(wfw_now)" \
11
+ --arg action "$action" \
12
+ --arg org "$org" \
13
+ --arg result "$result" \
14
+ --arg detail "$detail" \
15
+ '{ts: $ts, action: $action, org: $org, result: $result, detail: (if $detail == "" then null else $detail end)}' \
16
+ >> "$file"
17
+ }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # Idempotent loader: makes every command script work whether it's
3
+ # `source`d by bin/flowmcp (functions already in-process) or
4
+ # executed as its own subprocess (e.g. debug.sh shelling out to test.sh).
5
+ BOOTSTRAP_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ : "${WFW_LIB_DIR:=$BOOTSTRAP_LIB_DIR}"
7
+ : "${WFW_COMMANDS_DIR:=$BOOTSTRAP_LIB_DIR/../commands}"
8
+
9
+ if ! declare -f wfw_profile_exists >/dev/null 2>&1; then
10
+ source "$WFW_LIB_DIR/common.sh"
11
+ source "$WFW_LIB_DIR/secrets.sh"
12
+ source "$WFW_LIB_DIR/profiles.sh"
13
+ source "$WFW_LIB_DIR/audit.sh"
14
+ source "$WFW_LIB_DIR/clients.sh"
15
+ source "$WFW_LIB_DIR/oauth.sh"
16
+ source "$WFW_LIB_DIR/ui.sh"
17
+ source "$WFW_LIB_DIR/i18n.sh"
18
+ fi