aitextaroo 0.2.0__tar.gz → 0.2.2__tar.gz

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 (33) hide show
  1. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/PKG-INFO +19 -11
  2. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/README.md +18 -10
  3. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/__init__.py +1 -1
  4. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/agent_cli.py +48 -13
  5. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/client.py +35 -16
  6. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo.egg-info/PKG-INFO +19 -11
  7. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/pyproject.toml +1 -1
  8. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_agent_cli.py +32 -2
  9. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_client_replies.py +26 -1
  10. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/LICENSE +0 -0
  11. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/agents.py +0 -0
  12. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/bridge.py +0 -0
  13. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/cli.py +0 -0
  14. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/commands.py +0 -0
  15. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/conversation.py +0 -0
  16. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/credentials.py +0 -0
  17. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/formatting.py +0 -0
  18. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/models.py +0 -0
  19. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo/setup.py +0 -0
  20. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo.egg-info/SOURCES.txt +0 -0
  21. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo.egg-info/dependency_links.txt +0 -0
  22. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo.egg-info/entry_points.txt +0 -0
  23. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo.egg-info/requires.txt +0 -0
  24. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/aitextaroo.egg-info/top_level.txt +0 -0
  25. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/setup.cfg +0 -0
  26. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_agents.py +0 -0
  27. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_bridge.py +0 -0
  28. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_client.py +0 -0
  29. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_commands.py +0 -0
  30. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_conversation.py +0 -0
  31. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_formatting.py +0 -0
  32. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_models.py +0 -0
  33. {aitextaroo-0.2.0 → aitextaroo-0.2.2}/tests/test_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aitextaroo
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Client library for AI Text-a-roo — give any AI agent a phone number
5
5
  Author-email: AI Text-a-roo <hello@aitextaroo.com>
6
6
  License: MIT
@@ -74,13 +74,16 @@ aitextaroo send "Hello from my agent!"
74
74
 
75
75
  `--save` stores the API key in `~/.aitextaroo/credentials.json` with owner-only permissions.
76
76
  `aitextaroo` and `aitextaroo-bridge` find it there, or in `AITEXTAROO_API_KEY`, or in `--api-key`.
77
+ Verifying a phone that already has an account makes a new key: apps connected before are
78
+ signed out and webhook forwarding is turned off.
77
79
 
78
80
  ## The `aitextaroo` command
79
81
 
80
82
  Made for agents with a shell. Output is JSON; errors go to stderr with exit code 1.
81
83
  `aitextaroo verify-pin` exits 2 when the PIN is wrong, so `aitextaroo verify-pin <<< 4821 && …`
82
84
  only continues on a match. `verify-pin` and `set-pin` read the PIN from stdin when it isn't
83
- an argument, which keeps it out of shell history and the process list.
85
+ an argument. At a terminal they ask for it without echoing, which keeps it out of your shell
86
+ history (a command with the PIN written in it, like the `<<<` form, can still be saved there).
84
87
 
85
88
  ```bash
86
89
  aitextaroo send "Build finished: 318 tests passing."
@@ -88,11 +91,13 @@ aitextaroo ask "Deploy to production? Reply YES or NO" --timeout 120 # text, t
88
91
  aitextaroo wait --timeout 60 # wait for the next text
89
92
  aitextaroo recent # texts from the last 5 minutes
90
93
  aitextaroo relay --to +12125550100 --name Mom "Happy birthday!" # Pro: text a contact
91
- aitextaroo verify-pin <<< 4821 # check a PIN the user texted
92
- aitextaroo set-pin <<< 4821 # set the user's PIN (4-8 digits)
94
+ aitextaroo verify-pin # check a PIN (hidden prompt)
95
+ aitextaroo set-pin # set the user's PIN (4-8 digits)
96
+ aitextaroo set-pin --current # change it: current PIN, then the new one
97
+ aitextaroo remove-pin # needs the current PIN
93
98
  aitextaroo account
94
99
  aitextaroo upgrade # Stripe link for Pro
95
- aitextaroo webhook set https://example.com/hook --auth-header "Bearer <token>"
100
+ aitextaroo webhook set https://example.com/hook --auth-header "Bearer <token>" # add --pin if a PIN is set
96
101
  aitextaroo connect [claude-code|codex|gemini|cursor|muse-code|claude|chatgpt|grok]
97
102
  ```
98
103
 
@@ -162,8 +167,8 @@ asyncio.run(main())
162
167
  | `listen()` | Async iterator over inbound texts via SSE, with automatic reconnect and replay. |
163
168
  | `text_contact(to, text, recipient_name=None)` | Pro: text someone else; their replies go to the user's phone. |
164
169
  | `verify_pin(pin)` | Check a PIN the user texted: `{"verified": bool, "attempts_remaining": int \| None}`. |
165
- | `set_pin(pin)` / `remove_pin()` | Set, change or remove the user's security PIN (4–8 digits). |
166
- | `set_webhook(url, auth_header=None)` / `remove_webhook()` | Forward inbound texts to a URL (signed with `X-Signature`). |
170
+ | `set_pin(pin, current_pin=None)` / `remove_pin(current_pin)` | Set the security PIN the user chose (4–8 digits), change it (needs `current_pin`), or remove it (needs the current PIN). The user is texted before any change. |
171
+ | `set_webhook(url, auth_header=None, pin=None)` / `remove_webhook()` | Forward inbound texts to a URL (signed with `X-Signature`). `pin` is required if the user has one; the user is texted where their texts go and can reply `UNHOOK` to stop it. |
167
172
  | `account()` | Plan, assigned number, usage and daily limit. |
168
173
  | `upgrade_link()` | Stripe checkout link for Pro. |
169
174
  | `TextarooClient.request_verification(phone)` / `confirm_verification(phone, code)` | Sign-up, no key needed. |
@@ -174,8 +179,10 @@ asyncio.run(main())
174
179
  ## MCP server
175
180
 
176
181
  `https://api.aitextaroo.com/mcp` (streamable HTTP). Tools: `send_text`, `wait_for_reply`,
177
- `get_recent_messages`, `text_contact`, `verify_pin`, `get_account`, `get_upgrade_link`.
178
- Clients sign in with OAuth, or send `Authorization: Bearer <API key>`:
182
+ `get_recent_messages`, `text_contact`, `verify_pin`, `get_account`, `get_upgrade_link`,
183
+ `set_webhook`, `remove_webhook`, and `get_help` (how it works and how it's secured, for
184
+ answering the user's questions). Clients sign in with OAuth, or send
185
+ `Authorization: Bearer <API key>`:
179
186
 
180
187
  ```bash
181
188
  claude mcp add --transport http --scope user aitextaroo https://api.aitextaroo.com/mcp \
@@ -217,8 +224,9 @@ Details: <https://aitextaroo.com/security>.
217
224
 
218
225
  ## Releasing
219
226
 
220
- Bump `version` in `pyproject.toml` and `__version__` in `aitextaroo/__init__.py`, merge to
221
- `main`, then push a matching tag: `git tag v0.2.1 && git push origin v0.2.1`. The
227
+ Bump the version in `pyproject.toml`, `aitextaroo/__init__.py`, `plugin.json`,
228
+ `.claude-plugin/plugin.json` and `gemini-extension.json`, merge to `main`, then push a
229
+ matching tag: `git tag v0.2.2 && git push origin v0.2.2`. The
222
230
  [Release workflow](.github/workflows/release.yml) tests, builds and publishes to PyPI with
223
231
  Trusted Publishing.
224
232
 
@@ -41,13 +41,16 @@ aitextaroo send "Hello from my agent!"
41
41
 
42
42
  `--save` stores the API key in `~/.aitextaroo/credentials.json` with owner-only permissions.
43
43
  `aitextaroo` and `aitextaroo-bridge` find it there, or in `AITEXTAROO_API_KEY`, or in `--api-key`.
44
+ Verifying a phone that already has an account makes a new key: apps connected before are
45
+ signed out and webhook forwarding is turned off.
44
46
 
45
47
  ## The `aitextaroo` command
46
48
 
47
49
  Made for agents with a shell. Output is JSON; errors go to stderr with exit code 1.
48
50
  `aitextaroo verify-pin` exits 2 when the PIN is wrong, so `aitextaroo verify-pin <<< 4821 && …`
49
51
  only continues on a match. `verify-pin` and `set-pin` read the PIN from stdin when it isn't
50
- an argument, which keeps it out of shell history and the process list.
52
+ an argument. At a terminal they ask for it without echoing, which keeps it out of your shell
53
+ history (a command with the PIN written in it, like the `<<<` form, can still be saved there).
51
54
 
52
55
  ```bash
53
56
  aitextaroo send "Build finished: 318 tests passing."
@@ -55,11 +58,13 @@ aitextaroo ask "Deploy to production? Reply YES or NO" --timeout 120 # text, t
55
58
  aitextaroo wait --timeout 60 # wait for the next text
56
59
  aitextaroo recent # texts from the last 5 minutes
57
60
  aitextaroo relay --to +12125550100 --name Mom "Happy birthday!" # Pro: text a contact
58
- aitextaroo verify-pin <<< 4821 # check a PIN the user texted
59
- aitextaroo set-pin <<< 4821 # set the user's PIN (4-8 digits)
61
+ aitextaroo verify-pin # check a PIN (hidden prompt)
62
+ aitextaroo set-pin # set the user's PIN (4-8 digits)
63
+ aitextaroo set-pin --current # change it: current PIN, then the new one
64
+ aitextaroo remove-pin # needs the current PIN
60
65
  aitextaroo account
61
66
  aitextaroo upgrade # Stripe link for Pro
62
- aitextaroo webhook set https://example.com/hook --auth-header "Bearer <token>"
67
+ aitextaroo webhook set https://example.com/hook --auth-header "Bearer <token>" # add --pin if a PIN is set
63
68
  aitextaroo connect [claude-code|codex|gemini|cursor|muse-code|claude|chatgpt|grok]
64
69
  ```
65
70
 
@@ -129,8 +134,8 @@ asyncio.run(main())
129
134
  | `listen()` | Async iterator over inbound texts via SSE, with automatic reconnect and replay. |
130
135
  | `text_contact(to, text, recipient_name=None)` | Pro: text someone else; their replies go to the user's phone. |
131
136
  | `verify_pin(pin)` | Check a PIN the user texted: `{"verified": bool, "attempts_remaining": int \| None}`. |
132
- | `set_pin(pin)` / `remove_pin()` | Set, change or remove the user's security PIN (4–8 digits). |
133
- | `set_webhook(url, auth_header=None)` / `remove_webhook()` | Forward inbound texts to a URL (signed with `X-Signature`). |
137
+ | `set_pin(pin, current_pin=None)` / `remove_pin(current_pin)` | Set the security PIN the user chose (4–8 digits), change it (needs `current_pin`), or remove it (needs the current PIN). The user is texted before any change. |
138
+ | `set_webhook(url, auth_header=None, pin=None)` / `remove_webhook()` | Forward inbound texts to a URL (signed with `X-Signature`). `pin` is required if the user has one; the user is texted where their texts go and can reply `UNHOOK` to stop it. |
134
139
  | `account()` | Plan, assigned number, usage and daily limit. |
135
140
  | `upgrade_link()` | Stripe checkout link for Pro. |
136
141
  | `TextarooClient.request_verification(phone)` / `confirm_verification(phone, code)` | Sign-up, no key needed. |
@@ -141,8 +146,10 @@ asyncio.run(main())
141
146
  ## MCP server
142
147
 
143
148
  `https://api.aitextaroo.com/mcp` (streamable HTTP). Tools: `send_text`, `wait_for_reply`,
144
- `get_recent_messages`, `text_contact`, `verify_pin`, `get_account`, `get_upgrade_link`.
145
- Clients sign in with OAuth, or send `Authorization: Bearer <API key>`:
149
+ `get_recent_messages`, `text_contact`, `verify_pin`, `get_account`, `get_upgrade_link`,
150
+ `set_webhook`, `remove_webhook`, and `get_help` (how it works and how it's secured, for
151
+ answering the user's questions). Clients sign in with OAuth, or send
152
+ `Authorization: Bearer <API key>`:
146
153
 
147
154
  ```bash
148
155
  claude mcp add --transport http --scope user aitextaroo https://api.aitextaroo.com/mcp \
@@ -184,8 +191,9 @@ Details: <https://aitextaroo.com/security>.
184
191
 
185
192
  ## Releasing
186
193
 
187
- Bump `version` in `pyproject.toml` and `__version__` in `aitextaroo/__init__.py`, merge to
188
- `main`, then push a matching tag: `git tag v0.2.1 && git push origin v0.2.1`. The
194
+ Bump the version in `pyproject.toml`, `aitextaroo/__init__.py`, `plugin.json`,
195
+ `.claude-plugin/plugin.json` and `gemini-extension.json`, merge to `main`, then push a
196
+ matching tag: `git tag v0.2.2 && git push origin v0.2.2`. The
189
197
  [Release workflow](.github/workflows/release.yml) tests, builds and publishes to PyPI with
190
198
  Trusted Publishing.
191
199
 
@@ -14,5 +14,5 @@ Example:
14
14
  from aitextaroo.client import TextarooClient
15
15
  from aitextaroo.models import InboundMessage, StreamEvent
16
16
 
17
- __version__ = "0.2.0"
17
+ __version__ = "0.2.2"
18
18
  __all__ = ["TextarooClient", "InboundMessage", "StreamEvent"]
@@ -8,9 +8,11 @@ Usage:
8
8
  aitextaroo relay --to +12125550100 --name Mom "Happy birthday!"
9
9
  aitextaroo verify-pin <<< 4821
10
10
  aitextaroo set-pin <<< 4821
11
+ aitextaroo set-pin --current # change it: current PIN, then the new one
12
+ aitextaroo remove-pin # asks for the current PIN
11
13
  aitextaroo account
12
14
  aitextaroo upgrade
13
- aitextaroo webhook set https://example.com/hook [--auth-header "Bearer ..."]
15
+ aitextaroo webhook set https://example.com/hook [--auth-header "Bearer ..."] [--pin]
14
16
  aitextaroo webhook remove
15
17
  aitextaroo connect [claude-code|codex|gemini|cursor|muse-code|claude|chatgpt|grok]
16
18
 
@@ -18,8 +20,9 @@ Results are JSON on stdout (except `connect`, which prints instructions);
18
20
  errors go to stderr with exit code 1. `verify-pin` prints its result and exits
19
21
  with code 2 when the PIN is wrong, so `aitextaroo verify-pin <<< 4821 && ...` only
20
22
  proceeds on a match. `verify-pin` and `set-pin` read the PIN from stdin when
21
- it isn't given as an argument, which keeps it out of shell history and the
22
- process list. The API key comes from --api-key, AITEXTAROO_API_KEY, or
23
+ it isn't given as an argument; at a terminal they ask for it without echoing.
24
+ `webhook set` needs the user's PIN if they set one: pass bare `--pin` to read
25
+ it the same way. The API key comes from --api-key, AITEXTAROO_API_KEY, or
23
26
  ~/.aitextaroo/credentials.json (aitextaroo-setup --save).
24
27
  """
25
28
 
@@ -104,8 +107,11 @@ CONNECT: dict[str, str] = {
104
107
  "grok": (
105
108
  "Grok Bot — say in chat:\n"
106
109
  f" Add a custom MCP server called AI Text-a-roo at {MCP_URL}\n"
107
- "To text it back anytime, create a webhook routine and register it:\n"
108
- ' aitextaroo webhook set <routine URL> --auth-header "Bearer <routine key>"'
110
+ "To text it back anytime, ask Grok Bot to create a webhook routine and\n"
111
+ "register it with the set_webhook tool, or register it yourself:\n"
112
+ ' aitextaroo webhook set <routine URL> --auth-header "Bearer <routine key>" [--pin]\n'
113
+ "(--pin asks for your PIN, needed if you set one. You'll get a text saying\n"
114
+ "where your texts now go; reply UNHOOK anytime to stop forwarding.)"
109
115
  ),
110
116
  }
111
117
 
@@ -121,8 +127,16 @@ def main(argv: list[str] | None = None) -> None:
121
127
  "No API key. Run `aitextaroo-setup --phone +1... --save`, "
122
128
  "set AITEXTAROO_API_KEY, or pass --api-key."
123
129
  )
124
- if args.command in ("verify-pin", "set-pin"):
125
- args.pin = _read_pin(args.pin)
130
+ # Changing an existing PIN: the current one comes first (on stdin, the
131
+ # first line), then the new one.
132
+ if args.command == "set-pin" and args.current is not None:
133
+ args.current = _read_pin(args.current, "Current PIN: ")
134
+ # webhook set only needs the PIN when --pin was given (accounts with a PIN).
135
+ needs_pin = args.command in ("verify-pin", "set-pin", "remove-pin") or (
136
+ args.command == "webhook" and args.action == "set" and args.pin is not None
137
+ )
138
+ if needs_pin:
139
+ args.pin = _read_pin(args.pin, "New PIN: " if args.command == "set-pin" else "PIN: ")
126
140
  try:
127
141
  result = asyncio.run(_run(args, key))
128
142
  except TextarooError as exc:
@@ -159,14 +173,18 @@ async def _run(args: argparse.Namespace, key: str) -> Any:
159
173
  if args.command == "verify-pin":
160
174
  return await client.verify_pin(args.pin)
161
175
  if args.command == "set-pin":
162
- return await client.set_pin(args.pin)
176
+ return await client.set_pin(args.pin, current_pin=args.current)
177
+ if args.command == "remove-pin":
178
+ return await client.remove_pin(args.pin)
163
179
  if args.command == "account":
164
180
  return await client.account()
165
181
  if args.command == "upgrade":
166
182
  return {"checkout_url": await client.upgrade_link()}
167
183
  if args.command == "webhook":
168
184
  if args.action == "set":
169
- return await client.set_webhook(args.url, auth_header=args.auth_header)
185
+ return await client.set_webhook(
186
+ args.url, auth_header=args.auth_header, pin=args.pin
187
+ )
170
188
  return await client.remove_webhook()
171
189
  raise AssertionError(f"unhandled command {args.command}")
172
190
  finally:
@@ -220,7 +238,17 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
220
238
  p.add_argument("pin", nargs="?", help="The PIN (default: read from stdin)")
221
239
 
222
240
  p = sub.add_parser("set-pin", help="Set or change the user's security PIN (4-8 digits)")
223
- p.add_argument("pin", nargs="?", help="The PIN (default: read from stdin)")
241
+ p.add_argument("pin", nargs="?", help="The new PIN (default: read from stdin)")
242
+ p.add_argument(
243
+ "--current",
244
+ nargs="?",
245
+ const="-",
246
+ default=None,
247
+ help="The current PIN, needed to change one. Bare --current reads it from stdin first",
248
+ )
249
+
250
+ p = sub.add_parser("remove-pin", help="Remove the user's security PIN (needs the current one)")
251
+ p.add_argument("pin", nargs="?", help="The current PIN (default: read from stdin)")
224
252
 
225
253
  sub.add_parser("account", help="Plan, usage and the AI's phone number")
226
254
  sub.add_parser("upgrade", help="Stripe checkout link for Pro")
@@ -234,6 +262,13 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
234
262
  default=None,
235
263
  help='Authorization header value sent with each delivery, e.g. "Bearer <token>"',
236
264
  )
265
+ s.add_argument(
266
+ "--pin",
267
+ nargs="?",
268
+ const="-",
269
+ default=None,
270
+ help="The user's PIN, if they set one. Bare --pin reads it from stdin or a hidden prompt",
271
+ )
237
272
  hook.add_parser("remove", help="Stop webhook delivery")
238
273
 
239
274
  p = sub.add_parser("connect", help="Show how to add the MCP server to an agent")
@@ -242,15 +277,15 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
242
277
  return parser.parse_args(argv)
243
278
 
244
279
 
245
- def _read_pin(arg: str | None) -> str:
280
+ def _read_pin(arg: str | None, prompt: str = "PIN: ") -> str:
246
281
  """The PIN from the argument, or from stdin when it's omitted or "-".
247
282
 
248
- At a terminal it's a hidden prompt; otherwise the first line of stdin.
283
+ At a terminal it's a hidden prompt; otherwise the next line of stdin.
249
284
  """
250
285
  if arg not in (None, "-"):
251
286
  pin = arg
252
287
  elif sys.stdin.isatty():
253
- pin = getpass.getpass("PIN: ")
288
+ pin = getpass.getpass(prompt)
254
289
  else:
255
290
  pin = sys.stdin.readline()
256
291
  pin = pin.strip()
@@ -368,55 +368,74 @@ class TextarooClient:
368
368
  response = await client.post("/v1/verify-pin", json={"pin": pin})
369
369
  return _parse_json_response(response, "PIN verification failed")
370
370
 
371
- async def set_pin(self, pin: str) -> dict[str, Any]:
372
- """Set or change the user's security PIN (4–8 digits).
371
+ async def set_pin(self, pin: str, current_pin: str | None = None) -> dict[str, Any]:
372
+ """Set the user's security PIN (4–8 digits), or change it.
373
373
 
374
- Ask the user to choose it; don't store a copy.
374
+ Ask the user to choose it; never pick one for them, and don't store a
375
+ copy. Changing an existing PIN needs current_pin. The user is texted
376
+ before the change.
375
377
 
376
378
  Returns:
377
- {"status": "pin_set"}
379
+ {"status": "pin_set"} or {"status": "pin_changed"}
378
380
 
379
381
  Raises:
380
- TextarooError: If the PIN isn't 4–8 digits or the API returns an error.
382
+ TextarooError: "current_pin_required", "pin_incorrect", "pin_locked",
383
+ "owner_notice_failed" (nothing changed), or another API error.
381
384
  """
385
+ body: dict[str, Any] = {"pin": pin}
386
+ if current_pin:
387
+ body["current_pin"] = current_pin
382
388
  client = await self._get_client()
383
- response = await client.post("/v1/account/pin", json={"pin": pin})
389
+ response = await client.post("/v1/account/pin", json=body)
384
390
  return _parse_json_response(response, "Setting the PIN failed")
385
391
 
386
- async def remove_pin(self) -> dict[str, Any]:
387
- """Remove the user's security PIN.
392
+ async def remove_pin(self, current_pin: str) -> dict[str, Any]:
393
+ """Remove the user's security PIN, which needs the current one.
394
+
395
+ The user is texted before the change.
388
396
 
389
397
  Returns:
390
- {"status": "pin_removed"}
398
+ {"status": "pin_removed"} or {"status": "pin_not_set"}
391
399
 
392
400
  Raises:
393
- TextarooError: If the API returns an error.
401
+ TextarooError: "current_pin_required", "pin_incorrect", or another API error.
394
402
  """
395
403
  client = await self._get_client()
396
- response = await client.delete("/v1/account/pin")
404
+ response = await client.request(
405
+ "DELETE", "/v1/account/pin", json={"current_pin": current_pin}
406
+ )
397
407
  return _parse_json_response(response, "Removing the PIN failed")
398
408
 
399
409
  # ── Webhooks ────────────────────────────────────────────────
400
410
 
401
- async def set_webhook(self, url: str, auth_header: str | None = None) -> dict[str, Any]:
411
+ async def set_webhook(
412
+ self, url: str, auth_header: str | None = None, pin: str | None = None
413
+ ) -> dict[str, Any]:
402
414
  """Forward each text the user sends to a URL as a signed JSON POST.
403
415
 
416
+ The user is texted the destination host before forwarding starts, and
417
+ can text UNHOOK to stop it.
418
+
404
419
  Args:
405
420
  url: Public https URL that receives the deliveries.
406
421
  auth_header: Optional Authorization header value sent with each
407
422
  delivery (e.g., "Bearer <token>" for a Grok Bot webhook routine).
423
+ pin: The user's security PIN. Required if they have one.
408
424
 
409
425
  Returns:
410
- {"status": "registered", "url": ..., "secret": ..., "has_auth_header": bool}.
411
- Verify deliveries with the secret: X-Signature is a hex
412
- HMAC-SHA256 of the raw body.
426
+ {"status": "registered", "url": ..., "secret": ..., "has_auth_header": bool,
427
+ "owner_notified": bool}. Verify deliveries with the secret:
428
+ X-Signature is a hex HMAC-SHA256 of the raw body.
413
429
 
414
430
  Raises:
415
- TextarooError: If the API returns an error.
431
+ TextarooError: "pin_required" or "pin_incorrect", "owner_notice_failed"
432
+ (nothing changed), "rate_limit_exceeded", or another API error.
416
433
  """
417
434
  body: dict[str, Any] = {"url": url}
418
435
  if auth_header:
419
436
  body["auth_header"] = auth_header
437
+ if pin:
438
+ body["pin"] = pin
420
439
  client = await self._get_client()
421
440
  response = await client.post("/v1/webhook", json=body)
422
441
  return _parse_json_response(response, "Webhook registration failed")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aitextaroo
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Client library for AI Text-a-roo — give any AI agent a phone number
5
5
  Author-email: AI Text-a-roo <hello@aitextaroo.com>
6
6
  License: MIT
@@ -74,13 +74,16 @@ aitextaroo send "Hello from my agent!"
74
74
 
75
75
  `--save` stores the API key in `~/.aitextaroo/credentials.json` with owner-only permissions.
76
76
  `aitextaroo` and `aitextaroo-bridge` find it there, or in `AITEXTAROO_API_KEY`, or in `--api-key`.
77
+ Verifying a phone that already has an account makes a new key: apps connected before are
78
+ signed out and webhook forwarding is turned off.
77
79
 
78
80
  ## The `aitextaroo` command
79
81
 
80
82
  Made for agents with a shell. Output is JSON; errors go to stderr with exit code 1.
81
83
  `aitextaroo verify-pin` exits 2 when the PIN is wrong, so `aitextaroo verify-pin <<< 4821 && …`
82
84
  only continues on a match. `verify-pin` and `set-pin` read the PIN from stdin when it isn't
83
- an argument, which keeps it out of shell history and the process list.
85
+ an argument. At a terminal they ask for it without echoing, which keeps it out of your shell
86
+ history (a command with the PIN written in it, like the `<<<` form, can still be saved there).
84
87
 
85
88
  ```bash
86
89
  aitextaroo send "Build finished: 318 tests passing."
@@ -88,11 +91,13 @@ aitextaroo ask "Deploy to production? Reply YES or NO" --timeout 120 # text, t
88
91
  aitextaroo wait --timeout 60 # wait for the next text
89
92
  aitextaroo recent # texts from the last 5 minutes
90
93
  aitextaroo relay --to +12125550100 --name Mom "Happy birthday!" # Pro: text a contact
91
- aitextaroo verify-pin <<< 4821 # check a PIN the user texted
92
- aitextaroo set-pin <<< 4821 # set the user's PIN (4-8 digits)
94
+ aitextaroo verify-pin # check a PIN (hidden prompt)
95
+ aitextaroo set-pin # set the user's PIN (4-8 digits)
96
+ aitextaroo set-pin --current # change it: current PIN, then the new one
97
+ aitextaroo remove-pin # needs the current PIN
93
98
  aitextaroo account
94
99
  aitextaroo upgrade # Stripe link for Pro
95
- aitextaroo webhook set https://example.com/hook --auth-header "Bearer <token>"
100
+ aitextaroo webhook set https://example.com/hook --auth-header "Bearer <token>" # add --pin if a PIN is set
96
101
  aitextaroo connect [claude-code|codex|gemini|cursor|muse-code|claude|chatgpt|grok]
97
102
  ```
98
103
 
@@ -162,8 +167,8 @@ asyncio.run(main())
162
167
  | `listen()` | Async iterator over inbound texts via SSE, with automatic reconnect and replay. |
163
168
  | `text_contact(to, text, recipient_name=None)` | Pro: text someone else; their replies go to the user's phone. |
164
169
  | `verify_pin(pin)` | Check a PIN the user texted: `{"verified": bool, "attempts_remaining": int \| None}`. |
165
- | `set_pin(pin)` / `remove_pin()` | Set, change or remove the user's security PIN (4–8 digits). |
166
- | `set_webhook(url, auth_header=None)` / `remove_webhook()` | Forward inbound texts to a URL (signed with `X-Signature`). |
170
+ | `set_pin(pin, current_pin=None)` / `remove_pin(current_pin)` | Set the security PIN the user chose (4–8 digits), change it (needs `current_pin`), or remove it (needs the current PIN). The user is texted before any change. |
171
+ | `set_webhook(url, auth_header=None, pin=None)` / `remove_webhook()` | Forward inbound texts to a URL (signed with `X-Signature`). `pin` is required if the user has one; the user is texted where their texts go and can reply `UNHOOK` to stop it. |
167
172
  | `account()` | Plan, assigned number, usage and daily limit. |
168
173
  | `upgrade_link()` | Stripe checkout link for Pro. |
169
174
  | `TextarooClient.request_verification(phone)` / `confirm_verification(phone, code)` | Sign-up, no key needed. |
@@ -174,8 +179,10 @@ asyncio.run(main())
174
179
  ## MCP server
175
180
 
176
181
  `https://api.aitextaroo.com/mcp` (streamable HTTP). Tools: `send_text`, `wait_for_reply`,
177
- `get_recent_messages`, `text_contact`, `verify_pin`, `get_account`, `get_upgrade_link`.
178
- Clients sign in with OAuth, or send `Authorization: Bearer <API key>`:
182
+ `get_recent_messages`, `text_contact`, `verify_pin`, `get_account`, `get_upgrade_link`,
183
+ `set_webhook`, `remove_webhook`, and `get_help` (how it works and how it's secured, for
184
+ answering the user's questions). Clients sign in with OAuth, or send
185
+ `Authorization: Bearer <API key>`:
179
186
 
180
187
  ```bash
181
188
  claude mcp add --transport http --scope user aitextaroo https://api.aitextaroo.com/mcp \
@@ -217,8 +224,9 @@ Details: <https://aitextaroo.com/security>.
217
224
 
218
225
  ## Releasing
219
226
 
220
- Bump `version` in `pyproject.toml` and `__version__` in `aitextaroo/__init__.py`, merge to
221
- `main`, then push a matching tag: `git tag v0.2.1 && git push origin v0.2.1`. The
227
+ Bump the version in `pyproject.toml`, `aitextaroo/__init__.py`, `plugin.json`,
228
+ `.claude-plugin/plugin.json` and `gemini-extension.json`, merge to `main`, then push a
229
+ matching tag: `git tag v0.2.2 && git push origin v0.2.2`. The
222
230
  [Release workflow](.github/workflows/release.yml) tests, builds and publishes to PyPI with
223
231
  Trusted Publishing.
224
232
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aitextaroo"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Client library for AI Text-a-roo — give any AI agent a phone number"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -145,7 +145,19 @@ class TestCommands:
145
145
  capsys,
146
146
  )
147
147
  assert out["has_auth_header"] is True
148
- client.set_webhook.assert_awaited_once_with("https://hook.example", auth_header="Bearer t")
148
+ client.set_webhook.assert_awaited_once_with(
149
+ "https://hook.example", auth_header="Bearer t", pin=None
150
+ )
151
+
152
+ def test_webhook_set_reads_the_pin_from_stdin(self, capsys, monkeypatch) -> None:
153
+ monkeypatch.setattr("sys.stdin", io.StringIO("4821\n"))
154
+ client = self._client()
155
+ client.set_webhook.return_value = {"status": "registered", "owner_notified": True}
156
+ with patch.object(agent_cli, "TextarooClient", return_value=client):
157
+ _run_cli(["--api-key", "k", "webhook", "set", "https://hook.example", "--pin"], capsys)
158
+ client.set_webhook.assert_awaited_once_with(
159
+ "https://hook.example", auth_header=None, pin="4821"
160
+ )
149
161
 
150
162
  def test_api_error_exits_nonzero(self, capsys) -> None:
151
163
  from aitextaroo.client import TextarooError
@@ -197,7 +209,25 @@ class TestCommands:
197
209
  with patch.object(agent_cli, "TextarooClient", return_value=client):
198
210
  out = _run_cli(["--api-key", "k", "set-pin"], capsys)
199
211
  assert out == {"status": "pin_set"}
200
- client.set_pin.assert_awaited_once_with("482193")
212
+ client.set_pin.assert_awaited_once_with("482193", current_pin=None)
213
+
214
+ def test_changing_the_pin_reads_the_current_one_first(self, capsys, monkeypatch) -> None:
215
+ monkeypatch.setattr("sys.stdin", io.StringIO("1111\n4821\n"))
216
+ client = self._client()
217
+ client.set_pin.return_value = {"status": "pin_changed"}
218
+ with patch.object(agent_cli, "TextarooClient", return_value=client):
219
+ out = _run_cli(["--api-key", "k", "set-pin", "--current"], capsys)
220
+ assert out == {"status": "pin_changed"}
221
+ client.set_pin.assert_awaited_once_with("4821", current_pin="1111")
222
+
223
+ def test_remove_pin_reads_the_current_pin(self, capsys, monkeypatch) -> None:
224
+ monkeypatch.setattr("sys.stdin", io.StringIO("1111\n"))
225
+ client = self._client()
226
+ client.remove_pin.return_value = {"status": "pin_removed"}
227
+ with patch.object(agent_cli, "TextarooClient", return_value=client):
228
+ out = _run_cli(["--api-key", "k", "remove-pin"], capsys)
229
+ assert out == {"status": "pin_removed"}
230
+ client.remove_pin.assert_awaited_once_with("1111")
201
231
 
202
232
  def test_pin_prompt_is_hidden_at_a_terminal(self, capsys, monkeypatch) -> None:
203
233
  tty = io.StringIO()
@@ -138,11 +138,19 @@ class TestVerifyPin:
138
138
  assert json.loads(seen[0].content) == {"pin": "4821"}
139
139
  await client.close()
140
140
 
141
+ @pytest.mark.asyncio
142
+ async def test_change_pin_sends_the_current_one(self) -> None:
143
+ client, seen = _recording_client(200, '{"status": "pin_changed"}')
144
+ assert await client.set_pin("4821", current_pin="1111") == {"status": "pin_changed"}
145
+ assert json.loads(seen[0].content) == {"pin": "4821", "current_pin": "1111"}
146
+ await client.close()
147
+
141
148
  @pytest.mark.asyncio
142
149
  async def test_remove_pin(self) -> None:
143
150
  client, seen = _recording_client(200, '{"status": "pin_removed"}')
144
- assert await client.remove_pin() == {"status": "pin_removed"}
151
+ assert await client.remove_pin("1111") == {"status": "pin_removed"}
145
152
  assert (seen[0].method, seen[0].url.path) == ("DELETE", "/v1/account/pin")
153
+ assert json.loads(seen[0].content) == {"current_pin": "1111"}
146
154
  await client.close()
147
155
 
148
156
 
@@ -162,6 +170,23 @@ class TestWebhook:
162
170
  }
163
171
  await client.close()
164
172
 
173
+ @pytest.mark.asyncio
174
+ async def test_set_with_pin(self) -> None:
175
+ client, seen = _recording_client(200, '{"status": "registered", "owner_notified": true}')
176
+ await client.set_webhook("https://hook.example", pin="4821")
177
+ assert json.loads(seen[0].content) == {"url": "https://hook.example", "pin": "4821"}
178
+ await client.close()
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_pin_required_is_an_error(self) -> None:
182
+ client, _ = _recording_client(
183
+ 403, '{"error": {"code": "pin_required", "message": "This account has a security PIN"}}'
184
+ )
185
+ with pytest.raises(TextarooError) as exc_info:
186
+ await client.set_webhook("https://hook.example")
187
+ assert exc_info.value.code == "pin_required"
188
+ await client.close()
189
+
165
190
  @pytest.mark.asyncio
166
191
  async def test_remove(self) -> None:
167
192
  client, seen = _recording_client(200, '{"status": "removed"}')
File without changes
File without changes
File without changes