#!/bin/sh # WeMachines - one-line setup. # # curl -fsSL https://wemachines.com/install | sh # # Wires this repo to a WeMachines workspace so your coding agent keeps the team # updated while you work. It touches four things in the current repo and nothing # else: # # .mcp.json registers WeMachines' MCP server # .wemachines/ WORKFLOW.md, the protocol your agent # follows; config.json, the switch that # turns it down or off and the repo this # checkout is; and mcp-guard.sh, the hook # that enforces both # .claude/settings.json MERGED: registers mcp-guard.sh so the # workspace write tools cannot fire from a # folder that isn't this repo # AGENTS.md (or your existing CLAUDE.md) APPENDED: a short section pointing at # WORKFLOW.md, so your agent reads it. # Claude Code reads CLAUDE.md and NOT # AGENTS.md, so when the section lands in # AGENTS.md a one-line CLAUDE.md importing # it is written too - otherwise the whole # protocol is invisible to Claude Code # # It is deliberately boring: no sudo, no PATH changes, no daemons, no telemetry, # nothing downloaded beyond this file. It never overwrites or removes anything you # wrote: new files are only created when absent, .mcp.json and .claude/settings.json # are merged key-wise, and the instructions file is appended to behind a marker. The # one file it replaces is its own guard script, and only while that is exactly what an # earlier installer wrote - so a re-run also brings an old install's guard up to date. # Re-running is safe. # # Read before you pipe: https://wemachines.com/install set -eu MCP_URL="https://app.wemachines.com/api/mcp" VERSION="25" # --- output ------------------------------------------------------------------- if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); G=$(printf '\033[32m') Y=$(printf '\033[33m'); R=$(printf '\033[31m'); X=$(printf '\033[0m') else B=''; DIM=''; G=''; Y=''; R=''; X='' fi made=0 say() { printf '%s\n' "$*"; } ok() { printf ' %s+%s %s\n' "$G" "$X" "$1"; made=$((made+1)); } skip() { printf ' %s·%s %s %s(exists, left alone)%s\n' "$DIM" "$X" "$1" "$DIM" "$X"; } warn() { printf ' %s!%s %s\n' "$Y" "$X" "$1"; } die() { printf '%serror:%s %s\n' "$R" "$X" "$1" >&2; exit 1; } # --- where --------------------------------------------------------------------- if root=$(git rev-parse --show-toplevel 2>/dev/null); then in_git=1 else root=$(pwd) in_git=0 fi # Never the home folder, or the disk's root (T-3509). Everything this writes is scoped to # the folder it runs in, and in ~ that folder is the whole account: ~/.claude/settings.json # is Claude Code's USER settings, so the guard would gate every session on the computer, # and .mcp.json, AGENTS.md and .wemachines/ would sit above every project. A dotfiles repo # rooted at ~ is still ~. Refused before anything is written. home=$(cd "${HOME:-/nonexistent}" 2>/dev/null && pwd -P || printf '/nonexistent') here=$(cd "$root" && pwd -P) if [ "$here" = "$home" ] || [ "$here" = "/" ]; then die "$root is your home folder, not a project. Open your team's code repository (cd into it) and run this again there." fi if [ "$in_git" -eq 1 ]; then cd "$root" else warn "not a git repository - installing into $root" fi say "" say "${B}WeMachines${X} ${DIM}· setting up $root${X}" say "" # --- .mcp.json ----------------------------------------------------------------- # Project-scoped MCP config. Committed, so every teammate who clones the repo # gets the same connection - they each sign in as themselves on first use. write_mcp_json() { cat > .mcp.json <<-JSON { "mcpServers": { "wemachines": { "type": "http", "url": "$MCP_URL" } } } JSON } # Exit codes from the two merge helpers below: 0 merged, 3 already present, # anything else = couldn't do it (missing runtime, unparseable JSON, ...). merge_with_node() { # stderr silenced: an unparseable .mcp.json would otherwise dump a raw stack # trace at the user. We want the friendly "add this block yourself" path instead. WEMACHINES_MCP_URL="$MCP_URL" node 2>/dev/null <<-'NODE' const fs = require('fs'); const cfg = JSON.parse(fs.readFileSync('.mcp.json', 'utf8')); cfg.mcpServers = cfg.mcpServers || {}; if (cfg.mcpServers.wemachines) process.exit(3); cfg.mcpServers.wemachines = { type: 'http', url: process.env.WEMACHINES_MCP_URL }; fs.writeFileSync('.mcp.json', JSON.stringify(cfg, null, 2) + '\n'); NODE } merge_with_python() { WEMACHINES_MCP_URL="$MCP_URL" python3 2>/dev/null <<-'PY' import json, os, sys cfg = json.load(open('.mcp.json')) cfg.setdefault('mcpServers', {}) if 'wemachines' in cfg['mcpServers']: sys.exit(3) cfg['mcpServers']['wemachines'] = {'type': 'http', 'url': os.environ['WEMACHINES_MCP_URL']} open('.mcp.json', 'w').write(json.dumps(cfg, indent=2, ensure_ascii=False) + '\n') PY } merge_mcp_json() { # Merge into an existing config rather than clobbering it. Node and Python are # both optional, and a present-but-broken one shouldn't strand us - try each in # turn. If neither works we print the block and let the user paste it. s=0 if command -v node >/dev/null 2>&1; then merge_with_node || s=$? if [ "$s" -eq 0 ] || [ "$s" -eq 3 ]; then return "$s"; fi fi s=0 if command -v python3 >/dev/null 2>&1; then merge_with_python || s=$? return "$s" fi return 4 } if [ ! -f .mcp.json ]; then write_mcp_json ok ".mcp.json" else status=0; merge_mcp_json || status=$? case "$status" in 0) ok ".mcp.json ${DIM}(added \"wemachines\" to existing config)${X}" ;; 3) skip ".mcp.json ${DIM}already has \"wemachines\"${X}" ;; *) warn ".mcp.json exists and could not be merged automatically - add:" say "" say " \"wemachines\": { \"type\": \"http\", \"url\": \"$MCP_URL\" }" say "" ;; esac fi # --- .wemachines/WORKFLOW.md ----------------------------------------------------- mkdir -p .wemachines if [ -f .wemachines/WORKFLOW.md ]; then skip ".wemachines/WORKFLOW.md" else cat > .wemachines/WORKFLOW.md <<-'MD' # Working with the team through WeMachines This repo is connected to a WeMachines workspace over MCP - the team's shared tasks, library pages and chat. Treat it as the team's memory: someone will read what you leave behind, possibly weeks from now, possibly after you've forgotten this session existed. The scope rules (when the write tools may be used at all), the `auto_mode` switch in `.wemachines/config.json`, and the `start_work` / `end_work` contract are stated in the MCP server's own instructions and enforced by `.wemachines/mcp-guard.sh`. This file covers what those don't. You do not report progress by hand. Every write tool re-stamps your live session as it goes, so staying visible is a side effect of doing the work. ## Before you start `get_workspace` to confirm where you are, then `list_tasks` to see what's in flight - the thing being asked for is often already a task, sometimes already assigned to someone. If `get_workspace` answers with a slug other than the `workspace` in `.wemachines/config.json`, **stop and say so**: writing into the wrong workspace is worse than writing nothing. `get_workspace` also lists the repositories this workspace has connected. When there is more than one, **say which one you are in**: `.wemachines/config.json` names it under `repository`, and `start_work`, `create_task` and `run_coding_agent` each take it as a `repository` argument. It matters - a task that names no repo is briefed against the team's DEFAULT repo, so the branch, the commit trail and the "already landed" verdict all come from the wrong codebase, and a coding run has to stop and ask what to clone. Work that is not about code names no repository, and that is right. If the request is vague, `list_recent_chat_messages` usually explains what the team has already decided and why. `read_task` gives the full body and comments - the comments hold the corrections. `start_work` answers with a **briefing** before anything else happens: whether the task is fresh, yours to resume (and on which branch), taken by a teammate's AI right now, already landed in code, or closed. Read its `guidance` and do what it says - it is built from the board, the past sessions on the task and the commits and pull requests that reference it, which the request in front of you does not know about. A closed task is not started at all (`needs_confirmation`): tell the person, and only if they say to reopen it call `start_work` again with `confirm: true` - or `create_task` for what is genuinely new work. ## When someone is already there `read_task` returns `live_work` and `start_work` answers with a `conflict` when another member's AI is on that task. That is not a refusal - two people on one ticket is fine - but **stop and say so before you continue**, and if you both carry on, say which part you're taking so you don't collide. No task for what you're doing? Pass `title` to `start_work` and one is created for you, already in progress and assigned to you. Work nobody can see is worse than a slightly busier board. `create_task` puts the task on your list by default; pass `assignee_ids` when it is genuinely someone else's, or `[]` for backlog nobody has picked up. ## What deserves a write **Do not comment on a ticket unless you were asked to.** This is the rule people actually notice you breaking. A comment notifies teammates and sits on the ticket forever, so it is never the place to say you started, finished, or changed something - your live session already shows what you are on, and the commit already says what you did. A comment that repeats either is noise someone has to read past. Opening a ticket is different, and welcome: `create_task` for a real bug you found outside your scope, or for work that deserves its own line on the board. The narrow exception is being genuinely stuck. If a decision only a human can make is blocking you, say so to the person you're working with - a comment only when there is no such person (an autonomous run), and then one comment with the actual options, not a status update. Decisions and rationale belong in a page, not a chat message that scrolls away: `create_page` with what was decided and why the alternatives lost, or `update_page` when a page already holds it - change it in place, never file a copy. Chat is for the moment, pages are for the record. When the decision has NOT been made yet and what is missing is the team's actual opinion, `create_poll` puts it to them in chat and collects the answer where the question was asked. `send_chat_message` only when the team needs to know now - a deploy, a migration, a breaking change, a decision that blocks someone else's work. Work that has to happen again and again with nobody watching is a ROUTINE, not a timer inside what you built: `list_routines` for what already runs, then `create_routine` for the standing instruction - "every hour, refresh this app's data", "every Monday, post what competitors shipped". A `setInterval` in an app you shipped dies with its container and takes the job with it; a routine runs on WeMachines' servers and every run posts in its agent's own room in team chat, where the team sees it and can ask the agent to pause it or read back what it did. ## Commits Reference the task in every commit that contributes to one - the identifier at the end of the subject line (`fix login redirect (T-42)`), or a `Task: T-42` trailer if the repo has its own subject conventions. Name every task the commit touches: a commit that does two tickets' work carries both codes. A branch can carry it too (`t-42-login-redirect`), and so can a pull request's title. Teammates, the workspace AI and the board itself read the repo's history: a referenced commit ties the code back to the task that caused it; an unreferenced one breaks that trail. No task for the work? `create_task` first - anything more than a quick fix deserves one. Then the board follows the code. When the repo's GitHub App delivers pushes and pull requests to WeMachines, the server reads those references - and, for a pull request that bundles several tickets, asks the AI which of the open tasks the code delivered - and moves each task by the team's rules, which live on the repository in the app: **Integrations → GitHub → the repo → Workflow**. They read as sentences, and `start_work` tells you the ones that apply to the task you are on: > When someone starts work on a task, move it to In Progress. > When code is pushed to dev, move the task to In Review. > When a pull request merges into main, move the task to Done. Automatic moves are forward only and never into a cancelled column; without a rule a merge parks the task in the review column. Every move is written on the task's code trail with the commit or PR that caused it, and `read_task` shows it. Where a rule covers what you just did, your job at commit time is only the reference - do not move the task by hand as well. Where no rule covers it, move the task yourself. A commit is the moment the board goes stale - the code changed and the column did not - and nobody comes back to tidy it later. Call `update_task_status` for every task the commit touched: the column your workspace uses for "shipped, not yet verified" when the commit finishes the work, the in-progress column when it is only a step on the way. Name a status that does not exist and the tool answers with the ones that do. **Those rules automate the board. They do not limit what anyone can ask you for.** Every column is yours to move a task into, completed and cancelled included, whenever someone asks - "close T-42, I've checked it", "move the stale ones to Blocked". One thing to say out loud rather than do quietly: putting a task nobody asked about into a completed column. A completed column deletes nothing: 14 days after a task lands there it moves into the grey zone, kept and greyed out, and `read_task` still reaches it. ## Writing it well Bodies take **markdown, and it renders**: headings, tables, lists, `- [ ]` checklists with real boxes, code blocks, quotes and links become real formatting, so give a body structure instead of one flat paragraph. Chat messages and comments render the same set minus headings and tables. - A task: title it imperative and specific - "Cut onboarding to three steps", not "Onboarding" - then keep the body SHORT. A few sentences on what needs doing and how anyone will know it's done. Most tasks need no headings at all; `## Scope` / `## Done when` are for work genuinely big enough to have sections, and a three-heading document for a one-line fix is noise the assignee reads past. Never pad a body to look thorough. - A poll: the question in one line, and options short enough to read at a glance - the question carries the detail. Between two and ten of them, and people may pick several. - Refer to tasks, pages and apps by code (T-7, P-3, A-app-slug) and they render as live links carrying the item's current title. Use a table whenever you are comparing things. - Real content only: no placeholders, no invented numbers or names. If you do not know something, say so. The bar for anything you write: would a human on this team have bothered to write it? If not, don't. A workspace full of bot chatter is worse than an empty one, because people stop reading it. ## Asking how WeMachines works `ask_support` answers questions about WeMachines itself - what it is, how each section works, the AI and credits model. Use it instead of guessing at the platform's behaviour. It comes in sections: call it with no arguments to see the list, then ask for the one you need. Only `section: 'all'` returns the whole ~8,000-token block, and whatever it returns stays in your context for the rest of the session. MD ok ".wemachines/WORKFLOW.md" fi # --- .wemachines/config.json ----------------------------------------------------- # The switch. WORKFLOW.md tells the agent to announce its work; this is how you turn # that down without disconnecting. Committed, so it is a team decision rather than a # per-laptop one - and `auto_mode: ask` is the middle setting people actually want. # # It also carries `repository`: which of the workspace's repos THIS checkout is. A # workspace can connect several, and an MCP connection resolves to the workspace, never # to a repo - so without a committed answer the agent's only way to say which repo a task # is in is to guess. Read off `origin`, and only a github.com remote: the field names a # repo the workspace has connected, and a self-hosted remote is not one of those. # # Where the board moves a task is NOT set here any more (T-1540). It is the repository's # Workflow settings in the app - Integrations > GitHub > the repo > Workflow - where the whole # team can read the rules as sentences rather than only the people who can open a pull # request. A repo whose config file still carries the old `code_flow` or # `status_after_push` keys has them lifted onto that screen the first time an admin opens # it, once; after that the file's copy is not read again. # owner/name out of `origin`, for both remote spellings, or empty if it is not GitHub: # git@github.com:owner/name.git https://github.com/owner/name(.git) origin_repo() { url=$(git remote get-url origin 2>/dev/null) || return 0 printf '%s' "$url" | sed -n \ -e 's|^git@github\.com:\([^/]*/[^/]*\)$|\1|p' \ -e 's|^ssh://git@github\.com/\([^/]*/[^/]*\)$|\1|p' \ -e 's|^https\{0,1\}://[^@/]*@\{0,1\}github\.com/\([^/]*/[^/]*\)$|\1|p' | sed -e 's|\.git$||' | head -n 1 } REPO=$(origin_repo) # Adding the key to a config that predates it is the whole reason this merges rather # than only writing the file when absent - the same shape the CLAUDE.md bridge has, so a # re-run upgrades an existing install instead of leaving it half-configured. merge_repo_with_node() { WEMACHINES_REPO="$REPO" node 2>/dev/null <<-'NODE' const fs = require('fs'); const p = '.wemachines/config.json'; const cfg = JSON.parse(fs.readFileSync(p, 'utf8')); if (cfg.repository) process.exit(3); cfg.repository = process.env.WEMACHINES_REPO; fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n'); NODE } merge_repo_with_python() { WEMACHINES_REPO="$REPO" python3 2>/dev/null <<-'PY' import json, os, sys p = '.wemachines/config.json' cfg = json.load(open(p)) if cfg.get('repository'): sys.exit(3) cfg['repository'] = os.environ['WEMACHINES_REPO'] open(p, 'w').write(json.dumps(cfg, indent=2, ensure_ascii=False) + '\n') PY } merge_repo() { s=0 if command -v node >/dev/null 2>&1; then merge_repo_with_node || s=$? if [ "$s" -eq 0 ] || [ "$s" -eq 3 ]; then return "$s"; fi fi s=0 if command -v python3 >/dev/null 2>&1; then merge_repo_with_python || s=$? return "$s" fi return 4 } if [ -f .wemachines/config.json ]; then if [ -z "$REPO" ]; then skip ".wemachines/config.json" else status=0; merge_repo || status=$? case "$status" in 0) ok ".wemachines/config.json ${DIM}(added \"repository\": $REPO)${X}" ;; 3) skip ".wemachines/config.json ${DIM}already names its repository${X}" ;; *) warn ".wemachines/config.json exists and could not be merged automatically - add:" say "" say " \"repository\": \"$REPO\"" say "" ;; esac fi elif [ -n "$REPO" ]; then cat > .wemachines/config.json <<-JSON { "auto_mode": "on", "repository": "$REPO" } JSON ok ".wemachines/config.json ${DIM}(auto_mode: on | ask | off · repository: $REPO)${X}" else cat > .wemachines/config.json <<-'JSON' { "auto_mode": "on" } JSON ok ".wemachines/config.json ${DIM}(auto_mode: on | ask | off)${X}" fi # --- .wemachines/mcp-guard.sh ---------------------------------------------------- # Enforcement for the above. The MCP server tells every client to call start_work # before it changes anything - correct in this repo, wrong in every other folder a # globally-registered client follows you into. This blocks the write tools when the # working directory is not inside a WeMachines repo, and honours auto_mode when it is. # # NOT a <<- heredoc: that strips every leading tab, which would flatten the script's # own indentation. Content sits at column 0 and ships verbatim. # # The one file a re-run REPLACES, and only while it is exactly a guard an earlier # installer wrote (T-1463). A guard decides which tools are writes, and an installed one # keeps the answer it shipped with: until v23 that was a LIST of the write tools, so every # write added after an install passed that guard as a read - from any folder, the one # thing it exists to stop. The files above keep what the person wrote; this one is ours # until somebody edits it, and an edited guard is left exactly as it is, with a line on # how to take the current one. # # KNOWN_GUARDS is the sha256 of every guard an installer has written into .wemachines/: # 58349fa2 2026-08-21 to 2026-09-08, a list of write tools # cafc99f5 2026-09-08 to 2026-09-11, the same list # bf5a8116 v23, fails closed - read tools by name, everything else gated # When the heredoc below changes, APPEND the new file's hash and never remove one: a hash # that leaves this list is every install it describes no longer being refreshed. The test # in functions/utils/mcp-guard.test.ts fails until the current guard is listed. KNOWN_GUARDS=" 58349fa2a5297353e525cc653bc48f3817ac41cbbcf38932fa9501bb9dc1b51d cafc99f5a545c41a8c23fabc726f427da4d995a3b9b06be5671343d6df7bf52d bf5a8116d4aea3429f8e5aabad08e1f56e34b3df1de0b6845fb5e1c0ccebe389 " # The guard, written to $1: one text for what a new install gets and what an existing one # is compared against. write_guard() { cat > "$1" << 'GUARD' #!/bin/sh # WeMachines - MCP auto-mode guard. A Claude Code PreToolUse hook on mcp__wemachines__.* # # The MCP server tells every client to call start_work before it changes anything. That # is right inside the repo the workspace is linked to, and wrong everywhere else: register # the server globally (`claude mcp add -s user`, Cursor's global mcp.json, Claude Desktop) # and the same instruction rides along into every folder you open - so a private # conversation in ~/finances opens a task on a shared team board under your name. # # The server's instructions say so too, but a prompt is advisory. This is the enforcement: # # * WRITE tools are blocked unless the working directory sits inside a tree that has a # `.wemachines/` directory - i.e. a repo somebody actually ran the installer in. # * Inside such a repo, `.wemachines/config.json` decides: # "auto_mode": "on" allow (default when the file or key is absent) # "auto_mode": "ask" prompt first # "auto_mode": "off" block # WEMACHINES_AUTO_MODE in the environment overrides the file, for a one-off session: # WEMACHINES_AUTO_MODE=off claude # * READ tools are never blocked. The leak direction is outward - reading the workspace # from an unrelated folder publishes nothing to anyone. # # What it deliberately does NOT do: judge whether the conversation is on-topic. A hook # cannot read intent, so "only when the context is related to the workspace" stays with # the model (the SCOPE paragraph in the server's instructions and in WORKFLOW.md). This # guard covers the part that is mechanically decidable: WHERE you are running. # # Install it two ways, and prefer both: # # in this repo .claude/settings.json - enforces auto_mode for everyone who clones it # for every repo ~/.claude/hooks/wemachines-mcp-guard.sh + the same block in # ~/.claude/settings.json - the only one that covers folders that are # NOT WeMachines repos, which is where the leak actually happens # # {"hooks":{"PreToolUse":[{"matcher":"mcp__wemachines__.*","hooks":[ # {"type":"command","command":"sh ~/.claude/hooks/wemachines-mcp-guard.sh"}]}]}} # # Nothing here reads $CLAUDE_PROJECT_DIR, so the script behaves identically from either # location - it takes the directory from the hook payload's `cwd`. # # POSIX sh, no jq: this ships to other people's machines through the installer, and a # guard that fails open on a missing dependency is not a guard. # # Re-running the installer replaces this file while it is exactly what an installer wrote. # Edit it and it is yours: the installer leaves an edited guard alone. set -u payload=$(cat) # One field out of the hook payload. Values here (`tool_name`, `cwd`) are a tool name and a # filesystem path - no embedded quotes or escapes to worry about in practice. field() { printf '%s' "$payload" | sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 } tool=$(field tool_name) # Not ours -> say nothing. Silence is "allow" for a PreToolUse hook. case "$tool" in mcp__wemachines__*) ;; *) exit 0 ;; esac # Which tools publish to the workspace - read off the tool's NAME, failing CLOSED. A tool # is a read only when its name says so: get_*, list_*, read_*, find_*, ask_* and load_* # (a team skill), or workspace_read, the one gateway to every other read. Anything else is # treated as a write and gated below, including a tool that ships after this file was # written. Earlier guards carried a LIST of the write tools, and a list on your machine # cannot learn a tool that ships later: every write added after an install passed it as a # read, from any folder. Failing closed turns that around - a new write is gated the day it # ships, and WeMachines' own tests hold every read to these names so none is gated by # mistake. # # Also let through: the `mcp_*` tools a connection picks up when it is authorized as one of # the workspace's agents. They are marked `write` on the server so a read-only token cannot # reach them, but they call someone else's system - Linear, PostHog - and publish nothing # to the team's board, which is the only thing this guard exists to keep out of unrelated # folders. name=${tool#mcp__wemachines__} case "$name" in get_* | list_* | read_* | find_* | ask_* | load_* | workspace_read | mcp_*) exit 0 ;; esac # Reasons are plain ASCII with no quotes or backslashes, so they need no JSON escaping. decide() { printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":"%s"}}\n' "$1" "$2" exit 0 } # --- where are we? ------------------------------------------------------------- # Walk up for the `.wemachines/` marker the installer leaves. `cwd` comes from the payload # rather than $PWD because a hook runs in the harness's directory, not the session's. cwd=$(field cwd) [ -n "$cwd" ] || cwd=$PWD root='' dir=$cwd while [ -n "$dir" ] && [ "$dir" != '/' ]; do if [ -d "$dir/.wemachines" ]; then root=$dir break fi dir=$(dirname "$dir") done if [ -z "$root" ]; then decide deny "$cwd is not a WeMachines-connected repo (no .wemachines directory above it). WeMachines' write tools post to a shared team board, so they are blocked outside the linked repo - this keeps unrelated work off your team's board. Read tools still work. To connect this repo, run: curl -fsSL https://wemachines.com/install | sh" fi # --- is auto-mode on? ---------------------------------------------------------- mode=${WEMACHINES_AUTO_MODE:-} if [ -z "$mode" ] && [ -f "$root/.wemachines/config.json" ]; then mode=$(sed -n 's/.*"auto_mode"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$root/.wemachines/config.json" | head -n 1) fi [ -n "$mode" ] || mode=on case "$mode" in off) decide deny "WeMachines auto-mode is off (auto_mode in $root/.wemachines/config.json, or WEMACHINES_AUTO_MODE). $name would write to the team workspace. Set auto_mode to on to restore it, or ask the user to run this action explicitly." ;; ask) decide ask "WeMachines auto-mode is set to ask. $name writes to the team workspace - confirm before it posts." ;; esac exit 0 GUARD chmod +x "$1" } # sha256 of a file with whatever this machine has, or nothing. Nothing means "cannot tell # it is ours", which leaves the guard alone - the safe answer when unsure. sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1 elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d ' ' -f 1 elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | sed 's/^.*= //' fi } GUARD_FILE=.wemachines/mcp-guard.sh if [ ! -f "$GUARD_FILE" ]; then write_guard "$GUARD_FILE" ok "$GUARD_FILE ${DIM}(blocks workspace writes from other folders)${X}" else write_guard "$GUARD_FILE.new" if cmp -s "$GUARD_FILE.new" "$GUARD_FILE"; then rm -f "$GUARD_FILE.new" skip "$GUARD_FILE ${DIM}already current${X}" else sum=$(sha256_of "$GUARD_FILE") # Unquoted on purpose: collapses the list's newlines into single spaces. # shellcheck disable=SC2086 case " $(echo $KNOWN_GUARDS) " in *" ${sum:-unknown} "*) mv "$GUARD_FILE.new" "$GUARD_FILE" ok "$GUARD_FILE ${DIM}(updated - gates every write tool now, including ones added since you installed)${X}" ;; *) rm -f "$GUARD_FILE.new" warn "$GUARD_FILE has been edited, so it was left alone. The current guard also gates write tools added since yours was written - delete it and re-run to take it." ;; esac fi fi # --- .claude/settings.json ----------------------------------------------------- # Registers the guard for Claude Code. Merged the same way .mcp.json is, because this is # a file the user maintains. Other clients read the WORKFLOW.md rules but have no hook # system to enforce them - that gap is why the consent screen also offers read-only. CLAUDE_HOOK_CMD='sh "$CLAUDE_PROJECT_DIR/.wemachines/mcp-guard.sh"' merge_settings_with_node() { WEMACHINES_HOOK_CMD="$CLAUDE_HOOK_CMD" node 2>/dev/null <<-'NODE' const fs = require('fs'); const p = '.claude/settings.json'; const cfg = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {}; cfg.hooks = cfg.hooks || {}; cfg.hooks.PreToolUse = cfg.hooks.PreToolUse || []; const has = JSON.stringify(cfg.hooks.PreToolUse).includes('mcp-guard.sh'); if (has) process.exit(3); cfg.hooks.PreToolUse.push({ matcher: 'mcp__wemachines__.*', hooks: [{ type: 'command', command: process.env.WEMACHINES_HOOK_CMD, timeout: 10 }], }); fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n'); NODE } merge_settings_with_python() { WEMACHINES_HOOK_CMD="$CLAUDE_HOOK_CMD" python3 2>/dev/null <<-'PY' import json, os, sys p = '.claude/settings.json' cfg = json.load(open(p)) if os.path.exists(p) else {} cfg.setdefault('hooks', {}).setdefault('PreToolUse', []) if 'mcp-guard.sh' in json.dumps(cfg['hooks']['PreToolUse']): sys.exit(3) cfg['hooks']['PreToolUse'].append({ 'matcher': 'mcp__wemachines__.*', 'hooks': [{'type': 'command', 'command': os.environ['WEMACHINES_HOOK_CMD'], 'timeout': 10}], }) open(p, 'w').write(json.dumps(cfg, indent=2, ensure_ascii=False) + '\n') PY } merge_settings() { s=0 if command -v node >/dev/null 2>&1; then merge_settings_with_node || s=$? if [ "$s" -eq 0 ] || [ "$s" -eq 3 ]; then return "$s"; fi fi s=0 if command -v python3 >/dev/null 2>&1; then merge_settings_with_python || s=$? return "$s" fi return 4 } mkdir -p .claude status=0; merge_settings || status=$? case "$status" in 0) ok ".claude/settings.json ${DIM}(registered the guard hook)${X}" ;; 3) skip ".claude/settings.json ${DIM}already registers the guard${X}" ;; *) warn ".claude/settings.json could not be merged automatically - add under \"hooks\":" say "" say " \"PreToolUse\": [{ \"matcher\": \"mcp__wemachines__.*\", \"hooks\": [" say " { \"type\": \"command\", \"command\": \"$CLAUDE_HOOK_CMD\" } ]}]" say "" ;; esac # --- pointer in the agent instructions file ------------------------------------ # Appended to whichever instruction file the repo already uses, so it's picked up # without us bloating a file the user maintains. Marker keeps re-runs idempotent. MARKER="" if [ -f CLAUDE.md ]; then TARGET=CLAUDE.md elif [ -f AGENTS.md ]; then TARGET=AGENTS.md else TARGET=AGENTS.md; : > "$TARGET" fi # A CLAUDE.md that imports AGENTS.md (the bridge below writes exactly that) already # carries the section from there, so the marker lives in the OTHER file. Without this # the second run reads an unmarked CLAUDE.md and states the whole protocol twice. bridged=0 if [ "$TARGET" = CLAUDE.md ] && grep -qE '^@AGENTS\.md[[:space:]]*$' CLAUDE.md 2>/dev/null && grep -qF "$MARKER" AGENTS.md 2>/dev/null; then bridged=1 fi if grep -qF "$MARKER" "$TARGET" 2>/dev/null || [ "$bridged" -eq 1 ]; then skip "$TARGET ${DIM}already references the workflow${X}" else [ -s "$TARGET" ] && printf '\n' >> "$TARGET" cat >> "$TARGET" <<-MD $MARKER ## WeMachines This repo is connected to a WeMachines workspace over MCP, so the team can see your work as it happens. Read \`.wemachines/WORKFLOW.md\` and follow it - and whatever else you do, these three: - \`start_work\` BEFORE you touch anything - the moment you're asked to build, fix or change something. Pass \`title\` if the work has no task yet, and \`repository\` (the one \`.wemachines/config.json\` names) when the team has connected more than one. - reference the task in every commit that contributes to it - \`(T-42)\` on the subject line, or a \`Task: T-42\` trailer - then \`update_task_status\` to move what you shipped. - \`end_work\` when you stop, with what actually changed. You never report progress by hand - every write tool keeps your live session alive. That applies to work **on this repo's project**. The write tools post to a shared team board, so anything personal, general or off-project stays off it - answer it and write nothing, and never copy conversation text or file contents into a summary or comment. \`.wemachines/config.json\` sets \`auto_mode\`: \`ask\` means confirm first, \`off\` means only when you're asked for that action by name. MD ok "$TARGET ${DIM}(appended WeMachines section)${X}" fi # --- CLAUDE.md bridge ---------------------------------------------------------- # Claude Code reads CLAUDE.md and does NOT read AGENTS.md, so a section that landed # in AGENTS.md above never enters its context - the agent connects, gets the tools, # and still never calls start_work, because nothing ever told it to. Anthropic's # documented fix is a CLAUDE.md that imports the other file, which is one line and # leaves AGENTS.md the single place the protocol is written. Deliberately outside the # marker check above: a repo installed before v12 has the section in AGENTS.md already, # and re-running is what gives it the bridge. if [ "$TARGET" = AGENTS.md ] && [ ! -f CLAUDE.md ]; then printf '@AGENTS.md\n' > CLAUDE.md ok "CLAUDE.md ${DIM}(imports AGENTS.md - Claude Code doesn't read AGENTS.md)${X}" fi # --- next steps ---------------------------------------------------------------- say "" if [ "$made" -eq 0 ]; then say "${B}Already set up.${X} Nothing to change." else say "${B}Done.${X} Commit these so your team gets them too." fi say "" say "${B}Next${X}" say " 1. Open this repo in Claude Code, Cursor, Codex or any MCP client." say " 2. It will ask you to sign in to WeMachines and pick a workspace - and, if you" say " like, one of your agents to run as." say " ${DIM}No account yet? https://wemachines.com${X}" say " 3. Ask it: ${DIM}\"what's on my plate?\"${X}" say "" say " ${DIM}MCP setup: https://app.wemachines.com -> MCP · installer v$VERSION${X}" say ""