machina-cli 0.4.4__tar.gz → 0.4.7__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 (94) hide show
  1. {machina_cli-0.4.4 → machina_cli-0.4.7}/.github/workflows/release.yml +15 -5
  2. {machina_cli-0.4.4 → machina_cli-0.4.7}/CHANGELOG.md +9 -0
  3. {machina_cli-0.4.4 → machina_cli-0.4.7}/PKG-INFO +1 -1
  4. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/provision.py +83 -3
  5. {machina_cli-0.4.4 → machina_cli-0.4.7}/pyproject.toml +1 -1
  6. machina_cli-0.4.7/src/machina_cli/client.py +134 -0
  7. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/auth.py +71 -36
  8. machina_cli-0.4.4/src/machina_cli/client.py +0 -101
  9. {machina_cli-0.4.4 → machina_cli-0.4.7}/.github/workflows/ci.yml +0 -0
  10. {machina_cli-0.4.4 → machina_cli-0.4.7}/.gitignore +0 -0
  11. {machina_cli-0.4.4 → machina_cli-0.4.7}/CLAUDE.md +0 -0
  12. {machina_cli-0.4.4 → machina_cli-0.4.7}/README.md +0 -0
  13. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/agentic-harness-loop.md +0 -0
  14. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/credentials.md +0 -0
  15. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/PLAYBOOK-SCORECARD.md +0 -0
  16. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/README.md +0 -0
  17. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/VALIDATION.md +0 -0
  18. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/harness-loop-kit/context-verify.py +0 -0
  19. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs/loop-tools-connector.py +0 -0
  20. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/.gitignore +0 -0
  21. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/.vitepress/config.mts +0 -0
  22. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/.vitepress/theme/custom.css +0 -0
  23. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/.vitepress/theme/index.ts +0 -0
  24. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/README.md +0 -0
  25. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/agent.md +0 -0
  26. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/config.md +0 -0
  27. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/connector.md +0 -0
  28. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/credentials.md +0 -0
  29. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/deploy.md +0 -0
  30. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/document.md +0 -0
  31. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/execution.md +0 -0
  32. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/factory.md +0 -0
  33. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/login.md +0 -0
  34. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/loop.md +0 -0
  35. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/mapping.md +0 -0
  36. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/org.md +0 -0
  37. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/project.md +0 -0
  38. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/prompt.md +0 -0
  39. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/skills.md +0 -0
  40. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/sports.md +0 -0
  41. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/template.md +0 -0
  42. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/update.md +0 -0
  43. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/commands/workflow.md +0 -0
  44. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/authentication.md +0 -0
  45. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/configuration.md +0 -0
  46. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/installation.md +0 -0
  47. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/quickstart.md +0 -0
  48. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/repl.md +0 -0
  49. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/guide/running.md +0 -0
  50. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/index.md +0 -0
  51. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/package-lock.json +0 -0
  52. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/package.json +0 -0
  53. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/public/favicon.ico +0 -0
  54. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/public/machina-logo-dark.png +0 -0
  55. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/public/machina-logo-light.png +0 -0
  56. {machina_cli-0.4.4 → machina_cli-0.4.7}/docs-site/vercel.json +0 -0
  57. {machina_cli-0.4.4 → machina_cli-0.4.7}/install.ps1 +0 -0
  58. {machina_cli-0.4.4 → machina_cli-0.4.7}/install.sh +0 -0
  59. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/__init__.py +0 -0
  60. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/browser_auth.py +0 -0
  61. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/__init__.py +0 -0
  62. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/agent.py +0 -0
  63. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/config_cmd.py +0 -0
  64. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/connect.py +0 -0
  65. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/connector.py +0 -0
  66. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/credentials.py +0 -0
  67. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/deploy.py +0 -0
  68. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/document.py +0 -0
  69. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/execution.py +0 -0
  70. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/factory.py +0 -0
  71. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/loop.py +0 -0
  72. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/mapping.py +0 -0
  73. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/mcp.py +0 -0
  74. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/org.py +0 -0
  75. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/project.py +0 -0
  76. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/prompt.py +0 -0
  77. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/skills.py +0 -0
  78. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/sports.py +0 -0
  79. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/template.py +0 -0
  80. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/commands/workflow.py +0 -0
  81. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/config.py +0 -0
  82. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/factory_client.py +0 -0
  83. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/loop_client.py +0 -0
  84. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/main.py +0 -0
  85. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/models/__init__.py +0 -0
  86. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/project_client.py +0 -0
  87. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/repl.py +0 -0
  88. {machina_cli-0.4.4 → machina_cli-0.4.7}/src/machina_cli/updater.py +0 -0
  89. {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/__init__.py +0 -0
  90. {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_cli_output.py +0 -0
  91. {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_connect.py +0 -0
  92. {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_mcp_url.py +0 -0
  93. {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_org_usage.py +0 -0
  94. {machina_cli-0.4.4 → machina_cli-0.4.7}/tests/test_sports_integration.py +0 -0
@@ -78,15 +78,25 @@ jobs:
78
78
  # Write to file for gh release
79
79
  echo "$notes" > release_notes.md
80
80
 
81
- - name: Create GitHub Release
81
+ - name: Create or update GitHub Release
82
82
  env:
83
83
  GH_TOKEN: ${{ github.token }}
84
84
  run: |
85
85
  tag="${GITHUB_REF#refs/tags/}"
86
- gh release create "$tag" \
87
- --title "$tag" \
88
- --notes-file release_notes.md \
89
- artifacts/**/*
86
+ # Idempotent: a release may already exist if it was created from the GitHub
87
+ # UI / `gh release create` (which also creates the tag that triggers this
88
+ # workflow). In that case `gh release create` errors ("already exists") and
89
+ # the built binaries never get attached — leaving an empty release. So if the
90
+ # release exists, refresh its notes and upload the assets with --clobber.
91
+ if gh release view "$tag" >/dev/null 2>&1; then
92
+ gh release edit "$tag" --title "$tag" --notes-file release_notes.md
93
+ gh release upload "$tag" artifacts/**/* --clobber
94
+ else
95
+ gh release create "$tag" \
96
+ --title "$tag" \
97
+ --notes-file release_notes.md \
98
+ artifacts/**/*
99
+ fi
90
100
 
91
101
  publish-pypi:
92
102
  needs: [github-release]
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to machina-cli are documented here.
4
4
 
5
+ ## [0.4.7] - 2026-06-30
6
+
7
+ ### Added
8
+ - **The harness loop can now read documents and real conversations.** Its tool catalog gained `read_documents` (read any document on the project pod by name — copilot threads, harness sessions, fixtures, config docs, via the same `document_search` the MCP uses) and `fetch_conversations` (recent real SportingBOT end-user chat transcripts from PostHog — user context + bot answer + category, read from `posthog.ai_events`). Before, the loop only had `find_fixtures`/`calculate`/`get_datetime`/`echo`, so "analyse the recent chats and suggest bot improvements" got "give me the logs"; it now pulls live transcripts and returns grounded, evaluator-checked findings. (#41)
9
+
10
+ ### Fixed
11
+ - **`machina login --api-key` no longer discards a valid key.** The key was verified against `login/session`, which returns 5xx for API-key auth; the client read that as "key invalid", deleted the just-stored key, and silently fell back to the session token (printing "Authenticated as Unknown"). The client now never clears the key on a 5xx — it falls back for the current request only, and only when a session-token retry actually succeeds — and `login --api-key` verifies without that fallback and reports an honest result. The key is always kept. (#42)
12
+ - **Release pipeline no longer ships empty releases.** The `github-release` CI step used `gh release create`, which errors when the release already exists (e.g. cut from the GitHub UI, which also creates the triggering tag) and then dies before attaching the binaries. It is now idempotent — create, or upload assets onto the existing release. (#43)
13
+
5
14
  ## [0.4.4] - 2026-06-30
6
15
 
7
16
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: machina-cli
3
- Version: 0.4.4
3
+ Version: 0.4.7
4
4
  Summary: CLI for the Machina AI Agent platform
5
5
  Project-URL: Homepage, https://machina.gg
6
6
  Project-URL: Repository, https://github.com/machina-sports/machina-cli
@@ -23,7 +23,7 @@ What it creates (maps to the Loop-Engineering five moves — see VALIDATION.md):
23
23
  prompt loop-evaluate INDEPENDENT verifier: judge the answer (verification)
24
24
  prompt loop-repair bounded self-repair of a rejected answer (Cap 8.2)
25
25
  prompt loop-evaluate-2 re-verify the repaired answer (Cap 8.2)
26
- connector loop-tools dispatcher: calculate / get_datetime / echo / find_fixtures
26
+ connector loop-tools dispatcher: calculate / get_datetime / echo / find_fixtures / read_documents / fetch_conversations
27
27
  workflow loop-turn load -> ingest -> reason -> tool -> respond -> EVALUATE -> [repair -> re-eval] -> finalize
28
28
  workflow loop-resume beat path: resume an orphaned `active` session (scheduling)
29
29
  agent loop-runner executor path (inactive; CLI/MCP invokes it)
@@ -98,7 +98,7 @@ def _create(kind, body):
98
98
  # --- loop-tools connector source (pyscript, exec'd from the DB at call time) ---
99
99
  # Returns {status: True, data: {...}} — the connector contract; a bare dict is
100
100
  # treated as a FAILED connector. Inputs arrive under request_data['params'].
101
- LOOP_TOOLS_SRC = r'''"""Machina Loop Tools — meta-dispatcher (calculate/get_datetime/echo/find_fixtures)."""
101
+ LOOP_TOOLS_SRC = r'''"""Machina Loop Tools — meta-dispatcher (calculate/get_datetime/echo/find_fixtures/read_documents/fetch_conversations)."""
102
102
  import ast, json, operator
103
103
  from datetime import datetime, timezone
104
104
  _OPS={ast.Add:operator.add,ast.Sub:operator.sub,ast.Mult:operator.mul,ast.Div:operator.truediv,ast.Pow:operator.pow,ast.Mod:operator.mod,ast.USub:operator.neg,ast.UAdd:operator.pos,ast.FloorDiv:operator.floordiv}
@@ -136,6 +136,78 @@ def _find_fixtures(a):
136
136
  "home_analysis":(tf.get("home") or {}).get("analysis","")[:300] if v.get("has_pre_match_research") else "",
137
137
  "away_analysis":(tf.get("away") or {}).get("analysis","")[:300] if v.get("has_pre_match_research") else ""})
138
138
  return out if out else "no upcoming fixtures found"+((" for team "+team) if team else "")
139
+ def _read_documents(a):
140
+ # General doc reader: reads recent documents on THIS pod by exact name, using the
141
+ # SAME core.document.controller the MCP uses. Gives the loop eyes on its own pod
142
+ # (copilot-thread, harness_session, sportradar-fixture, config docs, ...).
143
+ try:
144
+ from core.document.controller import document_search
145
+ except Exception as ex:
146
+ return "read_documents unavailable: "+str(ex)
147
+ name=str(a.get("name","")).strip()
148
+ if not name:
149
+ return "read_documents needs name (e.g. copilot-thread, harness_session, sportradar-fixture)"
150
+ try: limit=int(a.get("limit",5))
151
+ except Exception: limit=5
152
+ contains=str(a.get("contains","")).strip().lower()
153
+ r=document_search(filters={"name":name},page=1,page_size=max(1,min(limit,10)),sorters=["created",-1])
154
+ dd=r.get("data") if isinstance(r,dict) else None
155
+ docs=dd.get("data") if isinstance(dd,dict) else (dd if isinstance(dd,list) else [])
156
+ out=[]
157
+ for d in (docs or []):
158
+ s=json.dumps(d.get("value",{}) or {},ensure_ascii=False,default=str)
159
+ if contains and contains not in s.lower(): continue
160
+ out.append({"name":d.get("name"),"created":str(d.get("created","")),"value":s[:900]})
161
+ return out if out else ("no '"+name+"' documents found")
162
+ def _fetch_conversations(a):
163
+ # Real SportingBOT end-user chats from PostHog. Verbatim text lives on the
164
+ # posthog.ai_events table (input/output_choices, anchored on trace_id, 30d retention);
165
+ # events carries only metadata. Key read from the posthog-surface-config doc (same as
166
+ # the surface-verify defense). Returns recent (user-context, bot-answer, category).
167
+ import urllib.request
168
+ try:
169
+ from core.document.controller import document_search
170
+ except Exception as ex:
171
+ return "fetch_conversations unavailable: "+str(ex)
172
+ key=""; pid_doc=""
173
+ try:
174
+ r=document_search(filters={"name":"posthog-surface-config"},page=1,page_size=1)
175
+ dd=r.get("data") if isinstance(r,dict) else None
176
+ docs=dd.get("data") if isinstance(dd,dict) else (dd if isinstance(dd,list) else [])
177
+ if docs:
178
+ v=docs[0].get("value",{}) or {}
179
+ key=v.get("query_key","") or v.get("posthog_query_key","") or v.get("key","") or v.get("posthog_key","")
180
+ pid_doc=str(v.get("project","") or "")
181
+ except Exception as ex:
182
+ return "fetch_conversations: cannot read posthog key: "+str(ex)
183
+ if not key or str(key).startswith("$"):
184
+ return "fetch_conversations: no posthog key in posthog-surface-config doc"
185
+ pid=str(a.get("project_id") or pid_doc or "257767").strip() or "257767"
186
+ try: limit=int(a.get("limit",4))
187
+ except Exception: limit=4
188
+ limit=max(1,min(limit,6))
189
+ cat=str(a.get("category","")).replace("'","").strip()
190
+ catf=("AND properties.sbot_category = '"+cat+"' ") if cat else ""
191
+ q=("WITH t AS (SELECT properties.$ai_trace_id AS tid, properties.sbot_category AS cat, timestamp AS ts "
192
+ "FROM events WHERE event='$ai_generation' AND timestamp >= now() - INTERVAL 72 HOUR "
193
+ "AND properties.$ai_trace_id != '' "+catf+
194
+ "ORDER BY timestamp DESC LIMIT "+str(limit)+") "
195
+ "SELECT t.cat AS category, right(toString(a.input),260) AS user_ctx, "
196
+ "substring(toString(a.output_choices),1,300) AS bot "
197
+ "FROM posthog.ai_events AS a INNER JOIN t ON a.trace_id=t.tid "
198
+ "ORDER BY t.ts DESC LIMIT "+str(limit))
199
+ body=json.dumps({"query":{"kind":"HogQLQuery","query":q}}).encode()
200
+ req=urllib.request.Request("https://us.posthog.com/api/projects/"+pid+"/query/",data=body,
201
+ headers={"Authorization":"Bearer "+key,"Content-Type":"application/json"},method="POST")
202
+ try:
203
+ with urllib.request.urlopen(req,timeout=45) as resp:
204
+ data=json.loads(resp.read() or "{}")
205
+ except Exception as ex:
206
+ return "fetch_conversations posthog error: "+str(ex)[:160]
207
+ out=[]
208
+ for row in (data.get("results") or []):
209
+ out.append({"category":row[0],"user_ctx":(row[1] or "")[:240],"bot":(row[2] or "")[:240]})
210
+ return out if out else "no recent conversations found"
139
211
  def dispatch(request_data: dict) -> dict:
140
212
  p=request_data.get("params",{}) or request_data
141
213
  tool=(p.get("tool_name") or "").strip()
@@ -145,17 +217,23 @@ def dispatch(request_data: dict) -> dict:
145
217
  except Exception:
146
218
  args={}
147
219
  if tool=="find_fixtures": r=_find_fixtures(args)
220
+ elif tool=="read_documents": r=_read_documents(args)
221
+ elif tool=="fetch_conversations": r=_fetch_conversations(args)
148
222
  elif tool=="get_datetime": r=datetime.now(timezone.utc).strftime("%A, %d %B %Y, %H:%M UTC")
149
223
  elif tool=="calculate": r=_calc(args)
150
224
  elif tool=="echo": r=str(args.get("text",""))
151
225
  else: r="unknown tool: "+tool
152
226
  return {"status": True, "data": {"tool_result": json.dumps(r, ensure_ascii=False, default=str)[:2800],
153
- "tool_ok": tool in ("find_fixtures","get_datetime","calculate","echo")}}
227
+ "tool_ok": tool in ("find_fixtures","read_documents","fetch_conversations","get_datetime","calculate","echo")}}
154
228
  '''
155
229
 
156
230
  CATALOG = [
157
231
  {"name": "find_fixtures", "params_hint": "team(str), limit(int=5)",
158
232
  "description": "Upcoming football fixtures (real project data) + AI pre-match analysis. Use for schedule / who-plays / pre-match questions."},
233
+ {"name": "read_documents", "params_hint": "name*(str), limit(int=5), contains(str?)",
234
+ "description": "Read recent documents stored on THIS project pod by exact name (e.g. copilot-thread, harness_session, sportradar-fixture, config docs). Use to inspect saved conversations/state on the pod before answering."},
235
+ {"name": "fetch_conversations", "params_hint": "limit(int=4), category(str?)",
236
+ "description": "Recent REAL SportingBOT end-user chat transcripts from PostHog (user context + bot answer + category). Use to analyze conversation quality and suggest concrete bot improvements."},
159
237
  {"name": "calculate", "params_hint": "expression*(str)", "description": "Evaluate arithmetic; never compute it yourself."},
160
238
  {"name": "get_datetime", "params_hint": "(no args)", "description": "Current UTC date/time."},
161
239
  {"name": "echo", "params_hint": "text*(str)", "description": "Echo text back verbatim."},
@@ -247,6 +325,8 @@ REASON_INSTR = (
247
325
  "- For ANY math -> needs_tool_call=true, call calculate {expression}. Never compute yourself.\n"
248
326
  "- For current date/time -> call get_datetime.\n"
249
327
  "- For upcoming matches / schedule / pre-match analysis -> call find_fixtures {team?, limit?}.\n"
328
+ "- To inspect saved documents/conversations/state on this pod (copilot-thread, harness_session, fixtures, config) -> call read_documents {name, limit?, contains?}.\n"
329
+ "- To analyze recent SportingBOT user chats or suggest bot improvements -> call fetch_conversations {limit?, category?}.\n"
250
330
  "- To repeat text -> call echo {text}.\n"
251
331
  "- Otherwise answer directly: needs_tool_call=false.\n"
252
332
  "name = exact tool from _3-available-tools; arguments_json = JSON-stringified args ('{}' if none). short_message = 1 line.")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "machina-cli"
7
- version = "0.4.4"
7
+ version = "0.4.7"
8
8
  description = "CLI for the Machina AI Agent platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,134 @@
1
+ """HTTP client for Machina Core API."""
2
+
3
+ from typing import Optional
4
+
5
+ import httpx
6
+ from rich.console import Console
7
+
8
+ from machina_cli.config import get_api_url, get_credential, resolve_auth_token
9
+
10
+ console = Console(stderr=True)
11
+
12
+ TIMEOUT = 30.0
13
+
14
+
15
+ class MachinaClient:
16
+ """HTTP client that handles authentication and error formatting."""
17
+
18
+ def __init__(self, api_url: Optional[str] = None):
19
+ self.api_url = (api_url or get_api_url()).rstrip("/")
20
+
21
+ def _headers(self) -> dict:
22
+ header_name, token = resolve_auth_token()
23
+ headers = {"Content-Type": "application/json"}
24
+ if header_name and token:
25
+ headers[header_name] = token
26
+ else:
27
+ console.print("[red]Not authenticated. Run `machina login` first.[/red]")
28
+ raise SystemExit(1)
29
+ return headers
30
+
31
+ def _handle_response(self, response: httpx.Response, quiet: bool = False) -> dict:
32
+ data = {}
33
+ try:
34
+ data = response.json()
35
+ except Exception:
36
+ pass
37
+
38
+ # Extract error message from response body if available
39
+ error_msg = ""
40
+ if isinstance(data, dict):
41
+ error = data.get("error", {})
42
+ if isinstance(error, dict):
43
+ error_msg = error.get("message", "")
44
+
45
+ def emit(*args):
46
+ if not quiet:
47
+ console.print(*args)
48
+
49
+ if response.status_code == 401:
50
+ emit(f"[red]{error_msg or 'Session expired.'}[/red]")
51
+ emit("[yellow]Run `machina login` to re-authenticate.[/yellow]")
52
+ raise SystemExit(1)
53
+ if response.status_code == 403:
54
+ emit(f"[red]{error_msg or 'Permission denied.'}[/red]")
55
+ raise SystemExit(1)
56
+ if response.status_code == 404:
57
+ emit(f"[red]{error_msg or 'Resource not found.'}[/red]")
58
+ raise SystemExit(1)
59
+ if response.status_code >= 500:
60
+ emit(f"[red]{error_msg or 'Server error. Try again later.'}[/red]")
61
+ raise SystemExit(1)
62
+
63
+ if isinstance(data, dict) and data.get("status") == "error":
64
+ emit(f"[red]Error: {error_msg or 'Unknown error'}[/red]")
65
+ raise SystemExit(1)
66
+
67
+ return data
68
+
69
+ def _request(self, method: str, path: str, **kwargs) -> dict:
70
+ """Execute a request, optionally falling back from API key to session token on 5xx."""
71
+ url = f"{self.api_url}/{path.lstrip('/')}"
72
+ skip_auth = kwargs.pop("skip_auth", False)
73
+ allow_fallback = kwargs.pop("allow_fallback", True)
74
+ quiet = kwargs.pop("quiet", False)
75
+ headers = {"Content-Type": "application/json"} if skip_auth else self._headers()
76
+ try:
77
+ with httpx.Client(timeout=TIMEOUT) as client:
78
+ response = getattr(client, method)(url, headers=headers, **kwargs)
79
+
80
+ # A 5xx while sending an API key is ambiguous: the key may be invalid,
81
+ # the endpoint may not accept API-key auth (it 500s instead of 401), or
82
+ # the server may simply be faulting. Fall back to the session token for
83
+ # THIS request only — never delete the stored key on a 5xx (that would
84
+ # silently discard a possibly-valid key, e.g. during `login --api-key`).
85
+ if allow_fallback and response.status_code >= 500 and headers.get("X-Api-Token"):
86
+ session = get_credential("session_token")
87
+ if session:
88
+ retry_headers = {k: v for k, v in headers.items() if k != "X-Api-Token"}
89
+ retry_headers["X-Session-Token"] = session
90
+ retry = getattr(client, method)(url, headers=retry_headers, **kwargs)
91
+ if retry.status_code < 500:
92
+ if not quiet:
93
+ console.print(
94
+ "[yellow]API key not accepted by the server here; "
95
+ "used your session token for this request.[/yellow]"
96
+ )
97
+ response = retry
98
+
99
+ return self._handle_response(response, quiet=quiet)
100
+ except httpx.ConnectError:
101
+ if not quiet:
102
+ console.print(
103
+ f"[red]Cannot reach Machina API at {self.api_url}. Check your connection.[/red]"
104
+ )
105
+ raise SystemExit(1)
106
+
107
+ def get(
108
+ self,
109
+ path: str,
110
+ params: Optional[dict] = None,
111
+ allow_fallback: bool = True,
112
+ quiet: bool = False,
113
+ ) -> dict:
114
+ return self._request("get", path, params=params, allow_fallback=allow_fallback, quiet=quiet)
115
+
116
+ def post(
117
+ self,
118
+ path: str,
119
+ json_data: Optional[dict] = None,
120
+ skip_auth: bool = False,
121
+ allow_fallback: bool = True,
122
+ quiet: bool = False,
123
+ ) -> dict:
124
+ return self._request(
125
+ "post",
126
+ path,
127
+ json=json_data or {},
128
+ skip_auth=skip_auth,
129
+ allow_fallback=allow_fallback,
130
+ quiet=quiet,
131
+ )
132
+
133
+ def delete(self, path: str, allow_fallback: bool = True, quiet: bool = False) -> dict:
134
+ return self._request("delete", path, allow_fallback=allow_fallback, quiet=quiet)
@@ -68,29 +68,44 @@ def do_login(
68
68
  # Mode 1: API key
69
69
  if api_key:
70
70
  store_credential("api_key", api_key)
71
- console.print("[green]API key stored successfully.[/green]")
71
+ console.print("[green]API key stored.[/green]")
72
72
 
73
- # Verify the key works — try login/session first, fall back to org search
73
+ # Best-effort identity. CRITICAL: allow_fallback=False so a 5xx never swaps in
74
+ # the session token (which would report the wrong identity), and the key is
75
+ # NEVER cleared on failure — verification is informational only. quiet=True so
76
+ # we own the messaging instead of leaking raw error lines. A 5xx here is
77
+ # commonly the server faulting on API-key auth, not a bad key.
74
78
  client = MachinaClient()
79
+ label = ""
75
80
  try:
76
- result = client.get("login/session")
77
- user_data = result.get("data", {})
78
- name = user_data.get("name", "Unknown")
79
- console.print(f"Authenticated as [bold]{name}[/bold]")
81
+ data = (
82
+ client.get("login/session", allow_fallback=False, quiet=True).get("data", {}) or {}
83
+ )
84
+ label = data.get("name") or data.get("email") or ""
80
85
  except SystemExit:
81
- # login/session may not support API key auth — try listing orgs instead
82
86
  try:
83
- result = client.post("user/organizations/search", {
84
- "filters": {}, "page": 1, "page_size": 1, "sorters": ["name", 1],
85
- })
86
- orgs = result.get("data", [])
87
+ orgs = (
88
+ client.post(
89
+ "user/organizations/search",
90
+ {"filters": {}, "page": 1, "page_size": 1, "sorters": ["name", 1]},
91
+ allow_fallback=False,
92
+ quiet=True,
93
+ ).get("data", [])
94
+ or []
95
+ )
87
96
  if orgs:
88
- org_name = orgs[0].get("organization_name", orgs[0].get("name", ""))
89
- console.print(f"Authenticated — org: [bold]{org_name}[/bold]")
90
- else:
91
- console.print("[green]API key verified.[/green]")
97
+ label = orgs[0].get("organization_name") or orgs[0].get("name") or ""
92
98
  except SystemExit:
93
- console.print("[yellow]API key stored but could not verify. Check the key is valid.[/yellow]")
99
+ label = ""
100
+
101
+ if label:
102
+ console.print(f"Authenticated as [bold]{label}[/bold]")
103
+ else:
104
+ console.print(
105
+ "[yellow]Key stored, but the server didn't confirm it (the auth endpoints "
106
+ "may not accept API keys, or returned an error). The key is kept — run a "
107
+ "command to use it, or set the MACHINA_API_KEY env var.[/yellow]"
108
+ )
94
109
  return
95
110
 
96
111
  # Mode 3: Username/password (explicit flag)
@@ -146,17 +161,27 @@ def do_login(
146
161
 
147
162
  @app.command()
148
163
  def login(
149
- api_key: Optional[str] = typer.Option(None, "--api-key", "-k", help="Authenticate with an API key"),
150
- with_credentials: bool = typer.Option(False, "--with-credentials", help="Use username/password instead of browser"),
151
- username: Optional[str] = typer.Option(None, "--username", "-u", help="Username (requires --with-credentials)"),
152
- password: Optional[str] = typer.Option(None, "--password", "-p", help="Password (requires --with-credentials)"),
164
+ api_key: Optional[str] = typer.Option(
165
+ None, "--api-key", "-k", help="Authenticate with an API key"
166
+ ),
167
+ with_credentials: bool = typer.Option(
168
+ False, "--with-credentials", help="Use username/password instead of browser"
169
+ ),
170
+ username: Optional[str] = typer.Option(
171
+ None, "--username", "-u", help="Username (requires --with-credentials)"
172
+ ),
173
+ password: Optional[str] = typer.Option(
174
+ None, "--password", "-p", help="Password (requires --with-credentials)"
175
+ ),
153
176
  ):
154
177
  """Login to the Machina platform.
155
178
 
156
179
  Default: opens browser for Clerk authentication (magic link / SSO).
157
180
  Use --api-key for CI/CD, or --with-credentials for username/password.
158
181
  """
159
- do_login(api_key=api_key, username=username, password=password, with_credentials=with_credentials)
182
+ do_login(
183
+ api_key=api_key, username=username, password=password, with_credentials=with_credentials
184
+ )
160
185
 
161
186
 
162
187
  @app.command()
@@ -168,6 +193,7 @@ def logout():
168
193
  # Open browser logout to clear session cookies (Clerk + machina session)
169
194
  session_url = get_config("session_url") or "https://session.machina.gg"
170
195
  import webbrowser
196
+
171
197
  console.print("[dim]Opening browser to clear session cookies...[/dim]")
172
198
  webbrowser.open(f"{session_url}/logout")
173
199
  console.print("[green]Logged out completely.[/green]")
@@ -186,11 +212,14 @@ def clear_session():
186
212
  # Open browser to clear session cookies on the SESSION app
187
213
  session_url = get_config("session_url") or "https://session.machina.gg"
188
214
  import webbrowser
215
+
189
216
  console.print("[dim]Opening browser to clear server-side cookies...[/dim]")
190
217
  webbrowser.open(f"{session_url}/logout")
191
218
 
192
219
  console.print()
193
- console.print("[bold]Session fully cleared.[/bold] Run [bold #FF5C1F]machina login[/bold #FF5C1F] to re-authenticate.")
220
+ console.print(
221
+ "[bold]Session fully cleared.[/bold] Run [bold #FF5C1F]machina login[/bold #FF5C1F] to re-authenticate."
222
+ )
194
223
 
195
224
 
196
225
  @app.command()
@@ -222,19 +251,25 @@ def whoami(
222
251
  auth_method = "API Key" if header_name == "X-Api-Token" else "Session Token"
223
252
 
224
253
  if json_output:
225
- print(json.dumps({
226
- "authenticated": True,
227
- "name": user_data.get("name"),
228
- "email": user_data.get("email"),
229
- "user_id": user_data.get("_id", user_data.get("id")),
230
- "auth_method": auth_method,
231
- }))
254
+ print(
255
+ json.dumps(
256
+ {
257
+ "authenticated": True,
258
+ "name": user_data.get("name"),
259
+ "email": user_data.get("email"),
260
+ "user_id": user_data.get("_id", user_data.get("id")),
261
+ "auth_method": auth_method,
262
+ }
263
+ )
264
+ )
232
265
  return
233
266
 
234
- console.print(Panel.fit(
235
- f"[bold]Name:[/bold] {user_data.get('name', 'N/A')}\n"
236
- f"[bold]Email:[/bold] {user_data.get('email', 'N/A')}\n"
237
- f"[bold]User ID:[/bold] {user_data.get('_id', user_data.get('id', 'N/A'))}\n"
238
- f"[bold]Auth:[/bold] {auth_method}",
239
- title="Current User",
240
- ))
267
+ console.print(
268
+ Panel.fit(
269
+ f"[bold]Name:[/bold] {user_data.get('name', 'N/A')}\n"
270
+ f"[bold]Email:[/bold] {user_data.get('email', 'N/A')}\n"
271
+ f"[bold]User ID:[/bold] {user_data.get('_id', user_data.get('id', 'N/A'))}\n"
272
+ f"[bold]Auth:[/bold] {auth_method}",
273
+ title="Current User",
274
+ )
275
+ )
@@ -1,101 +0,0 @@
1
- """HTTP client for Machina Core API."""
2
-
3
- from typing import Optional
4
-
5
- import httpx
6
- from rich.console import Console
7
-
8
- from machina_cli.config import _clear_credential, get_api_url, get_credential, resolve_auth_token
9
-
10
- console = Console(stderr=True)
11
-
12
- TIMEOUT = 30.0
13
-
14
-
15
- class MachinaClient:
16
- """HTTP client that handles authentication and error formatting."""
17
-
18
- def __init__(self, api_url: Optional[str] = None):
19
- self.api_url = (api_url or get_api_url()).rstrip("/")
20
-
21
- def _headers(self) -> dict:
22
- header_name, token = resolve_auth_token()
23
- headers = {"Content-Type": "application/json"}
24
- if header_name and token:
25
- headers[header_name] = token
26
- else:
27
- console.print("[red]Not authenticated. Run `machina login` first.[/red]")
28
- raise SystemExit(1)
29
- return headers
30
-
31
- def _handle_response(self, response: httpx.Response) -> dict:
32
- data = {}
33
- try:
34
- data = response.json()
35
- except Exception:
36
- pass
37
-
38
- # Extract error message from response body if available
39
- error_msg = ""
40
- if isinstance(data, dict):
41
- error = data.get("error", {})
42
- if isinstance(error, dict):
43
- error_msg = error.get("message", "")
44
-
45
- if response.status_code == 401:
46
- console.print(f"[red]{error_msg or 'Session expired.'}[/red]")
47
- console.print("[yellow]Run `machina login` to re-authenticate.[/yellow]")
48
- raise SystemExit(1)
49
- if response.status_code == 403:
50
- msg = error_msg or "Permission denied."
51
- console.print(f"[red]{msg}[/red]")
52
- raise SystemExit(1)
53
- if response.status_code == 404:
54
- msg = error_msg or "Resource not found."
55
- console.print(f"[red]{msg}[/red]")
56
- raise SystemExit(1)
57
- if response.status_code >= 500:
58
- msg = error_msg or "Server error. Try again later."
59
- console.print(f"[red]{msg}[/red]")
60
- raise SystemExit(1)
61
-
62
- if isinstance(data, dict) and data.get("status") == "error":
63
- console.print(f"[red]Error: {error_msg or 'Unknown error'}[/red]")
64
- raise SystemExit(1)
65
-
66
- return data
67
-
68
- def _request(self, method: str, path: str, **kwargs) -> dict:
69
- """Execute request with automatic API key fallback on 500."""
70
- url = f"{self.api_url}/{path.lstrip('/')}"
71
- skip_auth = kwargs.pop("skip_auth", False)
72
- headers = {"Content-Type": "application/json"} if skip_auth else self._headers()
73
- try:
74
- with httpx.Client(timeout=TIMEOUT) as client:
75
- response = getattr(client, method)(url, headers=headers, **kwargs)
76
-
77
- # If API key returns 500, it may be invalid/missing from Redis.
78
- # Clear it and retry with session token if available.
79
- if response.status_code >= 500 and headers.get("X-Api-Token"):
80
- if get_credential("session_token"):
81
- console.print(
82
- "[yellow]API key rejected by server. "
83
- "Falling back to session token...[/yellow]"
84
- )
85
- _clear_credential("api_key")
86
- headers = self._headers()
87
- response = getattr(client, method)(url, headers=headers, **kwargs)
88
-
89
- return self._handle_response(response)
90
- except httpx.ConnectError:
91
- console.print(f"[red]Cannot reach Machina API at {self.api_url}. Check your connection.[/red]")
92
- raise SystemExit(1)
93
-
94
- def get(self, path: str, params: Optional[dict] = None) -> dict:
95
- return self._request("get", path, params=params)
96
-
97
- def post(self, path: str, json_data: Optional[dict] = None, skip_auth: bool = False) -> dict:
98
- return self._request("post", path, json=json_data or {}, skip_auth=skip_auth)
99
-
100
- def delete(self, path: str) -> dict:
101
- return self._request("delete", path)
File without changes
File without changes
File without changes
File without changes
File without changes